自定义组件开发八 Scroller与平滑滚动

概述

Scroller 译为“滚动器”,是 ViewGroup 类中原生支持的一个功能。我们经常有这样的体验:打开联系人,手指向上滑动,联系人列表也会跟着一起滑动,但是,当我们松手之后,滑动并不会因此而停止,而是伴随着一段惯性继续滑动,最后才慢慢停止。这样的用户体验完全照顾了人的习惯和对事物的感知,是一种非常舒服自然的操作。要实现这样的功能,需要 Scroller 类的支持。

Scroller 类并不负责“滚动”这个动作,只是根据要滚动的起始位置和结束位置生成中间的过渡位置,从而形成一个滚动的动画。这一点至关重要。

所谓的“滚动”,事实上就是一个持续不断刷新 View 的绘图区域的过程,给定一个起始位置、结束位置、滚动的持续时间,Scroller 自动计算出中间位置和滚动节奏,再调用 invalidate()方法不断刷新,从这点看,好像也不是那么复杂。

还有一点需要强调的是,一个 View 的滚动不是自身发起的动作,而是由父容器驱动子组件来完成,换句话说,需要 Scroller 和 ViewGroup 的配合才能产生滚动这个过程。所以,我们不要误以为是 View 自己在滚动,显然不是,而是容器让子组件滚动,主动权在 ViewGroup 手中。

View 也可以滚动,但是滚动的不是自己,而是 View 中的内容。

滚动往往分别两个阶段:第一个阶段是手指在屏幕上滑动,容器内的子组件跟随手指的速率一起滑动,当手指松开后,进入第二个阶段——惯性滚动,滚动不会马上停止,而是给出一个负的加速度,滚动速度会越来越慢,直到最后处于静态状态。这符合 Android 中很多组件的使用场景。

本节我们不仅仅只学习 Scroller 类,更要学习 ViewGroup 是如何配合 Scroller 实现惯性滚动的。

认识scrollTo()和scrollBy()方法

View 类中有两个与滚动有关的方法——scrollTo()和 scrollBy(),这两个方法的源码如下:

    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();
            }
        }
    }
    public void scrollBy(int x, int y) {
        scrollTo(mScrollX + x, mScrollY + y);
    }

scrollTo(int x, int y)方法中,参数 x、y 是目标位置,方法先判断新的滚动位置是否确实发生了变化,如果是,先保存上一次的位置,再应用这一次的新位置(x,y),接着调用 onScrollChanged()方法,并刷新 View 组件。scrollTo()方法表示“滚动到……”之意。

scrollBy(int x, int y)方法则不同,是要原来的基础上水平方向滚动 x 个距离,垂直方向滚动 y个距离,最终还是调用了 scrollTo(int x, int y)方法。本质上,这两个方法是一样的。scrollBy()方法表示“滚动了……”之意。

我们写一个简单的案例来说明 scrollTo()和 scrollBy()的基本使用,并了解这两个方法给组件带来的影响。定义一个 TextView 组件,并放两个 Button,两个按钮分别调用 scrollTo()和 scrollBy()两个方法,并实现相同的功能。创建 scrolltoby.xml 布局文件,内容如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <TextView
        android:id="@+id/tv"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:background="#99CCCCCC"
        android:text="Android" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:orientation="horizontal">

        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:onClick="scrollBy"
            android:text="scrollBy" />

        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:background="@android:color/darker_gray"
            android:onClick="scrollTo"
            android:text="scrollTo" />
    </LinearLayout>
</LinearLayout>

定义 ScrollToByActivity 类,继承自 Activity,在 ScrollToByActivity 类中加载 scrolltoby.xml 文件,并获得 TextView 对象,定义 Button 的单击事件响应方法,分别调用 scrollTo()和 scrollBy()方法。

public class ScrollToByActivity extends Activity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.scrolltoby);
        tv = (TextView) findViewById(R.id.tv);
    }
    private TextView tv;


    public void scrollBy(View view){
        tv.scrollBy(-5, 0);
    }
    public void scrollTo(View view){
        int x = tv.getScrollX();
        int y = tv.getScrollY();
        Log.e("ScrollToByActivity","");
        tv.scrollTo(x - 5, y);
    }
}

ScrollToByActivity 类的 scrollBy()方法是第一个按钮的事件响应方法,调用了 tv.scrollBy(-5, 0)
语句,表示 x 方向每次移动 5 个单位距离,y 不变;scrollTo()方法是第二个按钮的事件响应方法,先调用 tv.getScrollX()和 tv.getScrollY()获取当前 tv 对象的滚动距离,再通过 tv.scrollTo(x - 5, y)方法在 x 方向移动 5 个单位距离,y 不变。这两个方法实现的功能是相同的。
这里写图片描述
仔细观察运行结果,可以得出以下几个结论:
移动的并不是 View 组件自身,而是组件的内容,当我们点击按钮时,文字“Android”
的位置向右开始移动;
因为移动的是 View 组件的内容,所以,我们发现其方向与图形坐标系相反,也就是
说,scrollBy()方法的在 x 方向上参数为负时,向右移动,为正时,向左移动,y 方向
上参数为负时,向下移动,为正时,向上移动。scrollTo()方法的新坐标比原坐标小,
x 方向向右移动,y 方向向下移动,反之亦然。

我们可能会疑惑为什么滚动子组件的时候方向与我们的习惯是相反的,其实通过阅读源码
能够有帮助我们理解。启动滚动后,调用 invalidate()方法刷新绘制,在该方法中,有如下的实现:

    public void invalidate(int l, int t, int r, int b) {
        if (ViewDebug.TRACE_HIERARCHY) {
            ViewDebug.trace(this, ViewDebug.HierarchyTraceType.INVALIDATE);
        }
        if (skipInvalidate()) {
            return;
        }
        if ((mPrivateFlags & (DRAWN | HAS_BOUNDS)) == (DRAWN | HAS_BOUNDS) ||
                (mPrivateFlags & DRAWING_CACHE_VALID) == DRAWING_CACHE_VALID ||
                (mPrivateFlags & INVALIDATED) != INVALIDATED) {
            mPrivateFlags &= ~DRAWING_CACHE_VALID;
            mPrivateFlags |= INVALIDATED;
            mPrivateFlags |= DIRTY;
            final ViewParent p = mParent;
            final AttachInfo ai = mAttachInfo;
//noinspection PointlessBooleanExpression,ConstantConditions
            if (!HardwareRenderer.RENDER_DIRTY_REGIONS) {
                if (p != null && ai != null && ai.mHardwareAccelerated) {
// fast-track for GL-enabled applications; just invalidate the whole hierarchy
// with a null dirty rect, which tells the ViewAncestor to redraw everything
                    p.invalidateChild(this, null);
                    return;
                }
            }
            if (p != null && ai != null && l < r && t < b) {
                final int scrollX = mScrollX;
                final int scrollY = mScrollY;
                final Rect tmpr = ai.mTmpInvalRect;
                tmpr.set(l - scrollX, t - scrollY, r - scrollX, b - scrollY);
                p.invalidateChild(this, tmpr);
            }
        }
    }

下划线所在这行代码 tmpr.set(l - scrollX, t - scrollY, r - scrollX, b - scrollY)用于重新定义子组件的位置和大小,通过一个减法运算来定义新的矩形区域,这就是为什么子组件滚动方向相反的原因。

接下来再来演示 scrollTo()和 scrollBy()方法对布局容器的影响。定义 scrolltoby_layout.xml 布
局文件,内容如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:id="@+id/linearlayout"
    android:orientation="vertical">
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Android 自定义组件开发详解" />
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:orientation="horizontal">
    <Button
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:onClick="scrollBy"
        android:text="scrollBy" />
    <Button
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:background="@android:color/darker_gray"
        android:onClick="scrollTo"
        android:text="scrollTo" />
    </LinearLayout>
</LinearLayout>

在本例中,我们为 LinearLayout 布局定义 id 为 linearlayout,并且调用该对象的 scrollBy()和
scrollTo()方法,以观察对LinearLayout布局的影响。定义ScrollToByLayoutActivity类,继承自Activity,内容如下:

public class ScrollToByLayoutActivity extends Activity {


    private LinearLayout linearlayout;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_scroll_to_by_layout);
        linearlayout = (LinearLayout) findViewById(R.id.linearlayout);
    }
    public void scrollBy(View view) {
        linearlayout.scrollBy(-5, 0);
    }
    public void scrollTo(View view) {
        int x = linearlayout.getScrollX();
        int y = linearlayout.getScrollY();
        linearlayout.scrollTo(x - 5, y);
    }

}

运行结果如图所示,和 View 一样,当调用 linearlayout.scrollBy(-5, 0)和
linearlayout.scrollTo(x - 5, y)方法移动 LinearLayout 时,并不是移动 LinearLayout 本身,而是移动LinearLayout 中的子组件,一个 TextView、两个 Button 共 3 个子组件发生了整体水平移动。

这里写图片描述
这里写图片描述

在 View 中,还定义了获取滚动距离的方法,方法原型如下:

public final int getScrollX()
返回 x 方向滚动过的距离,也是当前 view 的左上角相对于父视图的左上角的 x 轴偏
移量;
public final int getScrollY()
返回 y 方向滚动过的距离,也是当前 view 的左上角相对于父视图的左上角的 y 轴偏
移量。

Scroller 类

Scroller 类在滚动过程的的几个主要作用如下:
启动滚动动作;
根据提供的滚动目标位置和持续时间计算出中间的过渡位置;
判断滚动是否结束;
介入 View 或 ViewGroup 的重绘流程,从而形成滚动动画。

Scroller 类虽然对滑动作用非同小可,但定义的的方法并不多,我们最好是能阅读该类的源
码,了解 Scroller 的工作原理。下面是 Scroller 的方法说明。

public Scroller(Context context)
public Scroller(Context context, Interpolator interpolator)
public Scroller(Context context, Interpolator interpolator, boolean flywheel)
构 造 方 法 , interpolator 指 定 插 速 器 , 如 果 没 有 指 定 , 默 认 插 速 器 为
ViscousFluidInterpolator,flywheel 参数为 true 可以提供类似“飞轮”的行为;
public final void setFriction(float friction)
设置一个摩擦系数,默认为 0.015f,摩擦系数决定惯性滑行的距离;
public final int getStartX()
返回起始 x 坐标值;
public final int getStartY()
返回起始 y 坐标值;
public final int getFinalX()
返回结束 x 坐标值;
public final int getFinalY()
返回结束 y 坐标值;
public final int getCurrX()
返回滚动过程中的 x 坐标值,滚动时会提供 startX(起始)和 finalX(结束),currX 根
据这两个值计算而来;
public final int getCurrY()
返回滚动过程中的 y 坐标值,滚动时会提供 startY(起始)和 finalY(结束),currY 根
据这两个值计算而来;
public boolean computeScrollOffset()
计算滚动偏移量,必调方法之一。主要负责计算 currX 和 currY 两个值,其返回值为
true 表示滚动尚未完成,为 false 表示滚动已结束;
public void startScroll(int startX, int startY, int dx, int dy)
public void startScroll(int startX, int startY, int dx, int dy, int duration)
启动滚动行为,startX 和 startY 表示起始位置,dx、dy 表示要滚动的 x、y 方向的距离,
duration 表示持续时间,默认时间为 250 毫秒;
public final boolean isFinished()
判断滚动是否已结束,返回 true 表示已结束;
public final void forceFinished(boolean finished)
强制结束滚动,currX、currY 即为当前坐标;
public void abortAnimation()
与 forceFinished 功用类似,停止滚动,但 currX、currY 设置为终点坐标;
public void extendDuration(int extend)
延长滚动时间;
public int timePassed()
返回滚动已耗费的时间,单位为毫秒;
public void setFinalX(int newX)
设置终止位置的 x 坐标,可能需要调用 extendDuration()延长或缩短动画时间;
public void setFinalY(int newY)
设置终止位置的 y 坐标,可能需要调用 extendDuration()延长或缩短动画时间。

上面的方法中,常用的主要有 startScroll()、computeScrollOffset()、getCurrX()、getCurrY()和abortAnimation()等几个方法,下面我们通过一个简单的案例来演示 Scroller 类的基本使用。

定义一个名称为 BaseScrollerViewGroup 的类,继承自 ViewGroup,在该类中使用代码(非配置)定义一个子组件 Button。为了将重点放在 Scroller 类的使用上,BaseScrollerViewGroup 在定义时做了大量简化,比如 layout_width 和 layout_height 不支持 wrap_content、Button 直接加入容器、onLayout()方法中将 Button 的位置固定死等等。

public class BaseScrollerViewGroup extends ViewGroup {

    private Scroller scroller;
    private Button btnAndroid;

    public BaseScrollerViewGroup(Context context) {
        super(context);
    }

    public BaseScrollerViewGroup(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public BaseScrollerViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        scroller = new Scroller(context);
        btnAndroid = new Button(context);
        LayoutParams layoutParams = new LayoutParams(
                LayoutParams.WRAP_CONTENT,
                LayoutParams.WRAP_CONTENT);
        btnAndroid.setText("Android 自定义组件");
        this.addView(btnAndroid, layoutParams);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        btnAndroid.layout(10, 10, btnAndroid.getMeasuredWidth() + 10,
                btnAndroid.getMeasuredHeight() + 10);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.AT_MOST
                || MeasureSpec.getMode(heightMeasureSpec)
                == MeasureSpec.AT_MOST)
            throw new IllegalStateException("Must be MeasureSpec.EXACTLY.");
        measureChildren(widthMeasureSpec, heightMeasureSpec);
        setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec),
                MeasureSpec.getSize(heightMeasureSpec));
    }


    @Override
    public void computeScroll() {
        if (scroller.computeScrollOffset()) {
            //设置容器内组件的新位置
            this.scrollTo(scroller.getCurrX(), scroller.getCurrY());
            //重绘以刷新产生动画
            postInvalidate();
        }
    }

    /**
     * 开始滚动,外部调用
     */
    public void start() {
        //从当前位置开始滚动,x 方向向右滚动 900,
        //y 方向不变,也就是水平滚动
        scroller.startScroll(this.getScrollX(), this.getScrollY(),
                -900, 0, 10000);
        //重绘
        postInvalidate();
    }

    /**
     * 取消滚动,直接到达目的地
     */
    public void abort() {
        scroller.abortAnimation();
    }
}

我们首先定义了一个 Scroller 类型的成员变量 scroller,并在构造方法中进行了实例化。重点
是重写了 ViewGroup 的 computeScroll()方法,该方法的默认实现是空方法,在绘制 View 时调用。在 computeScroll()方法中,调用 scroller.computeScrollOffset()方法计算下一个位置的坐标值(currX,currY),再通过 this.scrollTo(scroller.getCurrX(), scroller.getCurrY())语句移动到该坐标位置,特别要注意的是一定要调用 invadate()或 postInvalidate()方法重绘,一旦 computeScrollOffset()方法返回false 表示滚动结束,停止重绘。

另外,我们还定义了两个用来与外部交互的方法:start()和 abort()。start()方法用于启动滚动
动作,执行了 scroller.startScroll(this.getScrollX(), this.getScrollY(), - 900, 0, 10000)语句,其中参数this.getScrollX()和 this.getScrollY()是容器内容的初始位置,x 方向向右移动 900 个单位距离(为负才表示向右),y 方向不变,也就是水平向右移动,为了更好的查看动画过程,将滚动持续时间设为 10 秒。和上面一样,就算调用了 startScroll()方法,也需要调用 invadate()或 postInvalidate()方法进行重绘。在 abort()方法中调用了 scroller.abortAnimation()方法,用来停止滚动。

我们通过一个测试程序来验证 BaseScrollerViewGroup 容器的工作过程。定义一个名为
base_scroller.xml 的布局文件,文件内有一个 BaseScrollerViewGroup 标签,另外有两个并排摆放的按钮,分别执行“开始滚动”和“停止滚动”的功能。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <com.trkj.lizanhong.chapter8.BaseScrollerViewGroup
        android:id="@+id/scroll_layout"
        android:layout_width="match_parent"
        android:layout_height="100dp" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:orientation="horizontal">

        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:onClick="start"
            android:text="开始滚动" />

        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:background="@android:color/holo_blue_bright"
            android:onClick="abort"
            android:text="停止滚动" />
    </LinearLayout>
</LinearLayout>

BaseScrllerActivity 类则很简单,为两个 Button 定义事件处理方法,分别调用 start()和
abort()方法用于开始滚动和停止滚动。

public class BaseScrllerActivity extends Activity {

    private BaseScrollerViewGroup scrollerViewGroup;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.base_scroller);
        scrollerViewGroup = (BaseScrollerViewGroup)
                findViewById(R.id.scroll_layout);
    }
    /**
     * 滚动
     * @param view
     */
    public void start(View view){
        scrollerViewGroup.start();
    }
    /**
     * 停止
     * @param view
     */
    public void abort(View view){
        scrollerViewGroup.abort();
    }
}

这里写图片描述

平滑滚动的原理

以上可以总结出平滑滚动的基本工作流程:
1) 调用 scroller 的 startScroll()方法定义滚动的起始位置和滚动的距离;
2) 通过 invalidate()或 postInvalidate()方法刷新,调用 draw(Canvas)方法重绘组件;
3) 调用 computeScroll()计算下一个位置的坐标;
4) 再次调用 invalidate()或 postInvalidate()方法刷新重绘;
5) 判断 computeScroll()方法的返回值,如果为 false 表示结束滚动,为 true 表示继续滚动。

上面的步骤其实构建了一个方法调用循环:1) -> 2) -> 3) -> 4) -> 5) -> 3) -> 4) -> 5)……,3) ->4) -> 5)就是一个循环,该循环用于不断计算下一个位置,并通过重绘移动到该位置,这样就产生了动画效果。

我们通过阅读源码的方式进一步了解平滑滚动的工作原理。当调用 invalidate()方法或
postInvalidate()方法后,将重绘请求发送到 ViewRoot,再分发到对应的组件,调用 draw(Canvas canvas)方法。

public void draw(Canvas canvas) {
……
// Step 4, draw the children
dispatchDraw(canvas);
……
}

在 draw(Canvas canvas)方法中又调用 dispatchDraw(Canvas canvas)方法,该方法负责将绘制请求分发给子组件。

protected void dispatchDraw(Canvas canvas) {
……
more |= drawChild(canvas, child, drawingTime);
……
}

dispatchDraw(Canvas canvas)方法又调用了 drawChild()方法完成子组件的绘制工作。

protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
……
child.computeScroll();
……
}

重点来了,drawChild()方法调用了 child.computeScroll()方法,但是该方法是一个空方法,什
么也没有,我们需要重写该方法才能实现平滑滚动。

案例:触摸滑屏

触摸滑屏的技术

Android 手机一个最明显的标识就是进入桌面后可以左右滑屏查看 App 应用图标,和
Windows XP 的桌面有显著区别,这也给有限的桌面带来无限的空间,和垂直滑动显示内容相比,左右滑动更方便用户的手势操作,带来更好的用户体验,并获得用户的一致认可。

触摸滑屏分为两个过程:一是手指在屏幕上滑动时屏幕跟随一起滑动,滑动速度与手指速
度相同,现在的硬件质量有了很大提升,基本上是很粘手的,以前硬件没有到达现在这个水平
时,总有一些延迟,以前魅族生产的第一部手机 M8 就遇到过这个问题,现在基本不存在了。
二是手指松开后,根据手指的速度、已滑动距离判断屏幕是要回滚还是滑动到下一屏。这两个
过程共同构成了滑屏的基本动作。

如 果 您 熟 悉 Android 的 事 件 处 理 机 制 , 一 定 清 楚 public boolean onIntercept-
TouchEvent(MotionEvent ev)方法的作用,主要用于截拦事件,事件一旦被截拦,便无法将事件传递给子组件。触屏滑动时,必须考虑这个问题,当屏幕正处于滑动状态时,容器内的子组件便不再接受任何事件,onInterceptTouchEvent()方法必须返回 true,事件便绕过子组件往回传递。所以,我们必须在该方法中判断用户手指的状态是不是滑动状态,如果是滑动状态,返回 true 值,否则返回 false 值。

触摸滑动的操作在 public boolean onTouchEvent(MotionEvent event)方法中完成,手指按下时,判断是否正在滑屏中,如果是,则马上停止,同时记下手指的初始坐标。手指移动过程中,获取手指移动的距离,并让容器内容以相同的方向移动相同的距离。手指松开后,根据手指移动速度和已移动的距离判断是要回滚还是移动到下一屏。

ViewGroup 的内容区域是无限大的,我们可以将无数组件都放进去,但因为屏幕空间有限,
所以只能看到一部分内容。就像运行中游戏,场景很大,但是看到的却很少。要实现触摸分屏,必须将容器内的每个子组件设置成与屏幕大小相同,但一次只显示其中的一个。
这里写图片描述

容器的总宽度是容器可见宽度乘以子元素的个数,而高度则为可见高度大小。

速度跟踪器 VelocityTracker

VelocityTracker 主要用于跟踪触摸屏事件(flinging 事件和其他 gestures 手势事件)的速率。
当我们要跟踪一个 touch 事件的时候,使用 obtain()方法得到这个类的实例,然后用
addMovement(MotionEvent)函数将你接受到的 motion event 加入到 VelocityTracker 类实例中。当我们需要使用到速率时,使用 computeCurrentVelocity(int)初始化速率的单位,并获得当前的事件的速率,然后使用 getXVelocity() 或 getXVelocity()获得横向和竖向的速率。另外,通过VelocityTracker 还可以知道手指的滑动方向。

VelocityTracker 的基本使用如下:
手指按下时(ACTION_DOWN),获取 VelocityTracker 对象

if(velocityTracker == null){
//创建 velocityTracker 对象
velocityTracker = VelocityTracker.obtain();
}
//关联事件对象
velocityTracker.addMovement(ev);

手指移动过程中(ACTION_MOVE),计算速率

velocityTracker.computeCurrentVelocity(1000);

获取 xy 两个方向的速率:
int velocityX = velocityTracker.getXVelocity();
int velocityY = velocityTracker.getYVelocity();

手指松开后(ACTION_UP),释放并回收资源
//释放 VelocityTracker 资源

if(velocityTracker != null){
    velocityTracker.clear();
    velocityTracker.recycle();
    velocityTracker = null;
}

触摸滑屏的分步实现

定义一个容器类 MultiLauncher,继承自 ViewGroup,容器类中的子组件将与容器大小相
同。

第一步:初始化。平滑滚动需要使用 Scroller 对象,另外还需要给定一个最小滑动距离,
通过 ViewConfiguration.get(context).getScaledTouchSlop()可以获取到当前手机上默认的最小滑动距离。

private Scroller scroller;
private int touchSlop = 0;//最小滑动距离,超过了,才认为开始滑动
private static final String TAG = "MultiLauncher";
public MultiLauncher(Context context) {
    this(context, null);
}
public MultiLauncher(Context context, AttributeSet attrs) {
    this(context, attrs, 0);
}
public MultiLauncher(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    scroller = new Scroller(context);
    touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
}

第二步:测量容器宽度与高度。不允许使用 MeasureSpec.AT_MOST,每个子组件与容器相
同,容器的 layout_width 值虽然为 MeasureSpec. EXACTLY,但容器大小 = 父容器的宽度 * 子组件的个数,高度与父容器相同。

/**
* 测量容器本身的大小
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    measureChildren(widthMeasureSpec, heightMeasureSpec);
    int width = this.measureWidth(widthMeasureSpec);
    int height = this.measureHeight(heightMeasureSpec);
    setMeasuredDimension(width, height);
}
/**
* 测量组件的宽度
* @param widthMeasureSpec
* @return
*/
private int measureWidth(int widthMeasureSpec){
    int mode = MeasureSpec.getMode(widthMeasureSpec);
    int size = MeasureSpec.getSize(widthMeasureSpec);
    int width = 0;
    if(mode == MeasureSpec.AT_MOST){
        throw new IllegalArgumentException("Must not be
        MeasureSpec.AT_MOST.");
    }else{
        width = size;
    }
    //容器的宽度是屏幕的 n 倍,n 是容器中子元素的个数
    return width * this.getChildCount();
}
/**
* 测量组件的高度
* @param heightMeasureSpec
* @return
*/
private int measureHeight(int heightMeasureSpec){
    int mode = MeasureSpec.getMode(heightMeasureSpec);
    int size = MeasureSpec.getSize(heightMeasureSpec);
    int height = 0;
    if(mode == MeasureSpec.AT_MOST){
        throw new IllegalArgumentException("Must not
        be MeasureSpec.AT_MOST.");
    }else{
        height = size;
    }
    return height;
}

第三步:定位子组件。默认情况下,屏幕出现第一个子组件,子组件占满容器的可见区
域,其他子组件以相同大小依次排列在后面。

protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int n = this.getChildCount();
    int w = (r - l) / n;//分屏的宽度
    int h = b - t;//容器的高度
    for(int i = 0; i < n; i ++){
        View child = getChildAt(i);
        int left = i * w;
        int right = (i + 1) * w;
        int top = 0;
        int bottom = h;
        child.layout(left, top, right, bottom);
    }
}

第四步:判断滚动状态,状态为分两种:停止状态和滑动状态。容器根据状态决定是否截
拦事件。

private static final int TOUCH_STATE_STOP = 0x001;//停止状态
private static final int TOUCH_STATE_FLING = 0x002;//滑动状态
private int touchState = TOUCH_STATE_STOP;
private int touchSlop = 0;//最小滑动距离,超过了,才认为开始滑动
private float lastionMotionX = 0;//上次触摸屏的 x 位置

public boolean onInterceptTouchEvent(MotionEvent ev) {
    int action = ev.getAction();
    final int x = (int) ev.getX();
    final int y = (int) ev.getY();
    if (action == MotionEvent.ACTION_MOVE &&
            touchState == TOUCH_STATE_STOP)
        return true;
    switch (action) {
        case MotionEvent.ACTION_DOWN:
            lastionMotionX = x;
            touchState = scroller.isFinished() ? TOUCH_STATE_STOP
                    : TOUCH_STATE_FLING;
            break;
        case MotionEvent.ACTION_MOVE:
            //滑动距离过小不算滑动
            final int dx = (int) Math.abs(x - lastionMotionX);
            if (dx > touchSlop) {
                touchState = TOUCH_STATE_FLING;
            }
            break;
        case MotionEvent.ACTION_CANCEL:
        case MotionEvent.ACTION_UP:
            touchState = TOUCH_STATE_STOP;
            break;
        default:
            break;
    }
    return touchState != TOUCH_STATE_STOP;
}

第五步:惯性滚屏。在下面的代码中,最重要的一个语句是 int dx = curScreen * splitWidth -
scrollX,获得当前屏的索引 curScreen(从 0 开始),乘以一屏的宽度,减去容器滚过的距离,得到的值就是剩下的惯性距离。假设一共有 5 屏,每屏宽度为 10,当前 curScreen 为 1 时表示将滚动到第 2 屏,如果容器已滚动了 6,则 dx = 1 * 10 - 6 = 4,意思是剩下的 4 个单位距离将自动滚过去。下面是从第 0 屏滚动到第 1 屏,从第 1 屏滚动到第 2 屏,再由第 2 屏滚动到第 1 屏,第 1屏滚动到第 0 屏各变量输出的值(测试手机为魅族 Pro5),大家可以通过这些输出结果找出一些规律或得到一些结论:
02-06 23:03:36.021 /? I/MultiLauncher: moveToScreen
02-06 23:03:36.021 /? I/MultiLauncher: curScreen:1
02-06 23:03:36.021 /? I/MultiLauncher: scrollX:498 dx:582 splitWidth:1080
02-06 23:03:37.691 /? I/MultiLauncher: moveToScreen
02-06 23:03:37.691 /? I/MultiLauncher: curScreen:2
02-06 23:03:37.691 /? I/MultiLauncher: scrollX:1532 dx:628 splitWidth:1080
02-06 23:05:30.451 /? I/MultiLauncher: moveToScreen
02-06 23:05:30.451 /? I/MultiLauncher: curScreen:1
02-06 23:05:30.451 /? I/MultiLauncher: scrollX:1648 dx:-568 splitWidth:1080
02-06 23:05:32.051 /? I/MultiLauncher: moveToScreen
02-06 23:05:32.051 /? I/MultiLauncher: curScreen:0
02-06 23:05:32.051 /? I/MultiLauncher: scrollX:342 dx:-342 splitWidth:1080

  private int curScreen; //当前屏
    private VelocityTracker velocityTracker;//速率跟踪器
    public void moveToScreen(int whichScreen){
        Log.i(TAG, "moveToScreen");
        curScreen = whichScreen;
        Log.i(TAG, "curScreen:" + curScreen);
        if(curScreen > getChildCount() - 1)
            curScreen = getChildCount() - 1;
        if(curScreen < 0) curScreen = 0;
        int scrollX = getScrollX();
        //每一屏的宽度
        int splitWidth = getWidth() / getChildCount();
        //要移动的距离
        int dx = curScreen * splitWidth - scrollX;
        Log.i(TAG, "dx:" + dx);
        //开始移动
        scroller.startScroll(scrollX, 0, dx, 0, Math.abs(dx));
        invalidate();
    }

手指滑动距离如果超过容器一半或者滑动速度足够快,则进入下一屏(或者上一屏)。如果
没有超过一半或速度很慢则回滚到初始位置。定义 moveToDestination()方法如下,最关键的语句是 int toScreen = (getScrollX() + splitWidth / 2 ) / splitWidth,getScrollX()是容器滚动过的距离,splitWidth 是每一屏的宽度。比如每一屏的宽度为 10,当前屏为第 2 屏,容器已滚过 23,则 toScreen= (23 + 10 / 2) / 10 = (23 + 5) / 10 = 28 / 10 = 2.8 = 2,也就是说要回滚到第 2 屏;如果容器已滚动28,则 toScreen = (28 + 10 / 2) / 10 = 32 / 10 = 3.2 = 3,表示要滚动到第 3 屏。

public void moveToDestination(){
    Log.i(TAG, "moveToDestination");
    //每一屏的宽度
    int splitWidth = getWidth() / getChildCount();
    //判断是回滚还是进入下一分屏
    int toScreen = (getScrollX() + splitWidth / 2 ) / splitWidth ;
    //移动到目标分屏
    moveToScreen(toScreen);
}

定义了 moveToNext()和 moveToPrevious()简化滑屏调用。

/**
* 滚动到下一屏
*/
public void moveToNext(){
    moveToScreen(curScreen + 1);
}
/**
* 滚动到上一屏
*/
public void moveToPrevious(){
    moveToScreen(curScreen - 1);
}

第六步:响应用户手指的按下、移动和松开事件,这是整个滑动的关键,特别是松开后,要
判断滚屏还是回滚。为了支持上一屏和下一屏,需要辨别手指滑动的方向,VelocityTracker 类可以获取 x 方向的速率,其正值代表向左滑动,负值代表向右滑动。如果 x 方向的速率在[-
SNAP_VELOCITY,SNAP_VELOCITY]之间,则要根据用户滑动的距离(滑动距离是否超过一屏的1/2)决定是要继续滚屏还是回滚到初始状态。

public boolean onTouchEvent(MotionEvent event) {
        if(velocityTracker == null){
            velocityTracker = VelocityTracker.obtain();
        }
        velocityTracker.addMovement(event);
        super.onTouchEvent(event);
        int action = event.getAction();
        final int x = (int) event.getX();
        switch (action){
            case MotionEvent.ACTION_DOWN:
                //手指按下时,如果正在滚动,则立刻停止
                if(scroller != null && !scroller.isFinished()){
                    scroller.abortAnimation();
                }
                lastionMotionX = x;
                break;
            case MotionEvent.ACTION_MOVE:
                //随手指滚动
                int dx = (int) (lastionMotionX - x);
                scrollBy(dx, 0);
                lastionMotionX = x;
                break;
            case MotionEvent.ACTION_UP:
                final VelocityTracker velocityTracker = this.velocityTracker;
                velocityTracker.computeCurrentVelocity(1000);
                int velocityX = (int) velocityTracker.getXVelocity();
                //通过 velocityX 的正负值可以判断滑动方向
                if(velocityX > SNAP_VELOCITY && curScreen > 0){
                    moveToPrevious();
                }else if(velocityX < -SNAP_VELOCITY && curScreen < (getChildCount()-1)){
                    moveToNext();
                }else {
                    moveToDestination();
                }
                if(velocityTracker != null){
                    this.velocityTracker.clear();
                    this.velocityTracker.recycle();
                    this.velocityTracker = null;
                }
                touchState = TOUCH_STATE_STOP;
                break;
            case MotionEvent.ACTION_CANCEL:
                touchState = TOUCH_STATE_STOP;
                break;
        }
        return true;
    }

我们接下来对 MultiLauncher 类进行简单测试,测试有两种情形:一种是手指的触摸滚屏,
另一种是点击按钮实现上一屏和下一屏的滚屏。定义 multi_launcher.xml 布局文件,布局中定义了 5 个 LinearLayout,代表五屏内容——事实上您可以定义任意多的分屏,也可以使用其他布局作为分屏容器。multi_launcher.xml 布局文件的内容如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <bczm.com.day0617.MultiLauncher

        android:id="@+id/ml"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1">

        <LinearLayout
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:background="#FF0000"
            android:orientation="vertical"></LinearLayout>

        <LinearLayout
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:background="#FFFF00"
            android:orientation="vertical"></LinearLayout>

        <LinearLayout
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:background="#00FF00"
            android:orientation="vertical"></LinearLayout>

        <LinearLayout
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:background="#0000FF"
            android:orientation="vertical"></LinearLayout>

        <LinearLayout
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:background="#00FFFF"
            android:orientation="vertical"></LinearLayout>
    </bczm.com.day0617.MultiLauncher>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:onClick="pre"
            android:text="上一屏" />

        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:background="@android:color/holo_green_light"
            android:onClick="next"
            android:text="下一屏" />
    </LinearLayout>
</LinearLayout>

定义继承自 Activity 的 MultiLauncherActivity 类,加载 multi_launcher.xml,并响应 Button 的
单击事件,实现上一屏和下一屏功能。

public class MultiLauncherActivity extends Activity {

    private MultiLauncher ml;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.multi_launcher);
        ml = (MultiLauncher) findViewById(R.id.ml);
    }
    public void pre(View view){
        ml.moveToPrevious();
    }
    public void next(View view){
        ml.moveToNext();
    }

}

这里写图片描述

运行后,手指在屏幕上滑动时,如果快速滑动,则切换到上一屏或下一屏,如果速度比较慢
或者滑动距离小于一屏的 1/2,则会自动缩回去。点击“上一屏”和“下一屏”按钮,也可以实现同
样的效果。当前屏为第 0 屏时无法继续上一屏,当前屏为最后一屏时无法继续下一屏。

练习 作业

1、请阅读 ViewPager 组件源码,用文字与代码结合的方式阐述 ViewPager 的工作原理和技术实现。
2、请使用 ViewGroup 实现左侧边栏组件,并为外部提供显示侧边栏和隐藏侧边栏的功能方法。运行效果如图所示。
这里写图片描述

谢谢认真观读本文的每一位小伙伴,衷心欢迎小伙伴给我指出文中的错误,也欢迎小伙伴与我交流学习。
欢迎爱学习的小伙伴加群一起进步:230274309

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值