其实对于接触过Android开发的人来说,视图的滑动并不陌生,因为这一功能特性可以说是随处可见。
常用的就例如ScrollView、HorizontalScrollView、ListView,还有熟悉的ViewPager等控件,就都支持这一特性。
之所以这一类的控件在Android系统中如此受欢迎,其实也不难想象,最显而易见的:
手机的屏幕(可视区域)是十分有限的,那么如何在有限的区域内提供给用户“无限”的内容,也就是促使滑动视图诞生的根本原因。
今天就来总结一些对于接触到Android的视图滑动相关的知识的一些理解,以便加深印象。
如何让视图滑动起来
其实在Android中让视图滑动的实现方式有很多种,例如在《Android群英传》一书中,就总结了足足7种方式:
- 通过View的layout()方法让View滑动。
- 通过调用View类的offsetLeftAndRight、offsetTopAndBottom让View滑动。
- 通过设置LayoutParams让View滑动。
- 通过scrollTo与scrollBy让View滑动。
- 通过Scroller类来让View滑动。
- 通过属性动画来让View进行滑动。
- 终极神器ViewDragHelper。
我们这里不对每种方式都依次进行尝试,因为万变不离其宗。所以我们的重点放在理解让视图进行滑动的原理上。
最初接触到Android开发的时候,自己对ViewPager这个控件十分感兴趣,因为很多主流的APP的主界面上都采用了这种效果。
那么,我们何不就通过模仿一个十分简单的类似ViewPager的效果的自定义控件,来了解视图滑动的原理呢?
scrollTo和scrollBy
如果不去查阅任何的相关资料,自己去研究如何实现让视图滑动。那么肯定看着最亲切的就是它们两兄弟了。
毕竟名字里就已经带着扎眼的“scroll”,而它们确实能够让View实现移动的效果。
其实初初接触之下,肯定都觉得这两个方法的使用还是十分简单明了的。
这样说也没错,但其实关于它们也还是有不少值得我们了解的细节的。
我们说scrollTo和scrollBy方法实际上都是实现让View的位置发生改变,这两个方法都接受两个int型的变量(x和y)。
而且就和它们的方法名的定义一样,它们的区别也很明显,就在于:
- scrollTo是代表View将会移动到坐标点(x,y)的位置。
- scrollBy是代表此次View在x轴,和y轴上移动的增量为x和y。
通过一个简单的Demo,我们可以很形象的体会它们的效果。假设现有如下布局:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent">
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="button"
android:layout_centerInParent="true"/>
</RelativeLayout>
那么我们首先通过如下代码查看scrollTo的效果:
public class MainActivity extends AppCompatActivity {
private RelativeLayout container;
private Button startScrollBtn;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
container = (RelativeLayout) findViewById(R.id.container);
startScrollBtn = (Button) findViewById(R.id.start_scroll);
startScrollBtn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
container.scrollTo(100, 100);
}
});
}
}
测试效果如下:
嘿嘿,你可能已经注意到了,我们测试的效果发现是原本居中显示的button进行了一定的位移,最终到了靠近屏幕左上方的一个位置。
但是,有趣的是:我们在代码中实际上是通过container,也就是button所在的父视图RelativeLayout来调用的scrollTo方法。
我们最初很可能会经历这样类似的迷茫,原本想调用父视图的scrollTo方法来让它发生位移,但却发现其子控件产生了位移。
为什么会造成这样的现象,我们不急着去查明原因。我们先接着看看如果是使用scrollBy,又会是什么情况。
OK,我们将之前的代码中scrollTo(100,100)改为scrollBy(100,100),再看看有什么事情发生:
。。。。。。。。。。。
第一次看到这里难免都会想吐槽?这。。。有区别吗?没错,其实我们发现我们将scrollTo改为了调用scrollBy,但其实产生的效果其实没有区别。
好吧,到了这里我们想要解开我们的种种疑问,自然只能从源码当中去找答案了。
scrollTo和scrollBy的源码分析
有了目的其实接下来的工作就很明确了,我们直接先打开scrollTo的源码看看:
/**
* Set the scrolled position of your view. This will cause a call to
* {@link #onScrollChanged(int, int, int, int)} and the view will be
* invalidated.
* @param x the x position to scroll to
* @param y the y position to scroll to
*/
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();
}
}
}
其实我们通过该方法的源码以及代码注释发现,我们之前理解的没有错,该方法的目的就是:
设置位移目的地的坐标,然后则会调用onScrollChanged方法,从而通知View重绘。
虽然该方法的功能性很明确,但注意一下,我们发现出现了两个成员变量”mScrollX”与 “mScrollX”,事实上也正是这两个变量控制着View的位移。
以”mScrollX”为例,我们来看一看源码中对该变量的说明:
/**
* The offset, in pixels, by which the content of this view is scrolled
* horizontally.
* {@hide}
*/
@ViewDebug.ExportedProperty(category = "scrolling")
protected int mScrollX;
这个时候,通过注释我们发现很关键的一个信息,所谓的这个关于位移的成员变量,指的是:
by which the content of this view is scrolled,也就是说,指的是该view的内容滚动的距离。
这个时候我们就能理解为什么,我们在之前的代码中,通过container调用该方法,但滚动的却是其内容(即button)。
同时这也是为什么,我们会看到有些资料和书籍当中会有,“scrollTo与scrollBy移动的不是View自身,而是View的内容”的说法。
好吧,这点我们弄懂了。但是为什么我们在之前的代码里,scrollTo与scrollBy的效果却没有任何区别?
带着这个疑问,我们打开scrollBy的源代码来瞧上一瞧:
public void scrollBy(int x, int y) {
scrollTo(mScrollX + x, mScrollY + y);
}
原来如此,scrollBy在底层仍然调用了scrollTo,不同的是:它是将传入的x,y两个参数分别加上mScrollX与mScrollY来作为最终调用scrollTo时的参数。
于是,我们很清楚的可以理解到,scrollBy是在之前发生过的位移的基础上,再去移动x,y的距离。
在我们之前的测试代码中,我们直接调用scrollBy(100,100),这个时候呢,因为mScrollX和mScrollY的默认值为初始的0.所以它与直接调用scrollTo(100,100)所达到的效果没有任何区别。
解惑的工作完成了吗?实际上并没有,因为我们发现,我们传入的参数为正数,按照对于视图系的正常理解来说,它应该向屏幕的右下方移动才对,然而事实却相反。
这样的结果是在告诉我们,以x轴为例,偏移量大于0则是向左位移;而偏移量小于0才是向右位移这样的观点吗?
这样的说法是否准确?事实上究竟是怎么样的原因造成这样的现象?我们还得继续来看。
继续深入,继续理解
要解开我们前面所说到的另一个困惑,我们先要了解一个概念,即“什么是可视区域”?
其实这个概念并不难理解。首先,我们之前也说了,一个手机的屏幕大小是固定的。
作为用户来说,我们可能很直观的认为当前屏幕内显示的内容就是View的内容。
但我们也知道,如果真的如此,那么一个View的局限性就太大了。所以,在Android中,一个View的内容显然不可能被限定在这样的框框里。
所谓“心有多大,舞台就有多大”,我们可以这样想象:一个View的内容可以是无限多的,但是呢,因为屏幕大小的限制,当前能够呈现在屏幕上的内容是有限的。
但是,当前没有被呈现在屏幕上的内容,我们并不能就说它不存在。现在仅仅是因为我们的“视野”不够,看不到他们罢了。
所以,当前呈现在手机屏幕上的,我们可以理解为“可视区域”,而当前没有在手机屏幕上的内容,它们位于“可视区域”之外,但并非不存在。
通过画一张简单的图,可以更容易理解这种关系:
在这里,图中蓝色的区域是我们的View的所有内容,红色区域则是显示在屏幕上的内容。
现在我们来分析一下,为什么会出现传入的位移量为正数,button却向左上方移动的情况。
图中红色区域的坐上角的点(即实际Android设备屏幕左上角),该点的坐标是视图系的原点。
假设我们现在调用了scrollTo(100,100),实际上我们就是让红色区域view的左上角从原点,移动到x,y轴坐标均为100的点。
最终就会得到如下图所示的效果:
也就是说,所谓的“可视区域”经scrooTo方法移动过后,变为了图中的”黄色区域”。
这个时候,我们发现了一个熟悉的情景。没错,button的位置与我们之前测试的效果是一致的。
由此,我们也就搞清楚了Android当中视图移动的原理了。那么,我们就不妨赶紧趁热打铁,自定义一个View,来模拟一个简易的ViewPager。
自定义一个简单的ViewPager
在开始写代码之前,我们先明确一下我们大概要做的工作。
- 首先,很显然,我们要实现的将是一个ViewGroup。
- 我们可以向该ViewGroup内添加子视图,每个子视图的宽高填满屏幕。
- 我们可以左右滑动屏幕来进行子视图的切换。
- 滑动距离超过屏幕的1/3的距离,则完成切换;否则取消切换。
那么,看上去我们要做的工作并不复杂,无非就是:
- 创建一个自定义视图类继承自ViewGroup。
- 重写onMeasure完成子视图的测量。
- 重写onLayout方法完成子视图的摆放。
- 重写onTouchEvent方法,处理View的滑动。
所以我们可能首先编写得到如下的代码:
package com.tsr.androidscrolltest.widgit;
import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
/**
* Created by Administrator on 2016/7/12.
*/
public class CustomScrollView extends ViewGroup{
private int mScreenWidth;
private int mLastX;
private int mStartX, mEndX;
public CustomScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
mScreenWidth = context.getResources().getDisplayMetrics().widthPixels;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int count = getChildCount();
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
measureChild(child, widthMeasureSpec, heightMeasureSpec);
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int count = getChildCount();
MarginLayoutParams layoutParams = (MarginLayoutParams) getLayoutParams();
layoutParams.width = mScreenWidth * count;
setLayoutParams(layoutParams);
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
child.layout(i * mScreenWidth, 0, (i + 1) * mScreenWidth, b);
}
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int x = (int) event.getX();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mLastX = x;
mStartX = getScrollX();
break;
case MotionEvent.ACTION_MOVE:
int dx = mLastX - x;
if (dx < 0) {
// X轴的偏移量为0,则证明屏幕还未滑动过
if(getScrollX() > 0){
scrollBy(dx,0);
}
} else if (dx > 0) {
if (getScrollX() < getWidth() - mScreenWidth) {
scrollBy(dx, 0);
}
}
mLastX = x;
break;
case MotionEvent.ACTION_UP:
mEndX = getScrollX();
int movedX = mEndX - mStartX;
if (movedX > 0) {
if (movedX < mScreenWidth / 3) {
// 回到原处
scrollBy(-movedX,0);
} else {
scrollBy(mScreenWidth - movedX,0);
}
} else {
if (-movedX < mScreenWidth / 3) {
scrollBy(-movedX,0);
} else {
scrollBy(- mScreenWidth - movedX,0);
}
}
break;
}
postInvalidate();
return true;
}
}
然后在布局文件中使用我们的自定义视图:
<?xml version="1.0" encoding="utf-8"?>
<com.tsr.androidscrolltest.widgit.CustomScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<View
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#3F51B5"></View>
<View
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FF4081"></View>
<View
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000"></View>
</com.tsr.androidscrolltest.widgit.CustomScrollView>
最终的效果如图所示:
到这里,其实我们已经基本上简单的模拟出了viewpager的一个效果。但是,我们肯定很明显的注意到,
在此时的效果当中,在我们手指松开滑动的时候,剩下的滑动距离是瞬间完成的,这让人感觉非常突兀,可谓逼死强迫症。
实际上,这样的结果是可想而知的,因为我们在onTouchEvent当中,当action为ACTION_UP时,
剩下的滑动距离,我们仍然是通过调用scrollBy完成的,该方法本来就是瞬间完成位移的效果的。
那么,我们就要想办法了。有没有什么方式可以让我们为这段位移添加上粘性的效果呢?当然有,那就是使用Scroller类。
使用Scroller实现平滑移动
对于Scroller的使用,实际上很简单。我们在我们之前的代码的基础上修改和加上如下的代码:
case MotionEvent.ACTION_UP:
mEndX = getScrollX();
int movedX = mEndX - mStartX;
if (movedX > 0) {
if (movedX < mScreenWidth / 3) {
// 回到原处
mScroller.startScroll(getScrollX(), 0, -movedX, 0);
} else {
mScroller.startScroll(getScrollX(), 0, mScreenWidth - movedX, 0);
mCurrentIndex ++;
}
} else {
if (-movedX < mScreenWidth / 3) {
mScroller.startScroll(getScrollX(), 0, -movedX, 0);
} else {
mScroller.startScroll(getScrollX(), 0, -mScreenWidth - movedX, 0);
mCurrentIndex --;
}
}
break;
}
//================================================
@Override
public void computeScroll() {
super.computeScroll();
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), 0);
}
postInvalidate();
}
我们所做的工作很简单,就是将手指松开滑动时,剩余指定距离的位移动作改为通过scroller来实现,而不再是scrollBy。
同时,我们重写了另外一个方法,即computeScroll()。再次运行程序,我们看看新的效果是否爽了不少:
我们发现,与之前相比,松开手指后的滑动有了一个平移的效果。
因为模拟器的缘故,这个效果并不十分明显,但我们还是可以感觉相比之前体验好了不少。
既然Scroller这么好用,我们当然要去探索一下它为什么能够提供给我们平滑移动的效果。
首先,我们看见我们是通过调用Scroller的startScroll方法来开启滑动的,那么我们就来看一看这个方法的源码:
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;
}
从源码中可以看到,该方法其实只是对滚动做了一些初始设置,比如设置滚动模式,开始时间,持续时间等等。但实际上并没有真正开启滚动。
那么,我们肯定会好奇,究竟什么时候才会开启滚动呢?我们注意到在之前的代码中,我们重写了computeScroll方法。
很显然,这肯定不会是毫无意义的一个步骤。所以我们就来看看为什么要重写这个方法。
很明显,我们很容易想到,我们得看看这个方法在什么时候会被调用。随着辛苦的查找,我们发现它的调用关系是。
在View类的绘制方法”draw()”当中,会调用dispatchDraw()来分发绘制,通知其孩子进行绘制工作。
dispatchDraw在View类中是一个空方法,其具体实现在ViewGroup当中。很好理解,显然ViewGroup通常才会有孩子。
ViewGroup的dispatchDraw方法当中的代码很多,但我们只需要知道该方法中会调用到drawChild方法来绘制子试图。
而API-23版本的源码当中,该方法的实现已经简化到了这种地步:
protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
return child.draw(canvas, this, drawingTime);
}
也就是说,该方法最终又会通过调用child(具体子试图)自己的draw方法来完成绘制。computeScroll正是在此方法中被调用。
看到这里你就说了,开头也是draw,现在又来个draw,绕这么大一圈干啥呢?我们需要分清的是:
- 在我们所说的最初的draw()方法,是“public void draw(Canvas canvas) ”该方法。
- 而在经过dispatchView → drawChild之后,再通过child.draw调用的是另一个重载方法”boolean draw(Canvas canvas, ViewGroup parent, long drawingTime)“。
了解了这一过程,我们再来回忆我们之前的代码,还记得我们在computeScroll中,调用了另一个方法“computeScrollOffset”。
这个方法用来判断是否完成了整个滑动,并且提供了getCurrX和getCurrY方法来获取当前的滑动坐标。
这个方法的源码如下:
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;
case FLING_MODE:
final float t = (float) timePassed / mDuration;
final int index = (int) (NB_SAMPLES * t);
float distanceCoef = 1.f;
float velocityCoef = 0.f;
if (index < NB_SAMPLES) {
final float t_inf = (float) index / NB_SAMPLES;
final float t_sup = (float) (index + 1) / NB_SAMPLES;
final float d_inf = SPLINE_POSITION[index];
final float d_sup = SPLINE_POSITION[index + 1];
velocityCoef = (d_sup - d_inf) / (t_sup - t_inf);
distanceCoef = d_inf + (t - t_inf) * velocityCoef;
}
mCurrVelocity = velocityCoef * mDistance / mDuration * 1000.0f;
mCurrX = mStartX + Math.round(distanceCoef * (mFinalX - mStartX));
// 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 {
mCurrX = mFinalX;
mCurrY = mFinalY;
mFinished = true;
}
return true;
}
从源码我们可以发现该方法是通过怎么样的形式来判断滑动是否结束和计算当前滑动坐标的。
回忆一下,我们在startScroll当中获取了一个当前时间赋值给了成员变量mStartTime,即理解为滑动开始时间。
在该方法中,我们再次获取当前的时间,并减去开始时间,就得到了一个差值。
我们将这个差值与设置的滑动动画持续时间mDuration进行比较,如果小于持续时间,则证明滑动还要继续。
然后根据Interpolator来计算出在该时间段里面移动的距离,赋值给mCurrX, mCurrY,从而计算出当前的滑动坐标。
到了,这里实际我们已经清楚Scroller的原理了。我们整理一下思路,能够有一个更为清晰的理解:
- 首先,通过调用Scroller的startScroll()方法来进行一些滚动的初始化设置。
- 接着,我们迫使View进行重绘(invalidate()或postInvalidate()).
- 经过一系列相关的方法调用,最终会触发computeScroll()方法。
- 通过重写触发computeScroll()方法,首先在其中调用computeScrollOffset()查看滑动是否结束了。
- 如果没有结束,则调用scrollTo()完成滚动。然后再次让View重绘,如此循环,直至完成滑动。
为你的viewPager添加pageIndex
事实上,到了这里我们已经基本完成了我们想要的一个简易的ViewPager了。
但是,“不手贱,无八哥”。在我的手贱之下,又发现了一个无聊的问题。
举例来说,本来如果现在view停留在第一个时,那么当我们手指向右滑动,原本是不允许view滚动的,因为其左边没有别的view。
但是呢,我现在抽疯似的一直反复的右滑,右滑。有的时候就会出现view向右滑动了一点的情况。
这个时候,屏幕的最左边就会出现一道宽度很小的白色空隙。我们来分析一下为什么出现这样的情况。
我们回顾一下我们之前在ACTION_MOVE当中的的代码,:
// X轴的偏移量为0,则证明屏幕还未滑动过
if(getScrollX() > 0){
scrollBy(dx,0);
}
} else if (dx > 0) {
if (getScrollX() < getWidth() - mScreenWidth) {
scrollBy(dx, 0);
}
我们看到例如“getScrollX() > 0”这样的一个判断,加上这个判断的初衷正是我们想要的如果当前处在“第一屏”,则手指右滑也不允许发生试图滚动。
按理说,这个判断是合理的,但为什么会出现前面所说的情况呢?我们在脑海里默默模拟一下我们的操作,
假设手指一直抽疯似的右滑右滑右滑,那么就可能出现一种情况,那就是:
手指在一次右滑结束,离开屏幕后;飞快的左移,想进行下一次的右滑操作,但是可能在不经意就会在左移的过程中触碰到屏幕。
这个时候,我们的程序会认为用户在执行一次左滑操作,很显然,这个时候的滑动时允许的。
那么屏幕中的View就会开始滑动一点点距离,值得注意的是:我们的手指向左滑,实际上View做的是向右移动。
也就是说,这个时候,getScroolX的值就满足大于0的条件了。而不巧的是,我们的手指这个时候正在抽疯,
飞速的连贯动作下,这个时候我们又开始了向右滑动手指的操作,那么,就出现了之前的bug了。
为了解决这个抽疯导致的问题,我们可以给每一屏的view添加pageIndex。
实际上,这是一个一举两得的方式,因为这样做我们不仅可以通过新的判断方式避免这种bug,还可以获取当前屏幕view的index,举例viewpager的效果又近了一点。
现在,我们为我们的自定义试图添加如下成员变量:
private int mCurrentIndex = 1, mPagesCount;
然后修改代码:
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int count = getChildCount();
MarginLayoutParams layoutParams = (MarginLayoutParams) getLayoutParams();
layoutParams.width = mScreenWidth * count;
setLayoutParams(layoutParams);
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
child.layout(i * mScreenWidth, 0, (i + 1) * mScreenWidth, b);
mPagesCount++;
}
}
case MotionEvent.ACTION_MOVE:
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
int dx = mLastX - x;
if (dx < 0) {
// 新的判断方式
if(mCurrentIndex > 1){
scrollBy(dx,0);
}
} else if (dx > 0) {
if (mCurrentIndex < mPagesCount) {
scrollBy(dx, 0);
}
}
mLastX = x;
break;
case MotionEvent.ACTION_UP:
mEndX = getScrollX();
int movedX = mEndX - mStartX;
if (movedX > 0) {
if (movedX < mScreenWidth / 3) {
// 回到原处
mScroller.startScroll(getScrollX(), 0, -movedX, 0);
} else {
mScroller.startScroll(getScrollX(), 0, mScreenWidth - movedX, 0);
mCurrentIndex ++;
}
} else {
if (-movedX < mScreenWidth / 3) {
mScroller.startScroll(getScrollX(), 0, -movedX, 0);
} else {
mScroller.startScroll(getScrollX(), 0, -mScreenWidth - movedX, 0);
mCurrentIndex --;
}
}
这个时候,就不会再出现之前的bug了。