Circular Progressbar in WPF

One of the things I have been working recently required me to use a Circular Progress bar. Incidently, I was surprised there wasn’t something useful in the WPF package, but it wasn’t that hard to do at the hindsight.

The core idea would be to draw two overlapping circles – one for the background circle and other indicating the progress. We will begin by defining certain characterstics of the Cricle (or better called Arc), which we will find useful later on. So, let us first define our model.

public class ProgressArc : PropertyChangedBase
{
    public Point StartPosition { get; set; } = new Point(50, 0);
    public Point EndPosition { get; set; } = new Point(100, 0);
    public Size Radius { get; set; }
    public double Thickness { get; set; } = 2;
    public double Angle { get; set; }
}

Each of the Circle/Arcs are characterized by 5 properties as defined in the Model above.

  • Start Position
  • End Position
  • Radius Of the Arc
  • Thickness of the Stroke
  • Angle

While most of the characterstics are self explanatory, the Angle might need a bit of exaplantion. Let us take a step back and think about what we are attempting to accomplish here. We would like to represent a Value within a Range (Maximum and Minimum Values) just as in regular ProgressBar, but this time as a Circle/Arc. When the Value is equavalent to the Maximum, we would like to see a full circle. But anything short of maximum value (or in other words less than 100%), we would need to draw an arc that ends at an angle, which would represent the percentage of completion. The angle could be calculated pretty easily with the following.

var percent = (currentValue / (maxValue - minValue) * 100);
var valueInAngle = (percent / 100) * 360;

Alright – so with that explanation under our belt, let us proceed to construct our view.

<Viewbox Stretch="Uniform" Margin="10">
    <Grid>
        <Canvas Height="100" Width="100" >
            <Path Stroke="LightGray" StrokeThickness="{Binding BackgroundCircle.Thickness}" >
                <Path.Data>
                    <PathGeometry>
                        <PathFigure  StartPoint="{Binding BackgroundCircle.StartPosition}">
                            <PathFigure.Segments>
                                <ArcSegment RotationAngle="0" SweepDirection="Clockwise"
                                            Size="{Binding BackgroundCircle.Radius}"
                                            IsLargeArc="True"
                                        Point="{Binding BackgroundCircle.EndPosition}"
                                            >

                                </ArcSegment>

                            </PathFigure.Segments>
                        </PathFigure>
                    </PathGeometry>
                </Path.Data>
            </Path>

            <Path Stroke="Blue" StrokeThickness="{Binding ValueCircle.Thickness}" StrokeEndLineCap="Round">
                <Path.Data>
                    <PathGeometry>
                        <PathFigure  StartPoint="{Binding ValueCircle.StartPosition}">
                            <PathFigure.Segments>
                                <ArcSegment RotationAngle="0" SweepDirection="Clockwise"
                                            Size="{Binding ValueCircle.Radius}"
                                            IsLargeArc="{Binding ValueCircle.Angle,Converter={StaticResource AngleToIsLargeConverter}}"
                                        Point="{Binding ValueCircle.EndPosition}"
                                            >

                                </ArcSegment>

                            </PathFigure.Segments>
                        </PathFigure>
                    </PathGeometry>
                </Path.Data>
            </Path>

        </Canvas>
        <TextBlock Text="{Binding CurrentValue}" FontSize="32" VerticalAlignment="Center" HorizontalAlignment="Center" />
    </Grid>
</Viewbox>

Of course, that would look pretty incomplete without our ViewModel. Let us define our ViewModel too before we discuss the finer points.

 public class ShellViewModel : Screen
{
    public ShellViewModel()
    {
        BackgroundCircle.Angle = 360;
        BackgroundCircle.PropertyChanged += OnCircleChanged;
        ValueCircle.PropertyChanged += OnCircleChanged;
        PropertyChanged += OnPropertyChanged; ;
    }

    private void OnPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
    {
        switch (e.PropertyName)
        {
            case nameof(MaxValue):
            case nameof(MinValue):
            case nameof(CurrentValue):
            case nameof(SelectedOverlayMode):
                RefreshControl();
                break;
        }
    }

    private void OnCircleChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
    {
        RefreshControl();
    }

    protected override void OnViewAttached(object view, object context)
    {
        base.OnViewAttached(view, context);
        RefreshControl();
    }
    private void RefreshControl()
    {
        ArcCalculatorBase arcCalculator;
        switch (SelectedOverlayMode)
        {
            case OverlayMode.Centered:
                arcCalculator = new CenteredArcCalculator(BackgroundCircle.Thickness, ValueCircle.Thickness); 
                break;
            case OverlayMode.InnerCircle:
                arcCalculator = new InsetArcCalculator(BackgroundCircle.Thickness, ValueCircle.Thickness);
                break;
            case OverlayMode.OuterCircle:
                arcCalculator = new OutsetArcCalculator(BackgroundCircle.Thickness, ValueCircle.Thickness);
                break;
            default:
                arcCalculator = new OutsetArcCalculator(BackgroundCircle.Thickness, ValueCircle.Thickness);
                break;
        }
        arcCalculator.Calculate(MinValue, MaxValue, CurrentValue);

        BackgroundCircle.Radius = arcCalculator.BackgroundCircleRadius;
        BackgroundCircle.StartPosition = arcCalculator.BackgroundCircleStartPosition;
        BackgroundCircle.EndPosition = arcCalculator.BackgroundCircleEndPosition;

        ValueCircle.Radius = arcCalculator.ValueCircleRadius;
        ValueCircle.StartPosition = arcCalculator.ValueCircleStartPosition;
        ValueCircle.EndPosition = arcCalculator.ValueCircleEndPosition;
        ValueCircle.Angle = arcCalculator.ValueAngle;
    }

    public ProgressArc BackgroundCircle { get; set; } = new ProgressArc();
    public ProgressArc ValueCircle { get; set; } = new ProgressArc();
    public double MinValue { get; set; } = 10;
    public double MaxValue { get; set; } = 120;
    public double CurrentValue { get; set; } = 60;

    public OverlayMode SelectedOverlayMode { get; set; }
}

The ViewModel defined is pretty straightforward. We have to instances (BackgroundCircle & ValueCircle) of our model (ProgressArc). These would represent our circles. We have few other properties, including the MinValueMaxValue and CurrentValue, which would be used to configure the ProgressBar.

Each time any of our configuration parameter changes( MinValueMaxValue or CurrentValue), we would need to recalculate the dimentions of the arcs. This is being done in the RefreshControl method. The SelectedOverlayMode property defines how the two circles needs to be aligned with respect to each other.

public enum OverlayMode
{
   InnerCircle,
   OuterCircle,
   Centered
}

Depending on the alignment of the two circles, we would need to alter our calculations. These are defined as the following.

 public abstract class ArcCalculatorBase
{
    protected const double ORIGIN = 50;
    protected double _backgroundCircleThickness;
    protected double _valueCircleThickness;
    public ArcCalculatorBase(double backgroundCircleThickness, double valueCircleThickness)
    {
        _backgroundCircleThickness = backgroundCircleThickness;
        _valueCircleThickness = valueCircleThickness;
    }

    public Size BackgroundCircleRadius { get; protected set; }
    public Size ValueCircleRadius { get; protected set; }

    public Point BackgroundCircleStartPosition { get; protected set; }
    public Point BackgroundCircleEndPosition { get; protected set; }

    public Point ValueCircleStartPosition { get; protected set; }
    public Point ValueCircleEndPosition { get; protected set; }

    public double ValueAngle { get; set; }

    protected double GetAngleForValue(double minValue, double maxValue, double currentValue)
    {
        var percent = (currentValue - minValue) * 100 / (maxValue - minValue);
        var valueInAngle = (percent / 100) * 360;
        return valueInAngle;
    }
    public abstract void Calculate(double minValue, double maxValue, double currentValue);
    protected Point GetPointForAngle(Size radiusInSize,double angle)
    {
        var radius = radiusInSize.Height;
        angle = angle == 360 ? 359.99 : angle;
        double angleInRadians = angle * Math.PI / 180;

        double px = ORIGIN + (Math.Sin(angleInRadians) * radius);
        double py = ORIGIN + (-Math.Cos(angleInRadians) * radius);

        return new Point(px, py);
    }
}

public class OutsetArcCalculator : ArcCalculatorBase
{
    public OutsetArcCalculator(double backgroundCircleThickness, double valueCircleThickness):base(backgroundCircleThickness,valueCircleThickness)
    {

    }

    public override void Calculate(double minValue,double maxValue,double currentValue)
    {
        BackgroundCircleRadius =  new Size(ORIGIN - _backgroundCircleThickness / 2, ORIGIN - _backgroundCircleThickness / 2);
        ValueCircleRadius = new Size(ORIGIN - _valueCircleThickness / 2, ORIGIN - _valueCircleThickness / 2); ;

        BackgroundCircleStartPosition = GetPointForAngle(BackgroundCircleRadius, 0);
        BackgroundCircleEndPosition = GetPointForAngle(BackgroundCircleRadius, 360);

        ValueAngle = GetAngleForValue(minValue, maxValue, currentValue);

        ValueCircleStartPosition = GetPointForAngle(ValueCircleRadius, 0);
        ValueCircleEndPosition = GetPointForAngle(ValueCircleRadius, ValueAngle);
    }
}

public class CenteredArcCalculator : ArcCalculatorBase
{
    public CenteredArcCalculator(double backgroundCircleThickness, double valueCircleThickness) : base(backgroundCircleThickness, valueCircleThickness)
    {

    }

    public override void Calculate(double minValue, double maxValue, double currentValue)
    {
        var maxThickness = Math.Max(_backgroundCircleThickness, _valueCircleThickness);
        
        BackgroundCircleRadius = new Size(ORIGIN - maxThickness / 2, ORIGIN - maxThickness / 2);
        ValueCircleRadius = new Size(ORIGIN - maxThickness / 2, ORIGIN - maxThickness / 2); ;

        BackgroundCircleStartPosition = GetPointForAngle(BackgroundCircleRadius, 0);
        BackgroundCircleEndPosition = GetPointForAngle(BackgroundCircleRadius, 360);

        ValueAngle = GetAngleForValue(minValue, maxValue, currentValue);

        ValueCircleStartPosition = GetPointForAngle(ValueCircleRadius, 0);
        ValueCircleEndPosition = GetPointForAngle(ValueCircleRadius, ValueAngle);
    }
}

public class InsetArcCalculator : ArcCalculatorBase
{
    public InsetArcCalculator(double backgroundCircleThickness, double valueCircleThickness) : base(backgroundCircleThickness, valueCircleThickness)
    {

    }

    public override void Calculate(double minValue, double maxValue, double currentValue)
    {
        var maxThickness = Math.Max(_backgroundCircleThickness, _valueCircleThickness);

        BackgroundCircleRadius = new Size((ORIGIN - maxThickness) + (_backgroundCircleThickness / 2), (ORIGIN - maxThickness) + (_backgroundCircleThickness / 2));
        ValueCircleRadius = new Size((ORIGIN - maxThickness) + (_valueCircleThickness / 2), (ORIGIN - maxThickness) + (_valueCircleThickness / 2));

        BackgroundCircleStartPosition = GetPointForAngle(BackgroundCircleRadius, 0);
        BackgroundCircleEndPosition = GetPointForAngle(BackgroundCircleRadius, 360);

        ValueAngle = GetAngleForValue(minValue, maxValue, currentValue);

        ValueCircleStartPosition = GetPointForAngle(ValueCircleRadius, 0);
        ValueCircleEndPosition = GetPointForAngle(ValueCircleRadius, ValueAngle);
    }
}

The GetPointForAngle() converts the given angle to a point on Canvas using the following formulae

Point = (InitialPosition + (Sin(AngleInRadians) * radius),InitialPosition + (-Cos(AngleInRadians) * radius))

The final outcode, based on different Overlay configuration are shown in figures below

Overlay with Equal Thickness
Center Aligned
Inner Circle Aligned
Outer Circle Aligned

The Complete code could found in here in my Github.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s