【View系列】让View滑动起来的几种方式

2,530 阅读7分钟

前言

前几的文章一直在扒源码,讲流程,阅读难度系数略微高点。今天我们聊个稍微轻松基础的话题:让View滑动起来的几种方式。Android中又很多可滑动的View,比如ScrollView、NestedScrollView、ListView、RecyclerView等。有时候我们想自定义一些可滑动view的时候,发现没有头绪,说明平时我们对相关可滑动的知识储备有点少。这篇文章带大家掌握使view滑动的几种基本姿势。最后还有一个简单可拖动的自定义ScrollableView,供大家查阅~~~

基本知识准备

由于滚动和View及触摸点MotionEvent息息相关,那我们有必要先回顾一下Android的坐标系及视图坐标系

  1. Android坐标系

​ Android坐标系以屏幕左上角为原点(0,0),x轴方向为向右,y轴方向向下。

  1. 视图坐标系

    View的left,top,right,bottom都是相对于父View而言的。

    MotionEvent的getX()、getY()的值是相对于当前触摸到的View的,getRawX()和getRawY()是相对于屏幕左侧和上侧的。

    文字描述可能不够清晰,想看图的可以搜搜网上的相关介绍。在我们对Android的坐标系有所了解后,再去看滑动相关代码的时候会比较轻松。

View滑动方式概览

  1. layout()
  2. offsetLeftAndRight()、offsetTopAndBottom()
  3. translationX、tanslationY、动画
  4. setX()、setY()
  5. scrollTo()、scrollBy()

虽然滑动方式很多,但大多数都是通过onTouchEvent方法中action_move的时候计算出来dx、dy来改变view的相关位置属性同时更新View的位置,然后我们看起来View就滑动了起来。话不多说,我们挨个过一遍~~

layout()

layout方法我们都很熟悉,它是用来摆放View的位置的:

public void layout(int l, int t, int r, int b) {
     ...
     setFrame(l, t, r, b);
     ...
}

protected boolean setFrame(int left, int top, int right, int bottom) {
	...
      //计算之前的大小
      int oldWidth = mRight - mLeft;
      int oldHeight = mBottom - mTop;
      int newWidth = right - left;
      int newHeight = bottom - top;
      //大小是否发生改变标识
      boolean sizeChanged = (newWidth != oldWidth) || (newHeight != oldHeight);

      ...

      //赋值left top right bottom
      mLeft = left;
      mTop = top;
      mRight = right;
      mBottom = bottom;
      //RenderNode记录view的left top right bottom 底层绘制的时候需要
      mRenderNode.setLeftTopRightBottom(mLeft, mTop, mRight, mBottom);

      if (sizeChanged) {
        //如果大小发生改变则调用sizeChange方法
        sizeChange(newWidth, newHeight, oldWidth, oldHeight);
      }
      ...
}

上面的代码清晰的表示了layout方法是用来确定view的上下左右的,因此我们也就可以通过更改上下左右的值来滑动view。首先我们自定义一个View,在onTouchEvent()方法中记录手指ACTION_DOWN触摸点的坐标:

override fun onTouchEvent(event: MotionEvent): Boolean {
   when (event.action) {
        MotionEvent.ACTION_DOWN -> {
          //获取motionEvent的x、y坐标
          lastX = event.x
          lastY = event.y
        }
   }
   ...
}

然后我们计算在ACTION_MOVE的时候 手指滑动的距离 并更改View的left top right bottom的值:

...
MotionEvent.ACTION_MOVE -> {
    //滑动的dx、dy值
    val dx = (event.x - lastX).toInt()
    val dy = (event.y - lastY).toInt()
        //更新view的left top right bottom
    layout(left + dx, top + dy, right + dx, bottom + dy)
}
...

这样连续收到ACTION_MOVE事件,我们连续调用layout方法更新view的位置,就让view移动起来了。

全部的自定义view的代码如下:

class ScrollableView constructor(context: Context, attributeSet: AttributeSet?) :
    View(context, attributeSet) {

    private var lastX = 0f
    private var lastY = 0f

    override fun onTouchEvent(event: MotionEvent): Boolean {
    	 when (event.action) {
            MotionEvent.ACTION_DOWN -> {
              //获取motionEvent的x、y坐标
              lastX = event.x
              lastY = event.y
            }
            MotionEvent.ACTION_MOVE -> {
              //滑动的dx、dy值
              val dx = (event.x - lastX).toInt()
              val dy = (event.y - lastY).toInt()
	      //更新view的left top right bottom
  	      layout(left + dx, top + dy, right + dx, bottom + dy)				
           }
      	}    
        return true
    }

}

引用这个自定义View运行以后达到的效果如下:

views_sapmles.gif

offsetLeftAndRight()、offsetTopAndBottom()

这两个方法从名字上可以看出也是更改上下左右的:

public void offsetLeftAndRight(int offset) {
     ...
     mLeft += offset;
     mRight += offset;
     mRenderNode.offsetLeftAndRight(offset);
     ...
}

public void offsetTopAndBottom(int offset) {
     ...
     mTop += offset;
     mBottom += offset;
     mRenderNode.offsetTopAndBottom(offset);
     ...
}

这种方式和上面的layout很像,所以我们只需要把上面的

layout(left + dx, top + dy, right + dx, bottom + dy)

更改成下面两行就能达到同样的效果:

offsetLeftAndRight(dx)
offsetTopAndBottom(dy)

translationX、tanslationY

做过属性动画的都知道更改translationX、tanslationY做的是位移动画,因此我们可以通过更改view的translationX、tanslationY值来做滑动。同样我们替换自定义view关键滑动代码为:

translationX += dx
translationY += dy

就可以达到上面gif的效果图。

setX()、setY()

首先我们瞅一下setX()方法的源码:

public void setX(float x) {
    setTranslationX(x - mLeft);
}

可以看到setX()内部调用了setTranslationX(),只不过参数值是x-left,所以通过setX()、setY()能间接更改view的translationX、tanslationY。然后方案就和translationX、tanslationY代码基本一致:

x += dx
y += dy

上面两行代码替换掉关键滑动代码也能实现文中gif的效果。

scrollTo()、scrollBy()

scrollTo(x,y)是滑动到一个具体值,而scrollBy(x,y)是滑动增量的x、y,其内部调用的还是scrollTo():

public void scrollBy(int x, int y) {
    scrollTo(mScrollX + x, mScrollY + y);
}

public void scrollTo(int x, int y) {
    if (mScrollX != x || mScrollY != y) {
      int oldX = mScrollX;
      int oldY = mScrollY;
      mScrollX = x;
      mScrollY = y;
      ...
    }
}

需要注意的是:scrollTo()滑动的到底是个啥?网上说滑动的是view/viewGroup的内容,实际上从源码里面找mScrollX、mScrollY的引用,可以知道这两个值影响的是画布canvas的位移,所以我们看到现象就是:

  • view调用scrollTo移动的是自己的内容,比如textView移动了text,imageView移动了图片
  • viewGroup移动的是自己的children

然后我们将上面自定义view的滑动关键代码改为:

(parent as View).scrollBy(-dx, -dy)

能实现同样的效果。注意这里如果想要view跟着我们的手指滑动,我们调用的是parent的scrollBy同时传入的是-dx、-dy,参数具体原因我个人认为是画布移动正方向的时候,实际画的内容没有移动所以画的内容相应往后移动了,反之亦然。

拓展:Scroller

上面的自定义view只是处理了action_down和action_move,所以看到的效果在手指离开屏幕的时候比较生硬,实际自定义view的时候并不是这么简单。一般会添加在action_up的时候通过获取手指离开屏幕时的滑动速度计算出惯性滑动距离来做一个平移滑动,这里面涉及到一个类Scroller。这篇文章不深入讲解Scroller,只是简单普及一下Scroller的作用:Scroller像是一个滑动算法,通过我们给定的:

  • startScrollX 起始滑动x值
  • startScrollY 起始滑动y值
  • deltaX x轴增量滑动值 最终滑动x值(finalX)是(startScrollX+deltaX)
  • deltaY y轴增量滑动值 最终滑动y值(finalY)是(startScrollY+deltaY)
  • duration 滑动时长

这几个值来计算后续每个时间点应该滑动到的位置。再配合着View的computeScroll()方法,每次进去判断scroller.computeScrollOffset()如果为true来获取scroller.currX、scroller.currY,并设置对应的view位置属性,来达到流畅滑动。其中简要普及两个点

  1. View的computeScroll()方法:这个方法会在每次调draw的时候回调,像是draw的一个钩子方法。
  2. scroller.computeScrollOffset()返回的是啥?
public boolean computeScrollOffset() {
    //如果结束返回false
    if (mFinished) {
        return false;
    }

    int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);

    //如果执行的时间小于duration则计算出当前的mCurrX、mCurrY
    if (timePassed < mDuration) {
      switch (mMode) {
      case SCROLL_MODE:
           //mInterpolator和动画的插值器基本一样
           final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
           mCurrX = mStartX + Math.round(x * mDeltaX);
           mCurrY = mStartY + Math.round(x * mDeltaY);
           break;  
           ...
      }
    } else {
        //如果执行的时间大于等于duration则标识此段滑动结束,并将mCurrX、mCurrY置为最终目标值
        //mFinished也为true了,下次再调用这个方法直接返回false
        mCurrX = mFinalX;
        mCurrY = mFinalY;
        mFinished = true;
    }
    return true;
}

从上述源码中可以知道scroller.computeScrollOffset()是用来标识当前滑动是否结束了,结束了返回false,没结束返回true。

讲完这个后,熟悉Android动画的人,感觉这个东西有点儿似成相识。确实,它和动画的设计思想是非常相似的,有Interpolator,也有duration。。所以我们是否可以认为srcoller既可以通过更改scrollX、scrollY达到平移滑动也可以通过translationX、translationY达到平移滑动呢,甚至更多方式。。。好了,Scroller暂时就讲到这,有兴趣的可以自行实践一波,后续我会扒一下Scroller的源码并给出相关实践demo,敬请期待。

上述源码见Github

最后推荐一下我维护的一个开源库《超好用的Android高亮引导库》。Github地址如下:github.com/hyy920109/H…

View系列推荐阅读:

  1. 【View系列】View事件分发源码探索
  2. 【View系列】View的measure流程源码全解析
  3. 【View系列】MeasureSpec.UNSPECIFIED是这么用的?
  4. 【View系列】手把手教你解决ViewPager2滑动冲突