Menü Schließen

Creating a WPF Spinner Control

In this post I will show how to create a spinning progress control in WPF. This is often used to notify the user that a long-running operation is still in progress, without without knowing the current or remaining progress.

The result looks like this and will turn infinitely:

First of all we create a new user control and add the following XAML markup:

<UserControl x:Class="ES.WPF.Controls.Spinner"
             d:DesignHeight="100" d:DesignWidth="100">
    <Canvas x:Name="canvas">
        <RotateTransform x:Name="Transform" Angle="0" CenterX="50" CenterY="50"/> 
        <EventTrigger RoutedEvent="Loaded">
              <DoubleAnimation Storyboard.TargetProperty="(Canvas.RenderTransform).(RotateTransform.Angle)" To="360" Duration="0:0:3" RepeatBehavior="Forever" />

The control contains a Canvas with custom render transform. This is used to define the current angle of the canvas, which is rotated as a whole. A DoubleAnimation is used to rotate the Canvas infinitely. The rest of the behavior will be defined in code behind.

Here we start with the basic framework of the code behind class and some basic methods:

public partial class Spinner : UserControl
  public Spinner()
    SizeChanged += Spinner_SizeChanged;

  #region Event Handlers

  private void Spinner_SizeChanged(object sender, SizeChangedEventArgs e)
    Transform.CenterX = ActualWidth / 2;
    Transform.CenterY = ActualHeight / 2;


When the size of the control is changed, we have to reset the center of the animation. The Refresh() method contains the code to create the visual representation. I’ll explain that later. But first I’ll show the different dependency properties that are used to control the visual representation in XAML:

#region Dependency Properties

#region BallBrush

public static DependencyProperty BallBrushProperty = DependencyProperty.Register(nameof(BallBrush), typeof(Brush), typeof(Spinner), new UIPropertyMetadata(Brushes.Blue, PropertyChangedCallback));

public Brush BallBrush
  get { return (Brush)GetValue(BallBrushProperty); }
  set { SetValue(BallBrushProperty, value); }


#region Balls

public static DependencyProperty BallsProperty = DependencyProperty.Register(nameof(Balls), typeof(int), typeof(Spinner), new UIPropertyMetadata(8, PropertyChangedCallback, CoerceBallsValue));

public int Balls
  get { return (int)GetValue(BallsProperty); }
  set { SetValue(BallsProperty, value); }

private static object CoerceBallsValue(DependencyObject d, object baseValue)
  var spinner = (Spinner)d;
  int value = Convert.ToInt32(baseValue);

  value = Math.Max(1, value);
  value = Math.Min(100, value);
  return value;


#region BallSize

public static DependencyProperty BallSizeProperty = DependencyProperty.Register(nameof(BallSize), typeof(double), typeof(Spinner), new UIPropertyMetadata(20d, PropertyChangedCallback, CoerceBallSizeValue));

public double BallSize
  get { return (double)GetValue(BallSizeProperty); }
  set { SetValue(BallSizeProperty, value); }

private static object CoerceBallSizeValue(DependencyObject d, object baseValue)
  var spinner = (Spinner)d;
  double value = Convert.ToDouble(baseValue);

  value = Math.Max(1, value);
  value = Math.Min(100, value);
  return value;


private static void PropertyChangedCallback(DependencyObject d, DependencyPropertyChangedEventArgs e)
  var spinner = (Spinner)d;


The BallBrush property is the color of the first ball. The other balls are rendered with the same color, but have a equally reduced opacity.

The Balls property defines the number of spinning balls (default is 8).

The BallSize property is the size of the balls (default 20).

Finally here’s the Refresh method:

private void Refresh()
  int n = Balls;
  double size = BallSize;

  double x = ActualWidth / 2;
  double y = ActualHeight / 2;
  double r = Math.Min(x, y) - size / 2;
  double doubleN = Convert.ToDouble(n);

  for (int i = 1; i <= n; i++)
    double doubleI = Convert.ToDouble(i);
    double x1 = x + Math.Cos(doubleI / doubleN * 2d * Math.PI) * r - size / 2;
    double y1 = y + Math.Sin(doubleI / doubleN * 2d * Math.PI) * r - size / 2;

    var e = new Ellipse 
      Fill = BallBrush, 
      Opacity = doubleI / doubleN, 
      Height = size, 
      Width = size 
    Canvas.SetLeft(e, x1);
    Canvas.SetTop(e, y1);

First I store the number of balls and the size in a local variable.

The x and y values are the center of the animation. The balls will be placed around this center with a distance of r (radius).

The actual x and y coordinates of each ball will be determined using the following formulas:

x1 = x + Cos(angle) * r;
y1 = y + Sin(angle) * r;

In some places we have to correct the coordinates by considering the size of the balls as we actually need the coordinates of the upper right corner of each ball.

Finally we add the balls as Ellipses to the canvas. The opacity is calculated using the for loop counter variable. As this is initialized with 1 we’ll never have a completely transparent ball.


<es:Spinner Balls="10" BallSize="10" Width="200" Height="100"/>


Ähnliche Beiträge

2 Kommentare

  1. Kevin


    thank you for that tutorial.
    But could you explain where and how to implement the code

    x1 = x + Cos(angle) * r;
    y1 = y + Sin(angle) * r;

    I’m not sure how this work, because using that in the for-loop of the „Refresh()“ method I get all ball spinning around the center, but laying on top of each other.

    Thank you.

    • pschimmel

      The formulas are based on the following circle equation:

      The formulas used in the for loop in the Refresh() method are
      double x1 = x + Math.Cos(doubleI / doubleN * 2d * Math.PI) * r – size / 2;
      double y1 = y + Math.Sin(doubleI / doubleN * 2d * Math.PI) * r – size / 2;
      where doubleI is the index of the current ball and doubleN is the number of total balls.
      doubleI / doubleN * 2d * Math.PI is the fraction of a whole circle that should define the position of the individual ball.
      What you described sounds like there is something wrong in this section.
      I added a working example to the post.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert