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


对最后一节我的疑问,非常有幸能得到原作者 @ZeeYoung欧阳哲 设计师的解答:

其实这个刷新动效最开始确实有假想一个App使用场景,而虚拟的App隶属于多平台云端共享的列表栏。 当有人通过其他手机或其他平台共享给你文件后,下拉刷新便可更新最新传来的文件数据,但仅仅只有名称、内容大小、格式等这些简单的字符数据。 刷新中,所以我当时思考了一下,无需实时链接刷新,在有网络的情况下,这些简单的文件信息已经被动接收完成,飞机只是让人觉得好玩 [哈哈] 。 而这个浮动按钮本身,是一个再分享的按钮,点击需分享的文件,选择要分享到的人,再点击飞机即可完成分享。文件本身的传输都是在收到文件名称后,点击后方的信息按钮查阅,自主选择要不要链接下载。

Read more

Android 上的低功耗蓝牙实践

这是我在 Droidcon Beijing 2016 和 GDG Devfest 2016 上做的分享,以下是正文: Slide 01 我今天分享的主题是 Android 上低功耗蓝牙的实践。这个主题比较小众。我在过去的一年多的时间里,主要是在做低功耗蓝牙相关的开发。接触过程中发现,BLE 的开发和通常的 Android APP 的开发有点不一样,这里需要访问硬件资源,而且涉及到一些协议相关的内容,而且这方面的资料也比较少。今天我从 Android 开发者的角度,来分享一下低功耗蓝牙开发实践。 Slide 02 今天分享的内容,主要包含如下几个部分:首先对蓝牙和低功耗蓝牙做一个简单的介绍;然后介绍 Android 上对低功耗蓝牙的支持;再介绍一下在 Android 平台上可以开发哪些低功耗蓝牙应用;然后是,开发过程中,可以帮助我们调试的工具;最后,总结一下所谓的 “最佳实践”,低功耗蓝牙开发的一些小经验。 Slide

By Race604

React Native 触摸事件处理详解

触控是移动设备的核心功能,也移动应用交互的基础,Android 和 iOS 各自都有完善的触摸事件处理机制。React Native(以下简称 RN)提供了一套统一的处理方式,能够方便的处理界面中组件的触摸事件、用户手势等。本文尝试介绍 RN 中触摸事件处理。 1. RN 基本触摸组件 RN 的组件除了 Text,其他组件默认是不支持点击事件,也不能响应基本触摸事件,所以 RN 中提供了几个直接处理响应事件的组件,基本上能够满大部分的点击处理需求TouchableHighlight, TouchableNativeFeedback, TouchableOpacity 和 TouchableWithoutFeedback。因为这几个组件的功能和使用方法基本类似,只是 Touch 的反馈效果不一样,所以一般我们用 Touchable** 代替。Touchable** 有如下几个回调方法: * onPressIn:点击开始; * onPressOut:点击结束或者离开; * onPress:单击事件回调; * onLongPress:长按事件回调。 它们的基本使用方法如下,

By Race604

React Native 中 ScrollView 性能探究

1 基本使用 ScrollView 是 React Native(后面简称:RN) 中最常见的组件之一。理解 ScrollView 的原理,有利于写出高性能的 RN 应用。 ScrollView 的基本使用也非常简单,如下: <ScrollView> <Child1 /> <Child2 /> ... </ScrollView> 它和 View 组件一样,可以包含一个或者多个子组件。对子组件的布局可以是垂直或者水平的,通过属性 horizontal=true/false 来控制。甚至还默认支持“下拉”刷新操作。另外还有一个特别赞的特性,超出屏幕的 View 会自动被移除,从而节省资源和提高绘制效率。我们来看如下一个例子: class

By Race604

30 天入门 Android 开发, Google 与你一起圆梦

经常会有朋友让我推荐 Android 开发入门的教程或者视频,我一直是推荐看官方的教程。大部分人或者觉得比较迷茫,或者觉得坚持不下去。这次推荐这个《30 天入门 Android 开发》是 Google 亲自发起的免费教学,以学习小组方式,大家可以一起学习和交流。一个好的开始,是成功的一半。让 Google 工程师带领你一起进入多彩的 Android 开发大门。点击这里 报名。 Android 设备已经随处可见,你想尝试一下在 Android 设备上的开发和创新吗?快来跟随 Google 的步伐,一起学习 Android 入门课吧! Google Study Jams 活动介绍 Study Jams 是一个学习 Google 在线课程的活动。该活动由学员自主发起课程学习小组,带领小组成员入门 Android 开发,最终将

By Race604