Android View的事件分发机制与滑动冲突解决方案

在Android开发中,如果是一些简单的布局,都很容易搞定,但是一旦涉及到复杂的页面,特别是为了兼容小屏手机而使用了ScrollView以后,就会出现很多滑动事件的冲突,最经典的就是ScrollView中嵌套了ListView。今天主要总结一下这方面的知识点,也当作以后复习的笔记。

这里主要讲述以下几点:

  • View的事件分发机制
  • 事件滑动冲突的思路及方法
  • ScrollView里面嵌套ViewPager滑动冲突问题
  • ViewPager里面嵌套ViewPager滑动冲突问题
  • Scrollview里面嵌套Listview滑动冲突问题

View的事件分发机制

关于View的事件分发机制讲解网上一搜一大堆,所以本文不细描述,而是让你理解主要的运行机制,当然也不是只是自己描述一下就结束了,会提供具体的博客参考,指引你去更详细的了解。
View的事件分发机制说白了就是点击事件的传递,也就是一个Down事件,若干个Move事件,一个Up事件构成的事件序列的传递。
先看下Down事件和Up事件的分发流程走向:

事件传递流程.png

下面讲述一下View事件分发机制涉及的几个方法

  • boolean dispatchTouchEcent(MotionEvent ev)
  • boolean onInterceptTouchEvent(MotionEvent event)
  • boolean onTouchEvent(MotionEvent event)
  • public void requestDisallowInterceptTouchEvent(boolean disallowIntercept)

前三个方法的关系用下面伪代码表示一下:

public boolean dispatchTouchEvent(MotionEvent ev){
    boolean consum = false;
    if(onInterceptTouchEvent(ev)){
        consum = onTouchEvent(ev);
    }else{
        consum = child.dispatchTouchEvent(ev);
    }

    return consum;
}

根据下面这幅图逐个简单介绍下上述的四个方法:

Paste_Image.png
  • dispatchTouchEcent:
    只要事件传递到了当前View,那么dispatchTouchEcent方法就一定会被调用,主要是用来分发事件的。返回结果表示是否消耗当前事件。
    ture:事件就此消费,不会继续往别的地方传了,事件终止。
    false:则回传给父View的onTouchEvent事件处理。
  • onInterceptTouchEvent:
    在dispatchTouchEcent方法内部调用此方法,用来判断是否拦截某个事件。如果当前View拦截了某个事件,那么在这同一个事件序列中,此方法不会再次被调用(需要注意的是ViewGroup才有这个方法,View没有onInterceptTouchEvent这个方法)。返回结果表示是否拦截当前事件。
    true:拦截事件,则交给它的 onTouchEvent 来处理。
    false:不拦截该事件,传递给子 view ,由子 view 的 dispatchTouchEvent 再来开始这个事件的分发。
  • onTouchEvent:
    在dispatchTouchEcent方法内调用此方法,用来处理事件。返回结果表示是否处理当前事件。
    true:表示消费该事件。
    false:表示不处理,那么在同一个事件序列里面,当前View无法再收到后续的事件。
  • requestDisallowInterceptTouchEvent:
    该方法中的参数disallowIntercept的意思就是childView告诉父容器要不要进行拦截。
    true :告诉所有父控件不要拦截,事件交由childrenView处理;
    false:告诉所有父控件拦截。在父控件的onInterceptTouchEvent()中可能类似这样的处理。

这里总结一下:(结合下图看)
事件总是从上往下进行分发,即先到达Activity,再到达ViewGroup,再到达子View,如果没有任何视图消耗事件的话,事件会顺着路径往回传递。

app1.png

  1. 事件从Activity.dispatchTouchEvent()开始传递,只要没有被停止或拦截,从最上层的View(ViewGroup)开始一直往下(子View)传递。子View 可以通过onTouchEvent()对事件进行处理。
  2. 事件由父View(ViewGroup)传递给子View,ViewGroup 可以通过onInterceptTouchEvent()对事件做拦截,停止其往下传递。
  3. 如果事件从上往下传递过程中一直没有被停止,且最底层子View 没有消费事件,事件会反向往上传递,这时父View(ViewGroup)可以进行消费,如果还是没有被消费的话,最后会到Activity 的onTouchEvent()函数。
  4. 如果View 没有对ACTION_DOWN 进行消费,之后的其他事件不会传递过来。
  5. OnTouchListener 优先于onTouchEvent()对事件进行消费。
    如果还不理解这几个方法的用处,请参考博文图解 Android 事件分发机制,一定要有耐心仔细看,相信看完之后你会相信事件并没有白白浪费的。

滑动冲突解决方案

滑动冲突的基本形式分为两种,其他复杂的滑动冲突都是由这两种基本形式演变而来:

  1. 外部滑动方向与内部方向不一致。
  2. 外部滑动方向与内部方向一致。

第一种可以理解为ScrollView 嵌套ViewPager,第二种可以理解为ViewPager嵌套ViewPager,稍后提供具体解决方案。
根据《Android开发艺术探索》讲述滑动冲突的拦截方法有两种:

外部拦截法

从父View着手,重写onInterceptTouchEvent方法,在父View需要拦截的时候拦截,不需要则不拦截返回false。其伪代码如下:

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;
}

在这里,首先down事件父容器必须返回false ,因为若是返回true,也就是拦截了down事件,那么后续的move和up事件就都会传递给父容器,子元素就没有机会处理事件了。其次是up事件也返回了false,一是因为up事件对父容器没什么意义,其次是因为若事件是子元素处理的,却没有收到up事件会让子元素的onClick事件无法触发。

内部拦截法

从子View入手,重写子元素的dispatchTouchEvent方法,父View先不要拦截任何事件,所有的 事件传递给 子View,如果子View需要此事件就消费掉,不需要此事件的话就通过requestDisallowInterceptTouchEvent方法交给父View处理。伪代码如下:

@Override
 public 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 MotionEvent.ACTION_MOVE: {
         int deltaX = x - mLastX;
         int deltaY = y - mLastY;
         if (父容器需要此类点击事件) {
             parent.requestDisallowInterceptTouchEvent(false);
         }
         break;
     }
     case MotionEvent.ACTION_UP: {
         break;
     }
     default:
         break;
     }

     mLastX = x;
     mLastY = y;
     return super.dispatchTouchEvent(event);
 }

然后修改父容器的onInterceptTouchEvent方法:

@Override
 public boolean onInterceptTouchEvent(MotionEvent event) {

     int action = event.getAction();
     if (action == MotionEvent.ACTION_DOWN) {
         return false;
     } else {
         return true;
     }
 }

ScrollView里面嵌套ViewPager滑动冲突问题

  • 外部拦截法
    如上面所述,从 父ViewScrollView着手,重写 OnInterceptTouchEvent方法,在上下滑动的时候拦截事件,在左右滑动的时候不拦截事件,返回 false,这样确保子View 的dispatchTouchEvent方法会被调用,代码如下:
public class VerticalScrollView extends ScrollView {

    public VerticalScrollView(Context context) {
        super(context);
    }

    public VerticalScrollView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public VerticalScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @TargetApi(21)
    public VerticalScrollView(Context context, AttributeSet attrs, int defStyleAttr, int
            defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
    }

    private float mDownPosX = 0;
    private float mDownPosY = 0;

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        final float x = ev.getX();
        final float y = ev.getY();

        final int action = ev.getAction();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                mDownPosX = x;
                mDownPosY = y;

                break;
            case MotionEvent.ACTION_MOVE:
                final float deltaX = Math.abs(x - mDownPosX);
                final float deltaY = Math.abs(y - mDownPosY);
                // 这里是够拦截的判断依据是左右滑动,读者可根据自己的逻辑进行是否拦截
                if (deltaX > deltaY) {
                    return false;
                }
        }

        return super.onInterceptTouchEvent(ev);
    }
}
  • 内部拦截法
    如上面上述,通过requestDisallowInterceptTouchEvent(true)方法来影响父View是否拦截事件,我们通过重写ViewPager的 dispatchTouchEvent()方法,在左右滑动的时候请求父View ScrollView不要拦截事件,其他的时候拦截事件,代码如下:
public class MyViewPager extends ViewPager {

    private static final String TAG = "MyViewPager ";

    int lastX = -1;
    int lastY = -1;

    public MyViewPager(Context context) {
        super(context);
    }

    public MyViewPager(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        int x = (int) ev.getRawX();
        int y = (int) ev.getRawY();
        int dealtX = 0;
        int dealtY = 0;

        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                dealtX = 0;
                dealtY = 0;
                // 保证子View能够接收到Action_move事件
                getParent().requestDisallowInterceptTouchEvent(true);
                break;
            case MotionEvent.ACTION_MOVE:
                dealtX += Math.abs(x - lastX);
                dealtY += Math.abs(y - lastY);
                Log.i(TAG, "dealtX:=" + dealtX);
                Log.i(TAG, "dealtY:=" + dealtY);
                // 这里是够拦截的判断依据是左右滑动,读者可根据自己的逻辑进行是否拦截
                if (dealtX >= dealtY) {
                    getParent().requestDisallowInterceptTouchEvent(true);
                } else {
                    getParent().requestDisallowInterceptTouchEvent(false);
                }
                lastX = x;
                lastY = y;
                break;
            case MotionEvent.ACTION_CANCEL:
                break;
            case MotionEvent.ACTION_UP:
                break;

        }
        return super.dispatchTouchEvent(ev);
    }
}

ViewPager里面嵌套ViewPager滑动冲突问题

内部拦截法:
从子View ViewPager着手,重写 子View的 dispatchTouchEvent方法,在子 View需要拦截的时候进行拦截,否则交给父View处理,代码如下:

public class ChildViewPager extends ViewPager {

    private static final String TAG = "ChildViewPager ";
    public ChildViewPager(Context context) {
        super(context);
    }

    public ChildViewPager(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        int curPosition;

        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                getParent().requestDisallowInterceptTouchEvent(true);
                break;
            case MotionEvent.ACTION_MOVE:
                curPosition = this.getCurrentItem();
                int count = this.getAdapter().getCount();
                Log.i(TAG, "curPosition:=" +curPosition);
                // 当当前页面在最后一页和第0页的时候,由父亲拦截触摸事件
                if (curPosition == count - 1|| curPosition==0) {
                    getParent().requestDisallowInterceptTouchEvent(false);
                } else {//其他情况,由孩子拦截触摸事件
                    getParent().requestDisallowInterceptTouchEvent(true);
                }

        }
        return super.dispatchTouchEvent(ev);
    }
}

Scrollview里面嵌套Listview滑动冲突问题

ScrollView里面嵌套ListView,通常会出现以下两个问题:

  • ListView的高度显示问题,常见的问题就是只显示一行;
  • ScrollView和ListView都有上下滑动事件,放在一起会存在滑动冲突。

常用方案有如下三种:

  1. 自定义ListView
public class ListViewForScroll extends ListView
{
    public ListViewForScroll(Context context)
    {
        super(context);
    }
    public ListViewForScroll(Context context, AttributeSet attrs)
    {
        super(context, attrs);
    }
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
    {
        intexpandSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2,    
                  MeasureSpec.AT_MOST);
        super.onMeasure(widthMeasureSpec, expandSpec);
    }
}

主要就是重载了onMeasure方法,改变了heightMeasureSpec。这里widthMeasureSpec和heightMeasureSpec用了32位的int作为参数,高2位代表模式,有三种UNSPECIFIED、EXACTLY、AT_MOST,这是自定义View的基础知识。低30位代表数值。
MeasureSpec.makeMeasureSpec函数中第一个参数是高度的值,第二个参数是模式,makeMeasureSpec则是把模式和值合成为一个int值,这里赋给了高度。
Integer.MAX_VALUE >> 2是int类型取30位时的最大整数,即Integer.MAX_VALUE是int的最大32位值,再右移2位,就是30位,同样是最大值,只不过是30位的最大值,所以在模式上也只能选择MeasureSpec.AT_MOST。最终这个ListView的显示高度会是其能显示出来的最大值,所有的条目都会显示出来。
优点:写法简单,不影响ListView使用。
缺点:
i. 由于高度设置成最大值,所有条目都会进行绘制,只是有些条目会在屏幕之外。举个例子,我传递的数据有20条,但是屏幕只够显示10条,此时用自定义的ListView会调用20次getView把所有条目都绘制出来,完全放弃了ListView的复用机制,跟直接写布局没有什么区别了,会造成页面加载速度缓慢的问题。
ii. ListView高度必须设置成match_parent。

  1. 动态测量ListView高度
public static void setListViewHeightBasedOnChildren(ListView listView) {
    ListAdapter listAdapter = listView.getAdapter();
    if (listAdapter == null) {
        return;
    }
    int totalHeight = 0;
    for (int i = 0; i < listAdapter.getCount(); i++) {
        View listItem = listAdapter.getView(i, null, listView);
        listItem.measure(0, 0);
        totalHeight += listItem.getMeasuredHeight();
    }
    ViewGroup.LayoutParams params = listView.getLayoutParams();
    params.height = totalHeight
            + (listView.getDividerHeight() * (listAdapter.getCount() - 1));
    listView.setLayoutParams(params);
}

这里就是去获取每个条目的View高度,然后所有子View高度相加得到总高度,并设置给ListView的LayoutParams。
优点:能够实现功能需求。
缺点:
i. 每个条目的布局只能用LinearLayout,而不能用RelativeLayout,因为LinearLayout重写了onMeasure方法,才能调用listItem.measure(0, 0)这句,而其他布局没有。
ii. ListView高度必须设置成match_parent。
iii. 在ListView设置Adaper和调用notifyDataSetChanged时候都要调用该方法。
iv. 由于高度设置成最大值,所有条目都会进行绘制,跟第一个方法“自定义ListView”存在同样的问题。

  1. 第三是自定义LinearLayout模拟ListView。
    public class LinearLayoutListView extends LinearLayout
      {
          private BaseAdapter adapter;
          private MyOnItemClickListener onItemClickListener;
          boolean footerViewAttached = false;
          private View footerview;
          public LinearLayoutListView(Context context)
          {
              super(context);
              initAttr(null);
          }
    
          public LinearLayoutListView(Context context, AttributeSet attrs)
          {
              super(context, attrs);
              initAttr(attrs);
          }
    
          public void initAttr(AttributeSet attrs)
          {
              setOrientation(VERTICAL);
          }
    
          /**
           * 初始化footerview
           *
           * @param footerView
           */
          public void initFooterView(final View footerView)
          {
              this.footerview = footerView;
          }
    
          /**
           * 设置footerView监听事件
           *
           * @param onClickListener
           */
          public void setFooterViewListener(OnClickListener onClickListener)
          {
              this.footerview.setOnClickListener(onClickListener);
          }
    
          public BaseAdapter getAdapter()
          {
              return adapter;
          }
    
          /**
           * 设置adapter并模拟listview添加????数据
           *
           * @param adpater
           */
          public void setAdapter(BaseAdapter adpater)
          {
              this.adapter = adpater;
              removeAllViews();
              if (footerViewAttached)
                  addView(footerview);
              notifyChange();
          }
    
          /**
           * 设置条目监听事件
           *
           * @param onClickListener
           */
          public void setOnItemClickListener(MyOnItemClickListener onClickListener)
          {
              this.onItemClickListener = onClickListener;
          }
    
          /**
           * 没有下一页了
           */
          public void noMorePages()
          {
              if (footerview != null && footerViewAttached)
              {
                  removeView(footerview);
                  footerViewAttached = false;
              }
          }
    
          /**
           * 可能还有下一??
           */
          public void mayHaveMorePages()
          {
              if (!footerViewAttached && footerview != null)
              {
                  addView(footerview);
                  footerViewAttached = true;
              }
          }
    
          /**
           * 通知更新listview
           */
          public void notifyChange()
          {
              int count = getChildCount();
              if (footerViewAttached)
              {
                  count--;
              }
              LayoutParams params = new LayoutParams(LayoutParams.FILL_PARENT, LayoutParams.WRAP_CONTENT);
              for (int i = count; i < adapter.getCount(); i++)
              {
                  final int index = i;
                  final LinearLayout layout = new LinearLayout(getContext());
                  layout.setLayoutParams(params);
                  layout.setOrientation(VERTICAL);
                  View v = adapter.getView(i, null, null);
                  v.setOnClickListener(new OnClickListener()
                  {
                      @Override
                      public void onClick(View v)
                      {
                          if (onItemClickListener != null)
                          {
                              onItemClickListener.onItemClick(LinearLayoutListView.this, layout, index,
                                      adapter.getItem(index));
                          }
                      }
                  });
                  ImageView imageView = new ImageView(getContext());
                  imageView.setBackgroundResource(R.color.background);
                  imageView.setLayoutParams(params);
                  layout.addView(v);
                  layout.addView(imageView);
                  addView(layout, index);
              }
          }
          public static interface MyOnItemClickListener
          {
              public void onItemClick(ViewGroup parent, View view, int position, Object o);
          }
      }
    

生硬的实现了ListView的基础功能,但是ListView的复用机制完全没有,跟直接写布局有何区别。
优点:能够实现功能需求。
缺点:
i. ListView高度要设置成match_parent
ii. 由于高度设置成最大值,所有条目都会进行绘制,跟“自定义ListView”存在同样的问题。

另外推荐解决滑动冲突方案的博文:
【Android】ListView、RecyclerView、ScrollView里嵌套ListView 相对优雅的解决方案:NestFullListView

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容