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

2024-02-15 13:48

本文主要是介绍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 所处的状态,这里借助两个辅助类 HeaderController 和 ScrollCheckerHeaderController 主要是保存和判断当前 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。


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

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

转载地址:http://www.race604.com/flyrefresh/

这篇关于FlyRefresh——让人眼前一亮的下拉刷新的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/711561

相关文章

ABAP怎么把传入的参数刷新到内表里面呢?

1.在执行相关的功能操作之前,优先执行这一段代码,把输入的数据更新入内表里面 DATA: lo_guid TYPE REF TO cl_gui_alv_grid.CALL FUNCTION 'GET_GLOBALS_FROM_SLVC_FULLSCR'IMPORTINGe_grid = lo_guid.CALL METHOD lo_guid->check_changed_data.CALL M

vue子路由回退后刷新页面方式

最近碰到一个小问题,页面中含有 <transition name="router-slid" mode="out-in"><router-view></router-view></transition> 作为子页面加载显示的地方。但是一般正常子路由通过 this.$router.go(-1) 返回到上一层原先的页面中。通过路由历史返回方式原本父页面想更新数据在created 跟mounted

局部刷新ListView,实现点赞功能

今天看到一个需要实现一个点赞的功能。自己想没想明白,后来看了http://blog.csdn.net/nupt123456789/article/details/39432781 这篇博客,才有了思路。特意感谢 这是我要用的ListView的item。要给ListView设置单个刷新,实现点击事件。 1.布局  (不要问我为什么是绝对布局,,我开心) <?xml version

React AntDesign Layout组件布局刷新页面错乱闪动

大家最近在使用React AntDesign Layout组件布局后刷新页面时,页面布局错乱闪动 经过组件属性的研究才发现,设置 hasSider 为 true 就能解决上面的问题,耽搁了半天的时间,接着踩坑接着加油!!! import { Layout, theme } from 'antd';import { Outlet } from "react-router-dom"impo

iOS UITableView下拉刷新上拉加载更多EGOTableViewPullRefresh类库使用初级剑侠篇(欢迎提建议和分享遇到的问题)

这篇文章说下:MJRefresh和  EGOTableViewPullRefresh 的使用方法最下面有原理说明,若有不对或者建议请评论指出,先谢谢了: 首先是英文原文和类库下载地址:https://github.com/emreberge/EGOTableViewPullRefresh      然后创建好自己使用的tableview控件接着: 添加 Quartz

Web项目部署后浏览器刷新返回Nginx的404错误对应解决方案

data: 2024/6/22 16:05:34 周六 limou3434 叠甲:以下文章主要是依靠我的实际编码学习中总结出来的经验之谈,求逻辑自洽,不能百分百保证正确,有错误、未定义、不合适的内容请尽情指出! 文章目录 1.源头2.排错3.原因4.解决 概要:… 资料:本文参考了 这份博文您可前去一看。 1.源头 在帮朋友部署和测试项目的时候,遇

【SpringCloud深入浅出系列】SpringCloud组件之集成Config实现配置自动刷新

SpringCloud 组件之集成 Config 实现配置自动刷新 一、项目说明二、实现配置自动刷新1.添加依赖2.添加配置3.控制类添加注解(1).新建数据配置类(2).修改控制类 4.启动测试 一、项目说明 之前已经在 SpringCloud 组件之集成 Config 实现分布式配置 文中实现了 Config 的分布式配置,存在以下问题: 对 gitee 远程仓库中的

AJAX实现不刷新页面点击按钮在目标位置加载目标内容

AJAX可以实现异步请求数据,即不刷新页面的情况下请求服务器,加载目标内容到页面。 AJAX 不是新的编程语言,而是一种使用现有标准的新方法。 AJAX 是与服务器交换数据并更新部分网页的艺术,在不重新加载整个页面的情况下 AJAX主要使用XMLHttpRequest对象来实现异步地与服务器交换数据,XMLHttpRequest 对象如果要用于 AJAX 的话,其 open() 方法

纠结了好长时间的  自动刷新    笔记

mPullRefreshListView  自动刷新 @Override public void onResume() { // TODO Auto-generated method stub super.onResume(); if (mListItems.size() != 0) { new Handler().postDelayed(new Runnabl

子窗口保存时,同时刷新父窗口

子窗口的js    function xx(){ window.opener.refreshs(); }                              父窗口的js      function refreshs(){                                    refresh();