相对于WPF/Silverlight,UWP的动画系统可以说有大幅提高,不过本文无意深入讨论这些动画API,本文将介绍使用Shape做一些进度、等待方面的动画,除此之外也会介绍一些相关技巧。

1. 使用StrokeDashOffset做等待提示动画

圆形的等待提示动画十分容易做,只要让它旋转就可以了:

38937-20170603222106055-1666217902.gif

但是圆形以外的形状就不容易做了,例如三角形,总不能让它单纯地旋转吧:

38937-20170603222117149-88787596.gif

要解决这个问题可以使用StrokeDashOffset。StrokeDashOffset用于控制虚线边框的第一个短线相对于Shape开始点的位移,使用动画控制这个数值可以做出边框滚动的效果:

38937-20170603222136836-580747891.gif

    
        
            
            
    
        
            
            
            
            

需要注意的是Shape的边长要正好能被StrokeDashArray中短线和缺口的和整除,即 满足边长 / StrokeThickness % Sum( StrokeDashArray ) = 0,这是因为在StrokeDashOffset=0的地方会截断短线,如下图所示:

38937-20170603222159555-158746311.png

另外注意的是边长的计算,如Rectangle,边长并不是(Height + Width) * 2,而是(Height - StrokeThickness) * 2 + (Width- StrokeThickness) * 2,如下图所示,边长应该从边框正中间开始计算:

38937-20170603222749946-1500020087.png

有一些Shape的边长计算还会受到Stretch影响,如上一篇中自定义的Triangle:

38937-20170603222720743-1994563484.png

    
        
        
        
    

2. 使用StrokeDashArray做进度提示动画

StrokeDashArray用于将Shape的边框变成虚线,StrokeDashArray的值是一个double类型的有序集合,里面的数值指定虚线中每一段以StrokeThickness为单位的长度。用StrokeDashArray做进度提示的基本做法就是将进度Progress通过Converter转换为分成两段的StrokeDashArray,第一段为实线,表示当前进度,第二段为空白。假设一个Shape的边长是100,当前进度为50,则将StrokeDashArray设置成{50,double.MaxValue}两段。

做成动画如下图所示:

38937-20170603222248961-2145120871.gif

    
        
        
    
    
    
    
        
    
    
        
            
                
                
                
            
            
                
                
                
                        

其中ProgressToStrokeDashArrayConverter和ProgressToStrokeDashArrayConverter2的代码如下:

public class ProgressToStrokeDashArrayConverter : DependencyObject, IValueConverter{    ///     /// 获取或设置TargetPath的值    ///       public Path TargetPath    {        get { return (Path)GetValue(TargetPathProperty); }        set { SetValue(TargetPathProperty, value); }    }    ///     /// 标识 TargetPath 依赖属性。    ///     public static readonly DependencyProperty TargetPathProperty =        DependencyProperty.Register("TargetPath", typeof(Path), typeof(ProgressToStrokeDashArrayConverter), new PropertyMetadata(null));    public virtual object Convert(object value, Type targetType, object parameter, string language)    {        if (value is double == false)            return null;        var progress = (double)value;        if (TargetPath == null)            return null;        var totalLength = GetTotalLength();        var firstSection = progress * totalLength / 100 / TargetPath.StrokeThickness;        if (progress == 100)            firstSection = Math.Ceiling(firstSection);        var result = new DoubleCollection { firstSection, double.MaxValue };        return result;    }    public object ConvertBack(object value, Type targetType, object parameter, string language)    {        throw new NotImplementedException();    }    protected double GetTotalLength()    {        var geometry = TargetPath.Data as PathGeometry;        if (geometry == null)            return 0;        if (geometry.Figures.Any() == false)            return 0;        var figure = geometry.Figures.FirstOrDefault();        if (figure == null)            return 0;        var totalLength = 0d;        var point = figure.StartPoint;        foreach (var item in figure.Segments)        {            var segment = item as LineSegment;            if (segment == null)                return 0;            totalLength += Math.Sqrt(Math.Pow(point.X - segment.Point.X, 2) + Math.Pow(point.Y - segment.Point.Y, 2));            point = segment.Point;        }        totalLength += Math.Sqrt(Math.Pow(point.X - figure.StartPoint.X, 2) + Math.Pow(point.Y - figure.StartPoint.Y, 2));        return totalLength;    }}public class ProgressToStrokeDashArrayConverter2 : ProgressToStrokeDashArrayConverter{    public override object Convert(object value, Type targetType, object parameter, string language)    {        if (value is double == false)            return null;        var progress = (double)value;        if (TargetPath == null)            return null;        var totalLength = GetTotalLength();        totalLength = totalLength / TargetPath.StrokeThickness;        var thirdSection = progress * totalLength / 100;        if (progress == 100)            thirdSection = Math.Ceiling(thirdSection);        var secondSection = (totalLength - thirdSection) / 2;        var result = new DoubleCollection { 0, secondSection, thirdSection, double.MaxValue };        return result;    }}

由于代码只是用于演示,protected double GetTotalLength()写得比较将就。可以看到这两个Converter继承自DependencyObject,这是因为这里需要通过绑定为TargetPath赋值。

这里还有另一个类ProgressWrapper:

public class ProgressWrapper : DependencyObject{    ///     /// 获取或设置Progress的值    ///       public double Progress    {        get { return (double)GetValue(ProgressProperty); }        set { SetValue(ProgressProperty, value); }    }    ///     /// 标识 Progress 依赖属性。    ///     public static readonly DependencyProperty ProgressProperty =        DependencyProperty.Register("Progress", typeof(double), typeof(ProgressWrapper), new PropertyMetadata(0d));}

因为这里没有可供Storyboard操作的double属性,所以用这个类充当Storyboard和StrokeDashArray的桥梁。UWPCommunityToolkit中也有一个差不多用法的类,这个类通用性比较强,可以参考它的用法。

3. 使用Behavior改进进度提示动画代码

只是做个动画而已,又是Converter,又是Wrapper,又是Binding,看起来十分复杂,如果Shape上面有Progress属性就方便多了。这时候首先会考虑附加属性,在XAML用法如下:

  
    
  
    
    

但其实这是行不通的,XAML有一个存在了很久的限制:。这个限制决定了XAML不能对自定义附加属性做动画。不过,这个限制只限制了不能对自定义附加属性本身做动画,但对附加属性中的类的属性则可以,例如以下这种写法应该是行得通的:

  
    
  
    
      
        
      
    

更优雅的写法是利用,很好地解释了XamlBehaviors的作用:

XAML Behaviors非常重要,因为它们提供了一种方法,让开发人员能够以一种简洁、可重复的方式轻松地向UI对象添加功能。他们无需创建控件的子类或重复编写逻辑代码,只要简单地增加一个XAML代码片段。

要使用Behavior改进现有代码,只需实现一个PathProgressBehavior:

public class PathProgressBehavior : Behavior
{    protected override void OnAttached()    {        base.OnAttached();        UpdateStrokeDashArray();    }    /// 
    /// 获取或设置Progress的值    ///       public double Progress    {        get { return (double)GetValue(ProgressProperty); }        set { SetValue(ProgressProperty, value); }    }    /*Progress DependencyProperty*/    protected virtual void OnProgressChanged(double oldValue, double newValue)    {        UpdateStrokeDashArray();    }    protected virtual double GetTotalLength(Path path)    {        /*some code*/    }    private void UpdateStrokeDashArray()    {        var target = AssociatedObject as Path;        if (target == null)            return;        double progress = Progress;        //if (target.ActualHeight == 0 || target.ActualWidth == 0)        //    return;        if (target.StrokeThickness == 0)            return;        var totalLength = GetTotalLength(target);        var firstSection = progress * totalLength / 100 / target.StrokeThickness;        if (progress == 100)            firstSection = Math.Ceiling(firstSection);        var result = new DoubleCollection { firstSection, double.MaxValue };        target.StrokeDashArray = result;    }}

XAML中如下使用:

  
    
  
  
    
      
    
  

这样看起来就清爽多了。

4. 模仿背景填充动画

先看看效果:

38937-20170603222317477-1236469777.gif

其实这篇文章里并不会讨论填充动画,不过首先声明做填充动画会更方便快捷,这一段只是深入学习过程中的产物,实用价值不高。

上图三角形的填充的效果只需要叠加两个同样大小的Shape,前面那个设置Stretch="Uniform",再通过DoubleAnimation改变它的高度就可以了。文字也是相同的原理,叠加两个相同的TextBlock,将前面那个放在一个无边框的ScrollViewer里再去改变ScrollViewer的高度。

    
        
        
    
    
    
    
        
    
    
        
        
        
            
                
        
            
                
                    
                
                        

ProgressToHeightConverter和ReverseProgressToHeightConverter的代码如下:

public class ProgressToHeightConverter : DependencyObject, IValueConverter{    ///     /// 获取或设置TargetContentControl的值    ///       public ContentControl TargetContentControl    {        get { return (ContentControl)GetValue(TargetContentControlProperty); }        set { SetValue(TargetContentControlProperty, value); }    }    ///     /// 标识 TargetContentControl 依赖属性。    ///     public static readonly DependencyProperty TargetContentControlProperty =        DependencyProperty.Register("TargetContentControl", typeof(ContentControl), typeof(ProgressToHeightConverter), new PropertyMetadata(null));    public object Convert(object value, Type targetType, object parameter, string language)    {        if (value is double == false)            return 0d;        var progress = (double)value;        if (TargetContentControl == null)            return 0d;        var element = TargetContentControl.Content as FrameworkElement;        if (element == null)            return 0d;        return element.Height * progress / 100;    }    public object ConvertBack(object value, Type targetType, object parameter, string language)    {        throw new NotImplementedException();    }      }public class ReverseProgressToHeightConverter : DependencyObject, IValueConverter{    ///     /// 获取或设置TargetContentControl的值    ///       public ContentControl TargetContentControl    {        get { return (ContentControl)GetValue(TargetContentControlProperty); }        set { SetValue(TargetContentControlProperty, value); }    }    ///     /// 标识 TargetContentControl 依赖属性。    ///     public static readonly DependencyProperty TargetContentControlProperty =        DependencyProperty.Register("TargetContentControl", typeof(ContentControl), typeof(ReverseProgressToHeightConverter), new PropertyMetadata(null));    public object Convert(object value, Type targetType, object parameter, string language)    {        if (value is double == false)            return double.NaN;        var progress = (double)value;        if (TargetContentControl == null)            return double.NaN;        var element = TargetContentControl.Content as FrameworkElement;        if (element == null)            return double.NaN;        return element.Height * (100 - progress) / 100;    }    public object ConvertBack(object value, Type targetType, object parameter, string language)    {        throw new NotImplementedException();    }}

再提醒一次,实际上老老实实做填充动画好像更方便些。

5. 将动画应用到Button的ControlTemplate

同样的技术,配合ControlTemplate可以制作很有趣的按钮:

38937-20170603222342571-1587349819.gif

PointerEntered时,按钮的边框从进入点向反方向延伸。PointerExited时,边框从反方向向移出点消退。要做到这点需要在PointerEntered时改变边框的方向,使用了ChangeAngleToEnterPointerBehavior:

public class ChangeAngleToEnterPointerBehavior : Behavior
{    protected override void OnAttached()    {        base.OnAttached();        AssociatedObject.PointerEntered += OnAssociatedObjectPointerEntered;        AssociatedObject.PointerExited += OnAssociatedObjectPointerExited;    }    protected override void OnDetaching()    {        base.OnDetaching();        AssociatedObject.PointerEntered -= OnAssociatedObjectPointerEntered;        AssociatedObject.PointerExited -= OnAssociatedObjectPointerExited;    }    private void OnAssociatedObjectPointerExited(object sender, PointerRoutedEventArgs e)    {        UpdateAngle(e);    }    private void OnAssociatedObjectPointerEntered(object sender, PointerRoutedEventArgs e)    {        UpdateAngle(e);    }    private void UpdateAngle(PointerRoutedEventArgs e)    {        if (AssociatedObject == null || AssociatedObject.StrokeThickness == 0)            return;        AssociatedObject.RenderTransformOrigin = new Point(0.5, 0.5);        var rotateTransform = AssociatedObject.RenderTransform as RotateTransform;        if (rotateTransform == null)        {            rotateTransform = new RotateTransform();            AssociatedObject.RenderTransform = rotateTransform;        }        var point = e.GetCurrentPoint(AssociatedObject.Parent as UIElement).Position;        var centerPoint = new Point(AssociatedObject.ActualWidth / 2, AssociatedObject.ActualHeight / 2);        var angleOfLine = Math.Atan2(point.Y - centerPoint.Y, point.X - centerPoint.X) * 180 / Math.PI;        rotateTransform.Angle = angleOfLine + 180;    }}

这个类命名不是很好,不过将就一下吧。

为了做出边框延伸的效果,另外需要一个类EllipseProgressBehavior:

public class EllipseProgressBehavior : Behavior
{    /// 
    /// 获取或设置Progress的值    ///       public double Progress    {        get { return (double)GetValue(ProgressProperty); }        set { SetValue(ProgressProperty, value); }    }    /// 
    /// 标识 Progress 依赖属性。    ///     public static readonly DependencyProperty ProgressProperty =        DependencyProperty.Register("Progress", typeof(double), typeof(EllipseProgressBehavior), new PropertyMetadata(0d, OnProgressChanged));    private static void OnProgressChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)    {        var target = obj as EllipseProgressBehavior;        double oldValue = (double)args.OldValue;        double newValue = (double)args.NewValue;        if (oldValue != newValue)            target.OnProgressChanged(oldValue, newValue);    }    protected virtual void OnProgressChanged(double oldValue, double newValue)    {        UpdateStrokeDashArray();    }    protected virtual double GetTotalLength()    {        if (AssociatedObject == null)            return 0;        return (AssociatedObject.ActualHeight - AssociatedObject.StrokeThickness) * Math.PI;    }    private void UpdateStrokeDashArray()    {        if (AssociatedObject == null || AssociatedObject.StrokeThickness == 0)            return;        //if (target.ActualHeight == 0 || target.ActualWidth == 0)        //    return;        var totalLength = GetTotalLength();        totalLength = totalLength / AssociatedObject.StrokeThickness;        var thirdSection = Progress * totalLength / 100;        var secondSection = (totalLength - thirdSection) / 2;        var result = new DoubleCollection { 0, secondSection, thirdSection, double.MaxValue };        AssociatedObject.StrokeDashArray = result;    }}

套用到ControlTemplate如下:

    
        
            
                
                    
                        
                            
                                
                                    
                                        
                                    
                                                                                    
                                        
                        
                            
                                
                                    
                                        
                                    
                                                                                    
                                    
                
                    
                        
                    
                                
                    
                        
                    
                    
                        
                    
                                
                    
                        
                    
                                
                    
        
        
            
                
                
            
            

注意:我没有鼓励任何人自定义按钮外观的意思,能用系统自带的动画或样式就尽量用系统自带的,没有设计师的情况下又想UI做得与众不同通常会做得很难看。想要UI好看,合理的布局、合理的颜色、合理的图片就足够了。

6. 结语

在学习Shape的过程中觉得好玩就做了很多尝试,因为以前工作中做过不少等待、进度的动画,所以这次就试着做出本文的动画。

XAML的传统动画并没有提供太多功能,主要是ColorAnimation、DoubleAnimation、PointAnimation三种,不过靠Binding和Converter可以弥补这方面的不足,实现很多需要的功能。
本文的一些动画效果参考了SVG的动画。话说回来,Windows 10 1703新增了,不过看起来只是简单地将SVG翻译成对应的Shape,然后用Shape呈现,不少高级特性都不支持(如下图阴影的滤镜),用法如下:

    
        
    

SvgImageSource:

38937-20170609212408715-1322436420.png

原本的Svg: