Android自定义View系列:事件拦截机制(触摸反馈机制)

TouchSlop

TouchSlop 是系统所能识别出的被认为是滑动的最小距离,不同设备 TouchSlop 的值也有所不同。

从 framework 层可以看到这个常量的定义:

<dimen name="config_viewConfigurationTouchSlop">8dp</dimen>
// 当两次滑动之间的距离小于 TouchSlop,则不认为是进行滑动操作
ViewConfiguration.get(getContext()).getScaledTouchSlop();

VelocityTracker

追踪手指在滑动过程中的速度,包括水平和竖直方向的速度。

如果 GestureDetector 不能满足需求,或者觉得 GestureDetector 过于复杂,可以自己处理 onTouchEvent() 事件。但需要使用 VelocityTracker 来计算手指移动速度。

使用方法:

private VelocityTracker velocityTracker = VelocityTracker.obtain();

@Override
public boolean onTouchEvent(MotionEvent event) {
	if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
		// 重置实例
		velocityTracker.clear();
	}
	// 把事件添加进 VelocityTracker
	velocityTracker.addMovement(event);

	switch(event.getActionMasked()) {
		case MotionEvent.ACTION_UP:
			// units:计算的时间长度,单位ms
			// 填入1000,那么 getXVelocity() 返回的值就是每1000ms内手指移动的像素数
			
			// maxVelocity:速度上限,超过这个速度,计算出的速度会回落到这个速度
			// 填了200,而实时速度是300,那么实际的返回速度将是200
			
			// 获取 maxVelocity 的方式:
			// ViewConfiguration viewConfiguration = ViewConfiguration.get(getContext());
        	// int maxVelocity = viewConfiguration.getScaledMaximumFlingVelocity()
			velocityTracker.computeCurrentVelocity(1000, 200);
			
			// 在获取速度之前需要调用 computeCurrentVelocity 计算
			// 速度计算公式: 速度 = (终点位置 - 起点位置) / 时间段  【速度根据滑动方向不同可以为负值】
			int xVelocity = (int) velocityTracker.getXVelocity();
			int yVelocity = (int) velocityTracker.getYVelocity();
			break;
	}
}

滑动

滑动效果实现原理:将一次滑动切割分成多个小滑动,不断进行滑动重绘达到弹性滑动的效果。

Scroller 实现弹性滑动

scrollTo()scrollBy() 进行滑动是瞬间完成的,用户体验不好,使用 Scroller 实现过渡效果滑动。

Scroller 注意事项

使用 Scroller 对 View 进行弹性滑动,只针对 View 的内容滑动,View 的位置没有发生改变,比如 Button 进行了弹性滑动,但 Button 进行单击事件仍然只有原先的位置有效,按位置滑动的 Button 影像不具备单击响应

Scroller scroller = new Scroller(mContext);
// OverScroller scroller = new OverScroller(mContext);

// onTouchEvent()中
// 缓慢滚动到指定位置
private void smoothScrollTo(int dextX, int dextY) {
    int scrollerX = getScrollerX();
    int delta = destX - scrollerX;
    //1秒内滑向destX
    mScroller.startScroll(scrollX, 0, delta, 0, 1000);
    invalidate();//invalidate()->draw()->computeScroll()->postInvalidate()->draw()->computeScroll()......
}

// onTouchEvent()外
@Override
public void computeScroll() {
    if (mScroller.computeScrollOffset()) {
        scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
        // postInvalidate();
    	postInvalidateOnAnimation();
    }
}

动画实现弹性滑动

// 动画实现的弹性滑动效果与 Scroller 相同,只对 View 的内容进行滑动,对 View 的位置不影响
final int startX = 0;
final int deltaX = 100;

ValueAnimator animator = ValueAnimator.ofInt(0, 1).setDuration(1000);
animator.addUpdateListener(new AnimatorUpdateListener() {
    @Override
    public void onAnimaotrUpdate(ValueAnimator animator) {
        float fraction = animator.getAnimatedFraction();
        (ViewGroup)mButton.getParent()).scrollTo(startX + (int) (deltaX * fraction), 0);
    }
});
animator.start();

Handler 延时策略实现弹性滑动

private static final int MESSAGE_SCROLL_TO = 1;
private static final int FRAME_COUNT = 30;
private static final int DELAYED_TIME = 33;

private int mCount = 0;

@SuppressLint("HandlerLeak")
private Handler mHandler = new Handler(){
    public void handleMessage(Message msg) {
        switch(msg.what) {
            case MESSAGE_SCROLL_TO:
                mCount++;
                if (mCount < FRAME_COUNT) {
                    float fraction = mCount / (float) FRAME_COUNT;
                    int scrollX = (int) (fraction * 100);
                    (ViewGroup)mButton.getParent()).scrollTo(scrollX, 0);
                    mHandler.sendEmptyMessageDelayed(MESSAGE_SCROLL_TO, DELAYED_TIME);
                }
                break;
        }
    }
};

View 的事件拦截机制

在这里插入图片描述

在这里插入图片描述

事件的分发过程由三个很重要的方法来共同完成: dispatchTouchEvent()onInterceptTouchEvent()onTouchEvent()

  • public boolean dispatchTouchEvent(MotionEvent ev)

用来进行事件的分发。如果事件能够传递给当前 View,那么此方法一定会被调用,返回结果受当前 View 的 onTouchEvent() 和下级 View 的 dispatchTouchEvent() 的影响,表示是否消耗当前事件。

dispatchTouchEvent() 它是一个总的调度方法。

  • public boolean onInterceptTouchEvent(MotionEvent event)

在上述方法内部调用,用来判断是否拦截某个事件,如果当前 View 拦截了某个事件,那么在同一个事件序列当中,此方法不会被再次调用,返回结果表示是否拦截当前事件。

在返回 true 确认拦截事件时,其实还会发送一次 MotionEvent.ACTION_CANCEL 事件,通知子 View 这个事件我已经拦截处理了,你恢复状态吧!

场景比如手指点击屏幕列表滑动时,子 View 响应到了按下事件改变了背景颜色,但列表拦截了事件去滑动 onInterceptTouchEvent() 被调用返回了 true,所以就会发送 MotionEvent.ACTION_CANCEL 通知子 View 恢复背景。

  • public boolean onTouchEvent(MotionEvent event)

dispatchTouchEvent() 中调用,用来处理点击事件,返回结果表示是否消耗当前事件,如果不消耗,则在同一个事件序列中,当前 View 无法再次接收到事件。

// View伪代码
public boolean dispatchTouchEvent(MotionEvent ev) {
	return onTouchEvent(ev);
}

// ViewGroup伪代码
public boolean dispatchTouchEvent(MotionEvent ev) {
	boolean consume = false;
	if (onInterceptTouchEvent(ev)) {
		consume = onTouchEvent(ev);
	} else {
		consume = child.dispatchTouchEvent(ev);
	}
	return consume;
}

根据上面伪代码,事件传递的规则:

对于一个根 ViewGroup 来说,点击事件产生后,首先会传递给它,这时它的 dispatchTouchEvent() 就会被调用

  • 如果这个 ViewGroup 的 onInterceptTouchEvent() 返回 true 表示它要拦截当前事件,接着事件就会交给这个 ViewGroup 处理,即它的 onTouchEvent() 就会被调用

  • 如果这个 ViewGroup 的 onInterceptTouchEvent() 返回 false 表示它不拦截事件,这时当前事件就会继续传递给它的子元素,接着子元素的 dispatchTouchEvent() 就会被调用,如此反复直到事件被最终处理

当一个 View 需要处理事件时,如果它设置了 onTouchListener,那么 onTouchListener 中的 onTouch() 就会被回调。

这时事件如果处理还要看 onTouch() 的返回值,如果返回 false,则当前 View 的 onTouchEvent() 方法会被调用;如果返回 true,那么 onTouchEvent() 将不会被调用。

由此可见,给 View 设置的 onTouchListener,其优先级比 onTouchEvent() 要高。在 onTouchEvent() 中,如果当前设置的有 onClickListener,那么它的 onClick() 会被调用。

View 事件拦截执行顺序

  • Activity#dispatchTouchEvent() ->

  • 递归: ViewGroup#dispatchTouchEvent() ->

    • ViewGroup#onInterceptTouchEvent() ->

    • ViewGroup#onTouchEvent()

    • child#dispatchTouchEvent()

    • super#dispatchTouchEvent()

      • View#onTouchEvent()
    • Activity#onTouchEvent()

dispatchTouchEvent() -> onInterceptTouchEvent() -> onTouchEvent()【优先级:onTouchListener 的 onTouch() -> onTouchEvent() -> onClickListener 的 onClick()

View 有三个方法,dispatchTouchEvent() 里面包含着 onInterceptTouchEvent()onTouchEvent() 两个方法。

事件传递机制的结论

  • 同一个事件序列是指从手指接触屏幕的那一刻起,到手指离开屏幕的那一刻结束,在这个过程中所产生的一系列事件,这个事件序列以 down 事件开始,中间含有数量不定的 move 事件,最终以 up 事件结束

  • 正常情况下,一个事件序列只能被一个 View 拦截且消耗。因为一旦一个元素拦截了某此事件,那么同一个事件序列内的所有事件都会直接交给它处理,因此同一个事件序列中的事件不能分别由两个 View 同时处理,但是通过特殊手段可以做到,比如一个 View 将本该自己处理的事件通过 onTouchEvent() 强行传递给其他 View 处理

  • 某个 View 一旦决定拦截,那么这一个事件序列都只能由它来处理(如果事件序列能够传递给它的话),并且它的 onInterceptTouchEvent() 不会再被调用。即一个 View 决定拦截一个事件后,那么系统会把同一个事件序列内的其他方法都直接交给它来处理,因此就不用再调用这个 View 的 onInterceptTouchEvent() 去询问它是否要拦截了

  • 某个 View 一旦开始处理事件,如果它不消耗 MotionEvent.ACTION_DOWN 事件(onTouchEvent() 返回了 false),那么同一事件序列中的其他事件都不会再交给它来处理,并且事件将重新交由它的父元素处理,即父元素的 onTouchEvent() 会被调用。

  • 如果 View 不消耗除 MotionEvent.ACTION_DOWN 以外的其他事件,那么这个点击事件会消失,此时父元素的 onTouchEvent() 并不会被调用,并且当前 View 可以持续收到后续的事件,最终这些消失的点击事件会传递给 Activity 处理(即 View 要消耗一个事件,是要一个完整的事件序列,由 down 到 up 结束)

  • ViewGroup 默认不拦截任何事件。Android 源码中 ViewGroup 的 onInterceptTouchEvent() 默认返回 false

  • View 没有 onInterceptTouchEvent(),一旦有点击事件传递给它,那么它的 onTouchEvent() 就会被调用

  • View 的 onTouchEvent() 默认都会消耗事件(返回 true),除非它是不可点击的(clickable 和longClickable 同时为 false)。View的 longClickable 属性默认都为 false,clickable 属性要分情况,比如 Button 的 clickable 属性默认为 true,而 TextView 的 clickable 属性默认为 false

  • View 的 enable 属性不影响 onTouchEvent() 的默认返回值。哪怕一个 View 是 disable 的状态,只要它的 clickable 或 longClickable 有一个为 true,那么它的 onTouchEvent() 就返回 true

  • onClick() 会发生的前提是当前 View 是可点击的,并且它收到了 down 和 up 的事件

  • 事件传递过程是由外向内的,即事件总是先传递给父元素,然后再由父元素分发给子 View,通过 requestDisallowInterceptTouchEvent() 可以在子元素中干预父元素的事件分发过程,但是 MotionEvent.ACTION_DOWN 事件除外

根据以上结论,伪代码如下:

public boolean dispatchTouchEvent(MotionEvent ev) {
	boolean consume = false;
	// ViewGroup 的 onInterceptTouchEvent() 默认返回 false
	
	// 父 View 确认拦截会发送一个 MotionEvent.ACTION_CANCEL 通知子 View 不要再处理事件了
	if (onInterceptTouchEvent(ev)) {
		consume = onTouchEvent(ev);
	} else {
		// View 没有 onInterceptTouchEvent()
		// 如果 View 的 dispatchTouchEvent() 返回 true,即回调 onTouchEvent()
		consume = child.dispatchTouchEvent(ev); 
		if (consume) {
			// 如果 View 的 onTouchListener 有设置,会优先传递给onTouchListener#onTouch()
			if (child.onTouchListener != null) {
				child.onTouchListener.onTouch(ev);
			} else {
				// View 的 onTouchEvent() 默认返回 true 拦截事件
				// 除非 clickable=false && longClickable=false 不可点击
				// onTouchEvent() 内部如果有 onClickListener,也会回调onClickListener#onClick
				consuem = child.onTouchEvent(ev); 
				if (!consume) {
					// View 不消耗事件,往上抛给父元素的 onTouchEvent() 处理
					child.getParent().onTouchEvent(ev); 
				} 
			}
		}
	}
}

滑动冲突

常见的滑动冲突场景

在这里插入图片描述

滑动冲突解决

解决滑动冲突的方式是配合外部拦截法或内部拦截法,再结合滑动差、速度差、滑动角度等方式进行处理。

外部拦截法

指点击事件都先经过父 View 的拦截处理,根据父 View 决定是否需要拦截。

伪代码说明:

// 外部拦截法,此段代码重写在与子 View 滑动冲突的父 View 中
public boolean onInterceptTouchEvent(MotionEvent event) {
    boolean intercepted = false;
    int x = (int) event.getX();
    int y = (int) event.getY();

    switch(event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            intercept = false; 
            break;
        case MotionEvent.ACTION_MOVE:         // 判断水平距离的滑动和竖直距离的滑动差进行相应拦截
            if (View需要当前点击事件) {  	 int deltaX = x - mLastXIntercept;
                intercept = true;            int deltaY = y - mLastYIntercept;
            } else {               		=>   if (Math.abs(deltaX) > Math.abs(deltaY)) {					
                intercept = false;           	intercept = true;
            }                                } else {
            break;                              intercept = false;
         case MotionEvent.ACTION_UP:         }
            intercept = false;
            break;
    }
    mLastXIntercept = x;
    mLastYIntercept = y;
    return intercept;
}

要重写父 View 的 onInterceptTouchEvent()

注意

  • MotionEvent.ACTION_DOWN 事件必须返回 false,如果 MotionEvent.ACTION_DOWN 被拦截,一系列事件(即 MotionEvent.ACTION_DOWN 、MotionEvent.ACTION_MOVE、MotionEvent.ACTION_UP)将被拦截,否则事件无法传递给子元素

  • MotionEvent.ACTION_MOVE 事件来确定是否需要拦截事件,拦截 true,不拦截 false

  • MotionEvent.ACTION_UP 事件不能返回 true,会导致子 View 无法接收 MotionEvent.ACTION_UP 事件 onClick 事件无法触发

内部拦截法

指父 View 不拦截任何事件全由子 View 拦截,需要事件则子 View 消耗,不需要抛给父 View 处理。

伪代码说明:

// 内部拦截法,此段代码重写在与外部父 View 滑动冲突的子 View 中
public boolean dispatchTouchEvent(MotionEvent event) {
    int x = (int) event.getX();
    int y = (int) event.getY();

    switch(event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            parent.requestDisallowInterceptTouchEvent(true);
            break;
        case MotionEvent.ACTION_MOVE:
            int deltaX = x - mLastX;
            int deltaY = y - mLastY;
            if (View需要此类点击事件) {
                parent.requestDisallowInterceptTouchEvent(false); // 这个方法只处理一次事件流,需要处理就需要再调用一次
            }
            break;
         case MotionEvent.ACTION_UP:
            break;
    }
    mLastX = x;
    mLastY = y;
    return super.dispatchTouchEvent(event);
}

public boolean onInterceptTouchEvent(MotionEvent event) {
    int action = event.getAction();
    if (action == MotionEvent.ACTION_DOWN) {
        return false;
    } else {
        return true;
    }
}

子 View 要重写 dispatchTouchEvent(),父 View 要重写 onInterceptTouchEvent()

注意

  • 内部拦截法与 Android 的事件分发机制不一致,需要配合 requestDisallowInterceptTouchEvent()

  • 父 View 要默认拦截除了 MotionEvent.ACTION_DOWN 以外的其他事件,当子 View 调用 parent.requestDisallowInterceptTouchEvent(false) 时,父 View 才能拦截下来

  • 父 View 不拦截 MotionEvent.ACTION_DOWN 事件,拦截了则传递不到子 View 去

自定义触摸反馈总结

自定义触摸反馈简单总结

  • 重写 onTouchEvent(),写入触摸反馈算法,并返回 true 处理事件流

  • 如果是 ViewGroup 并且可能与子 View 产生触摸判别冲突,还需要重写 onInterceptTouchEvent(),在 MotionEvent.ACTION_DOWN 返回 false,在合适的时候返回 true 来拦截事件流

  • 如果需要临时阻止父 View 的拦截,可以用 getParent().requestDisallowInterceptTouchEvent(),这是一个非持久的方法,仅对当前事件流有效

ViewGroup 触摸反馈详细操作

  • 除了重写 onTouchEvent(),还需要重写 onInterceptTouchEvent()

  • onInterceptTouchEvent() 中,MotionEvent.ACTION_DOWN 做的事和 onTouchEvent() 基本一致或完全一致

    • 原因:MotionEvent.ACTION_DOWN 在多数手势中起到的是起始记录的作用(例如记录手指落点),而 onInterceptTouchEvent() 调用后,onTouchEvent() 未必会被调用,因此需要把这个记录责任转交给 onInterceptTouchEvent()

    • 有时 MotionEvent.ACTION_DOWN 也会在经过 onInterceptTouchEvent() 之后再转交给自己的 onTouchEvent()(例如当没有触摸到子 View 或者触摸到的子 View 没有消费事件时)。因此需要确认在 onInterceptTouchEvent()onTouchEvent() 都被调用,程序状态不会出问题

  • onInterceptTouchEvent() 中,MotionEvent.ACTION_MOVE 一般的作用是确认滑动,即当用户朝某一方向滑动一段距离(touch slop)后,ViewGroup 要向自己的子 View 和父 View 确认自己将消费事件

    • 确认滑动的方式:Math.abs(event.getX() - downX) > ViewConfiguration.getXxxSlop()

    • 告知子 View 的方式:在 onInterceptTouchEvent() 中返回 true,子 View 会收到 MotionEvent.ACTION_CANCEL,并且后续事件不再发给子 View

    • 告知父 View 的方式:调用 getParent().requestDisallowInterceptTouchEvent(true)。这个方法会递归通知每一级父 View,让他们在后续事件中不再尝试通过 onInterceptTouchEvent() 拦截事件。这个通知仅在当前事件序列有效,即在这组事件结束后(即用户抬手后),父 View 会自动对后续的新事件序列启用拦截机制

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值