WPF Donut ProgressBar

I am trying to adapt the ProgressBar pie found in WPF 4 Unleashed to look like a donut. I feel like I'm halfway there, but I don't know how to solve the last problem.

Here is an image illustrating what I want and what I managed to achieve:

enter image description here

  • This is how I want it to look.
  • Here's what it looks like using the following code.
  • I found a suggestion in another question here in stackoverflow that was supposed to use crop on the path and double the stroke thickness. Since you can see that the path is positioned correctly, but any progress below 50% is not displayed correctly, as you can see.

So my question is: how can I fix it as if I want to?

Below is the corresponding xaml I'm 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 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(); } } 
+5
source share
3 answers

Your code is very close. The problem is not clipping. You simply did not take into account that when stroking the tracks, a stroke is drawn in the center. This means that geometrically, the move itself should be in the middle of where you want to draw it.

In your specific implementation, this means that you need to consider the thickness of the stroke in three different places:

  • The starting point of the arc. The starting point must be offset vertically to take into account the thickness of the stroke.
  • The size of the arc. The size of the arc must be reduced so that the path remains centered in time with the larger circle.
  • The end point of the arc. As in the case of the starting point, this needs to be adjusted, but in this case, you need to change the radius of the arc in your calculation.

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 so it looks 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 converters:

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

And then you get what you want.

There is probably a way to Ellipse background element and the Path element, so that the Path is drawn without the above, that is, with hard-coded sizes of 10, and then has the Grid evenly change both child elements, forcing them to line up correctly. But I did not see any obvious solutions in this direction and did not want to waste time on this. The above should work just fine for your purposes. :)

+1
source

The real problem here is that there is no Arc control from WPF. Instead of trying to curb the existing structure according to your requirements, why not just add it yourself? There are many WPF Arc implementations floating around the net, and they all look very similar, just make sure you select the one that updates the visual when angular DPs change. This should serve your purpose:

 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 preserving a clean architecture, I prefer to put custom shapes in a separate class library with a link to PresentationFramework, doing this also allows you to remove the namespace by placing the following line in the project, as described on this page :

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

You now have a reusable arc shape that you can use just like an ellipse, so replace your entire XAML path 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 hardcode the start and end angles here, but based on what you have already done, I’m sure that it’s not difficult for you to write a simple multi-converter for calculating angles with values ​​/ min / max.

+1
source

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

Github pull request status

I chose an alternative approach and left a sample code here . The idea was to create code that could calculate the color and apply a mask to get the shape.

I started with code that can create a polygon from the desired circle radius and percent completion.

 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; } 

This code allowed me to create the following.

enter image description here

Putting this together with the appropriate clipping shape:

 <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 adding polygons, I was able to achieve this.

enter image description here

+1
source

Source: https://habr.com/ru/post/1247542/


All Articles