WPF Circular Progress Indicator

This article shows how to make a WPF circular progress indicator that resembles Silverlight’s loading animation.

Introduction

I wanted to use a ProgressBar control in a WPF application I am working on and make this ProgressBar look like Silverlight’s loading animation. I was not able to achieve it by changing only the style of the ProgressBar so I ended up creating a custom control.

Getting Started

First, create a WPF Custom Control Library project. By default, this creates a custom control class which inherits from the Control class. Let’s rename it to ProgressIndicator and have it inherit from the RangeBase control, so that it will have properties like Value, Minimum, and Maximum. We’ll do a bit of copying from the ProgressBar implementation. The following code snippet shows the initial implementation of the ProgressIndicator class.

public class ProgressIndicator : RangeBase
{
public static readonly DependencyProperty IsIndeterminateProperty = DependencyProperty.Register(
"IsIndeterminate", typeof(bool), typeof(ProgressIndicator));

public bool IsIndeterminate
{
get { return (bool)GetValue(IsIndeterminateProperty); }
set { SetValue(IsIndeterminateProperty, value); }
}

static ProgressIndicator()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(ProgressIndicator), new FrameworkPropertyMetadata(typeof(ProgressIndicator)));
RangeBase.MaximumProperty.OverrideMetadata(typeof(ProgressIndicator), new FrameworkPropertyMetadata(100.0));
}
}

Listing 1. Initial ProgressIndicator Class

The IsIndeterminate property will tell us whether to show the Loading… text or the percentage completed. Next thing to do is define the control’s generic style. The control template should be a canvas containing ellipses or circles. The style, contained in Generic.xaml, is shown below.


<Style x:Key="EllipseStyle" TargetType="Ellipse" >
<Style.Setters>
<Setter Property="Width" Value="25"/>
<Setter Property="Height" Value="25"/>
<Setter Property="Fill" >
<Setter.Value>
<RadialGradientBrush>
<GradientStop Color="#CA2C8DDE" Offset="0.634"/>
<GradientStop Color="#39FFFFFF" Offset="1"/>
<GradientStop Color="#CA2C64DE" Offset="0.33"/>
<GradientStop Color="#B56A8FDE" Offset="0.062"/>
</RadialGradientBrush>
</Setter.Value>
</Setter>
</Style.Setters>
</Style>

<Style TargetType="local:ProgressIndicator">
<Style.Setters>
<Setter Property="Height" Value="75"/>
<Setter Property="Width" Value="75"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:ProgressIndicator">
<Grid>
<Canvas
x:Name="PART_Canvas"
SnapsToDevicePixels="True"
Width="{TemplateBinding Width}"
Height="{TemplateBinding Height}">
<Ellipse Style="{StaticResource EllipseStyle}"/>
<Ellipse Style="{StaticResource EllipseStyle}"/>
<Ellipse Style="{StaticResource EllipseStyle}"/>
<Ellipse Style="{StaticResource EllipseStyle}"/>
<Ellipse Style="{StaticResource EllipseStyle}"/>
<Ellipse Style="{StaticResource EllipseStyle}"/>
<Ellipse Style="{StaticResource EllipseStyle}"/>
<Ellipse Style="{StaticResource EllipseStyle}"/>
</Canvas>
<TextBlock
Name="LoadingTextBlock"
Text="Loading..."
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<TextBlock
Name="ProgressTextBlock"
Text="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=Value}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsIndeterminate" Value="True">
<Setter Property="Visibility" TargetName="ProgressTextBlock" Value="Hidden"/>
</Trigger>
<Trigger Property="IsIndeterminate" Value="False">
<Setter Property="Visibility" TargetName="LoadingTextBlock" Value="Hidden"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style.Setters>
</Style>

Listing 2. Default Control Style

The ellipses are not yet arranged in a circle. We could define the locations of these ellipses using XAML but it is hard to identify the points on the canvas manually. This might be easier though if Expression Blend is used. So our option is to use code to arrange the ellipses. The style of the ellipse sets the Fill property to a radial gradient brush. I got this brush from Walt Ritscher’s blog
. There are also useful concepts here that were used in creating the ProgressIndicator control. Now let’s arrange the ellipses by overriding the OnApplyTemplate method.

public override void OnApplyTemplate()
{
base.OnApplyTemplate();

canvas = GetTemplateChild(ElementCanvas) as Canvas;
if (canvas != null)
{
// Get the center of the canvas. This will be the base of the rotation.
double centerX = canvas.Width / 2;
double centerY = canvas.Height / 2;

// Get the no. of degrees between each circles.
double interval = 360.0 / canvas.Children.Count;
double angle = -135;

foreach (UIElement element in canvas.Children)
{
RotateTransform rotateTransform = new RotateTransform(angle, centerX, centerY);
element.RenderTransform = rotateTransform;
angle += interval;
}
}
}

Listing 3. Arranging the Elements

The logic here is to get the children of the canvas and arrange them in a circle by applying a rotate transform. The center of rotation will be the center of the canvas. The angle starts at -135 degrees and is incremented by a certain interval, which depends on the number of elements to arrange. The starting angle places the first element at the bottom of the circle. The following figure shows the control where the elements are arranged.



Figure 1. Control Template

So next thing to do is to create an animation wherein the ellipse will grow and shrink back. This can be done by using a storyboard. Let’s create another dependency property that will hold the storyboard instance. The following storyboard will be applied to all child elements of the canvas.


<Storyboard>
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.RenderTransform).(ScaleTransform.ScaleX)">
<SplineDoubleKeyFrame KeyTime="00:00:00" Value="0"/>
<SplineDoubleKeyFrame KeyTime="00:00:00.5" Value="1"/>
<SplineDoubleKeyFrame KeyTime="00:00:01" Value="1"/>
<SplineDoubleKeyFrame KeyTime="00:00:01.5" Value="0"/>
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.RenderTransform).(ScaleTransform.ScaleY)">
<SplineDoubleKeyFrame KeyTime="00:00:00" Value="0"/>
<SplineDoubleKeyFrame KeyTime="00:00:00.5" Value="1"/>
<SplineDoubleKeyFrame KeyTime="00:00:01" Value="1"/>
<SplineDoubleKeyFrame KeyTime="00:00:01.5" Value="0"/>
</DoubleAnimationUsingKeyFrames>
</Storyboard>

Listing 4. Grow and Shrink Storyboard

This storyboard dictates that the target element must have its RenderTransform property set to an instance of a ScaleTransform. We can easily put this in XAML. The problem is that on the OnApplyTemplate method, we set the RenderTransform property to a RotateTransform instance. This will replace any transform that we put up in our XAML file. As a workaround, we can wrap each element using a ContentControl, then apply the rotate transform on the ContentControl.


canvasElements = Array.CreateInstance(typeof(UIElement), canvas.Children.Count);
canvas.Children.CopyTo(canvasElements, 0);
canvas.Children.Clear();

foreach (UIElement element in canvasElements)
{
ContentControl contentControl = new ContentControl();
contentControl.Content = element;

RotateTransform rotateTransform = new RotateTransform(angle, centerX, centerY);
contentControl.RenderTransform = rotateTransform;
angle += interval;

canvas.Children.Add(contentControl);
}

Listing 5. Workaround on Setting Transform in XAML


To start the animation, we have to create a DispatcherTimer object and begin the animation for the current element every time the Tick event is raised. The interval between these ticks can be specified. The following code snippet shows the event handler for the Tick event.


private void DispatcherTimer_Tick(object sender, EventArgs e)
{
if (canvasElements != null && ElementStoryboard != null)
{
int trueIndex = clockwise ? index : canvasElements.Length - index - 1;

FrameworkElement element = canvasElements.GetValue(trueIndex) as FrameworkElement;
StartStoryboard(element);

clockwise = index == canvasElements.Length - 1 ? !clockwise : clockwise;
index = (index + 1) % canvasElements.Length;
}
}

Listing 6. Tick Event Handler

What the event handler does is begin the storyboard and determine the element wherein the animations will be applied. The storyboard for each element is started one at a time in a clockwise or counterclockwise direction. When the animation for the last element in the array is executed, the direction is reversed. The storyboard is started using the StartStoryboard method, which is shown below.


private void StartStoryboard(FrameworkElement element)
{
NameScope.SetNameScope(this, new NameScope());
element.Name = "Element";

NameScope.SetNameScope(element, NameScope.GetNameScope(this));
NameScope.GetNameScope(this).RegisterName(element.Name, element);

Storyboard storyboard = new Storyboard();
NameScope.SetNameScope(storyboard, NameScope.GetNameScope(this));

foreach (Timeline timeline in ElementStoryboard.Children)
{
Timeline timelineClone = timeline.Clone();
storyboard.Children.Add(timelineClone);
Storyboard.SetTargetName(timelineClone, element.Name);
}

storyboard.Begin(element);
}

Listing 7. StartStoryboard Method

The Begin method of the Storyboard object can’t be invoked right away. That is because the target object of the storyboard is not yet set. But first, we have to ensure that the name scope of both the element and storyboard are the same. Otherwise, the storyboard won’t be able to find the element and will throw an exception. Also, we might not be able to change the target name of the ElementStoryBoard animations because the ElementStoryBoard is read-only. That is why another Storyboard object is created. Next, I want to create another dependency property called IsRunning so that the animation will start when the value is set to true, and will stop when the value is set to false.


public static readonly DependencyProperty IsRunningProperty = DependencyProperty.Register(
"IsRunning", typeof(bool), typeof(ProgressIndicator), new FrameworkPropertyMetadata(IsRunningPropertyChanged));

public bool IsRunning
{
get { return (bool)GetValue(IsRunningProperty); }
set { SetValue(IsRunningProperty, value); }
}

private static void IsRunningPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
ProgressIndicator progressIndicator = (ProgressIndicator)d;

if ((bool)e.NewValue)
{
progressIndicator.Start();
}
else
{
progressIndicator.Stop();
}
}

private void Start()
{
dispatcherTimer.Tick += DispatcherTimer_Tick;
dispatcherTimer.Start();
}

private void Stop()
{
dispatcherTimer.Stop();
dispatcherTimer.Tick -= DispatcherTimer_Tick;
}

Listing 8. Starting and Stopping the Storyboard

Lastly, the control is not hidden when it is not running. We can bind the Visibility property of the control template’s main grid to the IsRunning property. Creating a value converter that converts a Boolean value to a Visibility enumeration value will do the trick.


[ValueConversion(typeof(bool), typeof(Visibility))]
public class BoolToVisibilityConverter : IValueConverter
{
#region IValueConverter Members

public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return ((bool)value) ? Visibility.Visible : Visibility.Hidden;
}

public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}

#endregion
}

Listing 9. Boolean to Visibility Value Converter

The following code shows how to use the control in a window. In this example, the IsRunning and IsIndeterminate properties are set to true.

<Window
x:Class="MainApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:CustomControls;assembly=CustomControls"
Title="MainWindow" Height="300" Width="300">
<Grid>
<controls:ProgressIndicator
IsRunning="True"
IsIndeterminate="True"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Grid>
</Window>

Listing 10. Using the ProgressIndicator Control

The following figure shows the ProgressIndicator control when the application is run.



Figure 2. Indeterminate Progress Indicator

Let’s try when the IsIndeterminate property is set to false. The following code snippet shows how to start the progress indicator, set the percentage in a long-running operation, and stop the control when the operation is finished.


public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();

Loaded += new RoutedEventHandler(MainWindow_Loaded);
}

private void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
progressIndicator.IsRunning = true;
ThreadPool.QueueUserWorkItem(new WaitCallback(
(object o) =>
{
for (int i = 0; i <= 100; i++)
{
progressIndicator.Dispatcher.Invoke(new Action(
() =>
{
progressIndicator.Value = i;
}
), null);

Thread.Sleep(100);
}

progressIndicator.Dispatcher.Invoke(new Action(
() =>
{
progressIndicator.IsRunning = false;
}
), null);
}
));
}
}

Listing 11. Determinate Progress Indicator

When the window loads, the progress indicator is shown by setting the IsRunning property to true. The Value property is updated every 100 milliseconds until it reaches 100. Afterwards, the IsRunning property is set to false and the control is hidden. You will notice that I used the control’s dispatcher to update the properties since we are working on another thread.



Figure 3. Progress Indicator with Percentage

That sums it up. The good thing about this control is that it does not restrict the animation to scaling. Also, the number of elements is not fixed. I guess the only thing that is fixed here is that how the animations are performed in clockwise and counterclockwise directions. If you are interested in the full code, you can download the Visual Studio 2008 solution here.

By Michael Detras   Popularity  (19782 Views)
Biography - Michael Detras
.NET developer. Interested in WPF, Silverlight, and XNA.
My blog