#1,206 – Changing Color of Custom Circular Progress

A couple of earlier posts (#1,155 and #1,156) demonstrated how to create a simple custom control that displays progress in a circular fashion. A reader of the blog asked how you would change the color of the progress indicator when it got to a certain value. The example below shows how to do that.

Below is the code for the indicator itself. This matches post #1,156 except that we’ve removed the static constructor that overrides the stroke and fill color of the indicator.

    public class CircularProgress : Shape
    {
        // Value (0-100)
        public double Value
        {
            get { return (double)GetValue(ValueProperty); }
            set { SetValue(ValueProperty, value); }
        }

        // DependencyProperty - Value (0 - 100)
        private static FrameworkPropertyMetadata valueMetadata =
                new FrameworkPropertyMetadata(
                    0.0,     // Default value
                    FrameworkPropertyMetadataOptions.AffectsRender,
                    null,    // Property changed callback
                    new CoerceValueCallback(CoerceValue));   // Coerce value callback

        public static readonly DependencyProperty ValueProperty =
            DependencyProperty.Register("Value", typeof(double), typeof(CircularProgress), valueMetadata);

        private static object CoerceValue(DependencyObject depObj, object baseVal)
        {
            double val = (double)baseVal;
            val = Math.Min(val, 99.999);
            val = Math.Max(val, 0.0);
            return val;
        }

        protected override Geometry DefiningGeometry
        {
            get
            {
                double startAngle = 90.0;
                double endAngle = 90.0 - ((Value / 100.0) * 360.0);

                double maxWidth = Math.Max(0.0, RenderSize.Width - StrokeThickness);
                double maxHeight = Math.Max(0.0, RenderSize.Height - StrokeThickness);

                double xStart = maxWidth / 2.0 * Math.Cos(startAngle * Math.PI / 180.0);
                double yStart = maxHeight / 2.0 * Math.Sin(startAngle * Math.PI / 180.0);

                double xEnd = maxWidth / 2.0 * Math.Cos(endAngle * Math.PI / 180.0);
                double yEnd = maxHeight / 2.0 * Math.Sin(endAngle * Math.PI / 180.0);

                StreamGeometry geom = new StreamGeometry();
                using (StreamGeometryContext ctx = geom.Open())
                {
                    ctx.BeginFigure(
                        new Point((RenderSize.Width / 2.0) + xStart,
                                  (RenderSize.Height / 2.0) - yStart),
                        true,   // Filled
                        true);  // Closed
                    ctx.ArcTo(
                        new Point((RenderSize.Width / 2.0) + xEnd,
                                  (RenderSize.Height / 2.0) - yEnd),
                        new Size(maxWidth / 2.0, maxHeight / 2),
                        0.0,     // rotationAngle
                        (startAngle - endAngle) > 180,   // greater than 180 deg?
                        SweepDirection.Clockwise,
                        true,    // isStroked
                        false);
                    ctx.LineTo(new Point((RenderSize.Width / 2.0), (RenderSize.Height / 2.0)), true, false);
                }

                return geom;
            }
        }
    }

Next is a snippet from the code-behind for a main window. This is just a click handler for a button that updates a property. (Note the use of SetProp method from post #1,205)

        private double _pctComplete = 0.0;
        public double PctComplete
        {
            get { return _pctComplete; }
            set { SetProp(ref _pctComplete, value); }
        }

        private void Button_Click(object sender, RoutedEventArgs e)
        {
            PctComplete = 0.0;

            DispatcherTimer timer = new DispatcherTimer();
            timer.Tick += (s, ea) =>
            {
                PctComplete += 1.0;
                if (PctComplete >= 100.0)
                    timer.Stop();
            };
            timer.Interval = new TimeSpan(0, 0, 0, 0, 150);  
            timer.Start();
        }

We then have a XAML fragment, where we define an instance of the progress indicator. We also set up a data trigger to set the Stroke and Fill properties to change color when they reach a certain level.

    <Window.Resources>
        <loc:GreaterThanConverter x:Key="greaterThanConverter"/>
        
        <Style x:Key="progChangeColor" TargetType="loc:CircularProgress">
            <Setter Property="Stroke" Value="Red"/>
            <Setter Property="Fill" Value="Red"/>
            <Style.Triggers>
                <DataTrigger Binding="{Binding PctComplete, Converter={StaticResource greaterThanConverter}, ConverterParameter=75}" Value="True">
                    <Setter Property="Stroke" Value="Green"/>
                    <Setter Property="Fill" Value="Green"/>
                </DataTrigger>
            </Style.Triggers>
        </Style>
    </Window.Resources>
    
    <StackPanel>
        <loc:CircularProgress
            Style="{StaticResource progChangeColor}"
             Height="100" Width="100" Margin="5"
             Value="{Binding PctComplete}"
             HorizontalAlignment="Center"/>
        <ProgressBar Maximum="100"
                 Value="{Binding PctComplete}"
                 Height="25" Margin="10"/>
        <Button Content="Start Timer" Click="Button_Click"
            HorizontalAlignment="Center"
            Padding="12,7"/>
    </StackPanel>

Note that we’re making use of a value converter that takes a value in (e.g. PctComplete) and outputs True when that value passes a certain point (e.g. 75). Here’s the code for that converter:

    public class GreaterThanConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            return ((double)value) > double.Parse(parameter as string);
        }

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

When everything is wired up and we run this, we see that the indicator starts out red.

And it then turns green when progress gets past 75%.

#1,161 – Using Custom Circular Progress Shape in Control Template

In the previous example, we created a custom circular progress shape and then composed it along with a TextBlock to get a labeled circular progress control.

The other approach that we could take is to just create a new control template containing our circular progress shape and a TextBlock and then use that new template as the control template for a standard ProgressBar.

Assuming that we start with the earlier code for CircularProgress, we compose the template and use it as follows:

	<Window.Resources>
        <loc:DoubleToPctConverter x:Key="dblToPct"/>
		<Style x:Key="pbarCircularStyle" TargetType="{x:Type ProgressBar}">
			<Setter Property="Foreground" Value="#01D328"/>
            <Setter Property="Maximum" Value="100"/>
            <Setter Property="Height" Value="100"/>
            <Setter Property="Width" Value="100"/>
            <Setter Property="Template">
				<Setter.Value>
					<ControlTemplate TargetType="{x:Type ProgressBar}">
						<Grid x:Name="TemplateRoot" SnapsToDevicePixels="true">
                            <TextBlock HorizontalAlignment="Center" VerticalAlignment="Center"
                                       FontSize="32" Foreground="DarkGray"
                                       Text="{TemplateBinding Value, Converter={StaticResource dblToPct}}" />
                            <loc:CircularProgress Stroke="{TemplateBinding Foreground}"
                                                  Value="{TemplateBinding Value}"/>
						</Grid>
					</ControlTemplate>
				</Setter.Value>
			</Setter>
		</Style>
	</Window.Resources>

    <StackPanel>
        <Grid>
			<ProgressBar Style="{DynamicResource pbarCircularStyle}"
						 Value="{Binding PctComplete}"
						 Margin="10"/>
        </Grid>
        <Button Content="Start Timer" Click="Button_Click"
                HorizontalAlignment="Center"
                Padding="12,7"/>
    </StackPanel>

1161-001

For completeness, here’s the source code for our value converter:

    public class DoubleToPctConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
        {
            string result = "";
            double d = (double)value;
            result = string.Format("{0}%", d);

            return result;
        }

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

#1,160 – Adding a Text Label to the Circular Progress Control

In an earlier post, we created a custom circular progress control, deriving from a Shape element.  Below, we add a text element in the middle of the progress arc to indicate % complete.  We could have also done this by building the text into the control itself.  But the approach shown below is an example of combining the progress control with a user-defined text label.

    <StackPanel>
        <Grid>
            <loc:CircularProgress
                x:Name="circProg"
                Value="{Binding PctComplete}"
                Height="100" Width="100" Margin="5"
                HorizontalAlignment="Center"/>
            <TextBlock
                HorizontalAlignment="Center"
                VerticalAlignment="Center"
                FontSize="36" Foreground="DarkGray"
                Text="{Binding ElementName=circProg, Path=Value}" />
        </Grid>
        <ProgressBar x:Name="prog2" Maximum="100"
                     Value="{Binding PctComplete}"
                     Height="25" Margin="10"/>
        <Button Content="Start Timer" Click="Button_Click"
                HorizontalAlignment="Center"
                Padding="12,7"/>
    </StackPanel>

1160-001

#1,157 – Overridden Default Property Values Appear in Property Pane

In an earlier example of a custom control, we used the OverrideMetadata method to provide new default values for several dependency properties, as used in our control.  For example, we set the Stroke property to a green brush and StrokeThickness to 10.0.

        static CircularProgress()
        {
            Brush myGreenBrush = new SolidColorBrush(Color.FromArgb(255, 6, 176, 37));
            myGreenBrush.Freeze();

            StrokeProperty.OverrideMetadata(
                typeof(CircularProgress),
                new FrameworkPropertyMetadata(myGreenBrush));
            FillProperty.OverrideMetadata(
                typeof(CircularProgress),
                new FrameworkPropertyMetadata(Brushes.Transparent));

            StrokeThicknessProperty.OverrideMetadata(
                typeof(CircularProgress),
                new FrameworkPropertyMetadata(10.0));
        }

These new values will show up as expected in the property pane within Visual Studio. Below, we can see both the green brush for Stroke and the value of 10.0 for StrokeThickness.

1157-001

#1,156 – Changing Circular Progress Control to Be Only an Arc

An earlier post presented a circular progress control that displayed a pie-shaped filled area indicating progress.

1155-001

We can change the control to draw just the outer part of the circle (an arc) as a progress indicator.  We make the following changes:

  • Do not draw line to origin at end of shape
  • Set isClosed parameter in BeginFigure to false
  • Default Fill property to be transparent
  • Default Stroke to have thickness of 10.0

Here is the updated code:

    public class CircularProgress : Shape
    {
        static CircularProgress()
        {
            Brush myGreenBrush = new SolidColorBrush(Color.FromArgb(255, 6, 176, 37));
            myGreenBrush.Freeze();

            StrokeProperty.OverrideMetadata(
                typeof(CircularProgress),
                new FrameworkPropertyMetadata(myGreenBrush));
            FillProperty.OverrideMetadata(
                typeof(CircularProgress),
                new FrameworkPropertyMetadata(Brushes.Transparent));

            StrokeThicknessProperty.OverrideMetadata(
                typeof(CircularProgress),
                new FrameworkPropertyMetadata(10.0));
        }

        // Value (0-100)
        public double Value
        {
            get { return (double)GetValue(ValueProperty); }
            set { SetValue(ValueProperty, value); }
        }

        // DependencyProperty - Value (0 - 100)
        private static FrameworkPropertyMetadata valueMetadata =
                new FrameworkPropertyMetadata(
                    0.0,     // Default value
                    FrameworkPropertyMetadataOptions.AffectsRender,
                    null,    // Property changed callback
                    new CoerceValueCallback(CoerceValue));   // Coerce value callback

        public static readonly DependencyProperty ValueProperty =
            DependencyProperty.Register("Value", typeof(double), typeof(CircularProgress), valueMetadata);

        private static object CoerceValue(DependencyObject depObj, object baseVal)
        {
            double val = (double)baseVal;
            val = Math.Min(val, 99.999);
            val = Math.Max(val, 0.0);
            return val;
        }

        protected override Geometry DefiningGeometry
        {
            get
            {
                double startAngle = 90.0;
                double endAngle = 90.0 - ((Value / 100.0) * 360.0);

                double maxWidth = Math.Max(0.0, RenderSize.Width - StrokeThickness);
                double maxHeight = Math.Max(0.0, RenderSize.Height - StrokeThickness);

                double xStart = maxWidth / 2.0 * Math.Cos(startAngle * Math.PI / 180.0);
                double yStart = maxHeight / 2.0 * Math.Sin(startAngle * Math.PI / 180.0);

                double xEnd = maxWidth / 2.0 * Math.Cos(endAngle * Math.PI / 180.0);
                double yEnd = maxHeight / 2.0 * Math.Sin(endAngle * Math.PI / 180.0);

                StreamGeometry geom = new StreamGeometry();
                using (StreamGeometryContext ctx = geom.Open())
                {
                    ctx.BeginFigure(
                        new Point((RenderSize.Width / 2.0) + xStart,
                                  (RenderSize.Height / 2.0) - yStart),
                        true,   // Filled
                        false);  // Closed
                    ctx.ArcTo(
                        new Point((RenderSize.Width / 2.0) + xEnd,
                                  (RenderSize.Height / 2.0) - yEnd),
                        new Size(maxWidth / 2.0, maxHeight / 2),
                        0.0,     // rotationAngle
                        (startAngle - endAngle) > 180,   // greater than 180 deg?
                        SweepDirection.Clockwise,
                        true,    // isStroked
                        false);
                //    ctx.LineTo(new Point((RenderSize.Width / 2.0), (RenderSize.Height / 2.0)), true, true);
                }

                return geom;
            }
        }
    }

Below is a screenshot showing the updated arc-based circular progress indicator.

1156-001

 

#1,155 – A Circular Progress Indicator

Using the earlier custom pie shape control as a base, we can now create a custom control that serves as a circular progress indicator.

Here’s the code for the circular progress control:

    public class CircularProgress : Shape
    {
        static CircularProgress()
        {
            Brush myGreenBrush = new SolidColorBrush(Color.FromArgb(255, 6, 176, 37));
            myGreenBrush.Freeze();

            StrokeProperty.OverrideMetadata(
                typeof(CircularProgress),
                new FrameworkPropertyMetadata(myGreenBrush));
            FillProperty.OverrideMetadata(
                typeof(CircularProgress),
                new FrameworkPropertyMetadata(myGreenBrush));
        }

        // Value (0-100)
        public double Value
        {
            get { return (double)GetValue(ValueProperty); }
            set { SetValue(ValueProperty, value); }
        }

        // DependencyProperty - Value (0 - 100)
        private static FrameworkPropertyMetadata valueMetadata =
                new FrameworkPropertyMetadata(
                    0.0,     // Default value
                    FrameworkPropertyMetadataOptions.AffectsRender,
                    null,    // Property changed callback
                    new CoerceValueCallback(CoerceValue));   // Coerce value callback

        public static readonly DependencyProperty ValueProperty =
            DependencyProperty.Register("Value", typeof(double), typeof(CircularProgress), valueMetadata);

        private static object CoerceValue(DependencyObject depObj, object baseVal)
        {
            double val = (double)baseVal;
            val = Math.Min(val, 99.999);
            val = Math.Max(val, 0.0);
            return val;
        }

        protected override Geometry DefiningGeometry
        {
            get
            {
                double startAngle = 90.0;
                double endAngle = 90.0 - ((Value / 100.0) * 360.0);

                double maxWidth = Math.Max(0.0, RenderSize.Width - StrokeThickness);
                double maxHeight = Math.Max(0.0, RenderSize.Height - StrokeThickness);

                double xStart = maxWidth / 2.0 * Math.Cos(startAngle * Math.PI / 180.0);
                double yStart = maxHeight / 2.0 * Math.Sin(startAngle * Math.PI / 180.0);

                double xEnd = maxWidth / 2.0 * Math.Cos(endAngle * Math.PI / 180.0);
                double yEnd = maxHeight / 2.0 * Math.Sin(endAngle * Math.PI / 180.0);

                StreamGeometry geom = new StreamGeometry();
                using (StreamGeometryContext ctx = geom.Open())
                {
                    ctx.BeginFigure(
                        new Point((RenderSize.Width / 2.0) + xStart,
                                  (RenderSize.Height / 2.0) - yStart),
                        true,   // Filled
                        true);  // Closed
                    ctx.ArcTo(
                        new Point((RenderSize.Width / 2.0) + xEnd,
                                  (RenderSize.Height / 2.0) - yEnd),
                        new Size(maxWidth / 2.0, maxHeight / 2),
                        0.0,     // rotationAngle
                        (startAngle - endAngle) > 180,   // greater than 180 deg?
                        SweepDirection.Clockwise,
                        true,    // isStroked
                        false);
                    ctx.LineTo(new Point((RenderSize.Width / 2.0), (RenderSize.Height / 2.0)), true, false);
                }

                return geom;
            }
        }
    }

Here’s an example of using this control in XAML. The example also includes a traditional progress bar, so that we can compare them.

    <StackPanel>
        <loc:CircularProgress
                 Height="100" Width="100" Margin="5"
                 Value="{Binding PctComplete}"
                 HorizontalAlignment="Center"/>
        <ProgressBar x:Name="prog2" Maximum="100"
                     Value="{Binding PctComplete}"
                     Height="25" Margin="10"/>
        <Button Content="Start Timer" Click="Button_Click"
                HorizontalAlignment="Center"
                Padding="12,7"/>
    </StackPanel>

Finally, here’s the code-behind, including the PctComplete property that we bind to and code that kicks off a timer that updates the property periodically.

    public partial class MainWindow : Window, INotifyPropertyChanged
    {
        public MainWindow()
        {
            this.DataContext = this;
            InitializeComponent();
        }

        public event PropertyChangedEventHandler PropertyChanged = delegate { };

        protected virtual void OnPropertyChanged(string prop)
        {
            PropertyChanged(this, new PropertyChangedEventArgs(prop));
        }

        private double pctComplete = 0.0;
        public double PctComplete
        {
            get { return pctComplete; }
            set
            {
                if (pctComplete != value)
                {
                    pctComplete = value;
                    OnPropertyChanged("PctComplete");
                }
            }
        }

        private void Button_Click(object sender, RoutedEventArgs e)
        {
            PctComplete = 0.0;

            DispatcherTimer timer = new DispatcherTimer();
            timer.Tick += (s, ea) =>
            {
                PctComplete += 1.0;
                if (PctComplete >= 100.0)
                    timer.Stop();
            };
            timer.Interval = new TimeSpan(0, 0, 0, 0, 30);  // 2/sec
            timer.Start();
        }
    }

Here’s the control in action:

1155-001