ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
#### 3.5.3 滑动冲突的解决方式 在3.5.1节中描述了三种典型的滑动冲突场景,在本节将会一一分析各种场景并给出具体的解决方法。首先我们要分析第一种滑动冲突场景,这也是最简单、最典型的一种滑动冲突,因为它的滑动规则比较简单,不管多复杂的滑动冲突,它们之间的区别仅仅是滑动规则不同而已。抛开滑动规则不说,我们需要找到一种不依赖具体的滑动规则的通用的解决方法,在这里,我们就根据场景1的情况来得出通用的解决方案,然后场景2和场景3我们只需要修改有关滑动规则的逻辑即可。 上面说过,针对场景1中的滑动,我们可以根据滑动的距离差来进行判断,这个距离差就是所谓的滑动规则。如果用ViewPager去实现场景1中的效果,我们不需要手动处理滑动冲突,因为ViewPager已经帮我们做了,但是这里为了更好地演示滑动冲突的解决思想,没有采用ViewPager。其实在滑动过程中得到滑动的角度这个是相当简单的,但是到底要怎么做才能将点击事件交给合适的View去处理呢?这时就要用到3.4节所讲述的事件分发机制了。针对滑动冲突,这里给出两种解决滑动冲突的方式:外部拦截法和内部拦截法。 **1.外部拦截法** 所谓外部拦截法是指点击事情都先经过父容器的拦截处理,如果父容器需要此事件就拦截,如果不需要此事件就不拦截,这样就可以解决滑动冲突的问题,这种方法比较符合点击事件的分发机制。外部拦截法需要重写父容器的onInterceptTouchEvent方法,在内部做相应的拦截即可,这种方法的伪代码如下所示。 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; } 上述代码是外部拦截法的典型逻辑,针对不同的滑动冲突,只需要修改父容器需要当前点击事件这个条件即可,其他均不需做修改并且也不能修改。这里对上述代码再描述一下,在onInterceptTouchEvent方法中,首先是ACTION_DOWN这个事件,父容器必须返回false,即不拦截ACTION_DOWN事件,这是因为一旦父容器拦截了ACTION_DOWN,那么后续的ACTION_MOVE和ACTION_UP事件都会直接交由父容器处理,这个时候事件没法再传递给子元素了;其次是ACTION_MOVE事件,这个事件可以根据需要来决定是否拦截,如果父容器需要拦截就返回true,否则返回false;最后是ACTION_UP事件,这里必须要返回false,因为ACTION_UP事件本身没有太多意义。 考虑一种情况,假设事件交由子元素处理,如果父容器在ACTION_UP时返回了true,就会导致子元素无法接收到ACTION_UP事件,这个时候子元素中的onClick事件就无法触发,但是父容器比较特殊,一旦它开始拦截任何一个事件,那么后续的事件都会交给它来处理,而ACTION_UP作为最后一个事件也必定可以传递给父容器,即便父容器的onInterceptTouchEvent方法在ACTION_UP时返回了false。 **2.内部拦截法** 内部拦截法是指父容器不拦截任何事件,所有的事件都传递给子元素,如果子元素需要此事件就直接消耗掉,否则就交由父容器进行处理,这种方法和Android中的事件分发机制不一致,需要配合requestDisallowInterceptTouchEvent方法才能正常工作,使用起来较外部拦截法稍显复杂。它的伪代码如下,我们需要重写子元素的dispatchTouchEvent方法: 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); } 上述代码是内部拦截法的典型代码,当面对不同的滑动策略时只需要修改里面的条件即可,其他不需要做改动而且也不能有改动。除了子元素需要做处理以外,父元素也要默认拦截除了ACTION_DOWN以外的其他事件,这样当子元素调用parent.requestDisal-lowInterceptTouchEvent(false)方法时,父元素才能继续拦截所需的事件。 为什么父容器不能拦截ACTION_DOWN事件呢?那是因为ACTION_DOWN事件并不受FLAG_DISALLOW_INTERCEPT这个标记位的控制,所以一旦父容器拦截ACTION_DOWN事件,那么所有的事件都无法传递到子元素中去,这样内部拦截就无法起作用了。父元素所做的修改如下所示。 public boolean onInterceptTouchEvent(MotionEvent event) { int action = event.getAction(); if (action == MotionEvent.ACTION_DOWN) { return false; } else { return true; } } 下面通过一个实例来分别介绍这两种方法。我们来实现一个类似于ViewPager中嵌套ListView的效果,为了制造滑动冲突,我们写一个类似于ViewPager的控件即可,名字就叫HorizontalScrollViewEx,这个控件的具体实现思想会在第4章进行详细介绍,这里只讲述滑动冲突的部分。 为了实现ViewPager的效果,我们定义了一个类似于水平的LinearLayout的东西,只不过它可以水平滑动,初始化时我们在它的内部添加若干个ListView,这样一来,由于它内部的Listview可以竖直滑动。而它本身又可以水平滑动,因此一个典型的滑动冲突场景就出现了,并且这种冲突属于类型1的冲突。根据滑动策略,我们可以选择水平和竖直的滑动距离差来解决滑动冲突。 首先来看一下Activity中的初始化代码,如下所示。 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"); initView(); } private void initView() { LayoutInflater inflater = getLayoutInflater(); mListContainer = (HorizontalScrollViewEx) findViewById(R.id. container); final int screenWidth = MyUtils.getScreenMetrics(this).widthPixels; final int screenHeight = MyUtils.getScreenMetrics(this).height- Pixels; 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); } } 上述初始化代码很简单,就是创建了3个ListView并且把ListView加入到我们自定义的HorizontalScrollViewEx中,这里HorizontalScrollViewEx是父容器,而ListView则是子元素,这里就不再多介绍了。 首先采用外部拦截法来解决这个问题,按照前面的分析,我们只需要修改父容器需要拦截事件的条件即可。对于本例来说,父容器的拦截条件就是滑动过程中水平距离差比竖直距离差大,在这种情况下,父容器就拦截当前点击事件,根据这一条件进行相应修改,修改后的HorizontalScrollViewEx的onInterceptTouchEvent方法如下所示。 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); mLastXIntercept = x; mLastYIntercept = y; return intercepted; } 从上面的代码来看,它和外部拦截法的伪代码的差别很小,只是把父容器的拦截条件换成了具体的逻辑。在滑动过程中,当水平方向的距离大时就判断为水平滑动,为了能够水平滑动所以让父容器拦截事件;而竖直距离大时父容器就不拦截事件,于是事件就传递给了ListView,所以ListView也能上下滑动,如此滑动冲突就解决了。至于mScroller. abortAnimation()这一句话主要是为了优化滑动体验而加入的。 考虑一种情况,如果此时用户正在水平滑动,但是在水平滑动停止之前如果用户再迅速进行竖直滑动,就会导致界面在水平方向无法滑动到终点从而处于一种中间状态。为了避免这种不好的体验,当水平方向正在滑动时,下一个序列的点击事件仍然交给父容器处理,这样水平方向就不会停留在中间状态了。 下面是HorizontalScrollViewEx的具体实现,只展示了和滑动冲突相关的代码: public 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 = scrollX / 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(); } @Override public void computeScroll() { if (mScroller.computeScrollOffset()) { scrollTo(mScroller.getCurrX(), mScroller.getCurrY()); postInvalidate(); } } … } 如果采用内部拦截法也是可以的,按照前面对内部拦截法的分析,我们只需要修改ListView的dispatchTouchEvent方法中的父容器的拦截逻辑,同时让父容器拦截ACTION_MOVE和ACTION_UP事件即可。为了重写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(MotionEvent event) { int x = (int) event.getX(); int y = (int) event.getY(); switch (event.getAction()) { case MotionEvent.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.requestDisallowInterceptTouch- Event(false); } break; } case MotionEvent.ACTION_UP: { break; } default: break; } mLastX = x; mLastY = y; return super.dispatchTouchEvent(event); } } 除了上面对ListView所做的修改,我们还需要修改HorizontalScrollViewEx的onInte-rceptTouchEvent方法,修改后的类暂且叫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; } } 上面的代码就是内部拦截法的示例,其中mScroller.abortAnimation()这一句不是必须的,在当前这种情形下主要是为了优化滑动体验。从实现上来看,内部拦截法的操作要稍微复杂一些,因此推荐采用外部拦截法来解决常见的滑动冲突。 前面说过,只要我们根据场景1的情况来得出通用的解决方案,那么对于场景2和场景3来说我们只需要修改相关滑动规则的逻辑即可,下面我们就来演示如何利用场景1得出的通用的解决方案来解决更复杂的滑动冲突。这里只详细分析场景2中的滑动冲突,对于场景3中的叠加型滑动冲突,由于它可以拆解为单一的滑动冲突,所以其滑动冲突的解决思想和场景1、场景2中的单一滑动冲突的解决思想一致,只需要分别解决每层之间的滑动冲突即可,再加上本书的篇幅有限,这里就不对场景3进行详细分析了。 对于场景2来说,它的解决方法和场景1一样,只是滑动规则不同而已,在前面我们已经得出了通用的解决方案,因此这里我们只需要替换父容器的拦截规则即可。注意,这里不再演示如何通过内部拦截法来解决场景2中的滑动冲突,因为内部拦截法没有外部拦截法简单易用,所以推荐采用外部拦截法来解决常见的滑动冲突。 下面通过一个实际的例子来分析场景2,首先我们提供一个可以上下滑动的父容器,这里就叫StickyLayout,它看起来就像是可以上下滑动的竖直的LinearLayout,然后在它的内部分别放一个Header和一个ListView,这样内外两层都能上下滑动,于是就形成了场景2中的滑动冲突了。当然这个StickyLayout是有滑动规则的:当Header显示时或者ListView滑动到顶部时,由StickyLayout拦截事件;当Header隐藏时,这要分情况,如果ListView已经滑动到顶部并且当前手势是向下滑动的话,这个时候还是StickyLayout拦截事件,其他情况则由ListView拦截事件。这种滑动规则看起来有点复杂,为了解决它们之间的滑动冲突,我们还是需要重写父容器StickyLayout的onInterceptTouchEvent方法,至于ListView则不用做任何修改,我们来看一下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 <= getHeader- Height()) { 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 boolean 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; } … } 从上面的代码来看,这个StickyLayout的实现有点复杂,在第4章会详细介绍这个自定义View的实现思想,这里先有大概的印象即可。下面我们主要看它的onIntercept-TouchEvent方法中对ACTION_MOVE的处理,如下所示。 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; } 我们来分析上面这段代码的逻辑,这里的父容器是StickyLayout,子元素是ListView。首先,当事件落在Header上面时父容器不会拦截事件;接着,如果竖直距离差小于水平距离差,那么父容器也不会拦截事件;然后,当Header是展开状态并且向上滑动时父容器拦截事件。另外一种情况,当ListView滑动到顶部了并且向下滑动时,父容器也会拦截事件,经过这些层层判断就可以达到我们想要的效果了。另外,giveUpTouchEvent是一个接口方法,由外部实现,在本例中主要是用来判断ListView是否滑动到顶部,它的具体实现如下: public boolean giveUpTouchEvent(MotionEvent event) { if (expandableListView.getFirstVisiblePosition() == 0) { View view = expandableListView.getChildAt(0); if (view ! = null && view.getTop() >= 0) { return true; } } return false; } 上面这个例子比较复杂,需要读者多多体会其中的写法和思想。到这里滑动冲突的解决方法就介绍完毕了,至于场景3中的滑动冲突,利用本节所给出的通用的方法是可以轻松解决的,读者可以自己练习一下。在第4章会介绍View的底层工作原理,并且会介绍如何写出一个好的自定义View。同时,在本节中所提到的两个自定义View:Horizontal-ScrollViewEx和StickyLayout将会在第4章中进行详细的介绍,它们的完整源码请查看本书所提供的示例代码。