自定义View之Scroller

前言

本篇文章主要介绍Scroller的用法,并配合上一篇文章《自定义View之scroll系列方法》中介绍的View.scrollTo()scrollBy()两个方法以及View.computeScroll()方法,实现平滑滑动效果。

并通过Scroller及view事件分发机制源码,为大家具体讲解其中的奥妙。

Scroller典型使用方式

Scroller 的典型代码是固定的,主要有如下3步:

  1. 创建Scroller的实例
  2. 调用startScroll()或fling()方法来初始化滚动数据并刷新界面
  3. 重写computeScroll()方法,并在其内部完成平滑滚动的逻辑

代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//第一步,创建Scroller的实例,一般在控件初始化的时候进行
Scroller mScroller = new Scroller(mContext);

//平滑移动到指定位置
private void smoothScrollTo(int destX, int destY) {
int curScrollX = getScrollX();
int delta = destX - curScrollX;
// 第二步,调用startScroll()方法来初始化滚动数据并刷新界面
// 1000ms内滑向destX,效果就是满满滑动
mScroller.startScroll(curScrollX, 0, delta, 0, 1000);
invalidate();//刷新UI
}

@Override
public void computeScroll() {
// 第三步,重写computeScroll()方法,并在其内部完成平滑滚动的逻辑
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
invalidate();
}
}

源码解析

Scroller

通过上面的示例,可以归纳出scroller工作的整体流程:

scroller流程

既然有了这么明确的流程图,那我们下面就来依据这个流程简单分析下Scroller的源码。可以发现Scroller这类的代码不多,确实是一个工具类,它只是保存了我们传递的几个参数,我们先看下构造方法:

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
/**
* Create a Scroller with the default duration and interpolator.
*/
public Scroller(Context context) {
this(context, null);
}

/**
* Create a Scroller with the specified interpolator. If the interpolator is
* null, the default (viscous) interpolator will be used. "Flywheel" behavior will
* be in effect for apps targeting Honeycomb or newer.
*/
public Scroller(Context context, Interpolator interpolator) {
this(context, interpolator,
context.getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.HONEYCOMB);
}

/**
* Create a Scroller with the specified interpolator. If the interpolator is
* null, the default (viscous) interpolator will be used. Specify whether or
* not to support progressive "flywheel" behavior in flinging.
*/
public Scroller(Context context, Interpolator interpolator, boolean flywheel) {
mFinished = true;
if (interpolator == null) {
mInterpolator = new ViscousFluidInterpolator();
} else {
mInterpolator = interpolator;
}
mPpi = context.getResources().getDisplayMetrics().density * 160.0f;
mDeceleration = computeDeceleration(ViewConfiguration.getScrollFriction());
mFlywheel = flywheel;

mPhysicalCoeff = computeDeceleration(0.84f); // look and feel tuning
}

scroller总共有3个构造方法,其实前两个都调用了第三个构造方法。

主要是初始化了一些参数,其中interpolator为插值器,用于处理滑动过程中各时间段的快慢过程。

其中computeDeceleration()方法通过传入的摩擦系数,计算减速度(用于fling()方法中计算当前速度)。

1
2
3
4
5
6
private float computeDeceleration(float friction) {
return SensorManager.GRAVITY_EARTH // g (m/s^2)
* 39.37f // inch/meter
* mPpi // pixels per inch
* friction;
}

下面我们看看与Scroller相关的startScroll()和fling()方法,源码如下:

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
private static final int DEFAULT_DURATION = 250;
private static final int SCROLL_MODE = 0;
private static final int FLING_MODE = 1;

//使用默认滑动时间进行滑动
public void startScroll(int startX, int startY, int dx, int dy) {
startScroll(startX, startY, dx, dy, DEFAULT_DURATION);
}

/**
* 使用传入的参数duration滑动过程时间来完成滑动
*
* @param startX 滑动起点水平坐标,正数表示内容往左移
* @param startY 滑动起点垂直坐标,正数表示内容往上移
* @param dx 水平方向移动距离
* @param dy 垂直方向移动距离
* @param duration 移动过程持续时间
*/
public void startScroll(int startX, int startY, int dx, int dy, int duration) {
mMode = SCROLL_MODE;
mFinished = false;
mDuration = duration;
mStartTime = AnimationUtils.currentAnimationTimeMillis();
mStartX = startX;
mStartY = startY;
mFinalX = startX + dx;
mFinalY = startY + dy;
mDeltaX = dx;
mDeltaY = dy;
mDurationReciprocal = 1.0f / (float) mDuration;
}

//在快速滑动时松开的基础上开始惯性滚动,滚动距离取决于fling的初速度
public void fling(int startX, int startY, int velocityX, int velocityY,
int minX, int maxX, int minY, int maxY) {
......
mMode = FLING_MODE;
mFinished = false;
......
mStartX = startX;
mStartY = startY;
......
mDistance = (int) (totalDistance * Math.signum(velocity));

mMinX = minX;
mMaxX = maxX;
mMinY = minY;
mMaxY = maxY;
......
mFinalY = Math.min(mFinalY, mMaxY);
mFinalY = Math.max(mFinalY, mMinY);
}

注:这里的滑动指的是View内容的滑动而非View本身位置的改变

可以看到,scroller中的方法只是初始化了一些数据,没有做滑动相关的事。所以说这些只是工具方法而已,实质的滑动其实是需要我们在startScroll或fling后面手动调运View.invalidate()View.postInvalidate()进行重绘,然后在View的draw方法中又会去调运自己的computeScroll()方法,computeScroll()方法View中是一个空实现,需要我们自己去实现,上面的代码已经实现了computeScroll()方法,我们在该方法中进行Scroller.computeScrollOffset()判断并触发View的滑动方法和重绘。

我们再看一下Scroller的computeScrollOffset方法的实现,如下所示。

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
//判断滚动是否还在继续,true继续,false结束
public boolean computeScrollOffset() {
//mFinished为true表示已经完成了滑动,直接返回为false
if (mFinished) {
return false;
}
//mStartTime为开始时的时间戳,timePassed就是当前滑动持续时间
int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
//mDuration为我们设置的持续时间,当当前已滑动耗时timePassed小于总设置持续时间时才进入if
if (timePassed < mDuration) {
//mMode有两中,如果调运startScroll()则为SCROLL_MODE模式,调运fling()则为FLING_MODE模式
switch (mMode) {
case SCROLL_MODE:
//根据Interpolator插值器计算在该时间段里移动的距离赋值给mCurrX和mCurrY
final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
mCurrX = mStartX + Math.round(x * mDeltaX);
mCurrY = mStartY + Math.round(x * mDeltaY);
break;
case FLING_MODE:
//各种数学运算获取mCurrY、mCurrX,实质类似上面SCROLL_MODE,只是这里时惯性的
......
// Pin to mMinX <= mCurrX <= mMaxX
mCurrX = Math.min(mCurrX, mMaxX);
mCurrX = Math.max(mCurrX, mMinX);

mCurrY = mStartY + Math.round(distanceCoef * (mFinalY - mStartY));
// Pin to mMinY <= mCurrY <= mMaxY
mCurrY = Math.min(mCurrY, mMaxY);
mCurrY = Math.max(mCurrY, mMinY);

if (mCurrX == mFinalX && mCurrY == mFinalY) {
mFinished = true;
}

break;
}
}
else {
//认为滑动结束,mFinished置位true,标记结束,下一次再触发该方法时一进来就判断返回false了
mCurrX = mFinalX;
mCurrY = mFinalY;
mFinished = true;
}
return true;
}

可以看见该方法的作用其实就是实时计算滚动的偏移量(也是一个工具方法),同时判断滚动是否结束(true代表没结束,false代表结束)。

仿ViewPager示例

既然已经将Scroller的主要方法讲解完毕了,那就小试牛刀。

scroller动画

代码

ScrollerLayout.java

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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
/**
* Created by nht on 2018/8/21.
* Scroller使用示例,仿ViewPager水平滑动
*/

public class ScrollerLayout extends ViewGroup {

/**
* 用于完成滚动操作的实例
*/
private Scroller mScroller;

/**
* 判定为拖动的最小移动像素数
*/
private int mTouchSlop;

/**
* 手机按下时的屏幕坐标
*/
private float mXDown;

/**
* 手机当时所处的屏幕坐标
*/
private float mXMove;

/**
* 上次触发ACTION_MOVE事件时的屏幕坐标
*/
private float mXLastMove;

/**
* 界面可滚动的左边界
*/
private int leftBorder;

/**
* 界面可滚动的右边界
*/
private int rightBorder;

public ScrollerLayout(Context context, AttributeSet attrs) {
super(context, attrs);
// 第一步,创建Scroller的实例
mScroller = new Scroller(context);
mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
// 为ScrollerLayout中的每一个子控件测量大小
measureChild(childView, widthMeasureSpec, heightMeasureSpec);
}
}

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if (changed) {
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
// 为ScrollerLayout中的每一个子控件在水平方向上进行布局
childView.layout(i * childView.getMeasuredWidth(), 0, (i + 1) * childView.getMeasuredWidth(), childView.getMeasuredHeight());
}
// 初始化左右边界值
leftBorder = getChildAt(0).getLeft();
rightBorder = getChildAt(getChildCount() - 1).getRight();
}
}

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
mXDown = ev.getRawX();
mXLastMove = mXDown;
break;
case MotionEvent.ACTION_MOVE:
mXMove = ev.getRawX();
float diff = Math.abs(mXMove - mXDown);
mXLastMove = mXMove;
// 当手指拖动值大于TouchSlop值时,认为应该进行滚动,拦截子控件的事件
if (diff > mTouchSlop) {
return true;
}
break;
}
return super.onInterceptTouchEvent(ev);
}

@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_MOVE:
mXMove = event.getRawX();
int scrolledX = (int) (mXLastMove - mXMove);
if (getScrollX() + scrolledX < leftBorder) {
scrollTo(leftBorder, 0);
return true;
} else if (getScrollX() + getWidth() + scrolledX > rightBorder) {
scrollTo(rightBorder - getWidth(), 0);
return true;
}
scrollBy(scrolledX, 0);
mXLastMove = mXMove;
break;
case MotionEvent.ACTION_UP:
// 当手指抬起时,根据当前的滚动值来判定应该滚动到哪个子控件的界面
int targetIndex = (getScrollX() + getWidth() / 2) / getWidth();
int dx = targetIndex * getWidth() - getScrollX();
// 第二步,调用startScroll()方法来初始化滚动数据并刷新界面
mScroller.startScroll(getScrollX(), 0, dx, 0);
invalidate();
break;
}
return super.onTouchEvent(event);
}

@Override
public void computeScroll() {
// 第三步,重写computeScroll()方法,并在其内部完成平滑滚动的逻辑
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
invalidate();
}
}
}
1
2
3
4
5
6
7
8
public class ScrollerActivity extends AppCompatActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_scroller);
}
}

布局文件

activity_scroller.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?xml version="1.0" encoding="utf-8"?>
<com.nhtzj.myapplication.scroller.ScrollerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">

<Button
android:layout_width="match_parent"
android:layout_height="100dp"
android:text="This is first child view" />

<Button
android:layout_width="match_parent"
android:layout_height="100dp"
android:text="This is second child view" />

<Button
android:layout_width="match_parent"
android:layout_height="100dp"
android:text="This is third child view" />

</com.nhtzj.myapplication.scroller.ScrollerLayout>

重绘流程

在介绍Scroller源码过程中说到了“手动调运View.invalidate()View.postInvalidate()进行重绘,然后在View的draw方法中又会去调运自己的computeScroll()方法”。

现在就从源码的角度说明一下调用View.invalidate()后到computeScroll()的流程。

View的刷新会调用View.draw()方法,其实在调用该方法前还有几个步骤,这里就不提了,想要了解的可以查看这篇文章《Android应用层View绘制流程与源码分析》,小编关于View绘制流程的学习也是从这里看起的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void draw(Canvas canvas) {
final int privateFlags = mPrivateFlags;
final boolean dirtyOpaque = (privateFlags & PFLAG_DIRTY_MASK) == PFLAG_DIRTY_OPAQUE &&
(mAttachInfo == null || !mAttachInfo.mIgnoreDirtyState);
mPrivateFlags = (privateFlags & ~PFLAG_DIRTY_MASK) | PFLAG_DRAWN;

/*
* Draw traversal performs several drawing steps which must be executed
* in the appropriate order:
*
* 1. Draw the background
* 2. If necessary, save the canvas' layers to prepare for fading
* 3. Draw view's content
* 4. Draw children
* 5. If necessary, draw the fading edges and restore layers
* 6. Draw decorations (scrollbars for instance)
*/

……
// Step 4, draw the children
dispatchDraw(canvas);
……
}

View 绘制流程

1.绘制背景

2.2和5,如果有需要,绘制渐隐(fading) 效果

3.绘制内容

4.绘制 children

6.绘制装饰物 (scrollbars)

这里看第四步dispatchDraw(),ViewGroup用于绘制子view,在View中是个空方法。

1
2
3
protected void dispatchDraw(Canvas canvas) {

}

查看ViewGroup.dispatchDraw()方法。

1
2
3
4
5
6
7
8
9
protected void dispatchDraw(Canvas canvas) {
......
for (int i = 0; i < childrenCount; i++) {
......
more |= drawChild(canvas, child, drawingTime);
......
}
......
}
1
2
3
protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
return child.draw(canvas, this, drawingTime);
}

可以看到ViewGroup.dispatchDraw()内调用了ViewGroup.drawChild()

这里引出了View.draw(Canvas canvas, ViewGroup parent, long drawingTime)方法,这个方法不同于 View.draw(Canvas canvas)

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
/**
* This method is called by ViewGroup.drawChild() to have each child view draw itself.
*
* This is where the View specializes rendering behavior based on layer type,
* and hardware acceleration.
*/
boolean draw(Canvas canvas, ViewGroup parent, long drawingTime) {
final boolean hardwareAcceleratedCanvas = canvas.isHardwareAccelerated();
/* If an attached view draws to a HW canvas, it may use its RenderNode + DisplayList.
*
* If a view is dettached, its DisplayList shouldn't exist. If the canvas isn't
* HW accelerated, it can't handle drawing RenderNodes.
*/
boolean drawingWithRenderNode = mAttachInfo != null
&& mAttachInfo.mHardwareAccelerated
&& hardwareAcceleratedCanvas;

……

int sx = 0;
int sy = 0;
if (!drawingWithRenderNode) {
computeScroll();
sx = mScrollX;
sy = mScrollY;
}

final boolean drawingWithDrawingCache = cache != null && !drawingWithRenderNode;
final boolean offsetForScroll = cache == null && !drawingWithRenderNode;

int restoreTo = -1;
if (!drawingWithRenderNode || transformToApply != null) {
restoreTo = canvas.save();
}
if (offsetForScroll) {
canvas.translate(mLeft - sx, mTop - sy);
}

……

// Fast path for layouts with no backgrounds
if ((mPrivateFlags & PFLAG_SKIP_DRAW) == PFLAG_SKIP_DRAW) {
mPrivateFlags &= ~PFLAG_DIRTY_MASK;
dispatchDraw(canvas);
} else {
draw(canvas);
}

……


}

其中drawingWithRenderNode用于判断是否开启硬件加速。

1
2
3
4
5
6
7
int sx = 0;
int sy = 0;
if (!drawingWithRenderNode) {
computeScroll();
sx = mScrollX;
sy = mScrollY;
}

代码运行中,先会调用 computeScroll()方法,然后将 mScrollX 和 mScrollY 赋值给变量 sx 和 sy 变量。

终于找到了第一节中重写的computeScroll()方法。

1
canvas.translate(mLeft - sx, mTop - sy);

这里解释了View.scrollTo()View.scrollBy()方法为什么当参数为正数时,内容是向左移动。

回过头去看《自定义View之scroll系列方法》中关于参数正负性和内容移动方向的对应关系,是否就更加清晰了呢。

1
2
3
4
5
6
7
if ((mPrivateFlags & PFLAG_SKIP_DRAW) == PFLAG_SKIP_DRAW) {
mPrivateFlags &= ~PFLAG_DIRTY_MASK;
dispatchDraw(canvas);
} else {
// 在这里调用 draw() 单参数方法。
draw(canvas);
}

之后在这里进行绘制子view。

参考

不再迷惑,也许之前你从未真正懂得 Scroller 及滑动机制

Android应用开发Scroller详解及源码浅析

Android Scroller完全解析,关于Scroller你所需知道的一切

坚持原创技术分享,您的支持是对我最大的鼓励!