自定义View之滑动事件

一滑动效果的产生

滑动一个View,本质区别就是移动一个View。改变当前View所在的坐标,原理和动画相似不断改变坐标位置实现。实现View的滑动就必须监听滑动的事件,并且根据事件传入的坐标,动态且不断改变View的坐标,从而实现View跟随用户触摸的滑动而滑动。

(1)、Android的坐标系

Android中将屏幕最左上角的顶点作为Android坐标系的原点,从这个点向右是X轴正方向,从这个点向下是Y轴正方向,如下图:

image.png

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

(2)、视图坐标系

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


image.png

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

(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进行总结,如下图:

image.png

这些方法可以分为如下两个类别:

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

二、实现滑动的七种方式

当了解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.xjf.drawview.DragView1
        android:layout_width="100dp"
        android:layout_height="100dp" />

</LinearLayout>

我们的目的就是让这个自定义的View随着手指在屏幕上的滑动而滑动。初始化时显示效果:

image.png

(1)、layout方法

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

//获取当前输入点的X、Y坐标(视图坐标)
int x = (int) event.getX();
int y = (int) event.getY();

接着,在Action_DOWN事件中记录触摸点的坐标,如下:

case MotionEvent.ACTION_DOWN:
    // 记录触摸点坐标
    lastX = x;
    lastY = y;
    break;

最后,可以在Action_MOVE事件中计算偏移量,并将偏移量作用到Layout方法中,在目前Layout的left,top,right,bottom基础上,增加计算出来的偏移量,代码如下所示:

case MotionEvent.ACTION_MOVE:
    // 计算偏移量
    int offsetX = x - lastX;
    int offsetY = y - lastY;
    // 在当前left、top、right、bottom的基础上加上偏移量
    layout(getLeft() + offsetX,
            getTop() + offsetY,
            getRight() + offsetX,
            getBottom() + offsetY);
    break;

这样没错移动后,View都会调用Layout方法来对自己重新布局,从而达到移动View的效果。

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

// 视图坐标方式
@Override
public boolean onTouchEvent(MotionEvent event) {
    int x = (int) event.getRawX();
    int y = (int) event.getRawY();
    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的基础上加上偏移量
            layout(getLeft() + offsetX,
                    getTop() + offsetY,
                    getRight() + offsetX,
                    getBottom() + offsetY);
            //重新设置初始化坐标
            lastX = x;
            lastY = y;
            break;

    }
    return true;
}

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

(2)、offsetLeftAndRight()与offsetTopAndBottom()

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

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

这里的offsetX、offsetY与在layout方法中计算offset方法一样。

(3)、LayoutParams

LayoutParams保存了一个View的布局参数。因此可以在程序中,通过改变LayoutParams来动态地修改一个布局的位置参数,从而达到改变View位置的效果。我们可以很方便在程序中使用getLayoutParams()来获取一个View的LayoutParams。当然,计算偏移量的方法与在Layout方法中计算offset也是一样。当获取到偏移量之后,就可以通过setLayoutParams来改变其LayoutParams,代码如下:

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

这里getLayoutParams()获取LayoutParams时,需要根据View所在View父布局的类型来设置不同的类型,比如这里将View放在LinearLayout中,那么就可以使用LinearLayout.LayoutParams。如果在RelativeLayout中,就要使用RelativeLayout.LayoutParams。这一切的前提是你必须要有一个父布局,不然系统无法获取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更加的方便,不需要考虑父布局的类型,当然它们的本质都是一样。

(4)、scrollTo与scrollBy

在一个View中,系统提供了scrollTo、scrollBy两种方式来改变一个View的位置。这两个方法的区别非常好理解,与英文中To与By的区别类似,scrollTo(x,y)表示移动到一个具体的坐标点(x,y),而scrollBy(dx,dy)表示移动的增量为dx,dy。

与前面几种方式相同,在获取偏移量后使用scrollBy来移动View,代码如下:

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

但是,当我们拖动View的时候,你会发现View并没有移动,其实方法没错,View确实移动了,只是移动的并不是我们想要的东西。scrollTo、scrollBy方法移动的是View的content,即让View的内容移动,如果在ViewGroup中使用scrollTo、scrollBy方法,那么移动的将是所有子View,如果在View中使用,那么移动的将是View的内容,例如TextView,content就是它的文本,ImageView,content就是它的drawable对象。

通过以上的分析,现在知道为什么不能再View中使用这两个方法来拖动这个View了。那么我们就该View所在的ViewGroup中来使用scrollBy方法,移动它的子View,代码如下:

((View) getParent()).scrollBy(offsetX, offsetY);

但是再次拖动View的时候,你会发现View虽然移动了,但却在乱动,并不是我们想要的跟随触摸点的移动而移动。这里先看一下视图移动,不妨这样想象一下手机屏幕是一个中空的盖板,盖板下面是一个巨大的画布,也就是我们想要显示的视图。当把这个盖板盖在画布上的某一处时,透过中间空的矩形,我们看见了手机屏幕上显示的视图,而画布上其他地方的视图,则被盖板盖住了无法看见。我们的视图与这个例子非常类似,我们没有看见视图,并不代表它就不存在,有可能只是在屏幕外面而已。当调用scrollBy方法时,可以想象为外面的盖板在移动,这么说比较抽象。

下图一中间的矩形相当于屏幕,及可视区域。后面的content就相当于画布,代表视图。可以看到,只有视图的中间部分目前是可视的,其他部分都不可见。在可见区域中,我们设置了一个Button,它的坐标为(20,10)。

下面使用scrollBy方法,将盖板(屏幕、可视区域),在水平方向上向X轴正方向(向右)平移20,在竖直方向上向Y轴正方向(下方)平移10,那么平移之后的可视区域如图二。

图一.png
图二.png

我们发现,虽然设置scrollBy(20,10),偏移量均为X轴、Y轴正方向上的正数,但是在屏幕的可视区域内,Button却向X轴、Y轴负方向上移动了。这就是因为参考系选择的不同,而产生的不同效果。

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

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

现在在运行一次发现和前面几种方式效果相同了,类似地使用绝对坐标时,也可以通过使用scrollTo发方法来实现这一效果。

(5)、Scroller

前面提到了scrollBy、scrollTo方法,就不得不再来说一说Scroller类。Scroller类与scrollBy、scrollTo方法十分相似。什么区别?先看例子,如果要完成这样一个效果;通过点击按钮,让一个ViewGroup的子View向右移动100个像素。问题看起来很简单,只要在按钮的点击事件中使用前面的scrollBy方法设置下偏移量就可以了吗?确实这样可以让一个子ViewGroup中的子View平移,但是不管使用scrollBy还是scrollTo方法,子view的平移都是瞬间发生的,在事件执行的时候平移就已经完成了,这样的效果会让人感觉非常突然,Google建议使用自然的过度动画来实现移动效果。因此Scroller类就这样诞生了,通过Scroller类可以实现平滑移动的效果,而不是瞬间就完成移动。

Scroller类的实现原理,其实它与前面使用的scrollTo和scrollBy方法来实现子View跟随手指移动的原理基本类似,虽然scrollBy芳芳法是让子View瞬间从某点移动到另一个点,但是由于在ACTION_MOVE事件中不断获取手指移动的微小的偏移量,这样就将一段距离划分成了N个非常小的偏移量。虽然每个偏移量里面,通过scrollBy方法进行了瞬间移动,但是在整体上却可以获得一个平滑移动的效果。这个原理与动画的实现原理也是基本类似的,它们都是利用了人眼的视觉暂留特性。

下面我们使用Scroller类实现平滑移动,在这个实例中,同样让子View跟随手指的滑动而滑动,但是在手指离开屏蔽时,让子View平滑的移动到初始化位置,即屏幕左上角。使用Scroller类需要如下三个步骤:

  • 初始化Scroller
    首先通过它的构造方法来创建一个Scroller对象,代码如下所示:
// 初始化Scroller
mScroller = new Scroller(context);
  • 重写computerScroller方法,实现模拟滑动

下面我们需要重写computerScroller()芳芳法,它是使用Scroller类的核心,系统在绘制View的时候会在draw()方法中调用该方法。这个方法实际就是使用的scrollTo方法。再结合Scroller对象,帮助获取到当前滚动值。我们可以通过不断地瞬间移动一个小的距离来实现整体上的平滑移动效果。代码如下:

@Override
public void computeScroll() {
    super.computeScroll();
    // 判断Scroller是否执行完毕
    if (mScroller.computeScrollOffset()) {
        ((View) getParent()).scrollTo(
                mScroller.getCurrX(),
                mScroller.getCurrY());
        // 通过重绘来不断调用computeScroll
        invalidate();
    }
}

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

  • startScroll开启模拟过程

我们在需要使用平滑移动的事件中,使用Scroller类的startScroll()方法来开启平滑移动过程。startScroll()方法具有两个重载方法。

public void startScroll(int startX, int startY, int dx, int dy)
public void startScroll(int startX, int startY, int dx, int dy, int duration)

可以看到它们的区别就是一个具有指定的支持时长,而另一个没有。很好理解,与在动画中设置duration和使用默认的显示时长是一个道理。其他四个坐标,则与他们的命名含义相同,就是起始坐标与偏移量。在获取坐标时,通常可以使用getScrollX()和getScrollY()方法来获取父视图中content所滑动到的点的坐标,需要注意的是这个值的正负,它与在scrollBy、scrollTo中讲解的情况是一样的。

根据以上三步,就可以使用Scroller类实现平滑移动,在构造方法中初始化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进行重绘,调用computeScroll()的模拟过程。当然,也可以给startScroll()方法增加一个duration的参数来设置滑动的持续时长。

(6)、属性动画

属性动画请参见我的另一篇:Android全套动画使用技巧

未完待续。。。。

三、开源代码库

最后再分享一个自己积攒很久的代码库,只有你想不到,没有用不到的,欢迎star

github.com/xijiufu

由于github服务器在美国,有时访问很慢,还提供了开源中国地址库,2个仓库代码均同步更新:

git.oschina.net/xijiufu

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容