安卓开发之View滑动与滑动冲突

View工作流程中自定义View的一些注意事项里面,我们提到过要避免View的滑动冲突,那么这次就来看下View的滑动与滑动冲突。安卓中我们常见的下拉刷新等操作的基础就是滑动,有些时候由于Android手机屏幕比较小,为了给用户呈现更多的内容,也会需要使用滑动来隐藏或显示一些内容。

View 滑动

View的滑动本质上来说是移动 View,也就是改变其当前所处的位置。它的原理与动画效果的实现非常相似,都是通过不断地改变坐标来实现这一效果。 所以要实现View的滑动,就必须监听用户触摸的事件,并根据事件传入的坐标,不断动态地改变View的坐标,从而实现View随着用户触摸的滑动而滑动。《Android开发艺术探索》提到了实现View滑动的三种方法:

  • 1.通过系统提供的scrollTo/scrollBy方法

方法源码如下:

	public void scrollTo(int x, int y) {
	    if (mScrollX != x || mScrollY != y) {
	        int oldX = mScrollX;
	        int oldY = mScrollY;
	        mScrollX = x;
	        mScrollY = y;
	        invalidateParentCaches();
	        onScrollChanged(mScrollX, mScrollY, oldX, oldY);
	        if (!awakenScrollBars()) {
	            postInvalidateOnAnimation();
	        }
	    }
	}
	
	public void scrollBy(int x, int y) {
	    scrollTo(mScrollX + x, mScrollY + y);
	}

从源码也可以看出来scrollBy本质上还是调用了scrollTo方法,不同的是scrollBy实现了基于当前位置的相对滑动即移动指定的增量,而scrollTo 则实现了基于所传递参数的绝对滑动即移动到一个具体的坐标点。

在滑动过程中, mScrollX的值总是等于View左边缘和View内容左边缘在水平方向的距离,而mScrollY的值总是等于View上边缘和View内容上边缘在竖直方向的距离。也就是在View中使用scrollTo/scrollBy方法实际上移动的是View的内容,但是如果在ViewGroup中使用的话,移动的就是所有子View了。并且还要注意的是从右向左滑动时,mScrollX为正值,反之为负值;从下往上滑动,mScrollY为正值,反之为负值。《Android群英传》中有个经典的例子:
在这里插入图片描述
上图中间的矩形相当于是屏幕,是可视区域,而后面的content是画布,代表视图,由于不处于屏幕可视区域内所以看不到。在可视区域内设置了一个Button,坐标为(20,10),这里和前面介绍的坐标系一样,X轴向右为正,Y轴向下为正。这时候如果使用scrollBy方法,将屏幕可视区域,在水平方向上向X轴正方向(右)平移20,在竖直方向上向Y轴正方向(下)平移10,也就是scrollBy(20, 10),那么平移之后的可视区域如下图所示:
在这里插入图片描述
我们可以看到,虽然scrollBy(20, 10)的偏移量均为X抽、Y轴正方向上的正方向,但是在屏幕的可视区域内, Button却向X抽、Y轴负方向上移动。这就是因为参考系选择的不同,而产生的不同效果。因此要实现View跟随手指移动而滑动的效果,就必须将偏移量改为负值,代码如下所示:

	int offsetX= x - lastX;
	int offsetY = y- lastY;
	((View) getParent().scrollBy(-offsetX, -offsetY);
  • 2.使用动画
    可以通过补间、属性动画来实现View的移动,主要通过改变View的translationX和translationY参数来实现。注意补间动画的View移动只是位置移动,并不能真正的改变view的位置,而属性动画可以。具体可以参考之前的文章:安卓开发之Animation学习(帧、补间、属性动画)
    在这里插入图片描述
  • 3.改变布局参数LayoutParams
    LayoutParams保存了View的布局参数,因此可以通过改变LayoutParams来动态修改一个布局的位置参数从而达到View滑动的效果。具体来说在通过改变LayoutParams来改变一个 View的位置时,通常改变的是这个View的Margin属性。

例如要把一个Button向右平移100px, 只需要将这个Button的LayoutParams里的marginLeft参数的值增加100px即可。也可以在Button的左边放置一个空的View,并设置默认宽度为0,当需要向右移动Button时,只需要重新设置空View的宽度即可,当空View的宽度增大时Button就自动被挤向右边,即实现了向右平移的效果。设置View的LayoutParams方式如下:

	MarginLayoutParams params = (MarginLayoutParams) mButton1.getLayoutParams();
	params.leftMargin += 100;
	mButton1.requestLayout();// 请求重新对View进行measure、layout

三种滑动方式的对比:

  • scrollTo/scrollBy:操作简单,适合对View内容的滑动,非平滑滑动
  • 动画:操作简单,主要适用于没有交互的View和实现复杂的动画效果
  • 改变布局参数:操作稍微复杂,适用于有交互的View,非平滑滑动

View的弹性滑动

上面已经提到了View滑动的三种方式,但是像scrollTo/scrollBy以及改变布局参数这种的滑动比起动画来说不是非平滑的滑动,更像是瞬间移动过去的,感官上比较生硬。因此这里学习一下View的弹性滑动,弹性滑动本质上来说是将一次大的滑动分成若干次小的滑动并在一个时间段内完成。

  • 1.使用Scroller
    Scroller 类的实现原理与scrollTo/scrollBy基本类似,它在ACTION_ MOVE事件中不断获取手指移动的微小的偏移量,将一段距离划分成了N个非常小的偏移量。在每个偏移量里面,实际还是通过scrollTo方法进行了瞬间移动,但是在整体上却可以获得一个平滑移动的效果。

Scroller典型用法如下:

	Scroller scroller = new Scroller(mContext); //初始化Scroller对象
	
	private void smoothScrollTo(int dstX, int dstY) {
	  int scrollX = getScrollX();//View的左边缘到View内容左边缘的距离
	  int scrollY = getScrollY();//View的上边缘到View内容上边缘的距离
	  int deltaX = dstX - scrollX;//x方向滑动的位移量
	  int deltaY = dstY - scrollY;//y方向滑动的位移量
	  scroller.startScroll(scrollX, scrollY, deltaX, deltaY, 1000); //1000s内慢慢滑动
	  invalidate(); //刷新界面
	}
	
	@Override
	public void computeScroll() {//系统在绘制View的时候会再draw方法中调用该方法
	  if (scroller.computeScrollOffset()) {//判断是否完成了整个滑动
	    scrollTo(scroller.getCurrX(), scroller.getCurY());
	    postInvalidate();//通过重绘不断的调用computeScroll方法
	  }
	}

首先初始化Scroller对象然后调用它的startScroll方法,该方法源码如下:

 public void startScroll(int startX,int startY,int dx,int dy,int duration){
	  mMode = SCROLL_MODE;
	  mFinished = false;
	  mDuration = duration;//滑动时间
	  mStartTime = AnimationUtils.currentAminationTimeMills();//开始时间
	  mStartX = startX;//滑动起点
	  mStartY = startY;//滑动起点
	  mFinalX = startX + dx;//滑动终点
	  mFinalY = startY + dy;//滑动终点
	  mDeltaX = dx;//滑动距离
	  mDeltaY = dy;//滑动距离
	  mDurationReciprocal = 1.0f / (float)mDuration;
  }

具体过程实际上是在MotionEvent.ACTION_UP事件触发时调用startScroll方法,startScroll方法中startX 和startY表示的是滑动的起点,dx 和dy表示的是要滑动的距离,duration 表示的是滑动时间,这里的滑动是指View内容的滑动而非View本身位置的改变。由该方法可见它并没有进行实际的滑动操作,只是保存了我们传入的几个参数。真正实现滑动的是startScroll方法下面的invalidate()方法,调用该方法会请求View重绘导致View的draw方法被调用,在View的draw方法中又会调用computeScroll方法完成弹性滑动。由于computeScroll是空方法所以得我们重写,computeScroll方法中首先会通过computeScrollOffset方法根据时间的流逝动态计算一小段时间里View滑动的距离,并得到当前View位置判断是否完成了整个滑动,没有的话就会去向Scroller 获取当前的scrollX和scrollY,然后通过scrollTo方法实现滑动;接着又调用postInvalidate 方法来进行第二次重绘,这一次重绘的过程和第一次重绘一样,会导致computeScroll 方法被调用;然后继续向Scroller获取当前的scrollX 和scrollY, 并通过scrollTo方法滑动到新的位置,如此反复直到整个滑动过程结束。

  • 2.通过动画
    具体可以参考之前的文章:安卓开发之Animation学习(帧、补间、属性动画)

  • 3.使用延时策略
    延时策的核心思想是通过发送一系列延时消息从而达到一种渐近式的效果,具体来说可以使用Handler 或View的postDelayed方法,也可以使用线程的sleep方法。对于postDelayed 方法来说,我们可以通过它来延时发送一个消息,然后在消息中来进行View的滑动,如果接连不断地发送这种延时消息,那么就可以实现弹性滑动的效果。对于sleep方法来说,通过在while循环中不断地滑动View和sleep,就可以实现弹性滑动的效果。

View滑动冲突

一般情况下,在一个界面里存在内外两层可同时滑动的情况时,会出现滑动冲突现象。常见的滑动冲突有三种场景:

  • 1.外部滑动和内部滑动方向不一致:如ViewPager嵌套ListView(ViewPager内部已处理这种冲突)
  • 2.外部滑动方向和内部滑动方向一致:如ScrollView嵌套ListView(实际上也已解决)
  • 3.上述两种情况的嵌套

针对上述三种场景的冲突处理规则如下:

  • 对于场景1:当用户左右滑动时,让外部的View拦截点击事件,当用户上下滑动时,让内部View拦截点击事件,也就是根据滑动是水平滑动还是竖直滑动来判断到底由谁来拦截事件。可根据滑动的距离或者滑动的角度去判断是上下滑动还是左右滑动。
  • 对于场景2:一般从业务上找突破点。即根据业务需求,规定何时让外部View拦截事件何时由内部View拦截事件。
  • 对于场景3:比前两种场景更加复杂,可同样根据需求在业务上找到突破点。

以第一种场景为例,滑动冲突的解决方式有如下几种:

  • 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://对于ACTION_DOWN事件必须返回false,一旦拦截后续事件将不能传递给子View
         intercepted = false;
         break;
      case MotionEvent.ACTION_MOVE://对于ACTION_MOVE事件根据需要决定是否拦截
         if (父容器需要当前事件) {
             intercepted = true;
         } else {
             intercepted = flase;
         }
         break;
   }
      case MotionEvent.ACTION_UP://对于ACTION_UP事件必须返回false,一旦拦截子View的onClick事件将不会触发
         intercepted = false;
         break;
      default : break;
   }
    mLastXIntercept = x;
    mLastYIntercept = y;
    return intercepted;
   }

在onInterceptTouchEvent方法中,首先是ACTION_DOWN这个事件,父容器必须返回false,即不拦截ACTION_ DOWN事件,否则后续的ACTION_ MOVE和ACTION_ UP 事件都会直接交由父容器处理,后续事件就不能再传递给子元素了;其次是ACTION_ MOVE事件,这个事件可以根据需要来决定是否拦截,如果父容器需要拦截就返回true,否则返回false;最后是ACTION_ UP事件,这里必须要返回false,如果返回了true,就会导致子元素无法接收到ACTION_ UP事件,这个时候子元素中的onClick事件就无法触发。

  • 2.内部拦截法
    指父容器不拦截任何事件,而将所有的事件都传递给子元素,如果子元素需要此事件就直接消耗,否则就交由父容器进行处理。该方法需要配合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);//为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.requestDisallow-InterceptTouchEvent(false)方法时,父容器才能继续拦截所需的事件,否则一旦父容器拦截了ACTION_DOWN事件,那么所有的事件都无法传递到子元素中去,这样内部拦截就无法起作用了。因此父容器需要重写onInterceptTouchEvent方法:

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

推荐阅读:

©️2020 CSDN 皮肤主题: 技术工厂 设计师:CSDN官方博客 返回首页