View 触发机制实现相关API(GestureDetector、OverScroller))

1,462 阅读8分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情

前言

前一篇文章讲了View的触发反馈机制,对于一个自定义View而言,手势的处理都是重写onTouchEvent函数,或者通过setOnTouchEventListener方法捕捉手势。但是手势的处理,如滑动、触摸、双击等检测对应的检测也并不是那么简单,自己一个个造轮子也过于麻烦,万幸的是google早已经给开发者提供了手势捕捉的类- GestureDetector。通过这个类我们可以识别很多的手势,主要是通过他的onTouchEvent(event)方法完成了不同手势的识别。虽然他能识别手势,但是不同的手势要怎么处理,应该是提供给程序员实现的。

GestureDetector

GestureDetector 中一共有三种主要的回调接口 ,OnGestureListenerOnDoubleTapListenerOnContextClickListener

这三个接口的方法如下。

public interface OnGestureListener {
        boolean onDown(MotionEvent e);
        void onShowPress(MotionEvent e);
        boolean onSingleTapUp(MotionEvent e);
        boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY);
        void onLongPress(MotionEvent e);
        boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY);
}
public interface OnDoubleTapListener {
    boolean onSingleTapConfirmed(MotionEvent e);
    boolean onDoubleTap(MotionEvent e);
    boolean onDoubleTapEvent(MotionEvent e);
}
​
​
public interface OnContextClickListener {
    boolean onContextClick(MotionEvent e);
}

GestureDetector 使用

GestureDector 负责监听手势,而 OnDoubleTapListenerOnGestureListener 用于开发者自己去处理对应手势的反馈

package com.example.androidtemp.view;
​
import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import android.widget.OverScroller;
import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;
public class TouchView extends View implements GestureDetector.OnGestureListener,GestureDetector.OnDoubleTapListener{
    private static final String TAG = "TouchView";
    GestureDetector gestureDetector = null;
    public TouchView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        gestureDetector = new GestureDetector(context,this);
    }
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        return gestureDetector.onTouchEvent(event);
    }
    @Override
    public boolean onSingleTapConfirmed(MotionEvent e) {
        Log.i(TAG, "onSingleTapConfirmed: ");
        return false;
    }
    @Override
    public boolean onDoubleTap(MotionEvent e) {
        Log.i(TAG, "onDoubleTap: ");
        return false;
    }
    @Override
    public boolean onDoubleTapEvent(MotionEvent e) {
        Log.i(TAG, "onDoubleTapEvent: ");
        return false;
    }
    @Override
    public boolean onDown(MotionEvent e) {
        Log.d(TAG, "onDown: ");
        return true;
    }
    @Override
    public void onShowPress(MotionEvent e) {
        Log.i(TAG, "onShowPress: ");
    }
    @Override
    public boolean onSingleTapUp(MotionEvent e) {
        Log.i(TAG, "onSingleTapUp: ");
        return false;
    }
    @Override
    public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
​
        Log.i(TAG, "onScroll: ");
        return false;
    }
    @Override
    public void onLongPress(MotionEvent e) {
        Log.i(TAG, "onLongPress: ");
    }
    @Override
    public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
        Log.i(TAG, "onFling: ");
        return false;
    }
}

onDown方法

onDown 方法是在ACTION_DOWN 事件时被调用的,其的返回值决定了View是否消费该事件,一般我们肯定是需要消费该事件的,因此其值为true.

public boolean onDown() {
    return true;
}

onShowPress方法

@Override
public void onShowPress(MotionEvent e) {
    //进行控件颜色的改变或其他一些动作
}

onShowPress 是用户按下时的一种回调,主要作用是用于给用户一种按压下的状态,可以在该回调中让控件颜色改变或进行一些动作。需要注意的是,onShowPress 方法不是立即回调的,在手指触碰后,在100ms左右后才会回调。在这100ms内如果手指抬起或滚动,该回调方法不会被触发。在前一篇文章View事件分发机制 中提到过自定义View 默认的super.onTouchEvent 实现中,按压状态也是有一个预按压状态的检测,此处的onShowPress的回调机制也是同理。

onLongPress 方法

用于检测长按事件的,即手指按下后不抬起,在一段时间后会触发该事件。

@Override 
public void onLongPress(MotionEvent e) {
}

onLongPress 回调被触发前 onShowPress 一定会被触发。

需要注意的是 onLongPress一旦被触发,其他事件都不会被触发了。

不过,onLongPress事件可以被禁止使用,通过如下代码设置,即不会触发长按事件

gestureDetector.setIsLongpressEnabled(false);

onSingleTapUp 方法

@Override
public boolean onSingleTapUp(MotionEvent e) {
    return false;
}

onSingleTapUP的返回值不是太重要,不过一般消费了就还是返回ture吧。

onSingleTapUp的意思顾名思义,即在 手指抬起时触发,不过他跟一般的onClick、以及onSingleTapConfirmed有一定区别

单击事件触发:

GCS: onSingleTapUp
GCS: onClick
GCS: onSingleTapConfirmed
类型触发次数摘要
onSingleTapUp1单击抬起
onSingleTapConfirmed1单击确认
onClick1单击事件

双击事件触发:

onSingleTapUp
onClick
onDoubleTap 
onClick
类型触发次数摘要
onSingleTapUp1在双击的第一次抬起时触发
onSingleTapConfirmed0双击发生时不会触发。
onClick2在双击事件时触发两次。

可以看出来这三个事件还是有所不同的,根据自己实际需要进行使用即可

onScroll

@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float 
        distanceY) {
    
    return true;
}

onScroll 方法是用于监听手指的滑动的,e1是第一次ACTION_DOWN的事件,e2是当前滚动事件。distanceX、distanceY记录了手指在x、y轴滑动的距离。

需要注意的时,该滑动距离记录的是上次滑动回调与这次回调之间的距离差值。且还有一个有意思的注意事项,该差值是 lastEvent-curEvent 得到的,这与正常的逻辑行为不太一致,不过google就这样干了,所以当我们在计算滑动偏移量时需要对 distanceX、distancesY进行一个 相减的操作而不是相加。

onFling

public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX,
                       float velocityY) {
    return true;
}

用户手指在屏幕快速滑动后,在抬起时(ACTION_UP)触发该事件。

Fling 中文直接翻译过来就是一扔、抛、甩,最常见的场景就是在 ListView 或者 RecyclerView 上快速滑动时手指抬起后它还会滚动一段时间才会停止。onFling 就是检测这种手势的。

四个参数的介绍如下

参数简介
e1手指按下时的 Event。
e2手指抬起时的 Event。
velocityX在 X 轴上的运动速度(像素/秒)。
velocityY在 Y 轴上的运动速度(像素/秒)。

利用 velocityXvelocityY 参数可以实现一个具有一定初速度的滑动,之后该速度随着滑动衰减,直到停止。

一般onFling 可以结合 OverScroller 实现一个均匀减速的滑动效果。

overScroller的用法在后方介绍。

onSingleTapConfirmedonDoubleTap

public boolean onSingleTapConfirmed(MotionEvent e) {
    return false;
}
public boolean onDoubleTap(MotionEvent e) {
    return false;
}
public boolean onDoubleTapEvent(MotionEvent e) {
    return false;
}

onSingleTapConfirmed用于监听单击事件,而onDoubleTap用于监听双击事件。这两个回调函数是互斥的。

onSingleTapConfigrmed的调用是延迟的,其在 手指按下300ms后触发。

onSingleTapConfigrmed 适合于在 既检测单击事件也检测双击时间时使用。

但是如果只是检测单击事件,onSingleTapUp更合适,onSingleTapConfigrmed会让用户明显感觉到延迟。

需要注意的是 onDoubleTap 事件并不是第二次抬起时触发的,而是第二次手触摸到屏幕时即(第二次ACTION_DOWN)事件时就会触发该事件,如果要保证在第二次抬起时才触发该事件,就需要使用onDoubleTapEvent方法了

onDoubleTapEvent

@Override
public boolean onDoubleTapEvent(MotionEvent e) {
    Log.i(TAG, "onDoubleTapEvent: event:" + e.getActionMasked());
    switch (e.getActionMasked()) {
        case MotionEvent.ACTION_UP:
            Log.i(TAG, "onDoubleTapEvent: ACTION_UP");
            break;
    }
    return true;
}

双击时,onDoubleTapEvent 将会在onDoubleTap 后触发.

双击触发日志:

TouchView: onDown: 
TouchView: onSingleTapUp: 
TouchView: onDoubleTap: 
TouchView: onDoubleTapEvent: event:0(ACTION_DOWN)
TouchView: onDown: 
TouchView: onDoubleTapEvent: event:2(ACTION_MOVE)
TouchView: onDoubleTapEvent: event:2(ACTION_MOVE)
TouchView: onDoubleTapEvent: event:1(ACTION_UP)
TouchView: onDoubleTapEvent: ACTION_UP

需要注意的是不论是双击还是单击,只要按下长时间未动且未抬起,都会触发onLongPress

第二次按下后常按再抬起日志

TouchView: onDown: 
TouchView: onSingleTapUp: 
TouchView: onDoubleTap: 
TouchView: onDoubleTapEvent: event:0
TouchView: onDown: 
TouchView: onDoubleTapEvent: event:2
TouchView: onDoubleTapEvent: event:2
TouchView: onDoubleTapEvent: event:2
TouchView: onShowPress: 
TouchView: onDoubleTapEvent: event:2
TouchView: onDoubleTapEvent: event:2
TouchView: onDoubleTapEvent: event:2
TouchView: onLongPress: 
ouchView: onDoubleTapEvent: event:1
TouchView: onDoubleTapEvent: ACTION_UP

OverScroller

onFling 方法中,曾说过 使用velocityX ,velocityY 两个参数可以实现 View的滑动效果.

public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX,
                       float velocityY) {
    return true;
}

示例

此处用一个可拖拉滑动的小圆球作为示例.

scroll效果图

Scroll效果.gif

Fling效果图

Fling效果.gif

代码如下

package com.example.androidtemp.view
​
import android.view.View
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.util.AttributeSet
import android.util.Log
import android.view.GestureDetector
import android.view.MotionEvent
import android.widget.OverScroller
import kotlin.math.max
import kotlin.math.min
​
private const val TAG = "SmallBallView"
class SmallBallView(context: Context?, attrs:AttributeSet?) :View(context,attrs) ,GestureDetector.OnGestureListener{
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
    private val BALL_DIAMETER_SIZE = 100 //球直径长度
    private var originOffsetX = 0f
    private var originOffsetY = 0f
    private var offsetX = 0f
    private var offsetY = 0f
    private val gestureDetector = GestureDetector(this.context,this)
    private val scroller = OverScroller(this.context)
    override fun onTouchEvent(event: MotionEvent): Boolean {
        return gestureDetector.onTouchEvent(event);
    }
​
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        originOffsetX = (w - BALL_DIAMETER_SIZE)/2f
        originOffsetY = (h - BALL_DIAMETER_SIZE)/2f
    }
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
​
        // 偏移
        canvas.translate(offsetX,offsetY)
​
        //中间位置画个圆
        canvas.drawArc(originOffsetX,originOffsetY,originOffsetX + BALL_DIAMETER_SIZE.toFloat(),originOffsetY + BALL_DIAMETER_SIZE.toFloat(),0f,360f,false,paint)
    }
​
    override fun onDown(e: MotionEvent?): Boolean = true
    override fun onShowPress(e: MotionEvent?) {}
    override fun onSingleTapUp(e: MotionEvent?): Boolean {
        return false
    }
    override fun onLongPress(e: MotionEvent?) {}
    override fun onScroll(
        e1: MotionEvent?,
        e2: MotionEvent?,
        distanceX: Float,
        distanceY: Float
    ): Boolean  {
        Log.i(TAG, "onScroll: ")
        offsetX -= distanceX
        offsetY -= distanceY
​
        //移动不能超过圆的一半
        offsetX = min(offsetX,width.toFloat()/2)
        offsetX = max(offsetX,-width.toFloat()/2)
        //移动不能超过圆的一半
        offsetY = min(offsetY,height.toFloat()/2)
        offsetY = max(offsetY,-height.toFloat()/2)
        invalidate()
        return true;
    }
​
    override fun onFling(
        e1: MotionEvent?,
        e2: MotionEvent?,
        velocityX: Float,
        velocityY: Float
    ): Boolean {
        //限制滑动不能超过一小圆的一半
        scroller.fling(offsetX.toInt(),offsetY.toInt(),velocityX.toInt(),velocityY.toInt(),-width/2,width/2,-height/2,height/2)
        postOnAnimation(scrollerRunnable)
        return true;
    }
    private val scrollerRunnable = object :Runnable {
        override fun run() {
            if (scroller.computeScrollOffset()) {
                offsetX = scroller.currX.toFloat()
                offsetY = scroller.currY.toFloat()
                invalidate()
                postOnAnimation(this)
            }
        }
    }
}

OverScroller方法介绍

  1. fling 方法
public void fling(int startX, int startY, int velocityX, int velocityY,
            int minX, int maxX, int minY, int maxY) {
    fling(startX, startY, velocityX, velocityY, minX, maxX, minY, maxY, 0, 0);
}
public void fling(int startX, int startY, int velocityX, int velocityY,
            int minX, int maxX, int minY, int maxY, int overX, int overY) {
    //实现逻辑省略,有兴趣的可以自己去看代码
}
参数简介
startX、startY开始滑动的X(Y)轴位置
velocityX、velocityY在 X(Y) 轴上的运动速度(像素/秒)。
minX、maxX滑动时X轴的两个边界值,滑动时一旦到达边界值,则立刻停止
minY、maxY滑动时Y轴的两个边界值,滑动时一旦到达边界值,则立刻停止
overX、overY在滑动时,可超出的滑动值,可超过边界值,不过超过边界值后,又会重新滑动回来
  1. startScroll 方法

startScroll的滚动默认以一种粘性液体的效果进行滚动。

public void startScroll(int startX, int startY, int dx, int dy) {
    startScroll(startX, startY, dx, dy, DEFAULT_DURATION);//DEFAULT_DURATION 250 ms
}
public void startScroll(int startX, int startY, int dx, int dy, int duration) {
    mMode = SCROLL_MODE;
    mScrollerX.startScroll(startX, dx, duration);
    mScrollerY.startScroll(startY, dy, duration);
}
参数简介
startX、startY开始滑动的X(Y)轴位置
dx、dy滚动到达的目标位置
duration滚动花费时间(单位ms),如果不指定默认时250ms