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

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

1433301728696813.gif

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

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

1433301729376226.gif

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

1 分析设计效果图

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

convert -coalesce animation.gif frame.png

从设计图中,得到大致如下的结论:总体上是一个下拉刷新的效果;

页面上大概分为两部分:头部和内容部分;

头部块叠放在内容块的下面;

内容块可以下拉,放手能够回弹,并触发飞机飞出的动画;

头部块随着下拉过程中有动画(这个是重点,后面会详细介绍);

2 软件设计

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

控件的布局关系大概如下图所示:

1433301730159604.jpg

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

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

1433301730636310.jpg

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() 的时候,计算出缩放比例:@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 

if (i 

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 

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

但是,在实现的过程中,我也注意到这个设计的些许不足:作为一个下拉刷新设计,一般包含至少三个状态:空闲状态,下拉,刷新中,刷新完成(可以细分为:刷新成功和刷新失败)。这个设计中,缺少了刷新中的状态,或者说不是很明确。我在实现中,使用纸飞机飞出,表示在刷新中,飞机飞回来,表示刷新完成。这样并不是很好,因为飞机飞出去,并不是一个很明显的刷新中的动画。对比普通的下拉刷新,是有一个转动的 ProgressBar 表示正在处理;

这个设计中,纸飞机按钮的作用是什么?按照 Material Design 的规范,这是一个 Float Action Button,主要用来做正向的操作。这里主要是用来刷新动画,如果点击这个按钮,纸飞机飞出去,动画并不能很好的连贯起来,感觉也是有点怪怪的。

最后,源代码在这里:FlyRefresh。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值