1. 滑动冲突的本质原因
- 本质原因是多个层级的 View 均支持触摸滑动,滑动方向可能一直也可能不一致,响应结果的混乱造成滑动冲突
- 有些封装好的组件比如 ViewPager 内部处理了滑动冲突,但有的组件比如 ScrollView 就没有,需要手动处理滑动冲突
2. 常见的滑动冲突场景
- 外部滑动方向和内部滑动方向不一致
- 外部滑动方向和内部滑动方向一致
- 上面两种情况的嵌套
3. 滑动冲突的处理规则
- 对于场景 1,处理规则是:根据滑动方向是水平滑动还是竖直滑动来判断由外部还是内部 View 拦截事件,一般是通过水平和竖直方向的距离差来判断滑动方向
- 对于场景 2 和场景 3,一般都能从业务上找到突破点,比业务上有规定:当处于某中状态时需要外部 View 响应滑动,处于另一种状态时需要内部 View 响应滑动
4. 滑动冲突的解决方式
外部拦截法
含义:指点击事件都先经过父容器的拦截处理,如果父容器需要此事件就拦截,如果不需要此事件就不拦截
Demo: 重写父容器的
onInterceptTouchEvent()
方法,做相应的拦截处理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// 伪代码
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercepted = false;
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
intercepted = false;
break;
case MotionEvent.ACTION_MOVE:
if (父容器需要当前点击事件) {
intercepted = true;
} else {
intercepted = false;
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;
break;
default:
break;
}
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;
}- 上述代码是外部拦截法的典型逻辑,针对不同的滑动冲突,只需要修改父容器需要当前点击事件这个条件即可,其他均不需做修改并且也不能修改
MotionEvent.ACTION_DOWN
事件:父容器必须返回 false,即不拦截 ACTION_DOWN 事件,这是因为一旦父容器拦截了 ACTION_DOWN,那么后续的 ACTION_MOVE 和 ACTION_UP 事件都会直接交由父容器处理,此时没法再传递给子元素了MotionEvent.ACTION_MOVE
事件:这个事件可以根据需要来决定是否拦截,如果父容器需要拦截就返回 true,否则返回 falseMotionEvent.ACTION_UP
事件:这里必须返回 false,因为 ACTION_UP 事件本身没有太多意义
内部拦截法
含义:指父容器不拦截任何事件,所有的事件都传递给子元素,如果子元素需要此事件就直接消耗掉,否则就交由父容器处理,一般需要配合
requestDisallowInterceptTouchEvent()
方法Demo: 重写子元素的
dispatchTouchEvent()
方法,且父容器也要做响应修改1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25public boolean dispatchTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
parent.requestDisallowInterceptTouchEvent(true);
break;
case MoitonEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int daltaY = y - mLastY;
if (父容器需要此类点击事件) {
parent.requestDisallowInterceptTouchEvent(false);
}
break;
case MotionEvent.ACTION_UP:
break;
default:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}1
2
3
4
5
6
7
8
9// 父容器所做的修改
public boolean onInterceptTouchEvent(MotionEvent event) {
int action = event.getAction();
if (action == MotionEvent.ACTION_DOWN) {
return false;
} else {
return true;
}
}- 上述代码是内部拦截法的典型代码,当面对不同的滑动策略时只需要修改里面的条件即可,其他不需要做改动而且也不能有改动
- 除了子元素需要做处理以外,父元素也要默认拦截除了 ACTION_DOWN 以外的其他事件,这样当子元素调用
parent.requestDisallowIncerceptTouchEvent(false)
方法时,父元素才能继续拦截所需的事件 - 父容器不能拦截 ACTION_DOWN 事件是因为 ACTION_DOWN 事件并不受 FLAG_DISALLOW_INTERCEPT 这个标记位的控制,所以一旦父容器拦截 ACTION_DOWN 事件,那么所有的事件都无法传递到子元素中去,这样内部拦截就无法起作用
5. Demo: 模拟场景 1
需求:实现一个类似于 ViewPager 中嵌套 ListView 的效果,模拟场景 1
思路:自定义一个控件 HorizontalScrollViewEx,可以水平滑动,其内部添加若干个 ListView
策略:选择水平和竖直的滑动距离差来解决滑动冲突,HorizontalScrollViewEx 是父容器,ListView 是子元素
实现:
准备代码:
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
40Activity 中的初始化代码,创建 3 个 ListView 并把 ListView 加入到自定义的 HorizontalScrollViewEx 中
public class DemoActivity_1 extends Activity {
private static final String TAG = "SecondActivity";
private HorizontalScrollViewEx mListContainer;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.demo_1);
"onCreate);
initViews();
}
private void initViews() {
LayoutInflater inflater = getLayoutInflater();
mListContainer = (HorizontalScrollViewEx) findViewById(R.id.container);
final int screenWidth = MyUtils.getScreenMetris(this).widthPixels;
final int screenHeight = MyUtils.getScreenMetrics(this).heightPixels;
for (int i = 0; i < 3; i++) {
ViewGroup layout = (ViewGroup) inflater.inflate(R.layout.content_layout, mListContainer, false);
screenWidth; =
TextView textView = (TextView) layout.findViewById(R.id.title);
" + (i + 1));
255/(i+1), 0));
createList(layout);
mListContainer.addView(layout);
}
}
private void createList(ViewGroup layout) {
ListView listView = (ListView) layout.findViewById(R.id.list);
datas = new ArrayList<String> ();
for (int i = 0; i < 50; i++) {
" + i);
}
adapter = new ArrayAdapter<String> (this, R.layout.content_list_item, R.id.name, datas);
listView.setAdapter(adapter);
}
}外部拦截法:只需修改父容器需要拦截事件的条件,本例中即为水平距离差比竖直距离差大,此时父容器拦截当前点击事件(一般比内部拦截法简答,推荐)
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// HorizontalScrollViewEx 的具体实现,只展示和滑动冲突相关的代码
pubic class HorizontalScrollViewEx extends ViewGroup {
private static final String TAG = "HorizontalScrollViewEx";
private int mChildrenSize;
private int mChildWidth;
private int mChildIndex;
// 分别记录上次滑动的坐标
private int mLastX = 0;
private int mLastY = 0;
// 分别记录上次滑动的坐标(onInterceptTouchEvent())
private int mLastXIntercept = 0;
private int mLastYIntercept = 0;
private Scroller mScroller;
private VelocityTracker mVelocityTracker;
...
private void init() {
mScroller = new Scroller(getContext());
mVelocityTracker = VelocityTracker.obtain();
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercepted = false;
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
intercepted = false;
if (!mScroller.isFinished()) {
mScroller.abortAnimation(); // 优化滑动体验
intercepted = true;
}
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
if (Math.abs(deltaX) > Math.abs(deltaY)) {
intercepted = true;
} else {
intercepted = false;
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;
break;
default:
break;
}
Log.d(TAG, "intercepted=" + intercepted);
mLastX = x;
mLastY = y;
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
mVelocityTracker.addMovement(event);
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (!mScroller.isFinished()) {
mScroller.abortAnimation(); // 优化滑动体验
}
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
scrollBy(-deltaX, 0);
break;
case MotionEvent.ACTION_UP:
int scrollX = getScrollX();
int scrollToChildIndex = scrolX / mChildWidth;
mVelocityTracker.computeCurrentVelocity(1000);
float xVelocity = mVelocityTracker.getXVelocity();
if (Math.abs(xVelocity >= 50)) {
mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
} else {
mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth;
}
mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));
int dx = mChildIndex * mChildWidth - scrollX;
smoothScrollBy(dx, 0);
mVelocityTracker.clear();
break;
default:
break;
}
mLastX = x;
mLastY = y;
return true;
}
private void smoothScrollBy(int dx, int dy) {
mScroller.startScroll(getScrollX(), 0, dx, 0, 500);
invalidate(); // 刷新 View 界面,必须在 UI 线程中调用
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate() // 刷新 View 界面,可以在非 UI 线程中调用
}
}
...
}内部拦截法:只需修改 ListView 的
dispatchTouchEvent()
方法中的父容器的拦截逻辑,同时让父容器拦截 ACTION_MOVE 和 ACTION_UP 事件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// 为了重写 ListView 的 dispatchTouchEvent() 方法,必须自定义一个 ListView,即 ListViewEx,然后对内部拦截法的模板代码进行修改,ListViewEx 的实现如下
public class ListViewEx extends ListView {
private static final String TAG = "ListViewEx";
private HorizontalScrollViewEx2 mHorizontalScrollViewEx2;
// 分别记录上次滑动的坐标
private int mLastX = 0;
private int mLastY = 0;
...
public boolean dispatchTouchEvent() {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MoitonEvent.ACTION_DOWN:
mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
if (Math.abs(deltaX) > Math.abs(deltaY)) {
mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(false);
}
break;
case MotionEvent.ACTION_UP:
break;
dafault:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17// 除了对 ListView 的修改,还需要修改 HorizontalScrollViewEx 的 onInterceptTouchEvent() 方法,修改后的类暂且叫 HorizontalScrollViewEx2,其 onInterceptTouchEvent() 如下
public boolean onInterceptTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
int action = event.getAction();
if (action == MotionEvent.ACTION_DOWN) {
mLastX = x;
mLastY = y;
if (!mScroller.isFinished()) {
mScroller.abortAnimation(); // 不是必须的,主要是为了优化滑动体验
return true;
}
return false;
} else {
return true;
}
}
6. Demo: 模拟场景 2
需求:提供一个可以上下滑动的父容器 StickyLayout,内部分别放一个 Header 和一个 ListView,模拟场景 2
思路:业务上父容器 StickyLayout 的滑动规则:
- 当 Header 显示时或者 ListView 滑动到顶部时,由父容器 StickyLayout 拦截事件
- 当 Header 隐藏时,如果 ListView 已经滑动到顶部且当前手势是向下滑动时,还是父容器拦截事件;其他情况则由子元素 ListView 拦截事件
策略:从具体业务上确定解决滑动冲突的逻辑,即确定满足具体业务的滑动规则
实现:重写父容器 StickyLayout 的
onInterceptTouchEvent()
方法,子元素 ListView 不用修改(外部拦截法)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// StickyLayout 的具体实现,只展示滑动冲突相关主要代码
public class StickyLayout extends LinearLayout {
private int mTouchSlop;
// 分别记录上次滑动的坐标
private int mLastX = 0;
private int mLastY = 0;
// 分别记录上次滑动的坐标(onInterceptTouchEvent())
private int mLastXIntercept = 0;
private int mLastYIntercept = 0;
...
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
int intercepted = 0;
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mLastXIntercept = x;
mLastYIntercept = y;
mLastX = x;
mLastY = y;
intercepted = 0;
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
if (mDisallowInterceptTouchEventOnHeader && y <= getHeaderHeight()) {
intercepted = 0;
} else if (Math.abs(deltaY) <= Math.abs(deltaX)) {
intercepted = 0;
} else if (mStatus == STATUS_EXPANDED && deltaY <= -mTouchSlop) {
intercepted = 1;
} else if (mGiveUpTouchEventListener != null) {
if (mGiveUpTouchEventListener.giveUpTouchEvent(event) && deltaY >= mTouchSlop) {
intercepted = 1;
}
}
break;
case MotionEvent.ACTION_UP:
intercepted = 0;
mLastXIntercept = mLastYIntercept = 0;
break;
default:
break;
}
if (DEBUG) {
Log.d(TAG, "intercepted=" + intercepted);
}
return intercepted != 0 && mIsSticky;
}
@Override
public booelan onTouchEvent(MotionEvent event) {
if (!mIsSticky) {
return true;
}
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
if (DEBUG) {
Log.d(TAG, "mHeaderHeight=" + mHeaderHeight + " deltaY=" + deltaY + " mLastY=" + mLastY);
}
mHeaderHeight += deltaY;
setHeaderHeight(mHeaderHeight);
break;
case MotionEvent.ACTION_UP:
// 这里做了一下判断,当松开手的时候,会自动向两边滑动,具体滑向哪边要看当前所处的位置
int destHeight = 0;
if (mHeaderHeight <= mOriginalHeaderHeight * 0.5) {
destHeight = 0;
mStatus = STATUS_COLLAPSED;
} else {
destHeight = mOriginalHeaderHeight;
mStatus = STATUS_EXPANDED;
}
// 慢慢滑向终点
this.smoothSetHeaderHeight(mHeaderHeight, destHeight, 500);
break;
default:
break;
}
mLastX = x;
mLastY = y;
return true;
}
...
}当事件落在 Header 上面时父容器不会拦截事件
如果竖直距离差小于水平距离差,父容器也不会拦截事件
当 Header 是展开状态并且向上滑动时父容器拦截事件
当 ListView 滑动到顶部并且向下滑动时,父容器也会拦截事件
1
2
3
4
5
6
7
8
9
10// giveUpTouchEvent() 是一个接口方法,由外部实现,主要用来判断 ListView 是否滑动到顶部,实现如下
public booelan giveUpTouchEvent(MotionEvent event) {
if (expandableListView.getFirstVisiblePosition() == 0) {
View view = expandableListView.getChildAt(0);
if (view != null && view.getTop() >= 0) {
return true;
}
}
return false;
}