FlyRefresh——让人眼前一亮的下拉刷新

泡在网上的日子 / 文 发表于2015-06-03 11:26 次阅读 下拉刷新

原文:http://www.race604.com/flyrefresh/ ,很给力有木有。


几天前在网上看到 @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:mHeaderViewmContent,另外还有 mFlyView,这头部和内容连接处的按钮。布局也比较简单,具体实现可以参考代码 layoutChildren()

滑动是这里这个类的实现重点,这里需要特别小心处理 Touch 事件。Touch 事件需要满足的是,如果 ContentView 可以整体滑动,我们的 Layout 就需要截获 Touch 事件。否这需要把 Touch 事件传递给子 View,这样才不会影响内部子 View 的功能。

在处理Touch事件的时候,需要时刻判断 View 所处的状态,这里借助两个辅助类 HeaderControllerScrollCheckerHeaderController 主要是保存和判断当前 Header 的高度和状态。ScrollChecker 用来检测 ContentView 是否可以滑动。为了让滑动流畅,还需要小心处理 Fling 状态,这里借助了 ScrollerVelocityTracker两个工具类。

另外值得一提的是,当滑动 Header 的高度大于 Normal height 的时候,ContentView 需要自动恢复回去。仔细观察原设计的动画,这个回弹过程是有类似橡皮筋一样的弹性的。这里利用了属性动画类,使用自定义的插值器实现,具体参考源代码的 'ElasticOutInterpolator' 类(参考自:AnimationEasingFunctions)。

因为这里这个类的功能和常见的下拉刷新的类似,这样就有很多优秀的开源库可以参考,我的实现中很大程度上借鉴了优秀的开源库:Ultra Pull To Refresh,让我避免了很多坑。

3.2 FlyRefreshLayout

这里 FlyRefreshLayout 直接继承与上面的 PullHeaderLayout。因为大部分工作都在基类中完成,这个类实现很简单。这个类主要是为了简化使用,默认添加了动画头部 MountanScenceView 和添加了刷新的接口 OnPullRefreshListener

纸飞机的动画就在这里实现。纸飞机动画包括三个部分:

  1. 随着下拉,逆时针转动;

  2. 放手的时候,触发刷新,发射出去;

  3. 刷新完成,飞机飞回来,回到原来的位置。

动画 1:实现非常简单,因为 PullHeaderLayoutonMoveHeader() 的回调,只要重载这个函数,设置旋转 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


收藏 赞 (12) 踩 (0)
上一篇:Android应用层View绘制流程与源码分析
1 背景 还记得前面 《Android应用setContentView与LayoutInflater加载解析机制源码分析》 这篇文章吗?我们有分析到Activity中界面加载显示的基本流程原理,记不记得最终分析结果就是下面的关系: 看见没有,如上图中id为content的内容就是整个View树的结构
下一篇:推荐几个非常有用的工具
今天忙中抽闲来给大家分享下我们最近在项目中采用到的一些能帮助团队提升工作效率的几个Android Studio插件和工具。