事件的分发机制和滑动冲突是view事件体系的难点,上篇总结了分发机制,这里开始着手滑动冲突。滑动冲突如何产生的呢?其实在界面中只要有内外两层可以同时滑动的控件时。便会产生滑动冲突。如何解决滑动冲突呢?本节便解开这个神秘的面纱。
一、常见的滑动冲突
1、滑动冲突场景
- 场景1:外部滑动方向和内部滑动方向不一致。
- 场景2:外部滑动方向和内部滑动方向一致。
- 场景3:上面两种情况的嵌套。
2、滑动冲突场景图
3、场景分析
场景一:
主要是viewpager和fragment配合使用所组成的页面滑动效果。而viewpager可以左右滑动切花页面,我们往往会在fragment中添加ListView或者Recyclerview控件让他上下滑动。
疑问:
这种情况正常啊,平时我们也经常使用,没碰见滑动冲突啊!!!其实viewpager这个控件已经帮我们处理这种滑动冲突场景。
试想:
我们没有使用viewpager,而是使用了HorizontalScrollView等控件,这时必须手动处理滑动冲突了。否则造成结果就是内外两层只能有一层滑动。
场景二:
情况比较特殊,当内外两层两个控件在同一方向上可以滑动时,显然存在逻辑问题,这时你的手指在屏幕上滑动时,系统不知道你想让哪个控件滑动。
问题:当你手指在屏幕滑动时,要么是只有一层能滑动,要么是内外两层都滑动的很卡顿。
开发常见:
ListView头部有一个可下拉的刷新头,那么就要判断ListView是否滑动到顶部,到顶部时滑动出现刷新头
场景三:
这种情况是1,2两种情况的嵌套。
常见案例:
最外层是Slidemenu,中间层是viewpager,内层viewpager页面都是listview。
解决:分别解决各层冲突即可
二、滑动冲突的处理规则
不管滑动冲突有多复杂都有特定的规则,我们根据规则处理即可。
- 场景1处理规则
当用户左右滑动时,需要让外部的view拦截事件。当用户上下滑动时需要让内部的view拦截事件。这时我们可以根据他们的特征来解决滑动冲突。确切的说就是根据是水平滑动还是竖直滑动来判断到底由谁来拦截事件。如下图,根据起始两点的坐标我们就可以计算出是水平滑动还是竖直滑动。计算规则有很多:比如根据滑动路径和水平方向形成的夹角、根据水平滑动的距离和竖直滑动的距离差值、特殊情况下根据水平数值方向的滑动速度差。
我们常用的就是根据水平滑动的距离和竖直滑动的距离差值来当计算规则,比如水平距离大时我们认为是水平滑动。反之为竖直滑动。
-
场景2处理规则
这种同方向的滑动我们是无法判断的,这时可以根据具体的业务来判断是谁先滑动。如下图。
这是个listview给他添加了个headview 可以下拉滑动,这个滑动有具体的业务逻辑,比如判断这个listview是否滑动到顶部、是否下滑,来决定headview的滑动。 -
场景3处理规则
这个也是根据逻辑而定,处理时一层层的解决即可。
三、滑动冲突的解决方式
我们先分析场景1,这也是最简单、最典型的一种滑动冲突,因为他的滑动规则比较简单。不管多复杂的滑动冲突他们之间的区别也就是滑动规则的不同。抛开滑动规则不说我们需要找到一种不依赖具体的滑动规则的通用解决方案。这里我们根据场景1得到了通用方案,然后场景2,3我们只需要修改具体的滑动逻辑即可。
但是要怎么做才能够将事件交给指定的View去处理呢?这里就要用到事件分发机制了。
两种通用的解决方式:
- 外部拦截法
- 内部拦截法
1、外部拦截法
外部拦截法是指当父控件接收到事件后,判断该事件是否需要,如果需要则就行拦截,否则就不拦截。外部拦截法,需要重写父控件的onInterceptTouchEvent()方法,在内部做拦截。相应的伪代码如下:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercepted = false;
switch (ev.getAction()){
// down固定代码
case MotionEvent.ACTION_DOWN:
intercepted = false;
break;
// move 修改逻辑即可
case MotionEvent.ACTION_MOVE:
if(父容器需要点击事件){// 也就是触发父容器滑动规则时,比如父容器滑动距离较大时
intercepted = true;
}else{
intercepted = false;
}
break;
// up固定代码
case MotionEvent.ACTION_UP:
intercepted = false;
break;
}
return intercepted;
}
上面的是外部拦截法的典型写法,面对不同的滑动类型,只需要修改上面的判断条件即可,其他不用修改也不能够修改。
1、在onInterceptTouchEvent方法中,首先是ACTION_DOWN这个事件,父容器必须返回false,即不拦截ACTION_DOWN事件,这是因为一旦父容器拦截了ACTION_DOWN事件,那么后续的ACTION_MOVE和ACTION_UP事件都会直接交由父容器进行处理,这个时候就没有办法再传递给子元素了。
2、其次是ACTION_MOVE事件,这个事件可以根据需求来决定是否拦截,如果父容器需要拦截就返回true,否则返回false。
3、最后是ACTION_UP事件,这里也直接返回false。这是因为,如果父容器在ACTION_UP时返回true,就会导致子元素无法触发onClick事件(在View的源码中onClick事件是在ACTION_UP中触发的)。
补充下对事件分发的认识收获:
外部拦截发栗子:
我们自定义个水平滑动的ScrollerLayout ,然后在这个布局中放入几个listview做测试。
package com.example.administrator.androidview;
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Scroller;
/**
* Create by SunnyDay on 2019/04/12
*
* 可以左右滑动的view
*/
public class ScrollerLayout extends ViewGroup {
private final Scroller mScroller;
private int mLeftBorder;
private int mRightBorder;
private float mXDown;
private float mXMove;
private float mXLastMove;
private int mLastXIntercept;
private int mLastYIntercept;
public ScrollerLayout(Context context, AttributeSet attrs) {
super(context, attrs);
mScroller = new Scroller(context);// 使用到了弹性滑动对象Scroller
}
/**
* 计算每个子view的大小
* */
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View childAt = getChildAt(i);
// 为每一个子View测量大小
measureChild(childAt, widthMeasureSpec, heightMeasureSpec);
}
}
/**
* 摆放子view的位置
* */
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if(changed){
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View childAt = getChildAt(i);
// 摆放每个view
childAt.layout(i * childAt.getMeasuredWidth(), 0
, (i + 1) * childAt.getMeasuredWidth(), childAt.getMeasuredHeight());
}
// 初始化左右边界值
mLeftBorder = getChildAt(0).getLeft();
mRightBorder = getChildAt(getChildCount() - 1).getRight();
}
}
/**
* 拦截事件
* */
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercepted = false;
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()){
case MotionEvent.ACTION_DOWN:
mXDown = ev.getRawX();
mXLastMove = mXDown;
intercepted = false;
// 如果滑动没有完成,就继续由父控件处理
if(!mScroller.isFinished()){
mScroller.abortAnimation();
intercepted = true;
}
break;
case MotionEvent.ACTION_MOVE:
mXMove = ev.getRawX();
mXLastMove = mXMove;
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;
}
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
if(!mScroller.isFinished()){
mScroller.abortAnimation();
}
break;
case MotionEvent.ACTION_MOVE:
mXMove = event.getRawX();
int scrolledX = (int) (mXLastMove - mXMove);
if(getScrollX() + scrolledX < mLeftBorder){
scrollTo(mLeftBorder, 0);
return true;
}else if(getScrollX() + getWidth() + scrolledX > mRightBorder){
scrollTo(mRightBorder - getWidth(), 0);
return true;
}
scrollBy(scrolledX, 0);
mXLastMove = mXMove;
break;
case MotionEvent.ACTION_UP:
int targetIndex = (getScrollX() + getWidth() / 2) / getWidth();
int dx = targetIndex * getWidth() - getScrollX();
mScroller.startScroll(getScrollX(), 0 , dx, 0);
invalidate();
break;
}
return super.onTouchEvent(event);
}
@Override
public void computeScroll() {
if(mScroller.computeScrollOffset()){
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
invalidate();
}
}
}
mainActivity中调用
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity"
android:orientation="vertical"
android:id="@+id/layout">
<com.example.administrator.androidview.ScrollerLayout
android:id="@+id/scrollerLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</LinearLayout>
public class MainActivity extends AppCompatActivity {
private static final String TAG = "aaa";
private ScrollerLayout mLayout;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mLayout = (ScrollerLayout) findViewById(R.id.scrollerLayout);
for (int i = 0; i < 3; i++) {
ListView listView = new ListView(this);
List<String> list = new ArrayList<>();
for (int i1 = 0; i1 < 50; i1++) {
list.add("page" + i + ", name: " + i1);
}
ArrayAdapter<String> adapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, list);
listView.setAdapter(adapter);
mLayout.addView(listView);
}
}
}
2、内部拦截法
内部拦截法是指父容器不拦截任何事件,所有的事件传递给子元素,如果子元素需要此事件则直接消耗掉,否则就交由父控件进行处理。这种方法和Android中的事件分发机制不一致,需要配合requestDisallowInterceptTouchEvent()方法才能正常工作,使用起来比较外部拦截法要稍微复杂一点,我们需要重写子元素的dispatchTouchEvent()方法,它的伪代码如下:
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getAction()){
case MotionEvent.ACTION_DOWN:
// 要求父控件不拦截事件
getParent().requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
if(父容器需要点击事件){
// 要求父控件拦截事件
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
case MotionEvent.ACTION_UP:
break;
}
return super.dispatchTouchEvent(ev);
}
1、getParent().requestDisallowInterceptTouchEvent(boolean);注意方法参数:
true 不拦截
false 拦截
2、 上面代码是内部拦截法的典型写法,面对不同滑动类型,只需要修改上面的判断条件即可,其他不用修改也不能够修改。除了子元素要做处理之外,父元素也要默认拦截除了ACTION_DOWN以外的其他事件,(如下代码)这样当子元素调用parenet.requestDisallowInterceptTouchEvent(false)方法时,父元素才能继续拦截所需的事件。
3、为什么父容器不能拦截ACTION_DOWN事件呢? 这是因为 ACTION_DOWN 事件不受 FLAG_DISALLOW_INTERCEPT 这个标记位的控制,所以一旦父容器拦截ACTION_DOWN事件,那么所有的事件都无法传递到子元素中去,这样内部拦截法就失去了作用了。父元素所做的修改如下:
/**
* 除了 actiondown 其他事件都拦截 固定写法
* */
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercepted = false;
int action = ev.getAction();
switch (ev.getAction()){
case MotionEvent.ACTION_DOWN:
intercepted = false;//不拦截
break;
case MotionEvent.ACTION_MOVE:
intercepted = true;//拦截
break;
case MotionEvent.ACTION_UP:
intercepted = true;//拦截
break;
}
return intercepted;
}
栗子:
自定义listview
package com.example.administrator.androidview;
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.widget.ListView;
/**
* Create by SunnyDay on 2019/04/12
*/
public class MyListView extends ListView {
public MyListView(Context context) {
super(context);
}
public MyListView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public MyListView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
private int mLastX;
private int mLastY;
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()){
case MotionEvent.ACTION_DOWN:
// 要求父控件不拦截事件
getParent().requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
// 如果是左右滑动
if(Math.abs(deltaX) > Math.abs(deltaY)){
// 要求父控件拦截事件
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
case MotionEvent.ACTION_UP:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(ev);
}
}
在mainactivity 和xml替换为我们自定义的MyListView就行了
修改父容器拦截方法:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercepted = false;
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()){
case MotionEvent.ACTION_DOWN:
mXDown = ev.getRawX();
mXLastMove = mXDown;
intercepted = false;
// 如果滑动没有完成,就继续由父控件处理
if(!mScroller.isFinished()){
mScroller.abortAnimation();
intercepted = true;
}
break;
case MotionEvent.ACTION_MOVE:
mXMove = ev.getRawX();
mXLastMove = mXMove;
intercepted = true;
break;
case MotionEvent.ACTION_UP:
intercepted = true;
break;
}
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;
}
四、小结
场景2,3的解决方案和如上类似,不在写出,如果想尝参看《安卓开发艺术》第三章。
本文栗子参考(手抄了一遍就当练习了):
Android 从0开始自定义控件之 View 的滑动冲突详解
The end
本文来自<安卓开发艺术探索>笔记总结