一个写代码的地方

Android View 滑动和弹性滑动读书笔记

3.2 View 的滑动

View 的基础知识 学习笔记中记录了 View 的基础知识和概念,本次笔记内容源于Android 开发艺术探索第3章 3.2-3.3 View 滑动和弹性滑动读书笔记,我们就可以根据需要对 View 做一些操作,在 Android 设备上,最常见的一个操作就是滑动,如此可见滑动是很重要的知识,只有具备了滑动的基础知识才能做出更复杂炫酷的滑动效果,复杂的效果是由不同的滑动基础组成,通常 View 的滑动可以由三种方式:使用 scrollTo 或 scrollBy、通过动画给 View 添加平移效果实现滑动、通过改变 View 的布局参数 LayoutParams 实现滑动,下面进行逐一分析。

3.2.1 使用 scrollTo 或 scrollBy 进行滑动

Android 中 View 为我们提供了两个专门的方法 scrollTo 或 scrollBy 来实现 View 的滑动,scrollTo 是滑动到某一坐标,是绝对滑动,View 调用的时候只能进行一次滑动,比如将 View 从 A 点滑动到 B 点;而 scrollBy是滑动一段距离,是相对滑动,可以进行连续滑动,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
24
25
26
27
28
29
30
31
32
33
34
/**
* 绝对滑动
* @param x 指定水平方向滑动像素
* @param y 指定竖直方向滑动像素
*/
public void scrollTo(int x, int y) {
//位置发生了改变
if (mScrollX != x || mScrollY != y) {
int oldX = mScrollX;
int oldY = mScrollY;
mScrollX = x;
mScrollY = y;d
//使父视图清出子view,作用是重新创建显示的内容
//主要应用场景是view 的渐变度改变,滑动,旋转等
invalidateParentCaches();
//使View内容进行滑动
onScrollChanged(mScrollX, mScrollY, oldX, oldY);
//触发scrollbars进行绘制
//如果正在动画播放完毕返回
//ture 否则返回false
if (!awakenScrollBars()) {
//在动画期间(下一个动画开始),使view重新绘制
postInvalidateOnAnimation();
}
}
}
/**
* 进行相对滑动
* @param x 指定水平方向滑动像素
* @param y 指定竖直方向滑动像素
*/
public void scrollBy(int x, int y) {
scrollTo(mScrollX + x, mScrollY + y);
}

通过源码可以看出实际上 scrollBy 调用了 scrollTo 方法,实现了基于当前位置的相对滑动。其中 mScrollX 、mScrollY 这两个参数表示 View 内容的偏移量,单位为 px,可以通过 getScrollX 和 getScrollY 得到,mScrollX 是等于View的左边缘和View内容左边缘在水平方向的距离,并且当 View 内容左边缘在View左边缘的右侧的时候,mScrollX 为负,反之为正,View 边缘是指 View 的位置,由四个顶点组成,而 View 内容边缘是指 View 的内容边缘;同理,mScrollY 是等于View的上边缘和View 内容上边缘在数值方向的距离,并且 View 内容上边缘在View上边缘下侧的时候,mScrollY为负,反之为正;scrollTo或 scrollBy 只能改变 View 内容的位置不能改变 View 在布局中的位置(即View的布局参数比如 Top、Left、X、Y 等不会改变),即不能改变 View 本身的位置,对于一个 View 来说,其本身是一个容器,而 View 内容是容器里面的东西,对于一个ViewGroup来说,那么它的内容是它的所有子 View。

为了方便理解,下面用几幅图描述 mScrollX、mScrollY 和 View 内容的关系:

view_scroll

3.2.2 使用动画

使用动画来移动view主要是操作view的translationX和translationY属性,既可以使用传统的view动画,也可以使用属性动画,使用后者需要考虑兼容性问题,如果要兼容Android 3.0以下版本系统的话推荐使用开元动画库nineoldandroids。采用 View 动画 示例代码如下,此动画可以使 View在100ms内从原始位置向右下角移动100个像素。在项目的res目录中,创建一个名为anim的目录,创建动画资源文件 translation.xml 代码如下:

1
2
3
4
5
6
7
8
9
10
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:fillAfter="true">
<translate
android:duration="100"
android:fromXDelta="0"
android:fromYDelta="0"
android:toXDelta="100"
android:toYDelta="100" />
</set>

java 代码如下:

1
2
3
Animation animation = AnimationUtils.loadAnimation(this,R.anim.translation);
//开始动画平移
targetView.startAnimation(animation);

注意,使用这种方式进行平移,View动画只是对View的影像做操作,它并不能真正改变View的位置参数,如果这个View设置了点击事件,点击动画后的新位置无法触发点击事件的,点击原来的位置可以出发事件,这这是因为View本身位置其实没有变化造成,解决方案是通过在目标位置创建一个一模一样的隐藏的view,设置同样的点击事件,当view移动到目标位置后,隐藏被移动的view,显示目标位置view,这样就可以达到点击效果。使用属性动画没有此问题,但3.0之前系统无属性动画,这个时候可以使用动画兼容库nineoldandroids来实现属性动画,尽管如此,使用动画兼容库在3.0以后手机上实现属性动画本质上还是View动画。在100ms内将view向右平移100个像素代码如下:

1
2
ObjectAnimator animator = ObjectAnimator.ofFloat(view, "translationX", 0.0f, 100.0f);
animator.setDuration(100).start();

fFloat()方法的第一个参数表示动画操作的对象(可以是任意对象),第二个参数表示操作对象的属性名字(只要是对象有的属性都可以),第三个参数之后就是动画过渡值。过度值可以有一个到N个,如果是一个值的话默认这个值是动画过渡值的结束值。如果有N个值,动画就在这N个值之间过渡。

3.2.3 改变布局参数

改变布局参数实现滑动,即改变LayoutParams,如想将一个View右平移100px,只需要将该View的LayoutParams里的marginLeft增加100px即可。

1
2
3
4
ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); //获取view的布局参数
params.leftMargin += 100; //修改leftMargin的值,相当于xml布局文件中的margin_left的值
view.setLayoutParams(params); //将新的params值设置进view
//或者调用 view.requestLayout();

作者还提供了另一种思路是:可以在目标View 旁边放置一个宽度为0的view,当需要移动目标view 的时候,只需要给旁边的view设定需要移动的宽度即可,这样目标view自然就会被挤到目标位置。

针对以上三种view滑动总结如下:

  1. scrollTo/scrollBy:只适合对view的内容的滑动
  2. 属性动画:操作简单,功能强大,能实现复杂的动画效果,不建议用于有交互的View。
  3. 改变布局参数:适用于交互性强的View。

根据以上三种滑动原理我们选择一种来实现一个可以在全屏进行滑动的view,实现原理是通过重写view 的TouchEvent方法,处理ACTION_MOVE方法,那么我们选择哪一种方式合适呢?第一种scrollBy方式,由于scrollBy是能实现view的内容滑动,而view本身没有滑动,所以所以不能满足需求,而属性动画和改变view布局方式,都可以满足,下面是选择较为简单方式,通过动画动画兼容库nineoldandroids方式实现,代码如下:

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
public class TestButton extends TextView {
private static final String TAG = "TestButton";
private int mScaledTouchSlop;
// 分别记录上次滑动的坐标
private int mLastX = 0;
private int mLastY = 0;
public TestButton(Context context) {
this(context, null);
}
public TestButton(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public TestButton(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init();
}
private void init() {
mScaledTouchSlop = ViewConfiguration.get(getContext())
.getScaledTouchSlop();
Log.d(TAG, "sts:" + mScaledTouchSlop);
}
@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(MotionEvent event) {
//获取当前点击事件在屏幕中的坐标,注意:不是相对view本身坐标,不能用getX 和getY
int x = (int) event.getRawX();
int y = (int) event.getRawY();
switch (event.getAction()) {
//按下手势,无需处理
case MotionEvent.ACTION_DOWN: {
break;
}
//在此处理滑动事件
case MotionEvent.ACTION_MOVE: {
//计算滑动距离
int deltaX = x - mLastX;
int deltaY = y - mLastY;
Log.d(TAG, "move, deltaX:" + deltaX + " deltaY:" + deltaY);
//就算偏移距离
int translationX = (int)ViewHelper.getTranslationX(this) + deltaX;
int translationY = (int)ViewHelper.getTranslationY(this) + deltaY;
//进行滑动
ViewHelper.setTranslationX(this, translationX);
ViewHelper.setTranslationY(this, translationY);
break;
}
//抬起手势无需处理
case MotionEvent.ACTION_UP: {
break;
}
default:
break;
}
//记录滑动后坐标
mLastX = x;
mLastY = y;
//事件消费,返回false,无法滑动,后面会讲到view的事件分发机制有关。
return true;
}
}

3.3 弹性滑动

实现弹性滑动的原理是:将一次大的滑动分成若干次小的滑动,并在一个时间段内完成。可以通过scroller、Handler#postDelayed 以及Thread#Sleep来完成,下面逐一介绍。

3.3.1 使用Scroller

在3.1.4章节介绍过通过Scroller实现滑动的典型代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Scroller mScroller = new Scroller(mContext);
// 缓慢滚动到指定的位置
private void smoothScrollTo(int destX, int destY){
int scrollX = getScrollX();
int deltaX = destX - scrollX;
// 以 1000ms 内滑向 destX, 效果是慢慢滑动
mScroller.startScroll(scrollX, destY, deltaX , 0, 1000);
// View 的重绘
invalidate();
}
// 重写 computeScroll 方法,并在内部完成平滑滚动的逻辑
@Override
public void computeScroll() {
//判断view是在指定时间内是否完成滑动
if (mScroller.computeScrollOffset()){
//通过mScroller获取当前位置,实现滑动
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
// 再次进行重绘
postInvalidate();
}
}

下面分析一下Scroller是如何实现弹性滑动的,上面代码我们调用了mScroller.startScroll(scrollX, destY, deltaX , 0, 1000);方法,下面一起来看一下源码:

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
/**
* @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;
}

注意以上滑动是实现view的内容滑动,而且可以看到startScroll方法竟然没有做任何滑动的事情,只是将要进行滑动的参数传递进来并进行了保存,因此仅仅调用startScroll方法是无法实现滑动的,而正在使view 进行滑动的关键代码是invalidate(),为什么呢?因为调用invalidate()方法后会使 view进行重新绘制,这样就会调用view的 draw 方法,view 的draw方法又会去调用computeScroll() 方法,computeScroll方法在view 内部是一个空方法,需要我们自己去实现,上面典型代码中我们调用scrollTo方法,因此实现了view 的滑动,接着又调用postInvalidate()方法,使view进行第二次重新绘制,这样绘制过程又和第一次一样重新走一遍,这样反复调用就完成了view 的滑动;那么什么时候完成停止绘制呢?判断条件就是mScroller.computeScrollOffset(),这个方法会根据时间的流逝计算当前Scrollx和ScrollYD 值,返回ture表示滑动未结束。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public boolean computeScrollOffset() {
if (mFinished) {
return false;
}
int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
if (timePassed < mDuration) {
switch (mMode) {
case SCROLL_MODE:
final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
mCurrX = mStartX + Math.round(x * mDeltaX);
mCurrY = mStartY + Math.round(x * mDeltaY);
break;
......
break;
}
}
else {
mCurrX = mFinalX;
mCurrY = mFinalY;
mFinished = true;
}
return true;
}

总结: Scroller 本身不能实现 View 的滑动,它需要配合 View 的 computeScroll 方法才能完成弹性滑动的效果。通过不断地让 View 重绘,而每一次重绘距离滑动其实起始时间会有一个时间间隔,通过这个时间间隔 Scroller 得出 View 当前的滑动位置,知道了滑动位置就可以通过 scrollTo 方法完成 View 的滑动。 View 的每一次重绘都会导致 View 的小幅度滑动,而多次的小幅度滑动组成了弹性滑动,这就是 Scroller 滑动的工作机制。

3.3.2 通过动画

动画本身就是一个循序渐进的过程,因此使用动画进行滑动自然就具有弹性效果,比如以下代码就可以让一个View 在100ms内向右移动100像素。

1
2
ObjectAnimator animator = ObjectAnimator.ofFloat(view, "translationX", 0.0f, 100.0f);
animator.setDuration(100).start();

这里我们是直接使用属性动画作用于 view 使其进行滑动,很简单,下面要作者提供了一个有意思的思路实现同样的效果,就是利用属性动画的特性结合ScrollTo 方法实现 Scroller 弹性滑动效果,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private void scroller(){
//动画起始位置
final int startX = 0;
//动画要滑动的距离
final int deltax = 1000;
final ValueAnimator animator = ValueAnimator.ofInt(0, 1).setDuration(2000);
//动画添加监听器
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
//获取动画完成比例值
float fraction = animator.getAnimatedFraction();
//根据比例值对目标view进行滑动
targetView.scrollTo(startX +(int)(deltax * fraction), 0);
}
});
//开始执行
animator.start();
}

上面代码属性动画animator 对象没有作用于任何 view 对象上,它的作用只是在2000ms内完成整个动画的过程,利用这个特性就可以在动画每一帧到来时获取动画完成比例,然后再根据这个比例计算出当前 view 所要滑动的距离,注意由于这里是使用的scrollTo() 方法,所以实质滑动的是 view 的内容。这个方法实现的思路其实和 Scroller 比较类似,都是通过计算 改变一个百分比配合 Scroller 实现 view 滑动,但是这个方法中我们还可以在onAnimationUpdate方法中添加一些我们想要实现的操作,使用更加灵活。

3.3.3 使用延时策略

延时策略的思想是通过发送一系列的消息从而达到一种渐进式的效果,具体来说就是使用 Handler 或 View 的 postDelayed 方法,也可以使用线程的 sleep 方法。对于postDelayed 来说可以发送一个延时消息,然后在消息中对 view 进行滑动,连续不断的发送消息,就可以对 view 实现弹性滑动。对于sleep 来说,可以通过在while 循环中不断的对 view 进行滑动和 sleep,这样把整个滑动过程切成无数个小的滑动操作达到弹性滑动的效果。

采用Handler大约在1000ms内将 view 向左滑动100像素,示例如下:

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
private static final int MESSAGE_SCROLL_TO = 1;
//发送消息次数,即要滑动次数
private static final int FRAME_COUNT = 30;
//延时时间
private static final int DELAYED_TIME = 33;
//从0次开始
private int mCount = 0;
@SuppressLint("HandlerLeak")
private Handler mHandler = new Handler() {
public void handleMessage(Message msg) {
switch (msg.what) {
case MESSAGE_SCROLL_TO:
mCount++;
if(mCount <= FRAME_COUNT) {
//计算滑动百分比
float fraction = mCount / (float) FRAME_COUNT;
//计算每次要滑动距离
int scrollX = (int) (fraction * 100);
//开始滑动
view.scrollTo(scrollX, 0);
//循环发送消息实现滑动
mHandler.sendEmptyMessageDelayed(MESSAGE_SCROLL_TO,DELAYED_TIME);
}
break;
default:
break;
}
}
};

以上是关于 view 滑动的基础知识基本讲解完毕!