ScrollView滑动源码解读

2,165 阅读16分钟

1 前言

滑动对于android来说,是一个必不可少;它不复杂,大家都知道在onTouchEvent中,让它滑动就完事了,说它复杂,其嵌套处理复杂;在本系列文章,最终是为了熟悉嵌套滑动机制;对于滑动,分为下面几篇文章来完成解读:

  1. 滑动基础
  2. ScrollView滑动源码解读
  3. NestedScrollView嵌套滑动源码解读
  4. CoordinatorLayout-AppBarLayout-CollapsingToolbarLayout复杂滑动逻辑源码解读

在本章内,主要介绍实现的一些相关基础框架逻辑

  1. 布局机制以及处理
  2. 事件机制
  3. ScrollView中非嵌套滑动处理(其中虽然也存在嵌套机制的,但是由于兼容性问题,我们使用NestedScrollView,下一章节介绍)

上一章基础中未对布局机制、以及事件分发机制进行介绍,这里会进行详细介绍下,因为只有把这些了然于胸,才可以对滑动处理问题顺利精准的追根溯源

2、布局机制

视图布局包括三个过程

  1. 测量:measure方法,供父容器调用进行测量过程,内部通过onMeasure方法进行实际测量
  2. 放置:layout方法,供父容器调用,在父容器进行摆放,内部通过onLayout进行后代视图摆放
  3. 绘制:draw方法,供父容器调用,进行背景色、自己本身绘制、子view绘制等过程,onDraw为本身绘制

2.1 measure过程

这里主要讲解onMeasure方法;此方法就是测量自身宽高的一个过程,这时分为两种情况

  1. 本身不是容器,那么就是自身宽高,这时仅仅依靠自身控件限制来设置宽高即可
  2. 是容器,则需要考虑其孩子视图的测量,依据孩子的宽高测量情况,进而对自身宽高设置

采用方法setMeasuredDimension进行宽度和高度设置,但这个过程,又需要其它的一些依赖,通过下面三个条件最终确定结果

  1. 分发时,传递的长度以及模式限制
  2. 孩子视图的布局属性
  3. 视图的逻辑考量:比如容器要考虑孩子容器放置方式、子view要不要对宽度和高度做某些限制处理

上面说的也很直白,作者也不想贴过多的源码,为了讲明白,我们需要讲述下面一些内容

  1. 一些成员变量
  2. MeasureSpec类使用
  3. LayoutParams类
  4. 一些常见使用方法,有利于我们进行测量

2.1.1 成员变量

  • 测量宽度/高度:测量过程时,设置的宽度和高度值;相关方法
protected final void setMeasuredDimension(int measuredWidth, int measuredHeight)//设置
public final int getMeasuredWidth()  // 获取测量宽度
public final int getMeasuredHeight()  //获取测量高度
  • 宽度/高度:放置过程时产生结果,根据相对于父布局中的上下左右的长度来决定的;获取方法如下
public final int getWidth()
public final int getHeight()

一般来说,测量宽度等于宽度,测量高度等于高度

2.1.2 MeasureSpec类

这个类是对一个整数进行合成、拆分处理的辅助类;高两位表示模式,低30位表示长度;有三种模式

  1. UNSPECIFIED:不限制模式,就是父容器对当前视图测量结果,都接收;
  2. EXACTLY:精确模式,也就是开发者自己定义了长度
  3. AT_MOST:最大模式,父容器能够给子类提供的最大长度

模式获取

MeasureSpec.getMode(widthMeasureSpec)

长度获取

MeasureSpec.getSize(widthMeasureSpec)

合成

MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY)

2.1.3 LayoutParams类

View类中提供了此类,这个类广义来说,是父容器对直接孩子视图的一些限制或者交互手段;但系统中实现的此类,都是限制测量的相关信息;因此,这个类是自定义容器时才需要考虑的;对孩子视图的限制,有两种途径

  1. 通过addView添加;下面容器方法,对应参数提取
protected LayoutParams generateDefaultLayoutParams()
protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p)
  1. 通过xml解析;下面容器方法进行参数提取
public LayoutParams generateLayoutParams(AttributeSet attrs)

LayouParams的子类一般来说应实现三种构造器,这三种构造器对应上述三个解析方法

public LayoutParams(Context c, AttributeSet attrs)
public LayoutParams(int width, int height)
public LayoutParams(LayoutParams source)

这里的宽度、高度,是下面的值

  1. MATCH_PARENT、FILL_PARENT:负值,表示和父容器一样的宽度或者高度
  2. WRAP_CONTENT:负值,表示能够容纳自身测量的宽高即可
  3. 正值:表示开发者对其设置的精确值

LayouParams一个有一个通用子类MarginLayoutParams,其增加四个方向的margin属性;LayoutParams中的宽高和MeasureSpec中的模式、长度,才是测量中不变部分的逻辑的核心体现;

2.1.4 标准测量规则

子视图标准规则如下方法内容

   /**
     * 标注规则如下:返回模式和长度合成值,写成(长度,模式)
     * 1. childDimension为正数,则返回(childDimension, EXACTLY)
     * 2. spec中模式为UNSPECIFIED,则返回(spec中长度,UNSPECIFIED)
     * 3. spec中模式为AT_MOST,childDimension为WRAP_CONTENT,则返回(spec长度,AT_MOST)
     * 4. spec中模式为EXACTLY,childDimension为WRAP_CONTENT,则返回(spec长度,AT_MOST)
     * 5. spec中模式为EXACTLY,childDimension为MATCH_PARENT,则返回(spec长度,EXACTLY)
     */
public static int getChildMeasureSpec(int spec, int padding, int childDimension)
  • spec: MeasureSpec合成的整数值;特别注意,若模式为UNSPECIFIED,长度在23版本以下,其返回0,不是有效的长度值
  • padding:包括margin在内的,已经被占用的长度;注意margin在父容器中被处理
  • childDimension:LayoutParams中宽高的取值

自身视图标准测量见下面方法

   /**
     * 有下面情况
     * 1. measureSpace模式为UNSPECIFIED,返回所size
     * 2. measureSpace模式为EXACTLY,返回所measureSpace中的长度
     * 3. measureSpace模式为AT_MOST,返回min(size,measureSpace中的长度)
     */
public static int resolveSizeAndState(int size, int measureSpec, int childMeasuredState)
  • size:自身规则得到的最小长度
  • measureSpec:自身测量时的限制,长度宽度合成值
  • childMeasuredState:测量状态,小于所需最小长度为MEASURED_STATE_TOO_SMALL,否则为MEASURED_STATE_MASK

2.2 layout过程

public void layout(int l, int t, int r, int b)

这个方法表示了放置过程,这里面会把参数记录下来;参数意义为:

  • l: view左边界距离父布局左边界的距离;对应成员变量mLeft
  • t:view上边界距离父布局上边界的距离;对应成员变量mTop
  • r: view右边界距离父布局左边界的距离;对应成员变量mRight;
  • b: view下边界距离父布局上边界的距离;对应成员变量mBottom

这个四个参数,在绘制的时候,通过这些参数正确找到相对屏幕的位置

如果测量视图不是容器,则在onLayout做任何处理,这时因为参数记录下来,已经能保证当前view绘制到正确位置;而如果是容器,则需要告诉子视图相对自身的摆放位置

2.3 draw过程

绘制过程:

  1. 绘制背景色:私有方法,不可重写
private void drawBackground(Canvas canvas)
  1. 本身绘制:一般来说容器此方法不需要做任何处理
protected void onDraw(Canvas canvas)
  1. 子视图绘制:通知子视图进行绘制
protected void dispatchDraw(Canvas canvas)
  1. 前景色绘制:绘制的最上层图层;系统提供的指示条、滚动条,就是在这里处理的,所以我觉得边缘特效在这里处理比较合适
public void onDrawForeground(Canvas canvas)

2.4 小结

布局过程中,在可滑动方向的测量应该是不限制模式的,这样才能存在可滑动距离;

布局中,测量由于本身处理涉及方面较多,而且还依赖于具体的需求,所以比较复杂;而放置过程则比较简单,利用了测量的结果进而放置;绘制过程则仅仅依赖用户实现的效果,其复杂度决定于需求

3、事件分发机制

这里讲的事件分发,是touch事件分发;之前也看过很多关于这个讲解,对他们只讲了大致流程,这估计都是所谓的写作借鉴吧,作者应该是理解了的;虽然是那三个方法,起到了作用,但是我觉得事件还是分2个流程来说,而不是来那三个方法的流程图,那些流程图看了把人都带偏了

  • View事件处理
  • ViewGroup对View事件分发

下面就从三种流程出发介绍;不过还是要稍微介绍一些基础

3.1 事件基础知识

触摸事件是一系列事件,事件是以ACTION_DOWN开始,以ACTION_UP或者ACTION_CANCEL事件结束;情况基本如下

  • ACTION_DOWN : 第一个手指按下事件
  • ACTION_UP: 最后一个手指抬起事件
  • ACTION_CANCEL:取消事件,也即是不能得到后续事件
  • ACTION_MOVE : 移动事件
  • ACTION_POINTER_DOWN:多个手指时按下事件
  • ACTION_POINTER_UP:多个手指时,抬起事件
ACTION_DOWN ----> 其它事件 -----> ACTION_UP/ACTION_CANCEL

相关的三个方法,也是我们考虑重写的方法

  • 分发源头方法
dispatchTouchEvent(MotionEvent ev) //事件处理开始方法
  • 父容器拦截事件,不向后代分发方法;方法是否执行,依赖当前容器的状态,允许拦截,默认允许
onInterceptTouchEvent(MotionEvent ev) //在 dispatchTouchEvent方法内调度,拦截事件,ViewGroup内方法,默认不拦截
  • 视图自身处理方法
onTouchEvent(MotionEvent event) // 在 dispatchTouchEvent方法内调度;其中默认包括了点击和长按事件的处理

有存在允许父容器拦截分发的方法,那么就存在后代视图告诉父容器,请不要拦截,方法如下:

requestDisallowInterceptTouchEvent(boolean disallowIntercept) // 参数true表示不允许拦截

多指滑动处理的方法也有很多种,一般要分情况来说;在嵌套滑动时采用了接力的形式,我们在嵌套滑动章节中,再说这个的一些处理方法;

3.2 View事件处理

View中dispatchTouchEvent相关代码这里就不贴了;从中解读几个关键点,这几个关键点也在执行上是顺序关系

  1. 对于事件处理,如果处于enable状态,滑动条会处理滑动(上一篇文章提到的滑动条),这个记住处理结果
  2. 处于enable状态且触摸监听者存在,则调用处理,并且记住处理结果
  3. 如果之前滑动处理结果为false,进行触摸回掉处理,并记住处理结果

这三个流程执行后,处理结果,即为View事件处理结果;需要提醒下,有默认状态不可点击,所以,默认不处理事件

触摸回调

这个也就是:

public boolean onTouchEvent(MotionEvent event) // true表示当前view处理此事件

同样,按照执行顺序,说出对自定义滑动影响的关键点

  1. disable状态时,返回点击状态
  2. 存在触摸代理者,则返回触摸代理处理的结果
  3. 若处于click状态或者开启了默认的长按事件,则一定返回true(也是这段代码中处理的点击时间监听、长按事件监听)
  4. 默认返回false

View的处理流程还是很简单的,只是检查自身是不是存在处理

3.3 ViewGroup对View事件分发

ViewGroup中对dispatchTouchEvent进行了重写;我个人观点啊,它考虑的是,谁来处理;也就是

  1. 后代视图是否处理
  2. 自己要不要处理

后代视图处理情况

  • 后代不允许拦截时,可以找到事件处理的后代
  • 后代允许拦截时,当前容器不拦截,可以找到事件处理的view

当后代不处理事件时,容器就是发起super.dispatchTouchEvent进行自身处理,也就是当成一个View对事件进行处理

关于onInterceptTouchEvent方法,大家可能会存在一定的误解,这里还是说明一下,不然在阅读大家自己阅读嵌套滑动时,可能会忽略一些或者对一些处理进行迷糊

  • 拦截并不只是onInterceptTouchEvent方法的结果,如果没有找到可处理的后代一样会拦截
  • onInterceptTouchEvent调用的情况,其满足下面条件
    • 当前为允许拦截模式
    • down事件或者找到了可处理事件的的后代
  • 拦截之前,有可能会根据onInterceptTouchEvent来做结果判断;当前不拦截,则会寻找可处理事件的后代,其根据为时间点在视图之内
  • 当前事件,若是拦截,则分发cancel给可处理后代,并且重置当前处理后代对象为null;这个事件过后,则不回执行onInterceptTouchEvent方法,而直接执行自身的onTouchEvent方法作为结果

4、ScrollView中非嵌套滑动机制

从上一篇滑动基础中,我们可以知道,我们准守一些规则

  1. 继承View架构的一些功能,
  2. 实现滑动逻辑:这里又可以分为布局过程、事件拦截处理、滑动处理

4.1 重写方法

ScrollView继承了FrameLayout,没有实现任何接口;而在对滑动需要处理的6个方法中:其中四个默认实现没有基本可以使用;一般情况下不需要重写的方法:

public int computeVerticalScrollOffset() // 已经滑动距离
public int computeVerticalScrollExtent() // 可见长度
public int computeHorizontalScrollOffset() // 已经滑动距离
public int computeHorizontalScrollExtent() // 可见长度

而需要重写的方法,是需要显示内容的总长度

public int computeHorizontalScrollRange()
public int computeVerticalScrollRange()

这个理应有如下逻辑

0 <= offset <= range - extent

默认实现的extent为宽度或者高度,也就是没有考虑padding的实际可见区域;而如果可以越过边界一小段距离时,range范围要加上越过边界的距离

当前控件,重写了竖直方向的逻辑,也是此方向可以滑动;但是有些地方需要注意

  1. 未考虑子视图的margin值

重写方法后,有下面两个方法大大减少开发

  • overScrollBy:自动计算当前有效滑动值,并得到过界状态,以onOverScrolled方法进行回掉
  • canScrollVertically/canScrollHorizontally:是否可以水平或者竖直继续滑动的判断

4.2 布局过程

测量过程

利用FrameLayout测量逻辑,并在其关键逻辑方法进行改动;改动的为测量孩子容器方法

  • measureChild:
  • measureChildWithMargins 由于当前容器为上下滑动,所以宽度的要求,保持标准逻辑,而高度的测量模式修改为了MeasureSpec.UNSPECIFIED

绘制过程 重写了draw方法,只是在原有View绘制的基础上,在画布最上层增加了边缘特效

4.3 事件拦截

事件拦截需要进行如下处理:

  1. onInterceptTouchEvent在down事件中不拦截
  2. 检测到滑动后,需要在onInterceptTouchEvent或者onTouchEvent进行拦截,并且不允许父布局再进行拦截; 为何要在两个方法内都进行拦截处理呢,因为onInterceptTouchEvent可能只在down事件中调用一次;调用情形如下:
  3. down事件时调用一次,返回true,直接拦截,这时onTouchEvent直接处理down事件以及若处理结果为true时的后续事件
  4. down事件时调用一次,未找到处理事件的孩子视图,这时onTouchEvent也直接处理down事件以及若处理结果为true时的后续事件
  5. move事件调用0次或者多次后,进行了拦截,这此事件分发子视图cancel事件;下次事件则由onTouchEvent处理,返回true时后续事件其继续处理,否则结束

是否滑动判断:滑动距离大于最小滑动长度

4.4 多手指处理

在多手指的event中,里面的位置包含多个手指事件信息;多手指时一些处理必须使用下面方法

  • 事件类型通过getActionMasked方法获取
  • 事件附带坐标,必须指定手指索引:getY(index)

每个手指有两个身份变量,索引以及id;这是由于历史版本处理问题而进行兼容而产生的;

  • index和id可互相查找:findPointerIndex(id) = index, id = getPointerId(index)
  • index是从0开始的,0移除后,下次的事件手指索引依然从0开始
  • 在down或者point-down事件中通过getActionIndex方法获取索引

4.5 移动处理

移动时,可以分为手指在屏幕上时的移动以及离开屏幕后的移动

注意:OverScroller只是个计算工具,要想把数据应用需要进行刷新,并在computeScroll方法中处理

4.5.1 computeScroll方法

平滑移动、以及滑翔都会用到次方法进行滑动更新

  • mScroller.computeScrollOffset()进行计算,并返回是否计算更新
  • 通过计算的x、y以及当前滑动变量对比,变化采用overScrollBy进行实质滑动

4.5.2 跟手移动

这个过程在move事件中处理;overScrollBy方法和onOverScrolled方法一起处理移动;

onOverScrolled方法,实际有下面两种方式进行了移动

  1. 通过scrollTo方法进行移动
  2. 通过直接修改scroll变量,并且刷新画布进而移动

但是我们能用的也就是scrollTo方法;这就google家和它家的区别;

其中如果是过界的滑动,且当前OverScroller计算还没有结束,则进行回弹处理

4.5.3 滑翔

在up事件中处理;但也分两种情况

  1. 滑动的速度大于最小值进行滑翔
  2. 若不能滑翔,此时越界,则进行回弹动效

滑翔的逻辑在flingWithNestedDispatch方法中,通过判断是否可滑翔,进而调用fling方法;判断逻辑

  1. 滑动在0-滑动范围之间,不包括0与最大值
  2. 当前滑动值大于0,速度小于0
  3. 当前滑动值小于最大滑动距离,速度大于0

5、小结

这篇文章是从自我的角度来解读了,没有贴出源码,也没有每句源码的意义,是希望读者在读相关章节时,自己对照源码,找到的自己的理解,以免由于每个人不同抽象理解而造成问题。

如果在此文章中您有所收获,请给作者一个鼓励,点个赞,谢谢支持

技术变化都很快,但基础技术、理论知识永远都是那些;作者希望在余后的生活中,对常用技术点进行基础知识分享;如果你觉得文章写的不错,请给与关注和点赞;如果文章存在错误,也请多多指教!

上一篇:滑动基础

下一篇: NestedScrollView嵌套滑动源码解读