4

Is there any easy way to create a dashed ellipse made up of individual horizontal dashes, where the dash sizes are consistent, and their amount can be specified?

Something like this:

enter image description here

I want to be able to control each dash individually, like changing its color or binding it to an action in my viewmodel.

The only way I can think of to achieve this, is to create a custom control that contains a Path element for each dash, together making up an ellipse shape, having to calculate the Path data based on the amount of dashes and size of the ellipse.

Shahin Dohan
  • 6,149
  • 3
  • 41
  • 58
  • 2
    You want an ItemsControl with a template that creates one "dash" and rotates using a RenderTransform, with the amount of rotation bound to a value in the templated object. Bind your ItemsControl to a collection of objects that each has a rotation value property, a color or brush property, and a command. In the tmeplate you can do a binding to invoke the command on click or whatever. – 15ee8f99-57ff-4f92-890c-b56153 Jan 19 '17 at 16:25
  • Didn't think of that.. would it create a perfect ellipse, where the gap sizes are also consistent? I also want to add stuff inside this ellipse later on such as text and other elements. – Shahin Dohan Jan 19 '17 at 16:29
  • If you want a perfect circle, that's dead easy. If you want any ellipse, you'll have to work a little harder. Throwing additional content in the middle is trivial. – 15ee8f99-57ff-4f92-890c-b56153 Jan 19 '17 at 16:31
  • Perfect circle is fine, no wacky ellipses required. I'll try this method out meanwhile and see if it fulfills my requirements. Thanks. – Shahin Dohan Jan 19 '17 at 16:34
  • You just need to set the `StrokeDashOffset` and `StrokeDashArray` - see my marching ants [example here](http://stackoverflow.com/questions/25877737/advanced-xaml-animation-effects-pulse-marching-ants-rotations-alerts) and just tinker with those values to get the look you want and use `Ellipse` instead of `Rectangle` :) – Chris W. Jan 19 '17 at 16:45
  • @ChrisW. That's infinitely easier than my idea, but can you vary the colors and make them individually clickable? – 15ee8f99-57ff-4f92-890c-b56153 Jan 19 '17 at 16:55
  • 1
    @EdPlunkett Awww damn Ed, I didn't even read that part of the OP's requirement. To do that I would do it one of a couple ways... Personally I would create the asset in Illustrator using the ellipse tool, set the stroke dashes, then convert it to individual paths and export to xaml so you could attach events to each Path individually. If I have time later I'll provide an example. – Chris W. Jan 19 '17 at 17:01

1 Answers1

1

I came back to this problem now, and managed to solve it in a very flexible and generic way. The requirments have changed a bit since then, no need for binding, but it can be added easily.

Note that this is a circle, which is what I wanted. The question should really say circle rather than ellipse, even though a circle is an ellipse, but I digress...

Here's the UserControl I came up with:

StatusRing.xaml.cs

public partial class StatusRing
{
    #region Dependency Property registrations

    public static readonly DependencyProperty DashesProperty = DependencyProperty.Register("Dashes",
        typeof(int), typeof(StatusRing), new PropertyMetadata(32, DashesChanged));

    public static readonly DependencyProperty DiameterProperty = DependencyProperty.Register("Diameter",
        typeof(double), typeof(StatusRing), new PropertyMetadata(150.00, DiameterChanged));

    public static readonly DependencyProperty DashHeightProperty = DependencyProperty.Register("DashHeight",
        typeof(double), typeof(StatusRing), new PropertyMetadata(20.00, DashHeightChanged));

    public static readonly DependencyProperty DashWidthProperty = DependencyProperty.Register("DashWidth",
        typeof(double), typeof(StatusRing), new PropertyMetadata(5.00, DashWidthChanged));

    public static readonly DependencyProperty DashFillProperty = DependencyProperty.Register("DashFill",
        typeof(SolidColorBrush), typeof(StatusRing), new PropertyMetadata(Brushes.DimGray, DashFillChanged));

    public static readonly DependencyProperty DashAccentFillProperty = DependencyProperty.Register("DashAccentFill",
        typeof(SolidColorBrush), typeof(StatusRing), new PropertyMetadata(Brushes.White, DashAnimationFillChanged));

    public static readonly DependencyProperty TailSizeProperty = DependencyProperty.Register("TailSize",
        typeof(int), typeof(StatusRing), new PropertyMetadata(10, TailSizeChanged));

    public static readonly DependencyProperty AnimationSpeedProperty = DependencyProperty.Register("AnimationSpeed",
        typeof(double), typeof(StatusRing), new PropertyMetadata(50.00, AnimationSpeedChanged));

    public static readonly DependencyProperty IsPlayingProperty = DependencyProperty.Register("IsPlaying",
        typeof(bool), typeof(StatusRing), new PropertyMetadata(false, IsPlayingChanged));

    #endregion Dependency Property registrations

    private readonly Storyboard glowAnimationStoryBoard = new Storyboard();

    public StatusRing()
    {
        Loaded += OnLoaded;
        InitializeComponent();
    }

    #region Dependency Properties

    public int Dashes
    {
        get => (int)GetValue(DashesProperty);
        set => SetValue(DashesProperty, value);
    }

    public double Diameter
    {
        get => (double)GetValue(DiameterProperty);
        set => SetValue(DiameterProperty, value);
    }

    public double Radius => Diameter / 2;

    public double DashHeight
    {
        get => (double)GetValue(DashHeightProperty);
        set => SetValue(DashHeightProperty, value);
    }

    public double DashWidth
    {
        get => (double)GetValue(DashWidthProperty);
        set => SetValue(DashWidthProperty, value);
    }

    public Brush DashFill
    {
        get => (SolidColorBrush)GetValue(DashFillProperty);
        set => SetValue(DashFillProperty, value);
    }

    public Brush DashAccentFill
    {
        get => (SolidColorBrush)GetValue(DashAccentFillProperty);
        set => SetValue(DashAccentFillProperty, value);
    }

    public int TailSize
    {
        get => (int)GetValue(TailSizeProperty);
        set => SetValue(TailSizeProperty, value);
    }

    public double AnimationSpeed
    {
        get => (double)GetValue(AnimationSpeedProperty);
        set => SetValue(AnimationSpeedProperty, value);
    }

    public bool IsPlaying
    {
        get => (bool)GetValue(IsPlayingProperty);
        set => SetValue(IsPlayingProperty, value);
    }

    #endregion Dependency Properties

    private void OnLoaded(object sender, RoutedEventArgs e)
    {
        var thisControl = sender as StatusRing;
        Recreate(thisControl);
    }

    #region Dependency Property callbacks

    private static void DashesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var thisControl = d as StatusRing;
        Recreate(thisControl);
    }

    private static void DiameterChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var thisControl = d as StatusRing;
        Recreate(thisControl);
    }

    private static void DashHeightChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var thisControl = d as StatusRing;
        Recreate(thisControl);
    }

    private static void DashWidthChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var thisControl = d as StatusRing;
        Recreate(thisControl);
    }

    private static void DashFillChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var thisControl = d as StatusRing;
        Recreate(thisControl);
    }

    private static void DashAnimationFillChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var thisControl = d as StatusRing;
        Recreate(thisControl);
    }

    private static void TailSizeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var thisControl = d as StatusRing;
        Recreate(thisControl);
    }

    private static void AnimationSpeedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var thisControl = d as StatusRing;
        if (thisControl.IsLoaded)
        {
            thisControl.glowAnimationStoryBoard.Stop();
            thisControl.glowAnimationStoryBoard.Children.Clear();

            ApplyAnimations(thisControl);

            thisControl.glowAnimationStoryBoard.Begin();
        }
    }

    private static void IsPlayingChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var thisControl = d as StatusRing;
        if (thisControl.IsLoaded)
        {
            var isPlaying = (bool)e.NewValue;
            if (isPlaying)
            {
                thisControl.glowAnimationStoryBoard.Begin();
            }
            else
            {
                thisControl.glowAnimationStoryBoard.Stop();
            }
        }
    }

    #endregion Dependency Property callbacks

    private static void Recreate(StatusRing thisControl)
    {
        if (thisControl.IsLoaded)
        {
            thisControl.glowAnimationStoryBoard.Stop();
            thisControl.glowAnimationStoryBoard.Children.Clear();
            thisControl.RootCanvas.Children.Clear();

            Validate(thisControl);
            BuildRing(thisControl);

            ApplyAnimations(thisControl);

            if (thisControl.IsPlaying)
            {
                thisControl.glowAnimationStoryBoard.Begin();
            }
            else
            {
                thisControl.glowAnimationStoryBoard.Stop();
            }
        }
    }

    private static void Validate(StatusRing thisControl)
    {
        if (thisControl.TailSize > thisControl.Dashes)
        {
            throw new Exception("TailSize cannot be larger than amount of dashes");
        }
    }

    private static void BuildRing(StatusRing thisControl)
    {
        var angleStep = (double)360 / thisControl.Dashes;

        for (double i = 0; i < 360; i = i + angleStep)
        {
            var rect = new Rectangle
            {
                Fill = thisControl.DashFill,
                Height = thisControl.DashHeight,
                Width = thisControl.DashWidth
            };

            //Rotate dash to follow circles circumference 
            var centerY = thisControl.Radius;
            var centerX = thisControl.DashWidth / 2;
            var rotateTransform = new RotateTransform(i, centerX, centerY);
            rect.RenderTransform = rotateTransform;

            var offset = thisControl.Radius - thisControl.DashWidth / 2;
            rect.SetValue(Canvas.LeftProperty, offset);

            thisControl.RootCanvas.Children.Add(rect);
        }

        thisControl.RootCanvas.Width = thisControl.Diameter;
        thisControl.RootCanvas.Height = thisControl.Diameter;
    }

    private static void ApplyAnimations(StatusRing thisControl)
    {
        var baseColor = ((SolidColorBrush)thisControl.DashFill).Color;
        var animatedColor = ((SolidColorBrush)thisControl.DashAccentFill).Color;

        var dashes = thisControl.RootCanvas.Children.OfType<Rectangle>().ToList();

        double animationPeriod = thisControl.AnimationSpeed;
        double glowDuration = animationPeriod * thisControl.TailSize;

        for (int i = 0; i < dashes.Count; i++)
        {
            var beginTime = TimeSpan.FromMilliseconds(animationPeriod * i);

            var colorAnimation = new ColorAnimationUsingKeyFrames
            {
                BeginTime = beginTime,
                RepeatBehavior = RepeatBehavior.Forever
            };

            var toFillColor = new LinearColorKeyFrame(animatedColor, TimeSpan.Zero);
            colorAnimation.KeyFrames.Add(toFillColor);

            var dimToBase = new LinearColorKeyFrame(baseColor, TimeSpan.FromMilliseconds(glowDuration));
            colorAnimation.KeyFrames.Add(dimToBase);

            var restingTime = animationPeriod * dashes.Count;
            var delay = new LinearColorKeyFrame(baseColor, TimeSpan.FromMilliseconds(restingTime));
            colorAnimation.KeyFrames.Add(delay);

            Storyboard.SetTarget(colorAnimation, dashes[i]);
            Storyboard.SetTargetProperty(colorAnimation, new PropertyPath("(Fill).(SolidColorBrush.Color)"));

            thisControl.glowAnimationStoryBoard.Children.Add(colorAnimation);
        }
    }
}

StatusRing.xaml:

<UserControl x:Class="WpfPlayground.StatusRing"
         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" 
         mc:Ignorable="d" 
         d:DesignHeight="450" d:DesignWidth="800">
<Canvas x:Name="RootCanvas" />

Usage:

<local:StatusRing Diameter="250" 
                  Dashes="32"
                  TailSize="16"
                  IsPlaying="True" />

Result:

StatusRing in action

The number of dashes, length and speed of animation, etc... are all configurable. The naming of the dependency properties could be better though...

Enjoy :-)

Shahin Dohan
  • 6,149
  • 3
  • 41
  • 58