相对于wpf/silverlight,uwp的动画系统可以说有大幅提高,不过本文无意深入讨论这些动画api,本文将介绍使用shape做一些进度、等待方面的动画,除此之外也会介绍一些相关技巧。
1. 使用strokedashoffset做等待提示动画圆形的等待提示动画十分容易做,只要让它旋转就可以了:
但是圆形以外的形状就不容易做了,例如三角形,总不能让它单纯地旋转吧:
要解决这个问题可以使用strokedashoffset。strokedashoffset用于控制虚线边框的第一个短线相对于shape开始点的位移,使用动画控制这个数值可以做出边框滚动的效果:
<page.resources><storyboard x:name="progressstoryboard"><doubleanimationusingkeyframes enabledependentanimation="true"
storyboard.targetproperty="(shape.strokedashoffset)"
storyboard.targetname="triangle"><easingdoublekeyframe keytime="0:1:0"
value="-500" /></doubleanimationusingkeyframes></storyboard></page.resources><grid background="#ffcccccc"><grid height="100"
horizontalalignment="center"><stackpanel orientation="horizontal"
verticalalignment="center"><textblock text="l"
fontsize="55"
margin="0,0,5,4" /><local:triangle x:name="triangle"
height="40"
width="40"
strokethickness="2"
stroke="royalblue"
strokedasharray="4.045 4.045"
strokedashoffset="0.05"
strokedashcap="round" /><textblock text="ading..."
fontsize="55"
margin="5,0,0,4" /></stackpanel></grid></grid>
需要注意的是shape的边长要正好能被strokedasharray中短线和缺口的和整除,即 满足边长 / strokethickness % sum( strokedasharray ) = 0,这是因为在strokedashoffset=0的地方会截断短线,如下图所示:
另外注意的是边长的计算,如rectangle,边长并不是(height + width) * 2,而是(height - strokethickness) * 2 + (width- strokethickness) * 2,如下图所示,边长应该从边框正中间开始计算:
有一些shape的边长计算还会受到stretch影响,如上一篇中自定义的triangle:
<stackpanel orientation="horizontal" horizontalalignment="center"><grid height="50"
width="50"><local:triangle stretch="fill"
strokethickness="5"
stroke="royalblue" /></grid><grid height="50"
width="50"
margin="10,0,0,0"><local:triangle stretch="none"
strokethickness="5"
stroke="royalblue" /></grid></stackpanel>
2. 使用strokedasharray做进度提示动画strokedasharray用于将shape的边框变成虚线,strokedasharray的值是一个double类型的有序集合,里面的数值指定虚线中每一段以strokethickness为单位的长度。用strokedasharray做进度提示的基本做法就是将进度progress通过converter转换为分成两段的strokedasharray,第一段为实线,表示当前进度,第二段为空白。假设一个shape的边长是100,当前进度为50,则将strokedasharray设置成{50,double.maxvalue}两段。
做成动画如下图所示:
<page.resources><style targettype="textblock"><setter property="fontsize"
value="12" /></style><local:progresstostrokedasharrayconverter x:key="progresstostrokedasharrayconverter"
targetpath="{binding elementname=triangle}" /><local:progresstostrokedasharrayconverter2 x:key="progresstostrokedasharrayconverter2"
targetpath="{binding elementname=triangle}" />
<toolkit:stringformatconverter x:key="stringformatconverter" /><local:progresswrapper x:name="progresswrapper" /><storyboard x:name="storyboard1"><doubleanimation duration="0:0:5"
to="100"
storyboard.targetproperty="progress"
storyboard.targetname="progresswrapper"
enabledependentanimation="true" /></storyboard></page.resources><grid background="{themeresource applicationpagebackgroundthemebrush}"><viewbox height="150"><stackpanel orientation="horizontal"><grid><local:triangle height="40"
width="40"
strokethickness="2"
stroke="darkgray" /><local:triangle x:name="triangle"
height="40"
width="40"
strokethickness="2"
stroke="royalblue"
strokedasharray="{binding progress,source={staticresource progresswrapper},converter={staticresource progresstostrokedasharrayconverter}}" /><textblock text="{binding progress,source={staticresource progresswrapper},converter={staticresource stringformatconverter},converterparameter='{}{0:0}'}"
horizontalalignment="center"
verticalalignment="center"
margin="0,15,0,0" /></grid><grid margin="20,0,0,0"><local:triangle height="40"
width="40"
strokethickness="2"
stroke="darkgray" /><local:triangle x:name="triangle2"
height="40"
width="40"
strokethickness="2"
stroke="royalblue"
strokedasharray="{binding progress,source={staticresource progresswrapper},converter={staticresource progresstostrokedasharrayconverter2}}" /><textblock text="{binding progress,source={staticresource progresswrapper},converter={staticresource stringformatconverter},converterparameter='{}{0:0}'}"
horizontalalignment="center"
verticalalignment="center"
margin="0,15,0,0" /></grid></stackpanel></viewbox></grid>
其中progresstostrokedasharrayconverter和progresstostrokedasharrayconverter2的代码如下:
public class progresstostrokedasharrayconverter : dependencyobject, ivalueconverter
{/// <summary>/// 获取或设置targetpath的值/// </summary> public path targetpath
{
get { return (path)getvalue(targetpathproperty);
}
set {
setvalue(targetpathproperty, value);
}
}/// <summary>/// 标识 targetpath 依赖属性。///
</summary>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
{/// <summary>/// 获取或设置progress的值/// </summary> public double progress
{get { return (double)getvalue(progressproperty); }set { setvalue(progressproperty, value); }
}/// <summary>/// 标识 progress 依赖属性。///
</summary>public static readonly dependencyproperty progressproperty =
dependencyproperty.register("progress", typeof(double), typeof(progresswrapper), new propertymetadata(0d));
}
因为这里没有可供storyboard操作的double属性,所以用这个类充当storyboard和strokedasharray的桥梁。uwpcommunitytoolkit中也有一个差不多用法的类bindablevalueholder,这个类通用性比较强,可以参考它的用法。
3. 使用behavior改进进度提示动画代码只是做个动画而已,又是converter,又是wrapper,又是binding,看起来十分复杂,如果shape上面有progress属性就方便多了。这时候首先会考虑附加属性,在xaml用法如下:
<usercontrol.resources>
<storyboard x:name="storyboard1"><doubleanimation duration="0:0:5"
to="100"
storyboard.targetproperty="(local:pathextention.progress)"
storyboard.targetname="triangle" />
</storyboard></usercontrol.resources><grid x:name="layoutroot"
background="white"><local:triangle x:name="triangle"
height="40"
local:pathextention.progress="0"
width="40"
strokethickness="2"
stroke="royalblue" ></local:triangle></grid>
但其实这是行不通的,xaml有一个存在了很久的限制:however, an existing limitation of the windows runtime xaml implementation is that you cannot animate a custom attached property.。这个限制决定了xaml不能对自定义附加属性做动画。不过,这个限制只限制了不能对自定义附加属性本身做动画,但对附加属性中的类的属性则可以,例如以下这种写法应该是行得通的:
<usercontrol.resources>
<storyboard x:name="storyboard1"><doubleanimation duration="0:0:5"
to="100"
storyboard.targetproperty="(local:pathextention.progress)"
storyboard.targetname="trianglepathextention" />
</storyboard></usercontrol.resources><grid x:name="layoutroot"
background="white"><local:triangle x:name="triangle"
height="40"
width="40"
strokethickness="2"
stroke="royalblue" > <local:pathhelper><local:pathextention x:name="trianglepathextention"
progress="0" /> </local:pathhelper></local:triangle></grid>
更优雅的写法是利用xamlbehaviors,这篇文章很好地解释了xamlbehaviors的作用:
xaml behaviors非常重要,因为它们提供了一种方法,让开发人员能够以一种简洁、可重复的方式轻松地向ui对象添加功能。
他们无需创建控件的子类或重复编写逻辑代码,只要简单地增加一个xaml代码片段。
要使用behavior改进现有代码,只需实现一个pathprogressbehavior:
public class pathprogressbehavior : behavior<uielement>
{protected override void onattached()
{base.onattached();updatestrokedasharray();
}/// <summary>/// 获取或设置progress的值/// </summary> 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中如下使用:
<usercontrol.resources>
<storyboard x:name="storyboard1"><doubleanimation duration="0:0:5"
to="100"
storyboard.targetproperty="progress"
storyboard.targetname="pathprogressbehavior"
enabledependentanimation="true"/>
</storyboard></usercontrol.resources><grid x:name="layoutroot"
background="white">
<local:triangle x:name="triangle"
height="40"
local:pathextention.progress="0"
width="40"
strokethickness="2"
stroke="royalblue" ><interactivity:interaction.behaviors>
<local:pathprogressbehavior x:name="pathprogressbehavior" /></interactivity:interaction.behaviors>
</local:triangle></grid>
这样看起来就清爽多了。
4. 模仿背景填充动画先看看效果:
其实这篇文章里并不会讨论填充动画,不过首先声明做填充动画会更方便快捷,这一段只是深入学习过程中的产物,实用价值不高。
上图三角形的填充的效果只需要叠加两个同样大小的shape,前面那个设置stretch="uniform",再通过doubleanimation改变它的高度就可以了。文字也是相同的原理,叠加两个相同的textblock,将前面那个放在一个无边框的scrollviewer里再去改变scrollviewer的高度。
<page.resources><style targettype="textblock"><setter property="fontsize"
value="12" /></style><local:progresstoheightconverter x:key="progresstoheightconverter"
targetcontentcontrol="{binding elementname=contentcontrol}" /><local:reverseprogresstoheightconverter x:key="reverseprogresstoheightconverter"
targetcontentcontrol="{binding elementname=contentcontrol2}" /><toolkit:stringformatconverter x:key="stringformatconverter" /><local:progresswrapper x:name="progresswrapper" /><storyboard x:name="storyboard1"><doubleanimation duration="0:0:5"
to="100"
storyboard.targetproperty="progress"
storyboard.targetname="progresswrapper"
enabledependentanimation="true" /></storyboard></page.resources><grid background="{themeresource applicationpagebackgroundthemebrush}"><grid><local:triangle height="40"
width="40"
strokethickness="2"
fill="lightgray" /><local:triangle height="40"
width="40"
stretch="fill"
strokethickness="2"
stroke="royalblue" /><contentcontrol x:name="contentcontrol"
verticalalignment="bottom"
horizontalalignment="center"
height="{binding progress,source={staticresource progresswrapper},converter={staticresource progresstoheightconverter}}"><local:triangle x:name="triangle3" height="40"
width="40"
strokethickness="2"
fill="royalblue"
stretch="uniform"
verticalalignment="bottom" /></contentcontrol><textblock text="{binding progress,source={staticresource progresswrapper},converter={staticresource stringformatconverter},converterparameter='{}{0:0}'}"
horizontalalignment="center"
verticalalignment="center"
margin="0,12,0,0"
foreground="white" /><contentcontrol x:name="contentcontrol2"
height="{binding progress,source={staticresource progresswrapper},converter={staticresource reverseprogresstoheightconverter}}"
verticalalignment="top"
horizontalalignment="center"><scrollviewer borderthickness="0"
padding="0,0,0,0"
verticalscrollbarvisibility="disabled"
horizontalscrollbarvisibility="disabled"
verticalalignment="top"
height="40"><grid height="40"><textblock text="{binding progress,source={staticresource progresswrapper},converter={staticresource stringformatconverter},converterparameter='{}{0:0}'}"
horizontalalignment="center"
verticalalignment="center"
margin="0,12,0,0" /></grid></scrollviewer></contentcontrol></grid></grid>
progresstoheightconverter和reverseprogresstoheightconverter的代码如下:
public class progresstoheightconverter : dependencyobject, ivalueconverter
{/// <summary>/// 获取或设置targetcontentcontrol的值/// </summary> public contentcontrol targetcontentcontrol
{
get {
return (contentcontrol)getvalue(targetcontentcontrolproperty);
}
set {
setvalue(targetcontentcontrolproperty, value);
}
}/// <summary>/// 标识 targetcontentcontrol 依赖属性。///
</summary>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
{/// <summary>/// 获取或设置targetcontentcontrol的值/// </summary>
public contentcontrol targetcontentcontrol
{
get {
return (contentcontrol)getvalue(targetcontentcontrolproperty);
}
set {
setvalue(targetcontentcontrolproperty, value);
}
}/// <summary>/// 标识 targetcontentcontrol 依赖属性。///
</summary>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可以制作很有趣的按钮:
pointerentered时,按钮的边框从进入点向反方向延伸。pointerexited时,边框从反方向向移出点消退。要做到这点需要在pointerentered时改变边框的方向,使用了changeangletoenterpointerbehavior:
public class changeangletoenterpointerbehavior : behavior<ellipse>
{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<ellipse>
{/// <summary>/// 获取或设置progress的值/// </summary>
public double progress
{
get {
return (double)getvalue(progressproperty);
}
set {
setvalue(progressproperty, value);
}
}/// <summary>/// 标识 progress 依赖属性。/// </summary>
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如下:
<controltemplate targettype="button"><grid x:name="rootgrid"><visualstatemanager.visualstategroups><visualstategroup x:name="commonstates"><visualstategroup.transitions><visualtransition generatedduration="0:0:1"
to="normal"><storyboard><doubleanimationusingkeyframes enabledependentanimation="true"
storyboard.targetproperty="(local:ellipseprogressbehavior.progress)"
storyboard.targetname="ellipseprogressbehavior"><easingdoublekeyframe keytime="0:0:1"
value="0"><easingdoublekeyframe.easingfunction><quinticease easingmode="easeout" /></easingdoublekeyframe.easingfunction></easingdoublekeyframe></doubleanimationusingkeyframes></storyboard></visualtransition><visualtransition generatedduration="0:0:1"
to="pointerover"><storyboard><doubleanimationusingkeyframes enabledependentanimation="true"
storyboard.targetproperty="(local:ellipseprogressbehavior.progress)"
storyboard.targetname="ellipseprogressbehavior"><easingdoublekeyframe keytime="0:0:1"
value="100"><easingdoublekeyframe.easingfunction><quinticease easingmode="easeout" /></easingdoublekeyframe.easingfunction></easingdoublekeyframe></doubleanimationusingkeyframes></storyboard></visualtransition></visualstategroup.transitions><visualstate x:name="normal"><storyboard><pointerupthemeanimation storyboard.targetname="rootgrid" /></storyboard></visualstate><visualstate x:name="pointerover"><storyboard><pointerupthemeanimation storyboard.targetname="rootgrid" /></storyboard><visualstate.setters><setter target="ellipseprogressbehavior.(local:ellipseprogressbehavior.progress)"
value="100" /></visualstate.setters></visualstate><visualstate x:name="pressed"><storyboard><pointerdownthemeanimation storyboard.targetname="rootgrid" /></storyboard></visualstate><visualstate x:name="disabled" /></visualstategroup></visualstatemanager.visualstategroups><contentpresenter x:name="contentpresenter"
automationproperties.accessibilityview="raw"
contenttemplate="{templatebinding contenttemplate}"
contenttransitions="{templatebinding contenttransitions}"
content="{templatebinding content}"
horizontalcontentalignment="{templatebinding horizontalcontentalignment}"
padding="{templatebinding padding}"
verticalcontentalignment="{templatebinding verticalcontentalignment}" /><ellipse fill="transparent" stroke="{templatebinding borderbrush}" strokethickness="2"><interactivity:interaction.behaviors><local:changeangletoenterpointerbehavior /><local:ellipseprogressbehavior x:name="ellipseprogressbehavior" /></interactivity:interaction.behaviors></ellipse></grid></controltemplate>
注意:我没有鼓励任何人自定义按钮外观的意思,能用系统自带的动画或样式就尽量用系统自带的,没有设计师的情况下
又想ui做得与众不同通常会做得很难看。想要ui好看,合理的布局、合理的颜色、合理的图片就足够了。
6. 结语在学习shape的过程中觉得好玩就做了很多尝试,因为以前工作中做过不少等待、进度的动画,所以这次就试着做出本文的动画。
xaml的传统动画并没有提供太多功能,主要是coloranimation、doubleanimation、pointanimation三种,不过靠binding和converter可以弥补这方面的不足,实现很多需要的功能。
本文的一些动画效果参考了svg的动画。话说回来,windows 10 1703新增了svgimagesource,不过看起来只是简单地将svg翻译成对应的shape,然后用shape呈现,不少高级特性都不支持(如下图阴影的滤镜),用法如下:
<image><image.source><svgimagesource urisource="feoffset_1.svg" /></image.source></image>
svgimagesource:
原本的svg:
以上就是用shape做动画实例代码的详细内容。