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.