几天前在网上看到 @Zee Young 的一个下拉刷新的设计 Replace。
第一眼看到这个设计就觉得眼前一亮,在Dribble上获得了 1.7k 多的 like,微博上也有大量转发。可见确实一个很成功的设计。我准备在 Android 上来实现它。
经过几天的折腾,最终实现并开源在 Github 上,项目地址: FlyRefresh,实际效果如下图:总体上还原了设计的70%~80%,还有一些细节需要改进。因为没有拿到设计师的设计源文件,动画和颜色的细节并没有能够做的完全一致。下面分享一下实现的过程。
1 分析设计效果图
要实现这个设计,就要非常仔细的分析这个动画的每个细节。由于没有设计源文件,我最开始就一直盯着这个 GIF 图看,然后构思一下大致的实现流程。在写代码的过程中,甚至把 GIF 图分解成一帧一帧的图片来分析,把 GIF 图分解的方法如下:
convert -coalesce animation.gif frame.png
从设计图中,得到大致如下的结论:
总体上是一个下拉刷新的效果;
页面上大概分为两部分:头部和内容部分;
头部块叠放在内容块的下面;
内容块可以下拉,放手能够回弹,并触发飞机飞出的动画;
头部块随着下拉过程中有动画(这个是重点,后面会详细介绍);
2 软件设计
软件上我打算把它实现成一个下拉刷新的控件。一说到下拉刷新,有一大堆的开源实现,都或多或少的需要一些修改才能满足我这里的需求,我打算自己实现一个量身定做的。
布局分为上下两块,上部实线框为头部,虚线框为内容区域。内容区域覆盖在头部上面。通常情况下,内容区域覆盖头部,留出头部 Normal height
的高度。内容区域可以上滑,最多覆盖到Shrink height
高度;下滑最多可以把头部区域留出Expended height
,下滑超过Normal height
的时候,放手会自动弹回。内容区域可以滑动的距离为Expended_height - Shrink_height
。
这是一个比较通用的布局模式,只要重载这个布局,基本上可以涵盖了所有下刷新的模式。例如Shrink_height=0
的话,头部可以全部收起来的;如果Shrink_height==Normal height
的话,就是一个有固定头部的下拉控件;如果Expended_height > Normal height > Shrink_height
,就是头部可以扩展收缩的下拉控件。
头部动画部分,这里可能不同的设计,变化最大的部分。但是有一个共同点,就是头部显示会根据内容块的滑动情况来变化。在软件上,设计出接口,不同的动画,实现此接口就可以。本文的 FlyRefresh
的动画只是这个接口的一个具体实现。如果要实现其他的刷新动画,并不需要做多大的改动。
3 具体实现
根据上面的设计,画出类图如下:
3.1 PullHeaderLayout
这是一个基类,实现了布局和滑动功能。从类图中可以看到,这个布局中主要包含两部分View:mHeaderView
,mContent
,另外还有 mFlyView
,这头部和内容连接处的按钮。布局也比较简单,具体实现可以参考代码 layoutChildren()
。
滑动是这里这个类的实现重点,这里需要特别小心处理 Touch 事件。Touch 事件需要满足的是,如果 ContentView
可以整体滑动,我们的 Layout 就需要截获 Touch 事件。否这需要把 Touch 事件传递给子 View,这样才不会影响内部子 View 的功能。
在处理Touch事件的时候,需要时刻判断 View 所处的状态,这里借助两个辅助类 HeaderController
和 ScrollChecker
。HeaderController
主要是保存和判断当前 Header 的高度和状态。ScrollChecker
用来检测 ContentView 是否可以滑动。为了让滑动流畅,还需要小心处理 Fling
状态,这里借助了 Scroller
和 VelocityTracker
两个工具类。
另外值得一提的是,当滑动 Header 的高度大于 Normal height
的时候,ContentView 需要自动恢复回去。仔细观察原设计的动画,这个回弹过程是有类似橡皮筋一样的弹性的。这里利用了属性动画类,使用自定义的插值器实现,具体参考源代码的 'ElasticOutInterpolator' 类(参考自:AnimationEasingFunctions)。
因为这里这个类的功能和常见的下拉刷新的类似,这样就有很多优秀的开源库可以参考,我的实现中很大程度上借鉴了优秀的开源库:Ultra Pull To Refresh,让我避免了很多坑。
3.2 FlyRefreshLayout
这里 FlyRefreshLayout
直接继承与上面的 PullHeaderLayout
。因为大部分工作都在基类中完成,这个类实现很简单。这个类主要是为了简化使用,默认添加了动画头部 MountanScenceView
和添加了刷新的接口 OnPullRefreshListener
。
纸飞机的动画就在这里实现。纸飞机动画包括三个部分:
随着下拉,逆时针转动;
放手的时候,触发刷新,发射出去;
刷新完成,飞机飞回来,回到原来的位置。
动画 1:实现非常简单,因为 PullHeaderLayout
有 onMoveHeader()
的回调,只要重载这个函数,设置旋转 view.setRotation(degree)
即可;
动画 2:仔细观察设计,这是一个组合动画:整体向右上角移动,同时绕 X 轴做 3D 转动,飞机头部慢慢趋向水平,并且慢慢缩小。这里需要实现,因为需要符合真实的物理效果,否这可能看起来会非常生硬。注意这里,我们可以使用 PathInterpolatorCompat
来帮助我们生成任意贝塞尔曲线插值器。
动画 3:这一步和动画2类似。
在纸飞机执行动画的同时,头部的山脉和树也会随着动,这里动效比较复杂,而且比较独立,我这里就写到一个专门的类 MountanScenceView
中,见 3.3 节。
3.3 MountanScenceView
最后来实现最抓人眼球的 MountanScenceView
。和之前的思路一样,我们先来分解一下原设计的动画:山脉按照远近分为三层景深,近处的山的颜色比较深,而且随着下拉的时候也会向下移动,并且呈现视差,并且伴随这树的扭动,这是整个动画的点睛之笔。
从画面的风格来看,这是矢量图,随着画面大小后者长宽变化,山脉应该能够自动适应,并充满视图。需要注意的是,不管画面怎么变化,需要保持长宽比不变。这样的话,用如果用图片就不能很好的满足要求了,所以决定是 Path
来手动绘制整个场景。因为场景要适应 View 的大小,所以在 onMeasure()
的时候,计算出缩放比例:
@Overrideprotected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); final float width = getMeasuredWidth(); final float height = getMeasuredHeight(); mScaleX = width / WIDTH; mScaleY = height / HEIGHT; updateMountainPath(mMoveFactor); updateTreePath(mMoveFactor, true);}
绘制山脉比较简单,Path
也不复杂,比如其中一个山的Path的生成如下:
private void updateMountainPath(float factor) { mTransMatrix.reset(); mTransMatrix.setScale(mScaleX, mScaleY); int offset1 = (int) (10 * factor); mMount1.reset(); mMount1.moveTo(0, 95 + offset1); mMount1.lineTo(55, 74 + offset1); mMount1.lineTo(146, 104 + offset1); mMount1.lineTo(227, 72 + offset1); mMount1.lineTo(WIDTH, 80 + offset1); mMount1.lineTo(WIDTH, HEIGHT); mMount1.lineTo(0, HEIGHT); mMount1.close(); mMount1.transform(mTransMatrix); ...}
其实由代码可知,其实就是画一个封闭的多边形。其中 offset1
是根据滑动的程度计算出的移动距离。
下面重点是看树的绘制。这里的树可以分解成两部分:树干和树枝。树干可以看成是一个矩形,然后上面加一个三角形;树枝是下部一个半圆,往上逐渐收缩成到一点。其实这里还是比较简单,但问题是需要随着滑动,树要逐渐弯曲。
这里我做了很多尝试,例如每条边都用贝塞尔曲线,效果不都是很理想。最后还是采用比较“简单粗暴”的方法:
整个树对称中心,用一条“不可见”的贝塞尔曲线支撑,树干和树枝围绕这条中心线密集的用直线堆积构建。树的弯曲效果,只需要移动贝塞尔曲线的控制点。
具体实现是这样的,首先我们还是利用 PathInterpolatorCompat
来创建一个贝塞尔曲线插值器:
Interpolator interpolator = PathInterpolatorCompat.create(0.8f, -0.5f * factor);
其中, (0.8, -0.5*factor)
是控制点,factor
是弯曲程度,这里的参数根据需要可以调整。然后对这个曲线进行采样,获得归一化曲线坐标,我这里采样25个点。我感觉这样实现并不完美,这里就是我前面说的“简单粗暴”的原因。采样的方法如下:
final int N = 25; final float dp = 1f / N; final float dy = -dp * height; float y = y0; float p = 0; float[] xx = new float[N + 1]; float[] yy = new float[N + 1]; for (int i = 0; i <= N; i++) { // 把归一化的采样坐标转换为实际坐标 xx[i] = interpolator.getInterpolation(p) * maxMove + x0; yy[i] = y; y += dy; p += dp;}
然后,沿着这些采样点,逐点用 path.lineTo()
构建树枝和树干。构建树干的代码如下:
final float trunkSize = width * 0.05f; mTrunk.reset(); mTrunk.moveTo(x0 - trunkSize, y0); int max = (int) (N * 0.7f); // 树干的高度为整个树的0.7 int max1 = (int) (max * 0.5f); // 三角形收缩开始的点 float diff = max - max1; // 添加树干左边的边缘for (int i = 0; i < max; i++) { if (i < max1) { // 等距 mTrunk.lineTo(xx[i] - trunkSize, yy[i]); } else { // 线性收缩 mTrunk.lineTo(xx[i] - trunkSize * (max - i) / diff, yy[i]); }}// 添加树干右边的边缘,这里和上面对称for (int i = max - 1; i >= 0; i--) { if (i < max1) { mTrunk.lineTo(xx[i] + trunkSize, yy[i]); } else { mTrunk.lineTo(xx[i] + trunkSize * (max - i) / diff, yy[i]); }}mTrunk.close();
因为树的形态基本一致,只是大小和颜色不一样,所以只要生成一个即可。生成树枝 Path
的代码和上面类似:
mBranch.reset(); int min = (int) (N * 0.4f); diff = N - min;mBranch.moveTo(xx[min] - branchSize, yy[min]); // 添加树枝底部的半圆弧mBranch.addArc(new RectF(xx[min] - branchSize, yy[min] - branchSize, xx[min] + branchSize, yy[min] + branchSize), 0f, 180f); // 添加树枝左边的边缘for (int i = min; i <= N; i++) { float f = (i - min) / diff; // 注意这里不是线性收缩,这样看起来树会更加圆润 mBranch.lineTo(xx[i] - branchSize + f * f * branchSize, yy[i]);}// 添加树枝右边的边缘,和上面对称for (int i = N; i >= min; i--) { float f = (i - min) / diff; mBranch.lineTo(xx[i] + branchSize - f * f * branchSize, yy[i]);}
到这里,最关键的部分就已经完成了。接下来就是把这些 Path
画出来。这里画的时候就是一些 canvas
的变换了,这里就不贴代码了。可以直接参考源代码。
3.4 列表动画的实现
列表本身不是 FlyRefresh
库的重点。为了尽量还原原设计,这里也实现一下。这里的列表可以用 ListView
或者 RecyclerView
。因为 RecyclerView
对动画控制更灵活,这里就选用它。
如果仔细观察,下拉回弹的时候,列表的第一项会因为惯性晃动一下。实现方法如下:
private void bounceAnimateView(View view) { ... Animator swing = ObjectAnimator.ofFloat(view, "rotationX", 0, 30, -20, 0); swing.setDuration(400); swing.setInterpolator(new AccelerateInterpolator()); swing.start();}
然后就是刷新完成,插入新的项的时候的动画。这可以通过给 RecyclerView
设置自定义的ItemAnimator
来实现。为了方便,我这里直接用了开源库 RecyclerView Animators,重载了BaseItemAnimator
,插入新项的动画如下:
@Overrideprotected void preAnimateAddImpl(RecyclerView.ViewHolder holder) { // 设置初始状态 View icon = holder.itemView.findViewById(R.id.icon); icon.setRotationX(30); View right = holder.itemView.findViewById(R.id.right); // 注意这里是沿着最左边旋转 right.setPivotX(0); right.setPivotY(0); right.setRotationY(90);}@Overrideprotected void animateAddImpl(final RecyclerView.ViewHolder holder) { View target = holder.itemView; View icon = target.findViewById(R.id.icon); Animator swing = ObjectAnimator.ofFloat(icon, "rotationX", 45, 0); swing.setInterpolator(new OvershootInterpolator(5)); View right = holder.itemView.findViewById(R.id.right); Animator rotateIn = ObjectAnimator.ofFloat(right, "rotationY", 90, 0); rotateIn.setInterpolator(new DecelerateInterpolator()); AnimatorSet animator = new AnimatorSet(); animator.setDuration(getAddDuration()); animator.playTogether(swing, rotateIn); animator.start();}
完成的其实就是 icon
的晃动和内容的 3D 旋转。
4 写在最后
首先,非常肯定的是 Zee Young 的这个设计是很成功。因为他的这个漂亮的设计,我的这个库在 Github 这几天也收获了 800 多个 Star,而且还一度在 Trending 的总榜排第一。我非常清楚,代码实现质量并不是多完美,大家都是被这个设计所吸引。
但是,在实现的过程中,我也注意到这个设计的些许不足:
作为一个下拉刷新设计,一般包含至少三个状态:空闲状态,下拉,刷新中,刷新完成(可以细分为:刷新成功和刷新失败)。这个设计中,缺少了刷新中的状态,或者说不是很明确。我在实现中,使用纸飞机飞出,表示在刷新中,飞机飞回来,表示刷新完成。这样并不是很好,因为飞机飞出去,并不是一个很明显的刷新中的动画。对比普通的下拉刷新,是有一个转动的
ProgressBar
表示正在处理;这个设计中,纸飞机按钮的作用是什么?按照 Material Design 的规范,这是一个 Float Action Button,主要用来做正向的操作。这里主要是用来刷新动画,如果点击这个按钮,纸飞机飞出去,动画并不能很好的连贯起来,感觉也是有点怪怪的。
最后,源代码在这里:FlyRefresh。
共同学习,写下你的评论
评论加载中...
作者其他优质文章