11
\$\begingroup\$

I've implemented countdown timer control with arc animation that looks like this

countdown control


Implementation notes:

  • For arc visualization I've created class Arc derived from Shape (code is based on this post).

  • I've created Countdown control (derived from UserControl). For setting timeout I've added Seconds dependency property. I'm using binding Content="{Binding Seconds}" to display seconds. Animation duration is set in code behind

    Animation.Duration = new Duration(TimeSpan.FromSeconds(Seconds));
    

    because I'm not sure if it's possible to do it in XAML without writing custom converter. I think that creating custom converter is not justified here.

  • For control's scaling content is wrapped in Viewbox сontrol.

  • For seconds animation I'm using DispatcherTimer, nothing special. Is it the best to go here?


Code

Arc.cs

public class Arc : Shape
{
    public Point Center
    {
        get => (Point)GetValue(CenterProperty);
        set => SetValue(CenterProperty, value);
    }

    // Using a DependencyProperty as the backing store for Center.  This enables animation, styling, binding, etc...
    public static readonly DependencyProperty CenterProperty = 
        DependencyProperty.Register("Center", typeof(Point), typeof(Arc), 
            new FrameworkPropertyMetadata(new Point(), FrameworkPropertyMetadataOptions.AffectsRender));

    // Start angle in degrees
    public double StartAngle
    {
        get => (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 FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsRender));

    // End angle in degrees
    public double EndAngle
    {
        get => (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 FrameworkPropertyMetadata(90.0, FrameworkPropertyMetadataOptions.AffectsRender));

    public double Radius
    {
        get => (double)GetValue(RadiusProperty);
        set => SetValue(RadiusProperty, value);
    }

    // Using a DependencyProperty as the backing store for Radius.  This enables animation, styling, binding, etc...
    public static readonly DependencyProperty RadiusProperty =
        DependencyProperty.Register("Radius", typeof(double), typeof(Arc),
            new FrameworkPropertyMetadata(10.0, FrameworkPropertyMetadataOptions.AffectsRender));

    public bool SmallAngle
    {
        get => (bool)GetValue(SmallAngleProperty);
        set => SetValue(SmallAngleProperty, value);
    }

    // Using a DependencyProperty as the backing store for SmallAngle.  This enables animation, styling, binding, etc...
    public static readonly DependencyProperty SmallAngleProperty =
        DependencyProperty.Register("SmallAngle", typeof(bool), typeof(Arc),
            new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.AffectsRender));

    static Arc() => DefaultStyleKeyProperty.OverrideMetadata(typeof(Arc), new FrameworkPropertyMetadata(typeof(Arc)));

    protected override Geometry DefiningGeometry
    {
        get
        {
            double startAngleRadians = StartAngle * Math.PI / 180;
            double endAngleRadians = EndAngle * Math.PI / 180;

            double a0 = StartAngle < 0 ? startAngleRadians + 2 * Math.PI : startAngleRadians;
            double a1 = EndAngle < 0 ? endAngleRadians + 2 * Math.PI : endAngleRadians;

            if (a1 < a0)
                a1 += Math.PI * 2;

            SweepDirection d = SweepDirection.Counterclockwise;
            bool large;

            if (SmallAngle)
            {
                large = false;
                double t = a1;
                d = (a1 - a0) > Math.PI ? SweepDirection.Counterclockwise : SweepDirection.Clockwise;
            }
            else
                large = (Math.Abs(a1 - a0) < Math.PI);

            Point p0 = Center + new Vector(Math.Cos(a0), Math.Sin(a0)) * Radius;
            Point p1 = Center + new Vector(Math.Cos(a1), Math.Sin(a1)) * Radius;

            List<PathSegment> segments = new List<PathSegment>
            {
                new ArcSegment(p1, new Size(Radius, Radius), 0.0, large, d, true)
            };

            List<PathFigure> figures = new List<PathFigure>
            {
                new PathFigure(p0, segments, true)
                {
                    IsClosed = false
                }
            };

            return new PathGeometry(figures, FillRule.EvenOdd, null);
        }
    }
}

Countdown.xaml

<UserControl x:Class="WpfApp3.Countdown"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:local="clr-namespace:WpfApp"
             mc:Ignorable="d"
             d:DesignHeight="450" d:DesignWidth="450" Loaded="UserControl_Loaded">
    <UserControl.Triggers>
        <EventTrigger RoutedEvent="UserControl.Loaded">
            <BeginStoryboard>
                <Storyboard>
                    <DoubleAnimation Name="Animation"
                        Storyboard.TargetName="Arc"
                        Storyboard.TargetProperty="EndAngle"
                        From="-90"
                        To="270" />
                </Storyboard>
            </BeginStoryboard>
        </EventTrigger>
    </UserControl.Triggers>
    <Viewbox>
        <Grid Width="100" Height="100">
            <Border Background="#222" Margin="5" CornerRadius="50">
                <StackPanel VerticalAlignment="Center" HorizontalAlignment="Center">
                    <Label Foreground="#fff" Content="{Binding Seconds}" FontSize="50" Margin="0, -10, 0, 0" />
                    <Label Foreground="#fff" Content="sec" HorizontalAlignment="Center" Margin="0, -20, 0, 0" />
                </StackPanel>
            </Border>

            <local:Arc
                x:Name="Arc"
                Center="50, 50"
                StartAngle="-90"
                EndAngle="-90"
                Stroke="#45d3be"
                StrokeThickness="5"
                Radius="45" />
        </Grid>
    </Viewbox>
</UserControl>

Countdown.xaml.cs

public partial class Countdown : UserControl
{
    public int Seconds
    {
        get => (int)GetValue(SecondsProperty);
        set => SetValue(SecondsProperty, value);
    }

    public static readonly DependencyProperty SecondsProperty =
        DependencyProperty.Register(nameof(Seconds), typeof(int), typeof(Countdown), new PropertyMetadata(0));

    private readonly DispatcherTimer _timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };

    public Countdown()
    {
        InitializeComponent();
        DataContext = this;
    }

    private void UserControl_Loaded(object sender, EventArgs e)
    {
        Animation.Duration = new Duration(TimeSpan.FromSeconds(Seconds));
        if (Seconds > 0)
        {
            _timer.Start();
            _timer.Tick += Timer_Tick;
        }
    }

    private void Timer_Tick(object sender, EventArgs e)
    {
        Seconds--;
        if (Seconds == 0) _timer.Stop();
    }
}

Control is placed on Window like this

<local:Countdown Width="300" Height="300" Seconds="25" />
\$\endgroup\$
3
\$\begingroup\$

I don't like that you are running two timers/timelines in parallel. Instead you could run the animation from code behind. It also gives you the opportunity to trigger it from other places than load:

private void StartAnimation()
{
  double from = -90;
  double to = 270;
  int seconds = Seconds;
  TimeSpan duration = TimeSpan.FromSeconds(Seconds);

  DoubleAnimation animation = new DoubleAnimation(from, to, new Duration(duration));

  Storyboard.SetTarget(animation, Arc);
  Storyboard.SetTargetProperty(animation, new PropertyPath("EndAngle"));

  Storyboard storyboard = new Storyboard();
  storyboard.CurrentTimeInvalidated += (s, e) =>
  {
    int diff = (int)((s as ClockGroup).CurrentTime.Value.TotalSeconds); 
    Seconds = seconds - diff;
  };

  storyboard.Children.Add(animation);
  storyboard.Begin();
}

private void UserControl_Loaded(object sender, RoutedEventArgs e)
{
  StartAnimation();
  //Animation.Duration = new Duration(TimeSpan.FromSeconds(Seconds));
  //if (Seconds > 0)
  //{
  //  _timer.Tick += Timer_Tick;
  //  _timer.Start();
  //}
}

<UserControl x:Class="WpfApp3.Countdown"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:WpfApp"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800"
             Loaded="UserControl_Loaded"
             >
  <Viewbox>
    <Grid Width="100" Height="100">
      <Border Background="#222" Margin="5" CornerRadius="50">
        <StackPanel VerticalAlignment="Center" HorizontalAlignment="Center">
          <Label Foreground="#fff" Content="{Binding Seconds}" FontSize="50" Margin="0, -10, 0, 0" />
          <Label Foreground="#fff" Content="sec" HorizontalAlignment="Center" Margin="0, -20, 0, 0" />
        </StackPanel>
      </Border>

      <local:Arc
                x:Name="Arc"
                Center="50, 50"
                StartAngle="-90"
                EndAngle="-90"
                Stroke="#45d3be"
                StrokeThickness="5"
                Radius="45" />
    </Grid>
  </Viewbox>
</UserControl>
\$\endgroup\$
10
\$\begingroup\$

There are a couple of things which could be more consistent.

    public static readonly DependencyProperty StartAngleProperty =
        DependencyProperty.Register("StartAngle", typeof(double), typeof(Arc),
            new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsRender));
    public static readonly DependencyProperty SecondsProperty =
        DependencyProperty.Register(nameof(Seconds), typeof(int), typeof(Countdown), new PropertyMetadata(0));

The dependency property in Countdown.xaml.cs uses nameof, but the ones in Arc.cs don't. IMO they should.


    // Using a DependencyProperty as the backing store for SmallAngle.  This enables animation, styling, binding, etc...
    public static readonly DependencyProperty SmallAngleProperty =
        DependencyProperty.Register("SmallAngle", typeof(bool), typeof(Arc),
            new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.AffectsRender));

    static Arc() => DefaultStyleKeyProperty.OverrideMetadata(typeof(Arc), new FrameworkPropertyMetadata(typeof(Arc)));

The dependency properties in Arc.cs all have a comment, but the constructor doesn't. IMO the constructor is the harder of the two to understand: if someone knows enough WPF to understand that without a comment, they don't need the comments on the dependency properties.


            double startAngleRadians = StartAngle * Math.PI / 180;
            double endAngleRadians = EndAngle * Math.PI / 180;

            double a0 = StartAngle < 0 ? startAngleRadians + 2 * Math.PI : startAngleRadians;
            double a1 = EndAngle < 0 ? endAngleRadians + 2 * Math.PI : endAngleRadians;

            if (a1 < a0)
                a1 += Math.PI * 2;

            SweepDirection d = SweepDirection.Counterclockwise;
            bool large;

            if (SmallAngle)
            {
                large = false;
                double t = a1;
                d = (a1 - a0) > Math.PI ? SweepDirection.Counterclockwise : SweepDirection.Clockwise;
            }
            else
                large = (Math.Abs(a1 - a0) < Math.PI);

I could definitely use some comments to explain what's going on here. The last line in particular looks very counterintuitive.

Also there's a dead line: t isn't used anywhere.


                    <Label Foreground="#fff" Content="{Binding Seconds}" FontSize="50" Margin="0, -10, 0, 0" />
                    <Label Foreground="#fff" Content="sec" HorizontalAlignment="Center" Margin="0, -20, 0, 0" />

Not very localisable...


            _timer.Start();
            _timer.Tick += Timer_Tick;

It's unlikely to occur in practice, but technically you've got a race condition there. I see only an advantage in switching the order of the two lines.


... For setting timeout I've added Seconds dependency property. I'm using binding Content="{Binding Seconds}" to display seconds. Animation duration is set in code behind

Animation.Duration = new Duration(TimeSpan.FromSeconds(Seconds));

because I'm not sure if it's possible to do it in XAML without writing custom converter. I think that creating custom converter is not justified here.

I think you can do it without a custom converter by making the binding property a TimeSpan (changing the name from Seconds to something like TimeRemaining) and using StringFormat in the binding for the label content. I haven't tested this.

If you do this then you might want to handle non-integer TimeSpan.TotalSeconds values by rounding to the nearest second in the setter.

\$\endgroup\$
  • \$\begingroup\$ Regarding "Not very localisable…" if I would need localization I'll just add something like Content="{x:Static p:Resources.Sec}". I'm using Resx-files based localization, don't know if it's the best option, but didn't find good alternatives, what do you think? But I assume adding this piece of code to the question would be redundant. \$\endgroup\$ – Vadim Ovchinnikov Jun 25 '18 at 19:54
  • \$\begingroup\$ I haven't found any good standard l10n approach for WPF. I ended up doing a DIY approach with a custom converter and various ugly hacks. If you've thought about the issue and made a decision that YAGNI then that's fair enough. \$\endgroup\$ – Peter Taylor Jun 25 '18 at 21:11
7
\$\begingroup\$

1) DispatcherTimer is good for periodic UI updates, but it should not be used to measure time, because it is just not accurate enough. For precise time measurement you have to use Stopwatch class inside the timer callback.

2) Seconds property apparently does two things: it starts as "countdown duration" but after control is loaded it acts as "remaining time". I would use two properties here, so that they can be databound separately when necessary.

3) DataContext = this; - don't set DataContext on public re-usable types. Someone (yourself included) can eventually decide to use this class in MVVM environment, but changing DataContext of your class will break it. Internally, you should use RelativeSource or ElementName properties of Binding class or DependencyProperty callbacks to do your bidding, while leaving DataContext empty.

Here is an example of using ElementName:

<UserControl x:Class="WpfApp3.Countdown"
         ...
         x:Name="this">

 ...
         <!-- Content binds directly to Countdown.Seconds dependency property, DataContext is ignored -->
         <Label Content="{Binding Seconds, ElementName=this}" />
 ...
 </UserControl>
\$\endgroup\$
  • \$\begingroup\$ Regarding last point: suppose I want to move my properties from control class to some view model class. When I have DataContext assigned I just need to change DataContext stored reference, but when I don't have one I need to rewrite all my bindings. So I'm not sure if using DataContext is bad idea here. Am I missing something? \$\endgroup\$ – Vadim Ovchinnikov Jun 27 '18 at 6:52
  • \$\begingroup\$ @VadimOvchinnikov, I'm not sure I follow. Why do you want to move the properties? Just leave them where they are. For MVVM to work, you need to have a property on each side: one property on your view, and another property on your viewmodel. Then you can bind them by setting viewmodel as datacontext: <Countdown Duration={Binding DurationPropertyOfViewModel} DataContext={Binding ViewModel}/> \$\endgroup\$ – Nikita B Jun 27 '18 at 8:35
3
\$\begingroup\$

Incorporated nearly all recommendations to my solution, but the most radical was accepted answer. Any feedback very welcome!

Also added public Start and Stop methods and Elapsed event.

Results:

Arc.cs

public class Arc : Shape
{
    public Point Center
    {
        get => (Point)GetValue(CenterProperty);
        set => SetValue(CenterProperty, value);
    }

    public static readonly DependencyProperty CenterProperty =
        DependencyProperty.Register(nameof(Center), typeof(Point), typeof(Arc),
            new FrameworkPropertyMetadata(new Point(), FrameworkPropertyMetadataOptions.AffectsRender));

    public double StartAngle
    {
        get => (double)GetValue(StartAngleProperty);
        set => SetValue(StartAngleProperty, value);
    }

    public static readonly DependencyProperty StartAngleProperty =
        DependencyProperty.Register(nameof(StartAngle), typeof(double), typeof(Arc),
            new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsRender));

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

    public static readonly DependencyProperty EndAngleProperty =
        DependencyProperty.Register(nameof(EndAngle), typeof(double), typeof(Arc),
            new FrameworkPropertyMetadata(90.0, FrameworkPropertyMetadataOptions.AffectsRender));

    public double Radius
    {
        get => (double)GetValue(RadiusProperty);
        set => SetValue(RadiusProperty, value);
    }

    public static readonly DependencyProperty RadiusProperty =
        DependencyProperty.Register(nameof(Radius), typeof(double), typeof(Arc),
            new FrameworkPropertyMetadata(10.0, FrameworkPropertyMetadataOptions.AffectsRender));

    public bool SmallAngle
    {
        get => (bool)GetValue(SmallAngleProperty);
        set => SetValue(SmallAngleProperty, value);
    }

    public static readonly DependencyProperty SmallAngleProperty =
        DependencyProperty.Register(nameof(SmallAngle), typeof(bool), typeof(Arc),
            new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.AffectsRender));

    static Arc() => DefaultStyleKeyProperty.OverrideMetadata(typeof(Arc), new FrameworkPropertyMetadata(typeof(Arc)));

    protected override Geometry DefiningGeometry
    {
        get
        {
            double startAngleRadians = StartAngle * Math.PI / 180;
            double endAngleRadians = EndAngle * Math.PI / 180;

            double a0 = StartAngle < 0 ? startAngleRadians + 2 * Math.PI : startAngleRadians;
            double a1 = EndAngle < 0 ? endAngleRadians + 2 * Math.PI : endAngleRadians;

            if (a1 < a0)
                a1 += Math.PI * 2;

            SweepDirection d = SweepDirection.Counterclockwise;
            bool large;

            if (SmallAngle)
            {
                large = false;
                d = (a1 - a0) > Math.PI ? SweepDirection.Counterclockwise : SweepDirection.Clockwise;
            }
            else
                large = (Math.Abs(a1 - a0) < Math.PI);

            Point p0 = Center + new Vector(Math.Cos(a0), Math.Sin(a0)) * Radius;
            Point p1 = Center + new Vector(Math.Cos(a1), Math.Sin(a1)) * Radius;

            List<PathSegment> segments = new List<PathSegment>
            {
                new ArcSegment(p1, new Size(Radius, Radius), 0.0, large, d, true)
            };

            List<PathFigure> figures = new List<PathFigure>
            {
                new PathFigure(p0, segments, true)
                {
                    IsClosed = false
                }
            };

            return new PathGeometry(figures, FillRule.EvenOdd, null);
        }
    }
}

Countdown.xaml

<UserControl x:Class="WpfApp.Countdown"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:WpfApp"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="450" Loaded="Countdown_Loaded">
    <Viewbox>
        <Grid Width="100" Height="100">
            <Border Background="#222" Margin="5" CornerRadius="50">
                <StackPanel VerticalAlignment="Center" HorizontalAlignment="Center">
                    <Label Foreground="#fff" Content="{Binding SecondsRemaining}" FontSize="50" Margin="0, -10, 0, 0" />
                    <Label Foreground="#fff" Content="sec" HorizontalAlignment="Center" Margin="0, -15, 0, 0" />
                </StackPanel>
            </Border>

            <uc:Arc
                x:Name="Arc"
                Center="50, 50"
                StartAngle="-90"
                EndAngle="-90"
                Stroke="#45d3be"
                StrokeThickness="5"
                Radius="45" />
        </Grid>
    </Viewbox>
</UserControl>

Countdown.xaml.cs

public partial class Countdown : UserControl
{
    public Duration Duration
    {
        get => (Duration)GetValue(DurationProperty);
        set => SetValue(DurationProperty, value);
    }

    public static readonly DependencyProperty DurationProperty =
        DependencyProperty.Register(nameof(Duration), typeof(Duration), typeof(Countdown), new PropertyMetadata(new Duration()));

    public int SecondsRemaining
    {
        get => (int)GetValue(SecondsRemainingProperty);
        set => SetValue(SecondsRemainingProperty, value);
    }

    public static readonly DependencyProperty SecondsRemainingProperty =
        DependencyProperty.Register(nameof(SecondsRemaining), typeof(int), typeof(Countdown), new PropertyMetadata(0));

    public event EventHandler Elapsed;

    private readonly Storyboard _storyboard = new Storyboard();

    public Countdown()
    {
        InitializeComponent();

        DoubleAnimation animation = new DoubleAnimation(-90, 270, Duration);
        Storyboard.SetTarget(animation, Arc);
        Storyboard.SetTargetProperty(animation, new PropertyPath(nameof(Arc.EndAngle)));
        _storyboard.Children.Add(animation);

        DataContext = this;
    }

    private void Countdown_Loaded(object sender, EventArgs e)
    {
        if (IsVisible)
            Start();
    }

    public void Start()
    {
        Stop();

        _storyboard.CurrentTimeInvalidated += Storyboard_CurrentTimeInvalidated;
        _storyboard.Completed += Storyboard_Completed;

        _storyboard.Begin();
    }

    public void Stop()
    {
        _storyboard.CurrentTimeInvalidated -= Storyboard_CurrentTimeInvalidated;
        _storyboard.Completed -= Storyboard_Completed;

        _storyboard.Stop();
    }

    private void Storyboard_CurrentTimeInvalidated(object sender, EventArgs e)
    {
        ClockGroup cg = (ClockGroup)sender;
        if (cg.CurrentTime == null) return;
        TimeSpan elapsedTime = cg.CurrentTime.Value;
        SecondsRemaining = (int)Math.Ceiling((Duration.TimeSpan - elapsedTime).TotalSeconds);
    }

    private void Storyboard_Completed(object sender, EventArgs e)
    {
        if (IsVisible)
            Elapsed?.Invoke(this, EventArgs.Empty);
    }
}

Example of usage:

<local:Countdown Width="300" Height="300" Duration="0:0:15" />
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.