0%

View 的事件体系(五):View 的滑动冲突

1. 滑动冲突的本质原因

  • 本质原因是多个层级的 View 均支持触摸滑动,滑动方向可能一直也可能不一致,响应结果的混乱造成滑动冲突
  • 有些封装好的组件比如 ViewPager 内部处理了滑动冲突,但有的组件比如 ScrollView 就没有,需要手动处理滑动冲突

2. 常见的滑动冲突场景

  • 外部滑动方向和内部滑动方向不一致
  • 外部滑动方向和内部滑动方向一致
  • 上面两种情况的嵌套

常见的滑动冲突场景

3. 滑动冲突的处理规则

  • 对于场景 1,处理规则是:根据滑动方向是水平滑动还是竖直滑动来判断由外部还是内部 View 拦截事件,一般是通过水平和竖直方向的距离差来判断滑动方向
  • 对于场景 2 和场景 3,一般都能从业务上找到突破点,比业务上有规定:当处于某中状态时需要外部 View 响应滑动,处于另一种状态时需要内部 View 响应滑动

4. 滑动冲突的解决方式

  1. 外部拦截法

    • 含义:指点击事件都先经过父容器的拦截处理,如果父容器需要此事件就拦截,如果不需要此事件就不拦截

    • 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,否则返回 false
      • MotionEvent.ACTION_UP 事件:这里必须返回 false,因为 ACTION_UP 事件本身没有太多意义
  2. 内部拦截法

    • 含义:指父容器不拦截任何事件,所有的事件都传递给子元素,如果子元素需要此事件就直接消耗掉,否则就交由父容器处理,一般需要配合 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
      25
      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 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
      40
      // Activity 中的初始化代码,创建 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);
      Log.d(TAG, "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);
      layout.getLayoutParams().width = screenWidth;
      TextView textView = (TextView) layout.findViewById(R.id.title);
      textView.setText("page " + (i + 1));
      layout.setBackgroundColor(Color.rgb(255/(i+1), 255/(i+1), 0));
      createList(layout);
      mListContainer.addView(layout);
      }
      }

      private void createList(ViewGroup layout) {
      ListView listView = (ListView) layout.findViewById(R.id.list);
      ArrayList<String> datas = new ArrayList<String> ();
      for (int i = 0; i < 50; i++) {
      datas.add("name " + i);
      }
      ArrayAdapter<String> 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;
      ...
      @Override
      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;
      }
-------------------- 本文结束感谢您的阅读 --------------------