欧同学

一种无痕过渡下拉刷新控件的实现思路

相信大家已经对下拉刷新熟悉得不能再熟悉了,市面上的下拉刷新琳琅满目,然而有很多在我看来略有缺陷,接下来我将说明一下存在的缺陷问题,然后提供一种思路来解决这一缺陷,废话不多说!往下看嘞!


1.市面一些下拉刷新控件普遍缺陷演示

以直播吧APP为例:

第1种情况:
滑动控件在初始的0位置时,手势往下滑动然后再往上滑动,可以看到滑动到初始位置时滑动控件不能滑动。

原因:
下拉刷新控件响应了触摸事件,后续的一系列事件都由它来处理,当滑动控件到顶端的时候,滑动事件都被下拉刷新控件消费掉了,传递不到它的子控件即滑动控件,因此滑动控件不能滑动。

这里写图片描述

第2种情况:
滑动控件滑动到某个非0位置时,这时下拉回0位置时,可以看到下拉刷新头部没有被拉出来。

原因:
滑动控件响应了触摸事件,后续的一系列事件都由它来处理,当滑动控件到顶端的时候,滑动事件都被滑动控件消费掉了,父控件即下拉刷新控件消费不了滑动事件,因此下拉刷新头部没有被拉出来。

这里写图片描述

可能大部分人觉得无关痛痒,把手指抬起再下拉就可以了,but对于强迫症的我而言,能提供一个无痕过渡才是最符合操作逻辑的,因此接下来我来讲解下实现的思路。


2.实现的思路讲解

2.1.事件分发机制简介(来源于Android开发艺术探索)

dispatchTouchEvent、onInterceptTouchEvent和onTouchEvent方法的关系伪代码

1
2
3
4
5
6
7
8
9
public boolean dispatchTouchEvent(MotionEvent ev) {
boolean consume = false;
if(onInterceptTouchEvent(ev)) {
consume = onTouchEvent(ev);
} else {
consume = child.dispatchTouchEvent(ev);
}
return consume;
}

1.由代码可知若当前View拦截事件,就交给自己的onTouchEvent去处理,否则就丢给子View继续走相同的流程。
2.事件传递顺序:Activity -> Window -> View,如果View都不处理,最终将由Activity的onTouchEvent
处理,是一种责任链模式的实现。
3.正常情况,一个事件序列只能被一个View拦截且消耗。
4.某个View一旦决定拦截,这一个事件序列只能由它处理,并且它的onInterceptTouchEvent不会再被调用
5.不消耗ACTION_DOWN,则事件序列都会由其父元素处理。

2.2.一般下拉刷新的实现思路猜想
首先,下拉刷新控件作为一个容器,需要重写onInterceptTouchEvent和onTouchEvent这两个方法,
然后在onInterceptTouchEvent中判断ACTION_DOWN事件,根据子控件的滑动距离做出判断,若还没滑动过,则onInterceptTouchEvent返回true表示其拦截事件,然后在onTouchEvent中进行下拉刷新的头部显示隐藏的逻辑处理;
若子控件滑动过了,不拦截事件,onInterceptTouchEvent返回false,后续其下拉刷新的头部显示隐藏的逻辑处理就无法被调用了。

2.3.无痕过渡下拉刷新控件的实现思路
从2.2中可以看出,要想无痕过渡,下拉刷新控件不能拦截事件,这时候你可能会问,既然把事件给了子控件,后续拉刷新头部逻辑怎么实现呢?
这时候就要用到一般都忽略的事件分发方法dispatchTouchEvent了,此方法在ViewGroup默认返回true表示分发事件,即使子控件拦截了事件,父布局的dispatchTouchEvent仍然会被调用,因为事件是传递下来的,这个方法必定被调用。
所以我们可以在dispatchTouchEvent时对子控件的滑动距离做出判断,在这里把下拉刷新的头部的逻辑处理掉,同时在函数调用return super.dispatchTouchEvent(event) 前把event的action设置为ACTION_CANCEL,这样子子控件就不会响应滑动的操作。


3.代码实现

3.1.确定需求

  • 需要适配任意控件,例如RecyclerView、ListView、ViewPager、WebView以及普通的不能滑动的View
  • 不能影响子控件原来的事件逻辑
  • 暴露方法提供手动调用刷新功能
  • 可以设置禁止下拉刷新功能

3.2.代码讲解

需要的变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class RefreshLayout extends LinearLayout {
// 隐藏的状态
private static final int HIDE = 0;
// 下拉刷新的状态
private static final int PULL_TO_REFRESH = 1;
// 松开刷新的状态
private static final int RELEASE_TO_REFRESH = 2;
// 正在刷新的状态
private static final int REFRESHING = 3;
// 正在隐藏的状态
private static final int HIDING = 4;
// 当前状态
private int mCurrentState = HIDE;
// 头部动画的默认时间(单位:毫秒)
public static final int DEFAULT_DURATION = 200;
// 头部高度
private int mHeaderHeight;
// 内容控件的滑动距离
private int mContentViewOffset;
// 最小滑动响应距离
private int mScaledTouchSlop;
// 记录上次的Y坐标
private float mLastMotionY;
// 记录一开始的Y坐标
private float mInitDownY;
// 响应的手指
private int mActivePointerId;
// 是否在处理头部
private boolean mIsHeaderHandling;
// 是否可以下拉刷新
private boolean mIsRefreshable = true;
// 内容控件是否可以滑动,不能滑动的控件会做触摸事件的优化
private boolean mContentViewScrollable = true;
// 头部,为了方便演示选取了TextView
private TextView mHeader;
// 容器要承载的内容控件,在XML里面要放置好
private View mContentView;
// 值动画,由于头部显示隐藏
private ValueAnimator mHeaderAnimator;
// 刷新的监听器
private OnRefreshListener mOnRefreshListener;

初始化时创建头部执行显示隐藏的值动画,添加头部到布局中,并且通过设置paddingTop隐藏头部

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
public RefreshLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
addHeader(context);
}
private void init() {
mScaledTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
mHeaderAnimator = ValueAnimator.ofInt(0).setDuration(DEFAULT_DURATION);
mHeaderAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
if (getContext() == null) {
// 若是退出Activity了,动画结束不必执行头部动作
return;
}
// 通过设置paddingTop实现显示或者隐藏头部
int offset = (Integer) valueAnimator.getAnimatedValue();
mHeader.setPadding(0, offset, 0, 0);
}
});
mHeaderAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
if (getContext() == null) {
// 若是退出Activity了,动画结束不必执行头部动作
return;
}
if (mCurrentState == RELEASE_TO_REFRESH) {
// 释放刷新状态执行的动画结束,意味接下来就是刷新了,改状态并且调用刷新的监听
mHeader.setText("正在刷新...");
mCurrentState = REFRESHING;
if (mOnRefreshListener != null) {
mOnRefreshListener.onRefresh();
}
} else if (mCurrentState == HIDING) {
// 下拉状态执行的动画结束,隐藏头部,改状态
mHeader.setText("我是头部");
mCurrentState = HIDE;
}
}
});
}
// 头部的创建
private void addHeader(Context context) {
// 强制垂直方法
setOrientation(LinearLayout.VERTICAL);
mHeader = new TextView(context);
mHeader.setBackgroundColor(Color.GRAY);
mHeader.setTextColor(Color.WHITE);
mHeader.setText("我是头部");
mHeader.setTextSize(TypedValue.COMPLEX_UNIT_SP, 25);
mHeader.setGravity(Gravity.CENTER);
addView(mHeader, LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
mHeader.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
// 算出头部高度
mHeaderHeight = mHeader.getMeasuredHeight();
// 移除监听
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
mHeader.getViewTreeObserver().removeOnGlobalLayoutListener(this);
} else {
mHeader.getViewTreeObserver().removeGlobalOnLayoutListener(this);
}
// 设置paddingTop为-mHeaderHeight,刚好把头部隐藏掉了
mHeader.setPadding(0, -mHeaderHeight, 0, 0);
}
});
}

在填充完布局后取出内容控件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Override
protected void onFinishInflate() {
super.onFinishInflate();
// 设置长点击或者短点击都能消耗事件,要不这样做,若孩子都不消耗,最终点击事件会被它的上级消耗掉,后面一系列的事件都只给它的上级处理了
setLongClickable(true);
// 获取内容控件
mContentView = getChildAt(1);
if (mContentView == null) {
// 为空抛异常,强制要求在XML设置内容控件
throw new IllegalArgumentException("You must add a content view!");
}
if (!(mContentView instanceof ScrollingView
|| mContentView instanceof WebView
|| mContentView instanceof ScrollView
|| mContentView instanceof AbsListView)) {
// 不是具有滚动的控件,这里设置标志位
mContentViewScrollable = false;
}
}

重头戏来了,分发对于下拉刷新的特殊处理:
1.mContentViewOffset用于判别内容页的滑动距离,在无偏移值时才去处理下拉刷新的操作;
2.在mContentViewOffset!=0即内容页滑动的第一个瞬间,强制把MOVE事件改为DOWN,是因为之前MOVE都被拦截掉了,若不给个DOWN让内容页重新定下滑动起点,会有一瞬间滑动一大段距离的坑爹效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
@Override
public boolean dispatchTouchEvent(final MotionEvent event) {
if (!mIsRefreshable) {
// 禁止下拉刷新,直接把事件分发
return super.dispatchTouchEvent(event);
}
if ((mCurrentState == REFRESHING
|| mCurrentState == RELEASE_TO_REFRESH
|| mCurrentState == HIDING)
&& mHeaderAnimator.isRunning()) {
// 正在刷新,正在释放,正在隐藏头部都不处理事件,并且不分发下去
return true;
}
// 支持多指触控
int actionMasked = MotionEventCompat.getActionMasked(event);
switch (actionMasked) {
case MotionEvent.ACTION_DOWN: {
// 记录响应的手指
mActivePointerId = event.getPointerId(0);
// 记录初始Y坐标
mInitDownY = mLastMotionY = event.getY(0);
}
break;
case MotionEvent.ACTION_POINTER_DOWN: {
// 另外一根手指按下,切换到这个手指响应
int pointerDownIndex = MotionEventCompat.getActionIndex(event);
if (pointerDownIndex < 0) {
Log.e("RefreshLayout", "296行-dispatchTouchEvent(): " + "Got ACTION_POINTER_DOWN event but have an invalid action index.");
return dispatchTouchEvent(event);
}
mActivePointerId = event.getPointerId(pointerDownIndex);
mLastMotionY = event.getY(pointerDownIndex);
}
break;
case MotionEvent.ACTION_POINTER_UP: {
// 另外一根手指抬起,切换回其他手指响应
final int pointerUpIndex = MotionEventCompat.getActionIndex(event);
final int pointerId = event.getPointerId(pointerUpIndex);
if (pointerId == mActivePointerId) {
// 抬起手指就是之前控制滑动手指,切换其他手指响应
final int newPointerIndex = pointerUpIndex == 0 ? 1 : 0;
mActivePointerId = event.getPointerId(newPointerIndex);
}
mLastMotionY = event.getY(event.findPointerIndex(mActivePointerId));
}
break;
case MotionEvent.ACTION_MOVE: {
// 移动事件
if (mActivePointerId == INVALID_POINTER) {
Log.e("RefreshLayout", "235行-dispatchTouchEvent(): " + "Got ACTION_MOVE event but don't have an active pointer id.");
return dispatchTouchEvent(event);
}
float y = event.getY(event.findPointerIndex(mActivePointerId));
// 移动的偏移量
float yDiff = y - mLastMotionY;
mLastMotionY = y;
if (mContentViewOffset == 0 && (yDiff > 0 || (yDiff < 0 && isHeaderShowing()))) {
// 内容控件还没滚动时,下拉或者在头部还在显示的时候上滑时,交由自己处理滑动事件
// 滑动的总距离
float totalDistanceY = mLastMotionY - mInitDownY;
if (totalDistanceY > 0 && totalDistanceY <= mScaledTouchSlop && yDiff > 0) {
// 下拉时,优化滑动逻辑,不要稍微一点位移就响应
return super.dispatchTouchEvent(event);
}
// 正在处理事件
mIsHeaderHandling = true;
if (mCurrentState == REFRESHING) {
// 正在刷新,不让contentView响应滑动
event.setAction(MotionEvent.ACTION_CANCEL);
}
// 处理下拉头部
scrollHeader(yDiff);
break;
} else if (mIsHeaderHandling) {
// 在头部隐藏的那一瞬间的事件特殊处理
if (mContentViewScrollable) {
// 1.可滑动的View,由于之前处理头部,之前的MOVE事件没有传递到内容页,这里需要要ACTION_DOWN来重新告知滑动的起点,不然会瞬间滑动一段距离
// 2.对于不滑动的View设置了点击事件,若这里给它一个ACTION_DOWN事件,在手指抬起时ACTION_UP事件会触发点击,因此这里做了处理
event.setAction(MotionEvent.ACTION_DOWN);
}
mIsHeaderHandling = false;
}
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP: {
// 处理手指抬起或取消事件
mActivePointerId = INVALID_POINTER;
if (isHeaderShowing()) {
// 头部显示情况下
if (actionMasked == MotionEvent.ACTION_CANCEL) {
// 取消的话强制不能刷新,状态改为下拉刷新,接下来autoScrollHeader就会隐藏头部
mCurrentState = PULL_TO_REFRESH;
}
autoScrollHeader();
}
}
break;
default:
break;
}
if (mCurrentState != REFRESHING
&& isHeaderShowing()
&& actionMasked != MotionEvent.ACTION_UP
&& actionMasked != MotionEvent.ACTION_POINTER_UP) {
// 不是在刷新的时候,并且头部在显示, 某些情况下不让contentView响应事件
event.setAction(MotionEvent.ACTION_CANCEL);
}
return super.dispatchTouchEvent(event);
}

头部的处理逻辑:拿到下拉偏移量,然后动态去设置头部的paddingTop值,即可实现显示隐藏;手指抬起时根据状态决定是显示刷新还是直接隐藏头部

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/**
* 拉动头部
*
* @param diff 拉动距离
*/
private void scrollHeader(float diff) {
// 除以3相当于阻尼值
diff /= 3;
// 计算出移动后的头部位置
int top = (int) (diff + mHeader.getPaddingTop());
// 控制头部位置最小不超过-mHeaderHeight,最大不超过mHeaderHeight * 3
mHeader.setPadding(0, Math.min(Math.max(top, -mHeaderHeight), mHeaderHeight * 3), 0, 0);
if (mCurrentState == REFRESHING) {
// 之前还在刷新状态,继续维持刷新状态
mHeader.setText("正在刷新...");
return;
}
if (mHeader.getPaddingTop() > mHeaderHeight / 2) {
// 大于mHeaderHeight / 2时可以刷新了
mHeader.setText("可以释放刷新...");
mCurrentState = RELEASE_TO_REFRESH;
} else {
// 下拉状态
mHeader.setText("正在下拉...");
mCurrentState = PULL_TO_REFRESH;
}
}
/**
* 执行头部显示或隐藏滑动
*/
private void autoScrollHeader() {
// 处理抬起事件
if (mCurrentState == RELEASE_TO_REFRESH) {
// 释放刷新状态,手指抬起,通过动画实现头部回到(0,0)位置
mHeaderAnimator.setIntValues(mHeader.getPaddingTop(), 0);
mHeaderAnimator.setDuration(DEFAULT_DURATION);
mHeaderAnimator.start();
mHeader.setText("正在释放...");
} else if (mCurrentState == PULL_TO_REFRESH || mCurrentState == REFRESHING) {
// 下拉状态或者正在刷新状态,通过动画隐藏头部
mHeaderAnimator.setIntValues(mHeader.getPaddingTop(), -mHeaderHeight);
if (mHeader.getPaddingTop() <= 0) {
mHeaderAnimator.setDuration((long) (DEFAULT_DURATION * 1.0 /
mHeaderHeight * (mHeader.getPaddingTop() + mHeaderHeight)));
} else {
mHeaderAnimator.setDuration(DEFAULT_DURATION);
}
mHeaderAnimator.start();
if (mCurrentState == PULL_TO_REFRESH) {
// 下拉状态的话,把状态改为正在隐藏头部状态
mCurrentState = HIDING;
mHeader.setText("收回头部...");
}
}
}

你可能会问了,这个mContentViewOffset怎么知道呢?接下来就是处理的方法,我会针对不同的滑动控件,去设置它们的滑动距离的监听,方法各种各样,通过handleTargetOffset去判别View的类型采取不同的策略;然后你可能会觉得要是我那个控件我也要实现监听咋办?这个简单,继承我已经实现的监听器,再补充你想要的功能即可,这个时候就不能再调handleTargetOffset这个方法了呗。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
// 设置内容页滑动距离
public void setContentViewOffset(int offset) {
mContentViewOffset = offset;
}
/**
* 根据不同类型的View采取不同类型策略去计算滑动距离
*
* @param view 内容View
*/
public void handleTargetOffset(View view) {
if (view instanceof RecyclerView) {
((RecyclerView) view).addOnScrollListener(new RecyclerViewOnScrollListener());
} else if (view instanceof NestedScrollView) {
((NestedScrollView) view).setOnScrollChangeListener(new NestedScrollViewOnScrollChangeListener());
} else if (view instanceof WebView) {
view.setOnTouchListener(new WebViewOnTouchListener());
} else if (view instanceof ScrollView) {
view.setOnTouchListener(new ScrollViewOnTouchListener());
} else if (view instanceof ListView) {
((ListView) view).setOnScrollListener(new ListViewOnScrollListener());
}
}
/**
* 适用于RecyclerView的滑动距离监听
*/
public class RecyclerViewOnScrollListener extends RecyclerView.OnScrollListener {
int offset = 0;
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
offset += dy;
setContentViewOffset(offset);
}
}
/**
* 适用于NestedScrollView的滑动距离监听
*/
public class NestedScrollViewOnScrollChangeListener implements NestedScrollView.OnScrollChangeListener {
@Override
public void onScrollChange(NestedScrollView v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
setContentViewOffset(scrollY);
}
}
/**
* 适用于WebView的滑动距离监听
*/
public class WebViewOnTouchListener implements View.OnTouchListener {
@Override
public boolean onTouch(View view, MotionEvent motionEvent) {
setContentViewOffset(view.getScrollY());
return false;
}
}
/**
* 适用于ScrollView的滑动距离监听
*/
public class ScrollViewOnTouchListener extends WebViewOnTouchListener {
}
/**
* 适用于ListView的滑动距离监听
*/
public class ListViewOnScrollListener implements AbsListView.OnScrollListener {
@Override
public void onScrollStateChanged(AbsListView absListView, int i) {
}
@Override
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
if (firstVisibleItem == 0) {
View c = view.getChildAt(0);
if (c == null) {
return;
}
int firstVisiblePosition = view.getFirstVisiblePosition();
int top = c.getTop();
int scrolledY = -top + firstVisiblePosition * c.getHeight();
setContentViewOffset(scrolledY);
} else {
setContentViewOffset(1);
}
}
}

最后参考谷歌大大的SwipeRefreshLayout提供setRefreshing来开启或关闭刷新动画,至于openHeader为啥要post(Runnable)呢?相信用过SwipeRefreshLayout在onCreate的时候直接调用setRefreshing(true)没有小圆圈出来的都知道这个坑!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public void setRefreshing(boolean refreshing) {
if (refreshing && mCurrentState != REFRESHING) {
// 强开刷新头部
openHeader();
} else if (!refreshing) {
closeHeader();
}
}
private void openHeader() {
post(new Runnable() {
@Override
public void run() {
mCurrentState = RELEASE_TO_REFRESH;
mHeaderAnimator.setDuration((long) (DEFAULT_DURATION * 2.5));
mHeaderAnimator.setIntValues(mHeader.getPaddingTop(), 0);
mHeaderAnimator.start();
}
});
}
private void closeHeader() {
mHeader.setText("刷新完毕,收回头部...");
mCurrentState = HIDING;
mHeaderAnimator.setIntValues(mHeader.getPaddingTop(), -mHeaderHeight);
// 0~-mHeaderHeight用时DEFAULT_DURATION
mHeaderAnimator.setDuration(DEFAULT_DURATION);
mHeaderAnimator.start();
}

3.3.效果展示

这里写图片描述

这里写图片描述

这里写图片描述

除了以上三个还有在Demo中实现了ListView、ViewPager、ScrollView、NestedScrollView,具体看代码即可

Demo地址:Github:RefreshLayoutDemo,觉得还不错的话给个Star哦。