几天前在网上看到 @Zee Young 的一个下拉刷新的设计 Replace。如下图:

replace-zeeyoung.gif

第一眼看到这个设计就觉得眼前一亮,在Dribble上获得了 1.7k 多的 like,微博上也有大量转发。可见确实一个很成功的设计。我准备在 Android 上来实现它。

经过几天的折腾,最终实现并开源在 Github 上,项目地址: FlyRefresh,实际效果如下图:

flyrefresh-screenshot.gif

总体上还原了设计的70%~80%,还有一些细节需要改进。因为没有拿到设计师的设计源文件,动画和颜色的细节并没有能够做的完全一致。下面分享一下实现的过程。

1 分析设计效果图

要实现这个设计,就要非常仔细的分析这个动画的每个细节。由于没有设计源文件,我最开始就一直盯着这个 GIF 图看,然后构思一下大致的实现流程。在写代码的过程中,甚至把 GIF 图分解成一帧一帧的图片来分析,把 GIF 图分解的方法如下:

convert -coalesce animation.gif frame.png  

从设计图中,得到大致如下的结论:

  1. 总体上是一个下拉刷新的效果;
  2. 页面上大概分为两部分:头部和内容部分;
  3. 头部块叠放在内容块的下面;
  4. 内容块可以下拉,放手能够回弹,并触发飞机飞出的动画;
  5. 头部块随着下拉过程中有动画(这个是重点,后面会详细介绍);

2 软件设计

软件上我打算把它实现成一个下拉刷新的控件。一说到下拉刷新,有一大堆的开源实现,都或多或少的需要一些修改才能满足我这里的需求,我打算自己实现一个量身定做的。 控件的布局关系大概如下图所示:

header-size

布局分为上下两块,上部实线框为头部,虚线框为内容区域。内容区域覆盖在头部上面。通常情况下,内容区域覆盖头部,留出头部 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 具体实现

根据上面的设计,画出类图如下:

flyrefresh-uml

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. 随着下拉,逆时针转动;
  2. 放手的时候,触发刷新,发射出去;
  3. 刷新完成,飞机飞回来,回到原来的位置。

动画 1:实现非常简单,因为 PullHeaderLayout 有 onMoveHeader() 的回调,只要重载这个函数,设置旋转 view.setRotation(degree)即可;

动画 2:仔细观察设计,这是一个组合动画:整体向右上角移动,同时绕 X 轴做 3D 转动,飞机头部慢慢趋向水平,并且慢慢缩小。这里需要实现,因为需要符合真实的物理效果,否这可能看起来会非常生硬。注意这里,我们可以使用 PathInterpolatorCompat 来帮助我们生成任意贝塞尔曲线插值器。

动画 3:这一步和动画2类似。

在纸飞机执行动画的同时,头部的山脉和树也会随着动,这里动效比较复杂,而且比较独立,我这里就写到一个专门的类 MountanScenceView 中,见 3.3 节。

3.3 MountanScenceView

最后来实现最抓人眼球的 MountanScenceView。和之前的思路一样,我们先来分解一下原设计的动画:山脉按照远近分为三层景深,近处的山的颜色比较深,而且随着下拉的时候也会向下移动,并且呈现视差,并且伴随这树的扭动,这是整个动画的点睛之笔。

从画面的风格来看,这是矢量图,随着画面大小后者长宽变化,山脉应该能够自动适应,并充满视图。需要注意的是,不管画面怎么变化,需要保持长宽比不变。这样的话,用如果用图片就不能很好的满足要求了,所以决定是 Path 来手动绘制整个场景。因为场景要适应 View 的大小,所以在 onMeasure() 的时候,计算出缩放比例:

@Override
protected 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,插入新项的动画如下:

@Override
protected 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);
}

@Override
protected 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 的总榜排第一。我非常清楚,代码实现质量并不是多完美,大家都是被这个设计所吸引。

但是,在实现的过程中,我也注意到这个设计的些许不足:

  1. 作为一个下拉刷新设计,一般包含至少三个状态:空闲状态,下拉,刷新中,刷新完成(可以细分为:刷新成功和刷新失败)。这个设计中,缺少了刷新中的状态,或者说不是很明确。我在实现中,使用纸飞机飞出,表示在刷新中,飞机飞回来,表示刷新完成。这样并不是很好,因为飞机飞出去,并不是一个很明显的刷新中的动画。对比普通的下拉刷新,是有一个转动的 ProgressBar 表示正在处理;
  2. 这个设计中,纸飞机按钮的作用是什么?按照 Material Design 的规范,这是一个 Float Action Button,主要用来做正向的操作。这里主要是用来刷新动画,如果点击这个按钮,纸飞机飞出去,动画并不能很好的连贯起来,感觉也是有点怪怪的。

最后,源代码在这里:FlyRefresh