现在已经是2018年,geogle的SwipeRefreshLayout效果已经可以满足一部分需求,github上的开源项目更是有很多成熟的炫酷的下拉刷新控件,那么为什么还要自己动手实现一个下拉刷新控件?
整个系列文章的脉络是:
github地址:https://github.com/BigPig-LittleTail/RefreshLayout
ps: 自己看文章和github开源库的时候最烦的就是没有readme和图的文章,不料自己也是其中一员,后续可能会补上动图。这是后续加上的效果图,readme还没有写。有加载进度条的头部的图片怎么也上传不上去。先拿这个没有进度条的凑合着吧。
凡事在动手之前都要问自己为什么要动手。这是整个系列的第一篇,目的是明确为什么要在现在下拉刷新控件满天飞的情况下还要自己去动手实现下拉刷新,以及对当前市面上的一些风格的总结和分析。以便后续选择实现的风格,学习相关的知识。
为什么要自己动手实现一个下拉刷新控件
- 需求的多变:面对错综复杂的产品需求,想直接从网上找到一个完美贴合需求的下拉刷新控件几乎是不可能的。
- 代码的可维护性:虽然github提供了很多炫酷效果的下拉刷新控件,但是很多控件为了满足不同的下拉效果,集成了大量不同风格的头部,增加了很多条件判断和变量。虽然每个头部的效果都很炫酷,但是这无形间增加了代码维护的难度。如果有新的需求或是发现了bug,修改起来困难。
- 可学习的点很多:实现下拉刷新涉及很多Android开发的基础知识,对于我这样的入门级开发者提供了很好的锻炼方式。
下拉刷新控件按效果分类
1)SwipeRefreshLayout风格类
- 主要使用的app:抖音
- 下拉效果:
- 手指向下滑动超过一定距离才会触发下拉
- 头部的视图在内容视图上层。
- 跟随手指滑动距离,控件淡入(有的没有),缩放改变(有的没有),加载进度条按距离加载。
- 松手时进入转菊花状态。
- 控件状态:复位态→下拉态→刷新态,此类控件可观角度只有三种状态,刷新成功与失败不在该控件上表现出。
- 阻尼性:这类控件的阻尼性表现在下拉的白色圆圈背景会在松手后有一个小小的回弹。
- 多点触控:不同的app有的支持有的不支持。
- 优点:内容视图的位置没有移动,不影响内容视图的位置;下拉过程中用户参与度高。
- 缺点:顶层视图遮挡内容视图,影响内容视图的展示;控件状态没有刷新成功提示,用户体验感差。
总结:SwipeRefreshLayout风格类与其他风格类最大的不同在于—-头部视图在内容视图上层。这是一种取舍,它放弃了内容视图移动带来的不良感官,转而用顶层视图遮挡内容视图的方式,忽略顶层视图遮挡内容视图造成的影响。这种风格的控件多应用在快消费的app中,首先这类app需要更短的刷新时间,所以要放弃成功和失败状态展示的耗时,第二,内容视图显然是展示的重点,视图的移动会带来更大的视觉损失,所以采用更小的遮挡来替代视图移动的损失。
示例:
2)qq风格类
- 主要使用的app:qq,ucNews
- 下拉效果:
- 手指向下滑动超过一定距离才会触发下拉
- 头部的视图和内容视图同层。
- 跟随手指滑动距离,控制加载进度,缩放改变(有的没有)
- 松手后进入转菊花
- 控件状态:这类控件可以有下拉完成态,和结果态,两种状态直接展现在控件当中
- 种类1:复位态→下拉态→下拉完成态→刷新态->结果态
- 种类2:复位态→下拉态→刷新态
- 阻尼性:这类控件的阻尼性表现在它可以无限拉动,但是距离越大阻力越大,松手回弹到header高度。
- 多点触控:不同的app有的支持有的不支持。
- 优点:头部可自定义程度高,能够根据需求作出更酷炫的动画;控件状态可为种类1或种类2,种类1刷新结果可以在控件中展现。
- 缺点:头部的视图和内容视图同层,拉动时一起滑动,内容视图一部分不可见。
总结:qq风格类与其他风格类最大的不同在于—-头部视图和内容视图同层。这种风格控件主要应用在慢消费的app中,也就是说很久才可能刷新一次的app,内容视图已经是用户不感兴趣或者不再重视的视图,用户更关心刷新结果,不关心当前内容视图。所以这类控件可以选择种类1或者种类2的控件状态,根据更具体的需求确定是否有下拉完成态和结果态。
示例:
典型控件实现分析
根据我们体验两种风格控件的感受,它们最大的区别—-头部视图和内容视图是否在同层
那么我们就要分析这种不同在代码中是如何体现的。
1)在SwipeRefreshLayout中
下拉过程,SwipeRefreshLayout风格和qq风格一个头部视图在内容视图,一个头部视图和内容视图同层。这是他们最大的区别。我们应该这样去思考上面这句话。
- 关注下拉过程,也就是事件中的ACTION_MOVE部分
- 关注谁滑动,如何滑动
- 关注头部视图层如何出现在顶部
有了这样的思考,我们就有目的性的去查看SwipeRefreshLayout的源码了。(当然这种目的性也要求具备事件分发的知识,现在我们先默认自己理解了事件分发。实际上,事件分发的学习伴随前期思考,阅读源码,自己实现的整个过程。这里我们不纠结应该在哪个部分写下事件分发的知识,我们把关注点重新移动到SwipeRefreshLayout的源码上)
首先我们找到SwipeRefreshLayout的onTouchEvent处理ACTION_MOVE的部分
(下面的源码中我加了一些注释和我省略内容的解)
@Override
public boolean onTouchEvent(MotionEvent ev) {
final int action = ev.getActionMasked();
int pointerIndex = -1;
// 省略一些判断返回false的代码...
switch (action) {
// 省略action的其他状态
case MotionEvent.ACTION_MOVE: {
// 获取活跃手指的下标
pointerIndex = ev.findPointerIndex(mActivePointerId);
// 小于0说明错误
if (pointerIndex < 0) {
Log.e(LOG_TAG, "Got ACTION_MOVE event but have an invalid active pointer id.");
return false;
}
// 获取当视图坐标的Y
final float y = ev.getY(pointerIndex);
// 用来判断拉动距离是否超过最小滑动距离
// ps:我感觉这个是冗余代码
startDragging(y);
if (mIsBeingDragged) {
final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE;
if (overscrollTop > 0) {
// 我们的目标方法的调用
moveSpinner(overscrollTop);
} else {
return false;
}
}
break;
}
}
return true;
}
- 我们看到在onTouchEvent中处理了事件的各个阶段,我们在这里关注的是ACTION_MOVE部分,看到它调用了moveSpinner方法,我们进一步看moveSpinner方法
private void moveSpinner(float overscrollTop) {
mProgress.setArrowEnabled(true);
// 这部分是计算偏移量的过程,最后的targetY是最初的偏移量,圆圈露出的高,和额外移动(就是为了
// 有往回弹那一下,才有额外移动)
float originalDragPercent = overscrollTop / mTotalDragDistance;
float dragPercent = Math.min(1f, Math.abs(originalDragPercent));
float adjustedPercent = (float) Math.max(dragPercent - .4, 0) * 5 / 3;
float extraOS = Math.abs(overscrollTop) - mTotalDragDistance;
float slingshotDist = mUsingCustomStart ? mSpinnerOffsetEnd - mOriginalOffsetTop
: mSpinnerOffsetEnd;
float tensionSlingshotPercent = Math.max(0, Math.min(extraOS, slingshotDist * 2)
/ slingshotDist);
float tensionPercent = (float) ((tensionSlingshotPercent / 4) - Math.pow(
(tensionSlingshotPercent / 4), 2)) * 2f;
float extraMove = (slingshotDist) * tensionPercent * 2;
int targetY = mOriginalOffsetTop + (int) ((slingshotDist * dragPercent) + extraMove);
// 23行到51行是头部的显示,缩放动画的控制,虽然进行了这样的显示和控制,但并不能给他带到视图顶层
// 答案应该就在最后一行的setTargetOffsetTopAndBottom函数了
if (mCircleView.getVisibility() != View.VISIBLE) {
mCircleView.setVisibility(View.VISIBLE);
}
if (!mScale) {
mCircleView.setScaleX(1f);
mCircleView.setScaleY(1f);
}
if (mScale) {
setAnimationProgress(Math.min(1f, overscrollTop / mTotalDragDistance));
}
if (overscrollTop < mTotalDragDistance) {
if (mProgress.getAlpha() > STARTING_PROGRESS_ALPHA
&& !isAnimationRunning(mAlphaStartAnimation)) {
// Animate the alpha
startProgressAlphaStartAnimation();
}
} else {
if (mProgress.getAlpha() < MAX_ALPHA && !isAnimationRunning(mAlphaMaxAnimation)) {
// Animate the alpha
startProgressAlphaMaxAnimation();
}
}
float strokeStart = adjustedPercent * .8f;
mProgress.setStartEndTrim(0f, Math.min(MAX_PROGRESS_ANGLE, strokeStart));
mProgress.setArrowScale(Math.min(1f, adjustedPercent));
float rotation = (-0.25f + .4f * adjustedPercent + tensionPercent * 2) * .5f;
mProgress.setProgressRotation(rotation);
setTargetOffsetTopAndBottom(targetY - mCurrentTargetOffsetTop);
}
这段代码分为两个部分,一部分是偏移量的计算,另一部分是动画控制,最终调用了setTargetOffsetTopAndBottom(targetY - mCurrentTargetOffsetTop),看来答案就在这里了。
void setTargetOffsetTopAndBottom(int offset) {
mCircleView.bringToFront();
ViewCompat.offsetTopAndBottom(mCircleView, offset);
mCurrentTargetOffsetTop = mCircleView.getTop();
}
这个函数的代码只有三行:
- mCircleView.bringToFront();——-将mCricleView带到顶层视图
- ViewCompat.offsetTopAndBottom(mCircleView, offset);——移动mCricleView
- mCurrentTargetOffsetTop = mCircleView.getTop();———更新布局的偏移量,以便mCricleView布局到正确位置。
注:setTargetOffsetTopAndBottom也在其他位置调用,但没有经过moveSpinner中的动画和是否可见处理的,视图层并不会显示在顶层。
至此,我们的探寻之路已经结束。
- 接下来看一下bringToFront的实现,它最后实际上调用了ViewGroup的bringChildToFront
public void bringChildToFront(View child) {
final int index = indexOfChild(child);
if (index >= 0) {
// 移除此View
removeFromArray(index);
// 添加此View
addInArray(child, mChildrenCount);
child.mParent = this;
// 重新布局
requestLayout();
invalidate();
}
}
2)在PullRefreshLayout中
这个控件有接近2000星,代码风格和SwipeRefreshLayout有的地方很类似。它的ACTION_MOVE中调用了setTargetOffsetTop,而作者机智的将bringToFront注释掉了,同时将mTarget也进行偏移,这样就变成qq风格的刷新了。
private void setTargetOffsetTop(int offset, boolean requiresUpdate) {
// mRefreshView.bringToFront();
mTarget.offsetTopAndBottom(offset);
mCurrentOffsetTop = mTarget.getTop();
mRefreshDrawable.offsetTopAndBottom(offset);
if (requiresUpdate && android.os.Build.VERSION.SDK_INT < 11) {
invalidate();
}
}
于是我也做了一个尝试,我把SwipeRefreshLayout的源码全部复制一遍,CircleView也复制一遍。只改变setTargetOffsetTopAndBottom方法。我注释了bringToFront行,加上了mTarget的偏移,结果也是一个qq风格刷新控件,完全不用学习,完全不用思考嘛,我也写出了一个2000星的控件啊!!!!!然后又尝试了一下,将bringToFront的注释去掉,mTarget的偏移不去掉,头部又遮挡了内容视图,只是由于偏移的问题,最后内容视图会白边闪烁一下而已。
好,以上内容全是自我鼓励,该学习还是学习的!
这是我自己的第一篇博客,主要记录自己学习过程中的所见所得。如有不足,还望指正。