阅读徐宜生《Android群英传》的笔记——第5章 Android Scroll 分析

5.1 滑动效果是如何产生的

滑动一个 View,本质上来说就是移动一个 View。改变其当前所处的位置,它的原理与动画效果的实现非常相似,都是通过不断地改变 View 的坐标来实现这一效果。所以,要实现 View 的滑动,就必须监听用户触摸的事件,并根据事件传入的坐标,动态且不断地改变 View 的坐标,从而实现 View 跟随用户触摸的滑动而滑动。
在讲解如何实现滑动效果之前,需要先来了解一些 Android 中的窗口坐标体系和屏幕的触控事件 —— MotionEvent。

5.1.1 Android 坐标系

在物理学中,要描述一个物体的运动,就必须选定一个参考系。所谓滑动,正是相对于参考系的运动。在 Android 中,将屏幕最左上角的顶点作为 Android 坐标系的原点,从这个点向右是 X 轴正方向,从这个点向下是 Y 轴正方向,如下图所示:

Android 坐标系
系统提供了 getLocationOnScreen(int location[]) 这样的方法来获取 Android 坐标系中点的位置,即该试图左上角在 Androd 坐标系中的坐标。另外,在触控事件中使用 getRawX(),getRawY() 方法所获得的坐标同样是 Android 坐标系中的坐标。

5.1.2 视图坐标系

Android 中除了上面所说的这种坐标系之外,还有一个视图坐标系,它描述了子视图在父视图中的位置关系。这两种坐标系并不矛盾也不复杂,他们的作用是相辅相成的。与 Android 坐标系类似 ,视图坐标系同样是以原点向右为 X 轴正方向,以原点向下为 Y 轴正方向,只不过在视图坐标系中,原点不再是 Android 坐标系中的屏幕最左上角,而是以父视图左上角为坐标原点,如下图所示:

视图坐标系

在触控事件中,通过 getX()、getY() 所获得的坐标就是视图坐标系中的坐标。

5.1.3 触控事件 —— MotionEvent

触控事件 MotionEvent 在用户交互中,占着举足轻重的地位,学好触控事件是掌握后续内容的基础。首先,来看看 MotionEvent 中封装的一些常用的事件常量,它定义了触控事件的不同类型。

// 单点触摸按下动作
public static final int ACTION_DOWN = 0;
// 单点触离开下动作
public static final int ACTION_UP = 1;
// 触摸点移动动作
public static final int ACTION_MOVE = 2;
// 触摸动作取消
public static final int ACTION_CANCEL = 3;
// 触摸动作超出边界
public static final int ACTION_OUTSIDE = 4;
// 多点触摸按下动作
public static final int ACTION_POINTER_DOWN = 5;
// 多点离开动作
public static final int ACTION_POINTER_UP = 6;

通常情况下,我们会在 onTouchEvent(MotionEvent event) 方法中通过 event.getAction() 方法来获取触控事件的类型,并使用 switch-case 方法来进行筛选,这个代码的模式基本固定,如下所示:

@Override
public boolean onTouchEvent(MotionEvent event) {
    // 获取当前输入点的 X、Y 坐标(视图坐标)

    int x = (int) event.getX();
    int y = (int) event.getY();
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            // 处理输入的按下事件

            break;
        case MotionEvent.ACTION_MOVE:
            // 处理输入的移动事件

            break;
        case MotionEvent.ACTION_UP:
            // 处理输入的离开事件

            break;
    }
    return true;
}

在不涉及多点操作的情况下,通常可以使用以上代码来完成触控事件的监听,不过这里只是一个代码模板,后面我们会在触控事件中完成具体的逻辑。
在 Android 中,系统提供了非常多的方法来获取坐标值、相对距离等。方法丰富固然好,但也给初学者带来了很多困惑,不知道在什么情况下使用什么方法,下面总结了一些 API,结合 Android 坐标系来看看该如何使用它们,如下图所示:
在这里插入图片描述
这些方法可以分成如下两个类别:

  • View 提供的获取坐标方法
    getTop():获取到的是 View 自身的顶边到其父布局顶边的距离
    getLeft():获取到的是 View自身的左边到其父布局左边的距离
    getRight():获取到的是 View 自身的右边到其父布局左边的距离
    getBottom():获取到的是 View自身的底边到其父布局顶边的距离
  • MotionEvent 提供的方法
    getX():获取点击事件距离控件左边的距离,即视图坐标
    getY():获取点击事件距离控件顶边的距离,即视图坐标
    getRawX():获取点击事件距离整个屏幕左边的距离,即绝对坐标
    getRawY():获取点击事件距离整个屏幕顶边的距离,即绝对坐标

5.2 实现滑动的七种方法

当了解了 Android 坐标系和触控事件后,我们再来看看如何使用系统提供的 API 来实现动态地修改一个 View 的坐标,即实现滑动效果。而不管采用哪一种方式,其实现的思想基本是一致的,当触摸 View 时,系统记下当前触摸点坐标;当手指移动时,系统记下移动后的触摸点坐标,从而获取到相对于前一次坐标点的偏移量,并通过偏移量来修改 View 的坐标,这也不断重复,从而实现滑动过程。
下面我们就通过一个实例,来看看在 Android 中该如何实现滑动效果。定义一个 View,并置于一个 LinearLayout 中,实现一个简单的布局,代码如下所示:

<?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.example.test.DragView
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:background="#000000" />

</LinearLayout>

自定义 View:

package com.example.test;

import android.content.Context;
import android.util.AttributeSet;
import android.view.View;

/**
* Created by HourGlassRemember on 2016/10/7.
*/
public class DragView extends View {

    public DragView(Context context) {
        this(context, null);
    }

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

    public DragView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

}

我们的目的就是让这个自定义的 View 随着手指在屏幕上的滑动而滑动,初始化时显示效果如下所示:
在这里插入图片描述

5.2.1 layout 方法

在 View 进行绘制时,会调用 onLayout() 方法来设置显示的位置。同样,可以通过修改 View 的 left、top、right、bottom 四个属性来控制 View 的坐标,与前面提供的模板代码一样,在每次回调 onTouchEvent的时候,我们都来获取一下触摸点的坐标,代码入下所示:

package com.example.test;

import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;

/**
* Created by HourGlassRemember on 2016/10/7.
*/
public class DragView extends View {

    private int lastX, lastY;

    public DragView(Context context) {
        this(context, null);
    }

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

    public DragView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                //记录触摸点坐标
                lastX = x;
                lastY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                //计算偏移量
                int offsetX = x - lastX;
                int offsetY = y - lastY;
                //在当前 left、top、right、bottom 的基础上,增加计算出来的偏移量
                //每次移动时,View 都会调用 Layout 方法来对自己重新布局,从而达到移动 View 的效果
                layout(getLeft() + offsetX, getTop() + offsetY, getRight() + offsetX, getBottom() + offsetY);
                break;
            case MotionEvent.ACTION_UP:
                break;
        }
        return true;
    }

}

在上面的代码中,使用的是 getX()、getY() 方法来获取坐标值,即通过视图坐标来获取偏移量。当然,同样可以使用 getRawX()、getRawY() 来获取坐标,并使用绝对坐标来计算偏移量,代码如下所示:

package com.example.test;

import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;

/**
* Created by HourGlassRemember on 2016/10/7.
*/
public class DragView extends View {

    private int lastX, lastY;

    public DragView(Context context) {
        this(context, null);
    }

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

    public DragView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int rawX = (int) event.getX();
        int rawY = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                //记录触摸点坐标
                lastX = rawX;
                lastY = rawY;
                break;
            case MotionEvent.ACTION_MOVE:
                //计算偏移量
                int offsetX = rawX - lastX;
                int offsetY = rawY - lastY;
                //在当前 left、top、right、bottom 的基础上,增加计算出来的偏移量
                //每次移动时,View 都会调用 Layout 方法来对自己重新布局,从而达到移动 View 的效果
                layout(getLeft() + offsetX, getTop() + offsetY, getRight() + offsetX, getBottom() + offsetY);
                //重新设置初始坐标
                lastX = rawX;
                lastY = rawY;
                break;
            case MotionEvent.ACTION_UP:
                break;
        }
        return true;
    }

}

使用绝对坐标系,有一点非常需要注意的地方,就是在每次执行完 ACTION_MOVE 的逻辑后,一定要重新设置初始坐标,这样才能准确地获取偏移量。

5.2.2 offsetLeftAndRight() 与 offsetTopAndBottom()

这个方法相当于系统提供的一个对左右、上下移动的 API 的封装。当计算出偏移量后,只需要使用如下代码就可以完成 View 的重新布局,效果与使用 Layout 方法一样,代码如下所示:

// 同时对 left 和 right 进行偏移
offsetLeftAndRight(offsetX);
// 同时对 top 和 bottom 进行偏移
offsetTopAndBotom(offsetY);

这里的 offsetX、offsetY 与在 Layout 方法中的计算的 offset 的方法一样,这里就不重复了。

5.2.3 LayoutParams

LayoutParams 保存了一个 View 的布局参数,因此可以在程序中改变 LayoutParams 来动态地修改一个布局的位置参数,从而达到改变 View 位置的效果。代码如下所示:

LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams)getLayoutParams();
layoutParams.leftMargin = getLeft() + offsetX;
layoutParams.topMargin = getTop() + offsetY;
setLayoutParams(layoutParams);

不过这里需要注意的是,通过 getLayoutParams() 获取 LayoutParams 时,需要根据 View 所在父布局的类型来设置不同的布局,当然,这一切的前提是你必须要有一个父布局,不然系统无法获取 LayoutParams。
在通过改变 LayoutParams 来改变一个 View 的位置时,通常改变的是这个 View 的 Margin 属性,所以除了使用布局的 LayoutParams 之外,还可以使用 ViewGroup.MarginLayoutParams 来实现这样一个功能,代码如下所示:

ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams)getLayoutParams();
layoutParams.leftMargin = getLeft() + offsetX;
layoutParams.topMargin = getTop() + offsetY;
setLayoutParams(layoutParams);

我们可以发现,使用 ViewGroup.MarginLayoutParams 更加的方便,不需要考虑父布局的类型,当然它们的本质都是一样的。

5.2.4 scrollTo 与 scrollBy

在一个 View 中,系统提供了 scrollTo、scrollBy 两种方式来改变一个 View 的位置,这两个方法的区别非常好理解,与英文中 To 与 By 的区别类似,scrollTo(x, y) 表示移到了一个 具体的坐标点 (x, y),而 scrollBy(x, y) 表示移动的增量为 dx,dy。
scrollTo、scrollBy 方法移动的是 View 的 content,即让 View 的内容移动,如果在 ViewGroup 中使用 scrollTo、scrollBy 方法,那么移动的将是所有子 View,但如果在 View 中使用,那么移动的将是 View 的内容,例如 TextView,content 就是它的文本,ImageView,content 就是它的 drawable 对象。
我们需要了解一下视图移动的一些知识,不妨这样想象手机屏幕是一个中空的盖板,盖板下面是一个巨大的画布,也就是我们想要显示的视图。当这个盖板盖在画布上的某一处时,透过中间空的矩形,我们看见了手机屏幕上显示的视图,而画布上其他地方的视图,则被盖住了无法看见。我们的视图与这个例子非常相似,我们没有看见视图,并不代表它就不存在,有可能只是在屏幕外面而已。来看一个具体的例子:
理解 scrollBy

在上图中,中间的矩形相当于屏幕,即可视区域。后面的 content 就相当于画布,代表视图。大家可以看到,只有视图的中间部分目前是可视的,其他部分都不可见。在可见区域中,我们设置了一个 Button,它的坐标是 (20, 10)。下面使用 scrollBy 方法,将盖板(屏幕,可视区域),在水平方向向 X 轴正方向(右方)平移 20,在竖直方向上向 Y 轴方向(下方)平移 10,那么平移之后的可视区域如下图所示:
移动之后的可视区域

通过上面的分析可以发现,如果将 scrollBy 中的参数 dx 和 dy 设置为正数,那么 content 将向坐标轴负方向移动;如果将 scrollBy 中的参数 dx 和 dy 设置为负数,那么 content 将向坐标轴正方向移动。因此要实现跟随手指移动而滑动的效果,就必须将偏移量改为负值,代码如下所示:

int offsetX = x - lastX;
int offsetY = y - lastY;
((View)getParent).scrollBy(-offsetX, -offsetY);

类似地,在使用绝对坐标时,也可以通过使用 scrollTo 方法实现这一效果。

5.2.5 Scroller

Scroller 类与 scrollTo、scrollBy 方法的区别是 scrollTo、scrollBy 方法,子 View 的平移都是瞬间发生的,在事件执行的时候平移就已经完成了,这样的效果让人感觉非常突兀,Google 建议使用自然的过渡动画来实现移动效果,当然也要遵循这一原则,因此,Scroller 类就应运而生了,通过 Scroller 类可以实现平滑移动的效果,而不再是瞬间完成的移动,这就是他们的区别。
说到 Scroller 类的实现原理,其实它与前面使用 scrollTo 和 scrollBy 方法来实现子 View 跟随手指移动的原理基本类似。虽然 scrollBy 方法是让子 View 瞬间从某点移动到另一个点,但是由于在 ACTION_MOVE 事件中不断获取手指移动的微小的偏移量,这样就将一段距离划分成了 N 个非常小的偏移量。虽然在每个偏移量里面,通过 scrollBy 方法进行了瞬间移动,但是在整体上却可以获得一个平滑移动的效果。这个原理与动画的实现原理也是基本类似的,它们都是利用了人眼的视觉暂留特性。
下面我们就在本章的例子中,演示一下如何使用 Scroller 类实现平滑移动。在这个实例中,同样让子 View 跟随手指的滑动而滑动,但是在手指离开屏幕时,让子 View 平滑的移动到初始位置,即屏幕左上角。一般情况下,使用 Scroller 类需要如下三个步骤:

  1. 初始化 Scroller
    首先,通过它的构造方法来创建一个 Scroller 对象,代码如下所示:
// 初始化 Scroller
mScroller = new Scroller(context);
  1. 重写 computerScroll() 方法,实现模拟滑动
    下面我们需要重写 computerScroll() 方法,它是使用 Scroller 类的核心,系统在绘制 View 的时候会在 draw() 方法中调用该方法。这个方法实际上就是使用 scrollTo 方法。再结合 Scroller 对象,帮助获取到当前的滚动值。我们可以通过不断地瞬间移动一个小的距离来实现整体上的平滑移动效果。通常情况下,computerScroll 的代码可以利用如下模板代码来实现。
@Override
public void computerScroll(){
    super.computerScroll();
    // 判断 Scroller 是否执行完毕
    if(mScroller.computerScrollOffset()){
        ((View)getParent()).scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
        // 通过重绘来不断调用 computerScroll
        invalidate();
    }
}

Scroller 类提供了 computerScrollOffset() 方法来判断是否完成了整个滑动,同时也提供了 getCurrX()、getCurrY() 方法来获得当前的滑动坐标。在上面的代码中,唯一需要注意的是 invalidate() 方法,因为只能在 computerScroll() 方法中获取模拟过程中的 scrollX 和 scrollY 坐标。但 computerScroll() 方法是不会自动调用的,只能通过 invalidate() -> draw() -> computerScroll() 来间接调用 computerScroll() 方法,所以需要在模板代码中调用 invalidate() 方法,实现循环获取 scrollX 和 scrollY 的目的。而当模拟过程结束后,scroller.computerScrollOffset() 方法会返回 false,从而中断循环,完成整个平滑移动过程。

  1. startScroll 开启模拟过程
    我们在需要使用平滑移动的事件中,使用 Scroller 类的 startScroll() 方法来开启平滑移动过程。startScroll() 方法具有两个重载方法:
  • public void startScroll(int startX, int startY, int dx, int dy, int duration);
  • public void startScroll(int startX, int startY, int dx, int dy);

可以看到它们的区别就是一个具有指定的持续时长,而另一个没有。这个非常好理解,与在动画中设置 duration 和使用默认的显示时长是一个道理。而其他四个坐标,则与它们的命名含义相同,就是起始坐标与偏移量。在获取坐标时,通常可以使用 getScrollX() 和 getScrollY() 方法来获取父视图中 content 所滑动到的点的坐标,不过要注意的是这个值的正负,它与在 scrollBy、scrollTo 中讲解的情况是一样的。
通过上面三个步骤,我们就可以使用 Scroler 类来实现平滑移动了,下面回到实例中,在构造方法中初始化 Scroller 对象,并重写 View 的 computerScroll() 方法。最后,需要监听手指离开屏幕的事件,并在该事件中通过 startScroll() 方法完成平滑移动。那么要监听手指离开屏幕的事件,只需要在onTouchEvent 中增加一个 ACTION_UP 监听选项即可,代码如下所示:

case MotionEvent.ACTION_UP:
    // 手指离开时,执行滑动过程
    View viewGroup = ((View)getParent());
    mScroller.startScroll(viewGroup.getScrollX(), viewGroup.getScrollY(), -viewGroup.getScrollX(), -viewGroup.getScrollY());
    invalidate();
    break;

在 startScroll() 方法中,我们获取子 View 移动的距离 —— getScrollX()、getScrollY(),并将偏移量设置为其相反数,从而将子 View 滑动到原位置。这里需要注意的还是 invalidate() 方法,需要使用这个方法来通知 View 进行重绘,从而来调用 computerScroll() 的模拟过程。当然,也可以给 startScroll() 方法增加一个 duration 的参数来设置滑动的持续时长。

5.2.6 属性动画
在第7章中,我们将详细讲解了如何使用属性动画来控制一个 View 的移动效果,在这里,同样可以使用属性动画来完成 View 的滑动特效,这与在属性动画中讲解的方法基本一致,这里就不重复了。

5.2.7 ViewDragHelper
Google 在其 support 库中为我们提供了 DrawLayout 和 SlidingPaneLayout 两个布局来帮助开发者实现侧边栏滑动的效果。这两个新的布局,大大方便了我们创建自己的滑动布局界面,然而,这两个功能强大的布局背后,却隐藏着一个鲜为人知却功能强大的类——ViewDraghelper。通过 ViewDragHelper,基本可以实现各种不同的滑动、拖动需求,因此这个方法也是各种滑动解决方案中的终极绝招。
ViewDragHelper 虽然功能强大,但其使用方法也是本章中最复杂的。读者朋友需要在理解 ViewDragHelper 基本使用方法的基础上,通过不断练习来掌握它的使用技巧。下面通过一个实例,来演示一下如何使用 ViewDragHelper 创建一个滑动布局。在这个例子中,准备实现类似 QQ 滑动侧边栏的布局,初始时显示内容界面,当用户手指滑动超过一段距离时,内容界面侧滑显示菜单界面,整个过程如下面两张图所示:
图1:初始状态
图1:初始状态
图2:滑动展开菜单界面
图2:滑动展开菜单界面

下面来看具体的代码是如何实现的:

  • 初始化 ViewDragHelper
    首先,自然是需要初始化 ViewDragHelper,ViewDragHelper 通常定义在一个 ViewGroup 的内部,并通过其静态工厂方法进行初始化,代码如下所示:
mViewDragHelper = ViewDragHelper.create(this, callback);

它的第一个参数是要监听的 View,通常需要是一个 ViewGroup,即 parentView;第二个参数是一个 Callback 回调,这个回调就是整个 ViewDragHelper 的逻辑核心,后面再来详细讲解。

  • 拦截事件
    接下来,就要重写拦截事件方法,将事件传递给 ViewDragHelper 进行处理,代码如下所示:
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
    return mViewDragHelper.shouldInterceptTouchEvent(event);
}

@Override
public boolean onTouchEvent(MotionEvent event) {
    // 将触摸事件传递给 ViewDragHelper,此操作必不可少
    mViewDragHelper.processTouchEvent(event);
    return true;
}

这一点我们在讲 Android 事件机制的时候已经进行了详细讲解,这里就不再重复了。

  • 处理 computerScroll()
    没错,使用 ViewDragHelper 同样需要重写下 computerScroll() 方法,因为 ViewDragHelper 内部也是通过 Scroller 来实现平滑移动的。通常情况下,可以使用如下所示的模板代码:
@Override
public void computeScroll() {
    if (mViewDragHelper.continueSettling(true)){
        ViewCompat.postInvalidateOnAnimation(this);
    }
}
  • 处理回调 Callback
    下面就是最关键的 Callback 实现,通过如下所示代码来创建一个 ViewDragHelper.Callback。
private ViewDragHelper.Callback callback = new ViewDragHelper.Callback() {
    @Override
    public boolean tryCaptureView(View child, int pointerId) {
        return false;
    }
};

IDE 自动帮我们重写了一个方法——tryCaptureView(),通过这个方法,我们可以指定在创建 ViewDragHelper 时,参数 parentView 中的哪一个子 View 可以被移动。例如在这个实例中自定义了一个 ViewGroup,里面定义了两个子 View——MenuView 和 MainView,当指定如下代码时,则只有 MainView 是可以被拖动的。

// 何时开始检测触摸事件
@Override
public boolean tryCaptureView(View child, int pointerId) {
    // 如果当前触摸的 child 是 mMainView 时开始检测
    return mMainView == child;
}

下面来看具体的滑动方法——clampViewPositionVertical() 和 clampViewPositionHorizontal(),分别对应垂直和水平方向上的滑动。如果要实现滑动效果,那么这两个方法是必须要重写的。因为它默认的返回值为 0,即不发生滑动。当然,如果只重写 clampViewPositionVertical() 或 clampViewPositionHorizontal() 中的一个,那么就只会实现该方向上的滑动效果了,代码如下所示:

@Override
public int clampViewPositionVertical(View child, int top, int dy){
       return top;
}

@Override
public int clampViewPositionHorizontal(View child, int left, int dy){
       return left;
}

clampViewPositionVertical(View child, int top, int dy) 中的参数 top,代表在垂直方向上 child 移动的距离,而 dy 则表示比较前一次的增量。同理,clampView PositionHorizontal(View child, int left, int dy) 也是类似的含义。通常情况下,只需要返回 top 和 left 即可,但当需要更加精确地计算 padding 等属性的时候,就需要对 left 进行一些处理,并返回合适大小的值。
仅仅是通过重写上面的这三个方法,就可以实现一个最基本的滑动效果了,当用手拖动 MainView 的时候,它就可以跟随手指的滑动而滑动了,代码如下所示:

private ViewDragHelper.Callback callback = new ViewDragHelper.Callback() {
    // 何时开始检测触摸事件
    @Override
    public boolean tryCaptureView(View child, int pointerId) {
        // 如果当前触摸的 child 是 mMainView 时开始检测
        return mMainView == child;
    }

    @Override
    public int clampViewPositionVertical(View child, int top, int dy) {
        return 0;
    }

    @Override
    public int clampViewPositionHorizontal(View child, int left, int dx) {
        return left;
    }
};

下面继续来优化这个实例。在讲解 Scroller 类时,曾实现了这样一个效果——在手指离开屏幕后,子 View 滑动回初始位置。当时我们是通过监听 ACTION_UP 事件,并通过调用Scroller 类来实现的,这里使用 ViewDragHelper 来实现这样的效果。在 ViewDragHelper.Callback 中,系统提供了这样的方法——onViewReleased(),通过重写这个方法,可以非常简单地实现当手指离开屏幕后实现的操作。当然,这个方法内部是通过 Scroller 类来实现的,这也是前面重写 computerScroll() 方法的原因,这部分代码如下所示:

// 拖动结束后调用
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
    super.onViewReleased(releasedChild, xvel, yvel);
    // 手指抬起后缓慢移动到指定位置
    if (mMainView.getLeft() < 500) {
        // 关闭菜单,相当于 Scroller 的 startScroll 方法
        mViewDragHelper.smoothSlideViewTo(mMainView, 0, 0);
        ViewCompat.postInvalidateOnAnimation(TestViewGroup.this);
    } else {
        // 打开菜单
        mViewDragHelper.smoothSlideViewTo(mMainView, 300, 0);
        ViewCompat.postInvalidateOnAnimation(TestViewGroup.this);
    }
}

设置让 MainView 移动后左边距小于500像素的时候,就使用 smoothSliderViewTo() 方法来将 MainView 还原到初始状态,即坐标为 (0, 0) 的点。而当其左边距大于500的时候,则将 MainView 移动到 (300, 0) 坐标,即显示 MenuView。读者朋友可以发现如下所示的这两行代码,与在使用 Scroller 类的时候使用的 startScroll() 方法是不是很像呢?

// ViewDragHelper
mViewDragHelper.smothSlideView(mMainView,0,0);
ViewCompat.postInvalidateOnAnimation(DragViewGroup.this);
// Scroller

mScroller.startScroll(x,y,dx,dy);
invalidate();

通过前面一步步的分析,现在要实现类似 QQ 侧滑菜单的效果,是不是就非常简单了呢?下面自定义一个 ViewGroup 来完成整个实例的编写。滑动的处理部分前面已经讲过了,在自定义 ViewGroup 的 onFinishInflate() 方法中,按顺序将子 View 分别定义成 MenuView 和 MainView,并在 onSizeChanged() 方法中获得 View 的宽度,如果你需要根据 View 的宽度来处理滑动后的效果,就可以使用这个值来进行判断。这部分代码如下所示:

/**
* 加载完布局后调用
*/
@Override
protected void onFinishInflate() {
    super.onFinishInflate();
    mMenuView = getChildAt(0);
    mMainView = getChildAt(1);
}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    mWidth = mMenuView.getMeasuredWidth();
}

最后,通过整个 ViewDragHelper 实现 QQ 侧滑功能的代码如下所示:

package com.zyt.viewdraghelper;

import android.content.Context;
import android.support.v4.view.ViewCompat;
import android.support.v4.widget.ViewDragHelper;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.widget.FrameLayout;

/**
* Created by HourGlassRemember on 2017/5/18.
*/
public class DragViewGroup extends FrameLayout {

    private ViewDragHelper mViewDragHelper;
    private View mMenuView, mMainView;
    private int mWidth = 0;

    private ViewDragHelper.Callback callback = new ViewDragHelper.Callback() {

        /**
        * 何时开始检测触摸事件。
        * 在这个例子中自定义了一个 ViewGroup,里面定义了两个 View——MenuView 和 MainView,
        * 当指定 mMainView == child 的时候,则只有 MainView 是可以被拖动的。
        * @param child
        * @param pointerId
        * @return
        */
        @Override
        public boolean tryCaptureView(View child, int pointerId) {
            // 如果当前触摸的 child 是 mMainView 时开始检测
            return mMainView == child;
        }

        /**
        * 处理水平方向的滑动
        * @param child
        * @param left 在水平方向上 child 移动的距离
        * @param dx 比较前一次的增量
        * @return 默认返回值是 0, 即不发生滑动
        */
        @Override
        public int clampViewPositionHorizontal(View child, int left, int dx) {
            return left;
        }

        /**
        * 处理垂直方向的滑动
        * @param child
        * @param top 在垂直方向上 child 移动的距离
        * @param dy 比较前一次的增量
        * @return 默认返回值是 0, 即不发生滑动
        */
        @Override
        public int clampViewPositionVertical(View child, int top, int dy) {
            return 0;
        }

        /**
        * 拖动结束后调用
        * @param releasedChild
        * @param xvel
        * @param yvel
        */
        @Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
            super.onViewReleased(releasedChild, xvel, yvel);
            // 手指抬起后缓慢移动到指定位置
            if (mMainView.getLeft() < 500) {
                // 关闭菜单
                // 相当于 Scroller 的 startScroll 方法——还原 mMainView 的坐标为原点 (0,0)
                mViewDragHelper.smoothSlideViewTo(mMainView, 0, 0);
                // 刷新界面
                ViewCompat.postInvalidateOnAnimation(DragViewGroup.this);
            } else {
                // 打开菜单
                // 将 mMainView 的坐标设置为 (300,0),从而显示 mMenuView 菜单
                mViewDragHelper.smoothSlideViewTo(mMainView, 300, 0);
                // 刷新界面
                ViewCompat.postInvalidateOnAnimation(DragViewGroup.this);
            }
        }
    };

    public DragViewGroup(Context context) {
        this(context, null, 0);
    }

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

    public DragViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        // 初始化 ViewDragHelper,参数说明:
        // 第一个参数:要监听的 View,通常需要一个 ViewGroup,即 parentView
        // 第二个参数:一个 Callback 回调,这个回调就是整个 ViewDragHelper 的逻辑核心
        mViewDragHelper = ViewDragHelper.create(this, callback);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        return mViewDragHelper.shouldInterceptTouchEvent(event);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // 将触摸事件传递给 ViewDragHelper,此操作必不可少
        mViewDragHelper.processTouchEvent(event);
        return true;
    }

    @Override
    public void computeScroll() {
        if (mViewDragHelper.continueSettling(true)) {
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    /**
    * 加载完布局后调用
    */
    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        mMenuView = getChildAt(0);
        mMainView = getChildAt(1);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mWidth = mMenuView.getMeasuredWidth();
    }

}

当然,这里只是非常简单地模拟了 QQ 侧滑菜单这个功能。ViewDragHelper 的很多强大功能还没能够得到展示。在 ViewDragHelper.Callback 中,系统定义了大量的监听事件来帮助我们处理各种事件。下面举例来说:

  • onViewCaptured():这个事件在用户触摸到 View 后回调
  • onViewDragStateChanged():这个事件在拖拽状态改变时回调,比如 idea,dragging 等状态。
  • onViewPositionChanged():这个事件在位置改变时回调,常用于滑动时更改 scale 进行缩放等效果。

使用 ViewDragHelper 可以帮助我们非常好地处理程序中的滑动效果。但同时 ViewDragHelper 的使用也比较复杂,需要开发者对事件拦截、滑动处理都有比较清除的认识。所以建议初学者循序渐进,在掌握前面几种解决方法的基础上,再来学习 ViewDragHelper,以实现更加丰富的滑动效果。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
图书简介: 本书从由总到分,让读者从整体上把握Android体系结构,融入“群英传”这一故事情节,针对各个知识点进行分类阐述,并结合一线实际开发经验和Android 5.0版本新特性来,紧跟市场需求进行讲解。然后通过实战应用案例,综合使用前面讲解到的知识点,进一步提高开发者水平。 相关截图: 图书目录: 第1 Android体系与系统架构 1 1.1 Google生态系统 2 1.2 Android系统架构 2 1.2.1 Linux 3 1.2.2 Dalvik与ART 3 1.2.3 Framework 3 1.2.4 Standard libraries 4 1.2.5 Application 4 1.3 Android App组件架构 4 1.3.1 Android四大组件如何协同工作 5 1.3.2 应用运行上下文对象 5 1.4 Android系统源代码目录与系统目录 6 1.4.1 Android系统源代码目录 6 1.4.2 Android系统目录 8 1.4.3 Android App文件目录 11 第2 Android开发工具新接触 13 2.1 Android开发IDE介绍 14 2.1.1 Android Studio初体验 14 2.1.2 Android Studio配置 15 2.2 Android Studio高级使用技巧 19 2.2.1 更新SDK 20 2.2.2 Android Studio常用界面 21 2.2.3 导入Android Studio工程 23 2.3 ADB命令使用技巧 24 2.3.1 ADB基础 24 2.3.2 ADB常用命令 25 2.3.3 ADB命令来源 29 2.4 模拟器使用与配置 29 第3 Android控件架构与自定义控件详解 32 3.1 Android控件架构 33 3.2 View的测量 34 3.3 View的绘制 37 3.4 ViewGroup的测量 38 3.5 ViewGroup的绘制 39 3.6 自定义View 39 3.6.1 对现有控件进行拓展 40 3.6.2 创建复合控件 43 3.6.3 重写View来实现全新的控件 51 3.7 自定义ViewGroup 54 .........................
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值