5

I am trying to adapt the pie ProgressBar found in the book WPF 4 Unleashed to look like a doughnut. I feel I am half way there but I don't know how to solve the last problem.

Here is a picture illustrating what I want and what I have managed to achieve:

enter image description here

  1. This is how I want it to look.
  2. This is what it looks like using the code below.
  3. I found a suggestion in a different question here at stackoverflow which was to use clipping on the path and double the stroke thickness. As you can see the path is positioned correctly now but any progress below 50% is not drawn correctly as you can see.

So my question is, how can I fix it to look like I want?

Below is the relevant xaml I am using:

<ControlTemplate x:Key="DonutProgressBar" TargetType="{x:Type ProgressBar}">
    <ControlTemplate.Resources>
        <conv:ValueMinMaxToIsLargeArcConverter x:Key="ValueMinMaxToIsLargeArcConverter" />
        <conv:ValueMinMaxToPointConverter x:Key="ValueMinMaxToPointConverter" />
    </ControlTemplate.Resources>
    <Grid>
        <Viewbox>
            <Grid Width="20" Height="20">
                <Ellipse x:Name="Background"
                         Stroke="{TemplateBinding BorderBrush}"
                         StrokeThickness="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=BorderThickness.Top}"
                         Width="20"
                         Height="20"
                         Fill="{TemplateBinding Background}" />
                <Path x:Name="Donut" 
                      Stroke="{TemplateBinding Foreground}"
                      StrokeThickness="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=BorderThickness.Top}">
                    <Path.Data>
                        <PathGeometry>
                            <PathGeometry.Figures>
                                <PathFigure StartPoint="10,0">
                                    <ArcSegment Size="10,10" SweepDirection="Clockwise">
                                        <ArcSegment.Point>
                                            <MultiBinding Converter="{StaticResource ValueMinMaxToPointConverter}">
                                                <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Value" />
                                                <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Minimum" />
                                                <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Maximum" />
                                            </MultiBinding>
                                        </ArcSegment.Point>
                                        <ArcSegment.IsLargeArc>
                                            <MultiBinding Converter="{StaticResource ValueMinMaxToIsLargeArcConverter}">
                                                <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Value" />
                                                <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Minimum" />
                                                <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Maximum" />
                                            </MultiBinding>
                                        </ArcSegment.IsLargeArc>
                                    </ArcSegment>
                                </PathFigure>
                            </PathGeometry.Figures>
                        </PathGeometry>
                    </Path.Data>
                </Path>
            </Grid>
        </Viewbox>
    </Grid>
</ControlTemplate>

...
<ProgressBar Width="70" Height="70" Value="40" Template="{StaticResource DonutProgressBar}" Background="{x:Null}" BorderBrush="#1F000000"  BorderThickness="6,6,1,1" />

...and the converters:

public class ValueMinMaxToPointConverter : IMultiValueConverter
{
    public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
    {
        double value = (double)values[0];
        double minimum = (double)values[1];
        double maximum = (double)values[2];

        double current = (value / (maximum - minimum)) * 360;

        if (current == 360)
            current = 359.999;

        current = current - 90;

        current = current * (Math.PI / 180.0);

        double x = 10 + 10 * Math.Cos(current);
        double y = 10 + 10 * Math.Sin(current);

        return new Point(x, y);
    }

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

public class ValueMinMaxToIsLargeArcConverter : IMultiValueConverter
{
    public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
    {
        double value = (double)values[0];
        double minimum = (double)values[1];
        double maximum = (double)values[2];

        return ((value * 2) >= (maximum - minimum));
    }

    public object[] ConvertBack(object value, Type[] targetType, object parameter, CultureInfo culture)
    {
        throw new NotSupportedException();
    }
}
dbostream
  • 781
  • 2
  • 11
  • 27

3 Answers3

4

The real problem here is the missing Arc control from WPF. Instead of trying to shoehorn the existing framework to fit your requirements why not just add it yourself? There are numerous WPF Arc implementations floating round the net and they all look very similar, just make sure you choose one that updates the visual whenever the angle DPs are changed. This should serve your purpose fine:

public class Arc : Shape
{
    public double StartAngle
    {
        get { return (double)GetValue(StartAngleProperty); }
        set { SetValue(StartAngleProperty, value); }
    }

    // Using a DependencyProperty as the backing store for StartAngle.  This enables animation, styling, binding, etc...
    public static readonly DependencyProperty StartAngleProperty =
        DependencyProperty.Register("StartAngle", typeof(double), typeof(Arc), new PropertyMetadata(0.0, AnglesChanged));

    public double EndAngle
    {
        get { return (double)GetValue(EndAngleProperty); }
        set { SetValue(EndAngleProperty, value); }
    }

    // Using a DependencyProperty as the backing store for EndAngle.  This enables animation, styling, binding, etc...
    public static readonly DependencyProperty EndAngleProperty =
        DependencyProperty.Register("EndAngle", typeof(double), typeof(Arc), new PropertyMetadata(0.0, AnglesChanged));


    protected override Geometry DefiningGeometry
    {
        get
        {
            return GetArcGeometry();
        }
    }

    private static void AnglesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var arc = d as Arc;
        if (arc != null)
            arc.InvalidateVisual();
    }

    private Geometry GetArcGeometry()
    {
        Point startPoint = PointAtAngle(Math.Min(StartAngle, EndAngle));
        Point endPoint = PointAtAngle(Math.Max(StartAngle, EndAngle));
        Size arcSize = new Size(Math.Max(0, (RenderSize.Width - StrokeThickness) / 2),
        Math.Max(0, (RenderSize.Height - StrokeThickness) / 2));
        bool isLargeArc = Math.Abs(EndAngle - StartAngle) > 180;
        StreamGeometry geom = new StreamGeometry();
        using (StreamGeometryContext context = geom.Open())
        {
            context.BeginFigure(startPoint, false, false);
            context.ArcTo(endPoint, arcSize, 0, isLargeArc,
            SweepDirection.Counterclockwise, true, false);
        }
        geom.Transform = new TranslateTransform(StrokeThickness / 2, StrokeThickness / 2);
        return geom;
    }

    private Point PointAtAngle(double angle)
    {
        double radAngle = angle * (Math.PI / 180);
        double xRadius = (RenderSize.Width - StrokeThickness) / 2;
        double yRadius = (RenderSize.Height - StrokeThickness) / 2;
        double x = xRadius + xRadius * Math.Cos(radAngle);
        double y = yRadius - yRadius * Math.Sin(radAngle);
        return new Point(x, y);
    }
}

In the interest of maintaining a clean architecture I prefer to put custom shapes in a separate class library with a reference to PresentationFramework, doing so also allows you to remove the namespace by placing the following line in your project as described on this page:

[assembly: XmlnsDefinition("http://schemas.microsoft.com/winfx/2006/xaml/presentation", "YourNamespace")]

You now have a re-usable Arc shape that you can use just like an ellipse, so replace your entire path XAML with something like this:

<Arc
    Stroke="{TemplateBinding Foreground}"
    StrokeThickness="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=BorderThickness.Top}"
    StartAngle="90" EndAngle="-45" />

Result:

enter image description here

Obviously I'm hard-coding the start and end angles here, but based on what you've already done I'm sure you'll have no trouble writing a simple multi-converter to calculate the angles from value/min/max.

Mark Feldman
  • 15,731
  • 3
  • 31
  • 58
  • Thank you for your solution, I will be using Peter´s suggestion for now but I will consider changing to yours when I start working on my real project. – dbostream Apr 21 '16 at 16:25
  • @MarkFeldman I'm trying to create a doughnut chart in WPF and this is useful. Currently the StartAngle starts from the 3 o'clock position. How can I change this code so that the StartAngle starts from the 12 o'clock position and the angle measures clockwise e.g. 1] StartAngle = 0, EndAngle = 90 => covers top right corner. 2] StartAngle = 0, EndAngle = 180 => covers right half. 3] StartAngle = 0, EndAngle = 270 => covers three quarters of the chart. I've tried changing the `SweepDirection.Counterclockwise` to `SweepDirection.Clockwise` but that doesn't work. – Bhav Aug 28 '19 at 14:57
  • 1
    @Bhav just add [a transform group](https://learn.microsoft.com/en-us/dotnet/framework/wpf/graphics-multimedia/how-to-apply-multiple-transforms-to-an-object), so that in addition to the TranslateTransform I'm already applying you also apply a rotation (to set the start point at 12 o'clock) and a flip (i.e. a [-1, 1] scale transform to make the rotation go counter-clockwise). – Mark Feldman Aug 29 '19 at 11:01
  • @MarkFeldman I'm still having issues. I would much appreciate it if you could please look at my code - https://stackoverflow.com/questions/57784182/wpf-doughnut-chart – Bhav Sep 04 '19 at 08:15
  • `Microsoft.Expression.Drawing` has built-in Arc support – Pavel Anikhouski Sep 04 '19 at 08:28
1

Your code is very close. The problem is not one of clipping. You have simply failed to take into account that when paths are stroked, the stroke is drawn centered on the path. This means that geometrically, the stroke itself must be in the middle of where you want it drawn.

In your particular implementation, this means you need to account for the stroke thickness in three different places:

  1. The start point of the arc. The start point needs to be offset vertically to account for the stroke thickness.
  2. The size of the arc. The size of the arc needs to be reduced, so that the path remains centered in the stroke of the larger circle.
  3. The end point of the arc. As with the start point, this needs to be adjusted, but in this case it is the radius of the arc in your calculation that needs adjusting.

For example, you can add a couple of converters:

class ThicknessToStartPointConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        if (!(value is double))
        {
            return Binding.DoNothing;
        }

        // Need to start the arc in the middle of the intended stroke
        return new Point(10, ((double)value) / 2);
    }

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

class ThicknessToSizeConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        if (!(value is double))
        {
            return Binding.DoNothing;
        }

        double widthHeight = 10 - ((double)value) / 2;

        return new Size(widthHeight, widthHeight);
    }

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

And then update your XAML to look like this:

<PathFigure StartPoint="{Binding StrokeThickness, ElementName=Donut, Converter={StaticResource thicknessToStartPointConverter}}">
  <ArcSegment Size="{Binding StrokeThickness, ElementName=Donut, Converter={StaticResource thicknessToSizeConverter}}" SweepDirection="Clockwise">
    <ArcSegment.Point>
      <MultiBinding Converter="{StaticResource ValueMinMaxToPointConverter}">
        <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Value" />
        <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Minimum" />
        <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Maximum" />
        <Binding Path="StrokeThickness" ElementName="Donut"/>
      </MultiBinding>
    </ArcSegment.Point>
    <ArcSegment.IsLargeArc>
      <MultiBinding Converter="{StaticResource ValueMinMaxToIsLargeArcConverter}">
        <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Value" />
        <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Minimum" />
        <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Maximum" />
      </MultiBinding>
    </ArcSegment.IsLargeArc>
  </ArcSegment>
</PathFigure>

With, of course, the necessary resources for the converters:

<l:ThicknessToStartPointConverter x:Key="thicknessToStartPointConverter"/>
<l:ThicknessToSizeConverter x:Key="thicknessToSizeConverter"/>

And then you will get what you want.

There's probably a way to compose the background Ellipse element and Path element together, such that the Path is drawn without the above, i.e. with the hard-coded sizes of 10, and then have the Grid resize both child elements equally, causing them to line up correctly. But I didn't see any obvious solutions along those lines and didn't feel like spending the time to figure it out. The above should work fine for your purposes. :)

Peter Duniho
  • 68,759
  • 7
  • 102
  • 136
1

I needed to recreate the GitHub Pull Request status circle in WPF.

GitHub Pull Request Status

I took an alternate approach and left some sample code here. The idea was to create code that could compute the color and apply a mask to obtain the shape.

I started out with the code that could create a polygon from the desired radius of the circle and completion percentage.

public static IEnumerable<Point> GeneratePoints(double size, float percentage)
{
    if (percentage < 0 || percentage > 1)
    {
        throw new ArgumentException();
    }

    var halfSize = size / 2;
    var origin = new Point(halfSize, halfSize);
    var topMiddle = new Point(halfSize, 0);
    var topRight = new Point(size, 0);
    var bottomRight = new Point(size, size);
    var bottomLeft = new Point(0, size);
    var topLeft = new Point(0, 0);

    if (percentage == 1)
    {
        return new[] { topLeft, topRight, bottomRight, bottomLeft };
    }

    var degrees = percentage * 360;
    var adjustedDegrees = (degrees + 90) % 360;

    if (adjustedDegrees >= 90 && adjustedDegrees < 135)
    {
        var angleDegrees = adjustedDegrees - 90;
        var angleRadians = ToRadians(angleDegrees);
        var tan = Math.Tan(angleRadians);
        var oppositeEdge = tan * halfSize;
        return new[] { origin, topMiddle, new Point(halfSize + oppositeEdge, 0) };
    }

    if (adjustedDegrees >= 135 && adjustedDegrees < 180)
    {
        var angleDegrees = adjustedDegrees - 135;
        var angleRadians = ToRadians(angleDegrees);
        var tan = Math.Tan(angleRadians);
        var oppositeEdge = tan * halfSize;
        return new[] { origin, topMiddle, topRight, new Point(size, oppositeEdge) };
    }

    if (adjustedDegrees >= 180 && adjustedDegrees < 225)
    {
        var angleDegrees = adjustedDegrees - 180;
        var angleRadians = ToRadians(angleDegrees);
        var tan = Math.Tan(angleRadians);
        var oppositeEdge = tan * halfSize;
        return new[] { origin, topMiddle, topRight, new Point(size, halfSize + oppositeEdge) };
    }

    if (adjustedDegrees >= 225 && adjustedDegrees < 270)
    {
        var angleDegrees = adjustedDegrees - 225;
        var angleRadians = ToRadians(angleDegrees);
        var tan = Math.Tan(angleRadians);
        var oppositeEdge = tan * halfSize;
        return new[] { origin, topMiddle, topRight, bottomRight, new Point(size - oppositeEdge, size) };
    }

    if (adjustedDegrees >= 270 && adjustedDegrees < 315)
    {
        var angleDegrees = adjustedDegrees - 270;
        var angleRadians = ToRadians(angleDegrees);
        var tan = Math.Tan(angleRadians);
        var oppositeEdge = tan * halfSize;
        return new[] { origin, topMiddle, topRight, bottomRight, new Point(halfSize - oppositeEdge, size) };
    }

    if (adjustedDegrees >= 315 && adjustedDegrees < 360)
    {
        var angleDegrees = adjustedDegrees - 315;
        var angleRadians = ToRadians(angleDegrees);
        var tan = Math.Tan(angleRadians);
        var oppositeEdge = tan * halfSize;
        return new[] { origin, topMiddle, topRight, bottomRight, bottomLeft, new Point(0, size - oppositeEdge) };
    }

    if (adjustedDegrees >= 0 && adjustedDegrees < 45)
    {
        var angleDegrees = adjustedDegrees;
        var angleRadians = ToRadians(angleDegrees);
        var tan = Math.Tan(angleRadians);
        var oppositeEdge = tan * halfSize;
        return new[] { origin, topMiddle, topRight, bottomRight, bottomLeft, new Point(0, halfSize - oppositeEdge) };
    }

    if (adjustedDegrees >= 45 && adjustedDegrees < 90)
    {
        var angleDegrees = adjustedDegrees - 45;
        var angleRadians = ToRadians(angleDegrees);
        var tan = Math.Tan(angleRadians);
        var oppositeEdge = tan * halfSize;
        return new[] { origin, topMiddle, topRight, bottomRight, bottomLeft, topLeft, new Point(oppositeEdge, 0) };
    }

    return new Point[0];
}

public static double ToRadians(float val)
{
    return (Math.PI / 180) * val;
}

That code allowed me to create the following.

enter image description here

Putting that together with the appropriate shape for clipping:

<Polygon.Clip>
    <CombinedGeometry GeometryCombineMode="Exclude">
        <CombinedGeometry.Geometry1>
            <EllipseGeometry Center="125 125" RadiusX="125" RadiusY="125" />
        </CombinedGeometry.Geometry1>
        <CombinedGeometry.Geometry2>
            <EllipseGeometry Center="125 125" RadiusX="100" RadiusY="100" />
        </CombinedGeometry.Geometry2>
    </CombinedGeometry>
</Polygon.Clip>

enter image description here

By adding some percentages and overlaying the polygons, I'm able to achieve this.

enter image description here

Stanley.Goldman
  • 4,317
  • 2
  • 23
  • 25