今天在Youtube看到一个不错的视频教程,主题是制作一个带有弹性动画的进度条控件,作者将其命名为SpringySlider。视频是FlutterChallenge频道发布的,这个频道还有很多其他高质量的Flutter自定义控件开发教程,感兴趣的通过可以搜索一下看看。本文是根据Youtube上的这个视频教程整理而来,大致分为九个部分,在视频控件下面的描述里面有一个目录,有每个部分的起始时间,点击之后可以直接跳转到相应的进度。SpringySlider控件的设计来自dribbble网站Kishi-Bashi的作品。效果看起来不错,还挺炫酷的。
视频教程最后完工的效果大概是这样的:
已经很接近效果图了。视频作者已经将实现效果的Flutter工程代码上传到Github仓库,如果想跟着这篇文章来了解这个控件,最好是先下载源码,便于随时查看。如果是打算通过视频学习,就没有下载的必要了,因为作者在视频中已经把编码过程和实现思路介绍的非常详细了,所以整个教程的时长接近三个小时。下面我们还是来介绍如何通过Flutter实现效果吧。
第一部分:AppBar
整个界面中包含了众多元素,我们先从简单的开始做起。从效果图中可以看出,顶部有一个菜单icon和settings文字,底部有两个文本,分别是more和status,就从这几个控件做起吧。首先我们来看一下AppBar的部分。
代码清晰可见,elevation用于设置阴影,这里赋值为0.0说明没有阴影效果,leading参数对应着菜单icon,actions传入的是一个FlatButton,即AppBar右边的settings按钮,这里引入了一个创建button的函数:_buildTextButton,是整个项目中创建button控件的统一方法,代码如下:
没有太复杂的逻辑,只是简单的返回一个FlatButton,定义了字体大小和颜色等。
primaryColor是在主入口MyApp中定义的:
同样地,界面底部的more和staus这两个button也是通过这个函数来生成的。
在这里我们赋值了两个颜色,一个是primaryColor: Color(0xFFFF6688),色值对应粉色,另一个是scaffoldBackgroundColor,色值为白色,整个界面只有这两种颜色,再找不到第三种颜色了。
第二部分:SliderMarks
下面我们来关注一下屏幕右侧的刻度,有点像是尺子上的刻度标记,在代码中我们用一个命名为SliderMarks的StatelessWidget控件来表示这部分的UI。
从效果图中可以看出,这些标记只是一行一行的短线,我们只需要通过CustomPaint把它们按照规则绘制出来即可。来看一下SliderMarks这个类:
代码中可以看出CustomPaint中painter变量类型是SliderMarksPainter,它继承的是CustomPainter,绘制工作主要是通过它来完成的。SliderMarksPainter的成员变量有以下几个:
markCount:从上到下总共需要绘制多少条短线(刻度)
markColor:短线的颜色,界面的上半部分为粉色,下半部分为白色
backgroundColor:背景颜色,正好和markColor对调,上半部分为白色,下半部分为粉色
paddingTop和paddingBottom:很明显是顶部边距和底部边距
我们来看一下SliderMarksPainter的关于绘制部分的代码:
首先通过 canvas.drawRect绘制一个背景色出来,接着通过一个for循环来绘制不同长度的刻度线。规则是这样:靠顶部和底部的刻度最大,相邻的次之,lerpDouble函数是用来取平均值用的,其余的刻度都是最小。长度计算好之后就可以通过canvas.drawLine绘制出来。
我们在界面上看到的白色和粉色两个部分,其实是通过z轴上的上下层Widget来实现的,这两个Widget放置在一个Stack中,下面一层Widget以白色为背景色,覆盖其上的上层Widget以粉色为背景色。
只要根据特定的规则对上层Widget进行裁剪,被剪掉的部分就会露出下层白色部分。这里所说的裁剪指的就是CustomClipper,我们看到的粉色区块都是通过这种方式来生成的。
包括拖拽过程中的曲线和松手后波纹的弹性动画也同样是通过这种方式来生成的。前面一张代码图中的SliderGoo就是为了完成这部分功能,goo单词的意思是黏性物,我们可以很形象地看到,粉色部分的动态效果就像是一个有弹性的黏性物。
先来看一个cliper一个简单的应用,就是屏幕角落的四个圆角,它的实现代码在这里:
其中borderRadius用于设置圆角的半径。ClipRRect的作用就是把它的child裁剪成带有圆角的矩形,而Scaffold是作为ClipRRect的child传入的,所有Scaffold就有了圆角的效果。在Flutter中,除了ClipRRect还有类似的ClipRect用于裁剪矩形,ClipOval用于裁剪椭圆形,还有自由度更高的ClipPath,可以实现任意形状的裁剪。上文中提到的SliderGoo就是一个ClipPath类型的Widget,它通过一个CustomClipper类型的子类,即SliderClipper来设置具体的裁剪路径,效果图中的动画也是通过逐帧的绘制裁剪后的图案来做成的。
第三部分: SliderPointsText
SliderPoints指的是效果图中的显示百分比数字的部分,“68 POINTS YOU HAVE”和“32 POINTS YOU NEED”,两者分别对应两个Points类型的Widget。在手指拖拽过程中,这两个Points的位置是跟随着手指动态变化的,但是和分割线的相对距离大致是固定的。
这里用到了一个LayoutBuilder类来生成布局,LayoutBuilder可以根据父Widget的大小来构建Widget树,通过BoxConstraints类型的参数传入父Widget的尺寸,Points在屏幕中的位置是根据当前设置的百分比和父Widget的高度来决定的,计算出位置参数后传入到Positioned中来设置布局时的位置。FractionalTranslation用于做相对偏移,Offset用于定义偏移比例,最终移动的距离取决于widget自身的尺寸和Offset中设置比例的乘积。
第四部分: SliderDragging
定义了一个枚举类型SpringySliderState来标志不同状态:
idle就是默认无任何操作的状态,dragging指代手指拖拽进度条过程中,springing对应从手指离开屏幕到波纹动画执行结束这段时间。由于界面中关键的几个Widget要呈现的内容都和这三个状态息息相关,所以我们定义了一个SpringySliderController类来统一管理状态的切换。
SliderDragger类是用来监听手指拖拽操作事件的,对应的state类是_SliderDraggerState,其build方法返回的是一个GestureDetector对象,这里是触摸事件最早触发的代码,拖拽动作是通过以下三个参数来监听的:
onPanStart:手指按压屏幕并开始滑动
onPanUpdate:滑动动作在持续地被执行
onPandEnd:滑动动作结束,手指离开屏幕
为这三个参数赋值的分别是在_SliderDraggerState中定义的_onPanStart,_onPanUpdate和_onPanEnd函数,我们先来看下_onPanStart做了哪些事情。
首先根据传入的DragStartDetails类型的参数确定了开始拖拽时的Y轴坐标startDragY,draggingHorzontailPercent用于计算触摸点X轴坐标相对于整个控件宽度所处的百分比,这个参数主要用于绘制波纹时确定波峰的位置,这部分后面还会介绍到。最后要执行sliderController的onDragStart方法,通知sliderController更新内部的一些变量。_onPanUpdate和_onPanEnd也是类似地在滑动动作地不同阶段计算一些距离或者百分比,最终调用sliderController相应地方法来实现事件地传递和重新计算内部维护的变量,而这些变量的更新又将会影响到Widget的绘制,这样就实现了滑动事件到UI更新的传递。
我们先关注以下手指拖拽时要实现的效果,大致是这样的一个类似于波浪的图形:
波浪形状的曲线可以通过贝塞尔曲线绘制出来,那么问题就在于如何计算绘制所需的参数。前文提到波纹形状的绘制都是通过继承CustomClipper<Path>的SliderClipper类实现的,其关键的回调函数是getClip,具体的实现是这样的:
通过switch语句将不同状态下的绘制规则区分开来,clipIdle返回的是一个矩形的Path,比较简单。_clipDragging对应的时拖拽过程中需要绘制的形状,那么就需要sliderController提供当前的横向和纵向的拖拽位置,和拖拽开始时的起始位置。
为了比较形象的描述这个波纹图形的组成,我对截图做了一些编辑作为补充。
请原谅这拙劣的手法,凑合看下吧。
绘制的关键就是确定几个关键点,就是下面代码里面的crestPoint,leftPoint和rightPoint。crestPoint位于波形的顶点,leftPoint和rightPoint在超出15%屏幕宽度的位置。还有两个用于绘制贝塞尔曲线的两个控制点,没有在示意图中标注出来,这两个点分别位于crestPoint左边和右边,和crestPoint的距离是controlPointWidth。有了这几个点就可以绘制出左右两边的贝塞尔曲线,在加上底部绘制出的矩形区域,一个波纹图形就形成了。
第五部分:SliderSpring
这一部分主要实现手指松开之后的动画,和前面的_clipDragging类似,这个阶段的图形绘制是通过_clipSpringing函数产生的,这部分动画我们姑且称之为springy(有弹性的)动画。
从前文知道绘制图形的一些关键参数都是通过sliderController传过来的,那sliderController对这些参数的计算规则是什么呢?既然要实现一个动画,每一帧需要绘制的图形形状都需要根据当前时间计算出来。在拖拽阶段我们有GestureDetector的onPanUpdate事件触发UI的更新,但是手指松开后只能依靠定时器来更新UI制作动画。这里使用的是TickProvider,_SpringySliderState采用了mixin的方式实现多继承以获得定时器。SpringySliderController需要这个定时器在每一帧渲染时触发回调函数并更新变量,其内部还有一个TickerProvider类型的变量_vsync,在_SpringySliderState的initState函数中是将this赋值给它的,SpringySliderController内部通过_vsync.createTicker启动定时器,设置的回调参数是_springTick函数,根据流逝的时间计算当前动画帧所需的参数并通知UI更新。
SpringySliderController继承了ChangeNotifier,在创建实例时执行了addListener,目的是在sliderController调用notifyListeners时触发界面更新。
我们再来看一下手指松开时SpringySliderController执行的onDragEnd函数:
先是切换_state变量的状态,进入spring动画阶段,这里出现了两个成对出现的变量,一个是以_springing为前缀,另一个是以_crest为前缀的变量,为了直观的描述两者的区别,我在动画中加入了两条彩色的横线来标注_springingPercent和_crestSpringingPercent的位置(需要对源码稍作改动,在SliderDragger中多添加几个SliderDebug,每个设置不同的颜色):
Gif中出现了三种颜色的横线,其中黑线对应springingPercent,绿色线对应crestPercent,蓝色线对应draggingPercent。draggingPercent这个变量是跟随手指拖拽时变化的。而springingPercent和crestPercent的数值是如何计算出来的呢?这里需要引入一个SpringSimulation类,Flutter源码注释中对这个SpringSimulation类的注释是:根据胡克定律模拟附着在弹簧上的粒子的运动。从上图中也能看出来,绿线和黑线有点像是被挂在一个弹簧上,在做阻尼振动,振幅在不断减小,但是两者振幅的衰减速度不同,这是因为我们使用了不同的参数来模拟它们的振动:
SpringDescription用来描述一个有弹性的柔体物,关键参数有:
mass:质量
stiffness:刚度,控制柔体中的拉伸量
damping:阻尼,应用于柔体运动的阻尼因子,过多的阻尼将阻止柔体移动
这个三个参数具体如何影响最终的动画效果大家可以设置不同的数值来测试,以调整到自己觉得合适的效果。SpringDescription类型的变量是用来描述spring动画特性的,在_startSpringing方法中会作为参数传入到SpringSimulation的构造函数中,像这样:
如果你想在调试时放慢整个动画的速度,可以修改lastFrameTime的数值,把最后除以1000.0改成除以10000.0或更大的数值,动画会变慢,便于观察动画的整个执行过程。
最后回过头来看一下,其实和clipDragging方法基本类似,只不过它的leftPoint和rightPoint用到的baseY值是根据SpringSimulation模拟出来的轨迹动态变化的,和crestPoint变化轨迹类似,从两条彩色线中也可以看出这一点来。
第六、七部分:DragCurve,Springy Curve
由于本文是根据作者的最终代码来讲解的,所以没有体现出中间过程中作出的各种修改。视频中这两个部分涉及到的代码改动其实已经体现到之前的表述中了,这里就不再赘述了。
第八部分 :Springy Wave
这部分内容作为选学内容了,为了更加贴合设计稿中的效果,作者又添加了额外的波形来力争达到更好的效果,但是本人看下来并没有对体验上有太大的改进,反而使得动画变得没有之前的流畅,所以这部分内容没有继续跟进。感兴趣的朋友可以参考视频教程学习下。
第九部分:Points Polish
目前为止这个控件还有一个小缺陷,就是在拖拽到屏幕顶部的时候,上边的文字会消失,并且显示的数值可能会超过100,这显然是一个问题,这部分主要就是讲解如何处理这些边缘情况,细节不去描述了,只介绍以下解决方案中用到的一个函数:num clamp(num lowerLimit, num upperLimit),该函数的特性是:如果参数位于最小数值和最大数值之间的数值范围内,则该函数将返回参数值。如果参数大于范围,该函数将返回最大数值。如果参数小于范围,该函数将返回最小数值,通过这个函数为变量的赋值设置了取值范围。
Flutter程序员公众号,关注Flutter相关话题~
网友评论