Android事件分發與滑動沖突

Android事件分發機制

一、概述

1. 事件

事件通常指觸摸或點擊事件,用戶觸摸屏幕時產生 Touch 事件。Touch 事件的相關細節封裝于 MotionEvent 對象中。

事件類型 具體動作
MotionEvent.ACTION_DOWN 按下事件(開始)
MotionEvent.ACTION_UP 抬起事件(結束)
MotionEvent.ACTION_MOVE 滑動事件
MotionEvent.ACTION_CANCEL 取消事件

2. 分發流程

事件分發流程

如上圖所示,onTouch事件產生后,先傳給Activity,再傳給View Group,最后傳給View。

事件分發流程的目的就是要找到第一個要處理事件的對象。一旦有一個對象消費了該事件,事件分發結束。反之,如果事件沒有被消費,則會被廢棄。

3. 重要方法

方法 作用
dispatchTouchEvent(event: MotionEvent?): Boolean 進行事件分發
onInterceptTouchEvent(event: MotionEvent?): Boolean 進行事件攔截
onTouchEvent(event: MotionEvent?): Boolean 進行事件消耗

三個方法之間的關系可以使用如下偽代碼表示:

fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
    val consume = false
    if (onInterceptTouchEvent(ev)) {
        consume = onTouchEvent(ev)
    } else {
        consume = child.dispatchTouchEvent(ev)
    }
    return consume
}

事件的傳遞規則:對于ViewGroup,點擊事件傳遞過來后,首先調用 dispatchTouchEvent 方法。如果其 onInterceptTouchEvent 方法返回true,表示攔截該事件,隨后它的 onTouch 方法被調用;如果 onInterceptTouchEvent 方法返回false,表示不攔截事件,該事件會繼續傳遞給子View,接著子View的 dispatchTouchEvent 方法被調用。重復該過程直至事件被消耗。

二、Activity的事件分發

1. Demo演示

(1) 重寫Activity的 dispatchTouchEventonTouchEvent 方法。

class MainActivity : AppCompatActivity() {
    companion object {
        const val TAG = "Activity"
    }
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }

    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
        val eventName = EventUtil.getActionName(ev)
        LogUtil.i(TAG, "dispatchTouchEvent $eventName Start", LogUtil.Depth.ACTIVITY)
        val result = super.dispatchTouchEvent(ev)
        LogUtil.i(TAG, "dispatchTouchEvent $eventName End with $result", LogUtil.Depth.ACTIVITY)
        return result
    }

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        val eventName = EventUtil.getActionName(event)
        LogUtil.i(TAG, "onTouchEvent $eventName", LogUtil.Depth.ACTIVITY)
        return super.onTouchEvent(event)
    }
}

(2) 自定義MyLayout (繼承自FrameLayout) 并重寫 dispatchTouchEvent方法

class MyLayout : FrameLayout {
    companion object {
        const val TAG = "MyLayout"
    }

    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)

    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
        val eventName = EventUtil.getActionName(ev)
        val result = false  // false or true
        LogUtil.i(TAG, "dispatchTouchEvent $eventName End with $result", LogUtil.Depth.VIEW_GROUP)
        return result
    }
}

(3) activity_main.xml

<com.example.eventdispatch.ui.MyLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="200dp"
    android:layout_height="200dp"
    android:layout_gravity="center"
    android:background="@color/colorPrimary"
    android:gravity="center">
</com.example.eventdispatch.ui.MyLayout>

當MyLayout dispatchTouchEvent 返回false時,表示其不對事件進行分發。ACTION_DOWN事件傳遞到MyLayout時,dispatchTouchEvent 被調用,返回false,事件返回給Activity,Activity的 onTouchEvent 被調用。當ACTION_MOVE或ACTION_UP事件到來時,由于上一個事件由Activity處理,因此該事件不再向下傳遞,直接交給Activity處理。點擊MyLayout,打印的Log如下:

I/Activity:     dispatchTouchEvent ACTION_DOWN Start
I/  MyLayout:   dispatchTouchEvent ACTION_DOWN End with false
I/Activity:     onTouchEvent ACTION_DOWN
I/Activity:     dispatchTouchEvent ACTION_DOWN End with false
I/Activity:     dispatchTouchEvent ACTION_UP Start
I/Activity:     onTouchEvent ACTION_UP
I/Activity:     dispatchTouchEvent ACTION_UP End with false

當MyLayout dispatchTouchEvent 返回true時,事件被MyLayout消耗,Activity的 onTouchEvent 不會被調用。點擊MyLayout,打印的Log如下:

I/Activity:     dispatchTouchEvent ACTION_DOWN Start
I/  MyLayout:   dispatchTouchEvent ACTION_DOWN End with true
I/Activity:     dispatchTouchEvent ACTION_DOWN End with true
I/Activity:     dispatchTouchEvent ACTION_UP Start
I/  MyLayout:   dispatchTouchEvent ACTION_UP End with true
I/Activity:     dispatchTouchEvent ACTION_UP End with true

2. 源碼分析

注: 本文所有源碼為API Level 29

點擊事件產生后,最先傳遞給當前Activity,Activity的 dispatchTouchEvent 方法被調用。

Activity的 dispatchTouchEvent 方法如下:

/**
 * Acticity.java
 * Line 3989-3997
 */
public boolean dispatchTouchEvent(MotionEvent ev) {
    if (ev.getAction() == MotionEvent.ACTION_DOWN) {
        // 空方法
        onUserInteraction();
    }
    if (getWindow().superDispatchTouchEvent(ev)) {
        return true;
    }
    return onTouchEvent(ev);
}

可以看到,事件首先交給Activity所屬的Window進行分發,如果返回true,則事件分發結束;如果返回false,意味著事件沒有被處理,Activity的 onTouchEvent 被調用。

getWindow 返回Window對象,Window是一個抽象類,PhoneWindow是其唯一的實現類。因此 getWindow().superDispatchTouchEvent(ev) 就是調用PhoneWindow的 superDispatchTouchEvent(ev) 方法。

PhoneWindow的 superDispatchTouchEvent 方法如下:

/**
 * PhoneWindow.java
 * Line 1847-1850
 */
@Override
public boolean superDispatchTouchEvent(MotionEvent event) {
    return mDecor.superDispatchTouchEvent(event);
}

PhoneWindow將事件傳遞給了DecorView對象mDecor,mDecor是 getWindow().getDecorView() 返回的View,Activity中通過 setContentView 設置的View是它的一個子View。

DecorView的 superDispatchTouchEvent 方法如下:

/**
 * DecorView.java
 */
public class DecorView extends FrameLayout implements RootViewSurfaceTaker, WindowCallbacks{
    // ...
    // Line464-466
    public boolean superDispatchTouchEvent(MotionEvent event) {
        return super.dispatchTouchEvent(event);
    }
}

DecorView繼承自FramgLayout,FrameLayout又繼承自ViewGroup,所以 mDecor.superDispatchTouchEvent(event) 其實就是調用ViewGroup的 dispatchTouchEvent 方法。至此,事件已經分發給ViewGroup了。

3. 分發流程圖

Activity分發流程

三、ViewGroup的事件分發

1. Demo演示

(1) 自定義MyLayout (繼承自FrameLayout) 并重寫 dispatchTouchEvent 方法、onInterceptTouchEvent 方法、onTouchEvent 方法。

class MyLayout : FrameLayout {
    companion object {
        const val TAG = "MyLayout"
    }

    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)

    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
        val eventName = EventUtil.getActionName(ev)
        LogUtil.i(TAG, "dispatchTouchEvent $eventName Start", LogUtil.Depth.VIEW_GROUP)
        val result = super.dispatchTouchEvent(ev)
        LogUtil.i(TAG, "dispatchTouchEvent $eventName End with $result", LogUtil.Depth.VIEW_GROUP)
        return result
    }

    override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
        val eventName = EventUtil.getActionName(ev)
        LogUtil.i(TAG, "onInterceptTouchEvent $eventName Start", LogUtil.Depth.VIEW_GROUP)
        val result = false  // false or true
        LogUtil.i(TAG, "onInterceptTouchEvent $eventName End with $result", LogUtil.Depth.VIEW_GROUP)
        return result
    }

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        val eventName = EventUtil.getActionName(event)
        LogUtil.i(TAG, "onTouchEvent $eventName Start", LogUtil.Depth.VIEW_GROUP)
        val result = super.onTouchEvent(event)
        LogUtil.i(TAG, "onTouchEvent $eventName End with $result", LogUtil.Depth.VIEW_GROUP)
        return result
    }
}

(2) activity_main.xml

<com.example.eventdispatch.ui.MyLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="200dp"
    android:layout_height="200dp"
    android:layout_gravity="center"
    android:background="@color/colorPrimary"
    android:gravity="center">

    <Button
        android:id="@+id/my_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="Button" />

</com.example.eventdispatch.ui.MyLayout>

(3) MainActivity中,為button添加點擊事件。

class MainActivity : AppCompatActivity() {
    companion object {
        const val TAG = "Activity"
    }

    private lateinit var button: Button

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        button = findViewById(R.id.my_button)
        button.setOnClickListener {
            LogUtil.i(MyLayout.TAG, "onClick", LogUtil.Depth.VIEW_GROUP)
        }
    }
    
    // ...
}

當MyLayout的 onInterceptTouchEvent 方法返回false時,點擊button,打印的Log如下:

I/Activity:     dispatchTouchEvent ACTION_DOWN Start
I/  MyLayout:   dispatchTouchEvent ACTION_DOWN Start
I/  MyLayout:   onInterceptTouchEvent ACTION_DOWN Start
I/  MyLayout:   onInterceptTouchEvent ACTION_DOWN End with false
I/  MyLayout:   dispatchTouchEvent ACTION_DOWN End with true
I/Activity:     dispatchTouchEvent ACTION_DOWN End with true
I/Activity:     dispatchTouchEvent ACTION_UP Start
I/  MyLayout:   dispatchTouchEvent ACTION_UP Start
I/  MyLayout:   onInterceptTouchEvent ACTION_UP Start
I/  MyLayout:   onInterceptTouchEvent ACTION_UP End with false
I/  MyLayout:   dispatchTouchEvent ACTION_UP End with true
I/Activity:     dispatchTouchEvent ACTION_UP End with true
I/  MyLayout:   onClick

可以看出,此時按鈕的點擊事件觸發,但是MyLayout的 onTouchEvent 方法未被調用。說明MyLayout并沒有攔截事件,而是將它傳遞給了button。

當MyLayout的 onInterceptTouchEvent 方法返回true時,點擊button,打印的Log如下:

I/Activity:     dispatchTouchEvent ACTION_DOWN Start
I/  MyLayout:   dispatchTouchEvent ACTION_DOWN Start
I/  MyLayout:   onInterceptTouchEvent ACTION_DOWN Start
I/  MyLayout:   onInterceptTouchEvent ACTION_DOWN End with true
I/  MyLayout:   onTouchEvent ACTION_DOWN Start
I/  MyLayout:   onTouchEvent ACTION_DOWN End with false
I/  MyLayout:   dispatchTouchEvent ACTION_DOWN End with false
I/Activity:     onTouchEvent ACTION_DOWN
I/Activity:     dispatchTouchEvent ACTION_DOWN End with false
I/Activity:     dispatchTouchEvent ACTION_UP Start
I/Activity:     onTouchEvent ACTION_UP
I/Activity:     dispatchTouchEvent ACTION_UP End with false

這種情況下按鈕的點擊事件沒有觸發,但是MyLayout的 onTouchEvent 方法被調用。說明MyLayout攔截了事件,沒有將它傳遞給button。

2. 源碼分析

如上所述,Activity在 dispatchTouchEvent 方法內將點擊事件傳遞給了ViewGroup的 dispatchTouchEvent 方法。

ViewGroup的 dispacthTouchEvent 方法如下:

/**
 * ViewGroup.java
 * Line 2577-2791
 */
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
    boolean handled = false;
    if (onFilterTouchEventForSecurity(ev)) {
        // ...
    }
    if (!handled && mInputEventConsistencyVerifier != null) {
        mInputEventConsistencyVerifier.onUnhandledEvent(ev, 1);
    }
    return handled;
}

不難看出,ViewGroup的 dispatchTouchEvent 的方法返回handled的值,默認為false。而改變handled值的部分位于第一個if塊內,dispatchTouchEvent 被調用時首先進入 onFilterTouchEventForSecurity(ev) 方法。

onFilterTouchEventForSecurity(ev) 方法如下:

/**
 * View.java
 * Line 13474-13482
 */
public boolean onFilterTouchEventForSecurity(MotionEvent event) {
    if ((mViewFlags & FILTER_TOUCHES_WHEN_OBSCURED) != 0
            && (event.getFlags() & MotionEvent.FLAG_WINDOW_IS_OBSCURED) != 0) {
        return false;
    }
    return true;
}

if語句塊表示如果該視圖不位于頂部,并且有屬性設置不在頂部時不響應事件,則不分發該事件。

FILTER_TOUCHES_WHEN_OBSCUREDandroid:filterTouchWhenObscured 屬性相對應,如果為true,表示有其他視圖在該視圖之上,該視圖不響應觸摸事件。

MotionEvent.FLAG_WINDOW_IS_OBSCURED 為true表示該窗口被隱藏。

當沒有設置相關屬性時,onFilterTouchEventForSecurity(ev) 方法返回true。因此分發過程都會進入 if (onFilterTouchEventForSecurity(ev)) 語句塊內,其內容如下:

if (onFilterTouchEventForSecurity(ev)) {
    // Line 2591-2601
    final int action = ev.getAction();
    final int actionMasked = action & MotionEvent.ACTION_MASK;

    if (actionMasked == MotionEvent.ACTION_DOWN) {
        cancelAndClearTouchTargets(ev);
        resetTouchState();
    }
    // ...
}

其中 cancelAndClearTouchTargets 方法和 resetTouchState 方法的作用是在點擊后重置觸摸狀態。

if (onFilterTouchEventForSecurity(ev)) {
    // Line 2604-2618
    final boolean intercepted;
    if (actionMasked == MotionEvent.ACTION_DOWN
            || mFirstTouchTarget != null) {
        final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
        if (!disallowIntercept) {
            intercepted = onInterceptTouchEvent(ev);
            ev.setAction(action);
        } else {
            intercepted = false;
        }
    } else {
        intercepted = true;
    }
}

disallowIntercept 代表禁用事件攔截功能,默認為false。進入到 if (!disallowIntercept) 語句塊內,調用 onInterceptTouchEvent 方法。

onInterceptTouchEvent 方法如下:

/**
 * ViewGroup.java
 * Line 3224-3232
 */
public boolean onInterceptTouchEvent(MotionEvent ev) {
    if (ev.isFromSource(InputDevice.SOURCE_MOUSE)
            && ev.getAction() == MotionEvent.ACTION_DOWN
            && ev.isButtonPressed(MotionEvent.BUTTON_PRIMARY)
            && isOnScrollbarThumb(ev.getX(), ev.getY())) {
        return true;
    }
    return false;
}

在上一個if語句塊內intercepted = onInterceptTouchEvent(ev),如果不攔截,則 intercepted 為false,進入 if (!canceled && !intercepted) 語句塊。

if (onFilterTouchEventForSecurity(ev)) {
    // Line 2634-2736
    if (!canceled && !intercepted) {
        // ...
        if (actionMasked == MotionEvent.ACTION_DOWN
                || (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
                || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {

            // ...
            final int childrenCount = mChildrenCount;
            if (newTouchTarget == null && childrenCount != 0) {
                // ...
                for (int i = childrenCount - 1; i >= 0; i--) {
                    // 判斷子元素能夠接受點擊事件
                    if (!child.canReceivePointerEvents()
                            || !isTransformedTouchPointInView(x, y, child, null)) {
                        ev.setTargetAccessibilityFocus(false);
                        continue;
                    }

                    newTouchTarget = getTouchTarget(child);
                    if (newTouchTarget != null) {
                        newTouchTarget.pointerIdBits |= idBitsToAssign;
                        break;
                    }

                    // 調用子元素的dispatchTouchEvent方法
                    resetCancelNextUpFlag(child);
                    if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
                        // ...
                    }
                    // ...
                }
                // ...
            }
        }
    }
    if (mFirstTouchTarget == null) {
    // No touch targets so treat this as an ordinary view.
    handled = dispatchTransformedTouchEvent(ev, canceled, null,
            TouchTarget.ALL_POINTER_IDS);
    }
}

在該語句塊內,可以看到通過for循環遍歷所有子元素,判斷每個子元素是否可以接受點擊事件:(1) canReceivePointerEvents 判斷事件的坐標是否落在子元素的區域內;(2) isTransformedTouchPointInView 判斷子元素是否在播放動畫。判斷結束后執行ViewGroup的 dispatchTransformedTouchEvent 方法。

如果 intercepted 為true,則ViewGroup攔截事件。此時不會進入第3行的if語句。又由于沒有對mFirstTouchTarget賦值,因此進入if (mFirstTouchTarget == null)語句塊,執行ViewGroup的 dispatchTransformedTouchEvent 方法。

dispatchTransformedTouchEvent方法如下:

/**
 * ViewGroup.java
 */
private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
        View child, int desiredPointerIdBits) {
    // Line 3072-3087
    if (child == null) {
        handled = super.dispatchTouchEvent(transformedEvent);
    } else {
        final float offsetX = mScrollX - child.mLeft;
        final float offsetY = mScrollY - child.mTop;
        transformedEvent.offsetLocation(offsetX, offsetY);
        if (! child.hasIdentityMatrix()) {
            transformedEvent.transform(child.getInverseMatrix());
        }

        handled = child.dispatchTouchEvent(transformedEvent);
    }

    transformedEvent.recycle();
    return handled;    
}

不難發現,當參數child為null時,對應上述intercepted 為true的情況,此時調用 super.dispatchTouchEvent(event),即 View.dispatchTouchEvent(event),事件由ViewGroup處理;當child不為null時,對應上述intercepted 為false的情況,此時調用 child.dispatchTouchEvent(event) 方法,事件由ViewGroup分發至View。

3. 分發流程圖

ViewGroup分發流程

四、View的事件分發

1. Demo演示

(1) 自定義MyButton(繼承自AppCompatButton)并重寫 dispatchTouchEvent 方法、onInterceptTouchEvent 方法。

class MyButton : AppCompatButton {
    companion object {
        const val TAG = "MyButton"
    }

    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)

    override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
        val eventName = EventUtil.getActionName(event)
        LogUtil.i(TAG, "dispatchTouchEvent $eventName Start", LogUtil.Depth.VIEW)
        val result = super.dispatchTouchEvent(event)
        LogUtil.i(TAG, "dispatchTouchEvent $eventName End with $result", LogUtil.Depth.VIEW)
        return result
    }

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        val eventName = EventUtil.getActionName(event)
        LogUtil.i(TAG, "onTouchEvent $eventName", LogUtil.Depth.VIEW)
        return super.onTouchEvent(event)
    }
}

(2) activity_main.xml

<com.example.eventdispatch.ui.MyLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/my_layout"
    android:layout_width="200dp"
    android:layout_height="200dp"
    android:layout_gravity="center"
    android:background="@color/colorPrimary"
    android:gravity="center">

    <com.example.eventdispatch.ui.MyButton
        android:id="@+id/my_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="Button" />
</com.example.eventdispatch.ui.MyLayout>

(3) 在MainActivity中為myButton添加 OnTouchListenerOnClickListener

class MainActivity : AppCompatActivity() {
    companion object {
        const val TAG = "Activity"
    }

    private lateinit var myButton: MyButton

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        myButton = findViewById(R.id.my_button)
        myButton.apply {
            setOnTouchListener { _, ev ->
                val eventName = EventUtil.getActionName(ev)
                LogUtil.i(MyButton.TAG, "onTouch $eventName", LogUtil.Depth.VIEW)
                false
            }
            setOnClickListener {
                LogUtil.i(MyButton.TAG, "onClick", LogUtil.Depth.VIEW)
            }
        }
    }
}

當myButton的 onTouch 返回false時,打印的Log如下:

I/Activity:     dispatchTouchEvent ACTION_DOWN Start
I/  MyLayout:   dispatchTouchEvent ACTION_DOWN Start
I/  MyLayout:   onInterceptTouchEvent ACTION_DOWN Start
I/  MyLayout:   onInterceptTouchEvent ACTION_DOWN End with false
I/    MyButton: dispatchTouchEvent ACTION_DOWN Start
I/    MyButton: onTouch ACTION_DOWN
I/    MyButton: onTouchEvent ACTION_DOWN
I/    MyButton: dispatchTouchEvent ACTION_DOWN End with true
I/  MyLayout:   dispatchTouchEvent ACTION_DOWN End with true
I/Activity:     dispatchTouchEvent ACTION_DOWN End with true
I/Activity:     dispatchTouchEvent ACTION_UP Start
I/  MyLayout:   dispatchTouchEvent ACTION_UP Start
I/  MyLayout:   onInterceptTouchEvent ACTION_UP Start
I/  MyLayout:   onInterceptTouchEvent ACTION_UP End with false
I/    MyButton: dispatchTouchEvent ACTION_UP Start
I/    MyButton: onTouch ACTION_UP
I/    MyButton: onTouchEvent ACTION_UP
I/    MyButton: dispatchTouchEvent ACTION_UP End with true
I/  MyLayout:   dispatchTouchEvent ACTION_UP End with true
I/Activity:     dispatchTouchEvent ACTION_UP End with true
I/    MyButton: onClick

onTouch返回true時,打印的Log如下:

I/Activity:     dispatchTouchEvent ACTION_DOWN Start
I/  MyLayout:   dispatchTouchEvent ACTION_DOWN Start
I/  MyLayout:   onInterceptTouchEvent ACTION_DOWN Start
I/  MyLayout:   onInterceptTouchEvent ACTION_DOWN End with false
I/    MyButton: dispatchTouchEvent ACTION_DOWN Start
I/    MyButton: onTouch ACTION_DOWN
I/    MyButton: dispatchTouchEvent ACTION_DOWN End with true
I/  MyLayout:   dispatchTouchEvent ACTION_DOWN End with true
I/Activity:     dispatchTouchEvent ACTION_DOWN End with true
I/Activity:     dispatchTouchEvent ACTION_UP Start
I/  MyLayout:   dispatchTouchEvent ACTION_UP Start
I/  MyLayout:   onInterceptTouchEvent ACTION_UP Start
I/  MyLayout:   onInterceptTouchEvent ACTION_UP End with false
I/    MyButton: dispatchTouchEvent ACTION_UP Start
I/    MyButton: onTouch ACTION_UP
I/    MyButton: dispatchTouchEvent ACTION_UP End with true
I/  MyLayout:   dispatchTouchEvent ACTION_UP End with true
I/Activity:     dispatchTouchEvent ACTION_UP End with true

對比發現,當View的 onTouch 返回false時,onTouchEventonClick 都被調用,返回true時,二者都不會被調用。據此分析:onClick 方法在 onTouchEvent 方法中被調用。

2. 源碼分析

如上所述,當ViewGroup的child(即子View)不為null時,子View的 dispatchTouchEvent 方法被調用。

View的 dispatchTouchEvent 方法如下:

/**
 * View.java
 * Line 13395-13449
 */
public boolean dispatchTouchEvent(MotionEvent event) {
    // ...
    boolean result = false;
    // ...
    if (onFilterTouchEventForSecurity(event)) {
        if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
            result = true;
        }
        ListenerInfo li = mListenerInfo;
        if (li != null && li.mOnTouchListener != null
                && (mViewFlags & ENABLED_MASK) == ENABLED
                && li.mOnTouchListener.onTouch(this, event)) {
            result = true;
        }

        if (!result && onTouchEvent(event)) {
            result = true;
        }
    }
    // ...
    return result;
}

li.mOnTouchListener 表示View的OnTouchListener,如果通過 setOnTouchListener 方法為View設置監聽事件,則 li.mOnTouchListener 不為空。(mViewFlags & ENABLED_MASK) == ENABLED 代表View enable

當設置 onTouch監聽事件并返回false時,14行的if語句判斷條件為false,進入 if (!result && onTouchEvent(event)) 內,View的 onTouchEvent 方法被調用;如果 onTouch 返回true,進入第14行的if語句塊,result被置為true,因此20行的 onTouchEvent 方法不會被調用。

onTouchEvent 方法如下:

/**
 * View.java
 * Line 14754-14962
 */
public boolean onTouchEvent(MotionEvent event) {
    // ...
    final boolean clickable = ((viewFlags & CLICKABLE) == CLICKABLE
            || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
            || (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE;
    
    // ...

    if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
        switch (action) {
            case MotionEvent.ACTION_UP:
                // ...
                boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0;
                if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {
                    boolean focusTaken = false;
                    if (isFocusable() && isFocusableInTouchMode() && !isFocused()) {
                        focusTaken = requestFocus();
                    }

                    if (prepressed) {
                        setPressed(true, x, y);
                    }

                    if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
                        removeLongPressCallback();
                        
                        if (!focusTaken) {
                            if (mPerformClick == null) {
                                mPerformClick = new PerformClick();
                            }
                            if (!post(mPerformClick)) {
                                performClickInternal();
                            }
                        }
                    }
                    // ...
                }
                // ...
                break;
                // ...
        }

        return true;
    }

    return false;
}

如果View的 clickablelongClickable 有一個為true,將會進入switch語句,并且在 action為MotionEvent.ACTION_UP 時,執行36行的 performClickInternal 方法,該方法內部又調用了 performClick 方法。

performClick 方法如下:

/**
 * View.java
 * Line 7131-7151
 */
public boolean performClick() {
    notifyAutofillManagerOnClick();

    final boolean result;
    final ListenerInfo li = mListenerInfo;
    if (li != null && li.mOnClickListener != null) {
        playSoundEffect(SoundEffectConstants.CLICK);
        li.mOnClickListener.onClick(this);
        result = true;
    } else {
        result = false;
    }

    sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);

    notifyEnterOrExitForAutoFillIfNeeded(true);

    return result;
}

如果View設置了 OnClickListener,則會執行12行,調用 onClick 方法。這印證了上面Demo演示中的分析:onClick 方法在 onTouchEvent 方法中被調用。因此,當View設置了OnTouchListenerOnClickListener,事件分發的優先級為 OnTouchListener.onTouch > onTouchEvent > OnClickListener.onClick

3. 分發流程圖

View事件分發

五、滑動沖突

1. 常見場景

常見的產生滑動沖突的兩種場景如下:

滑動沖突

(1) 內外滑動方向不一致

主要產生于ViewPager與Fragment組合,Fragment內又使用RecyclerView的場景。ViewPager內部已經處理了沖突,使用時無需處理。而如果使用自定義可水平滑動的ViewGroup,則必須手動處理沖突。

解決這種沖突,一般根據滑動過程中兩點之間的水平和垂直距離差來判斷由誰攔截事件。

(2) 內外滑動方向一致

主要產生于ScrollView嵌套的場景或ScrollView內嵌RecyclerView的場景。例如兩個ScrollView嵌套時,只有外層可以滑動。

2. 解決方式

(1) 外部攔截法:事件先經過父容器(ViewGroup)處理,如果父容器需要該事件則攔截。這種方式符合事件分發機制,可以通過重寫 onInterceptTouchEvent 方法進行處理。偽代碼如下:

override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
    var intercepted = false
    val x = event.x.toInt()
    val y = event.y.toInt()
    when (event.action) {
        MotionEvent.ACTION_DOWN -> {
            intercepted = false
        }
        MotionEvent.ACTION_MOVE -> {
            intercepted = if (滿足父容器的攔截要求) {
                true
            } else {
                false
            }
        }
        MotionEvent.ACTION_UP -> {
            intercepted = false
        }
    }
    mLastXIntercept = x
    mLastYIntercept = y
    return intercepted
}

滑動沖突的處理邏輯主要表現為對ACTION_MOVE事件的處理,如果滿足父容器的攔截條件則攔截該事件。而對于ACTION_DOWN事件,必須返回false,不對其進行攔截。否則后續事件全部被父容器攔截,無法傳遞給子元素。ACTION_UP事件沒有太大意義,也需返回false。

(2) 內部攔截法:父容器不攔截任何事件,所有事件都傳遞給子元素(View),如果需要該事件則直接消耗,否則交給父容器處理。這種方式不符合事件分發機制,需要重寫 dispatchTouchEvent 方法并調用父容器的 requestDisallowInterceptTouchEvent 方法,決定是否需要父容器對事件進行攔截。偽代碼如下:

override fun dispatchTouchEvent(event: MotionEvent): Boolean {
    val x = event.x.toInt()
    val y = event.y.toInt()
    when (event.action) {
        MotionEvent.ACTION_DOWN -> {
            parent.requestDisallowInterceptTouchEvent(true)
        }
        MotionEvent.ACTION_MOVE -> {
            val deltaX = x - mLastX
            val deltaY = y - mLastY
            if (父容器需要此類點擊事件) {
                parent.requestDisallowInterceptTouchEvent(false)
            }
        }
        MotionEvent.ACTION_UP -> {}
    }
    mLastX = x
    mLastY = y
    return super.dispatchTouchEvent(event)
}

父容器需要重寫 onInterceptTouchEvent 方法:

override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
    var intercepted = false
    val action = event.action
    if (action == MotionEvent.ACTION_DOWN) {
        super.onInterceptTouchEvent(ev)
        return false
    }
    return true
}

3. Demo演示

滑動方向一致

自定義MyScrollView繼承自ScrollView,嵌套使用時,將會產生只有外層ScrollView可以滑動的情況,產生了滑動沖突。此時MyScrollView既是父容器也是子元素。

(1) 外部攔截法

將MyScrollView當作父容器,重寫 onInterceptTouchEvent 方法,返回false即可。

override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
    return false
}

(2) 內部攔截法

將MyScrollView當作子元素,重寫 dispatchTouchEvent 方法。

override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
    when(ev?.action) {
        MotionEvent.ACTION_DOWN -> {
            parent.requestDisallowInterceptTouchEvent(true)
        }
    }
    return super.dispatchTouchEvent(ev)
}

父容器(同樣是MyScrollView)重寫 onInterceptTouchEvent 方法。

override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
    if (ev?.action == MotionEvent.ACTION_DOWN) {
        super.onInterceptTouchEvent(ev)
        return false
    }
    return true
}

注:可以直接使用NestedScrollView代替ScrollView,該組件支持嵌套使用,無需手動解決滑動沖突。

滑動方向不一致

自定義ConflictViewPager繼承自ViewPager,重寫 onInterceptTouchEvent 方法返回false。ConflictViewPager中的每個fragment中放有一個RecyclerView,此時RecyclerView可以正常上下滑動;而如果左右滑動,ConflictViewPager中的fragment并不會進行切換,產生滑動沖突。

(1) 外部攔截法

重寫 onInterceptTouchEvent 方法如下:

class OuterViewPager : ViewPager {
    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)

    private var mLastXIntercept = 0
    private var mLastYIntercept = 0

    override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        var intercepted = false
        val x = ev.x.toInt()
        val y = ev.y.toInt()
        when (ev.action) {
            MotionEvent.ACTION_DOWN -> {
                intercepted = false
                super.onInterceptTouchEvent(ev)
            }
            MotionEvent.ACTION_MOVE -> {
                val deltaX = x - mLastXIntercept
                val deltaY = y - mLastYIntercept
                intercepted = abs(deltaX) > abs(deltaY)
            }
            MotionEvent.ACTION_UP -> {
                intercepted = false
            }
        }
        mLastXIntercept =  x
        mLastYIntercept =  y
        return intercepted
    }
}

解決沖突的主要邏輯在 MotionEvent.ACTION_MOVE 中:如果水平距離大于豎直距離,表示產生了水平滑動,OuterViewPager攔截事件;如果產生豎直滑動,OuterViewPager不攔截事件,事件會傳遞給RecyclerView。

(2) 內部攔截法

自定義MyRecyclerView繼承自RecyclerView,重寫 dispatchTouchEvent 方法:

class MyRecyclerView: RecyclerView {
    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)

    private var mLastX = 0
    private var mLastY = 0

    override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
        val x = ev.x.toInt()
        val y = ev.y.toInt()
        when (ev.action) {
            MotionEvent.ACTION_DOWN -> {
                parent.requestDisallowInterceptTouchEvent(true)
            }
            MotionEvent.ACTION_MOVE -> {
                val deltaX = x - mLastX
                val deltaY = y - mLastY
                if (abs(deltaX) > abs(deltaY)) {
                    parent.requestDisallowInterceptTouchEvent(false)
                }
            }
            MotionEvent.ACTION_UP -> {}
            else -> {}
        }
        mLastX = x
        mLastY = y
        return super.dispatchTouchEvent(ev)
    }
}

自定義InnerViewPager繼承自ViewPager,重寫 onInterceptTouchEvent 方法:

override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
    if (ev.action == MotionEvent.ACTION_DOWN) {
        super.onInterceptTouchEvent(ev)
        return false
    }
    return true
}

解決沖突的主要邏輯同樣在 MotionEvent.ACTION_MOVE 中:如果產生水平滑動,InnerViewPager攔截事件;如果產生豎直滑動,MyRecyclerView攔截事件。

Demo鏈接

點擊查看

參考文章

Android事件分發機制詳解
Android事件分發機制完全解析,帶你從源碼的角度徹底理解(上)
Android事件分發機制完全解析,帶你從源碼的角度徹底理解(下)
Understanding Android touch flow control
Android開發藝術探索,任玉剛,電子工業出版社,2015.9

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,837評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,196評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,688評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,654評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,456評論 6 406
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,955評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,044評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,195評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,725評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,608評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,802評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,318評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,048評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,422評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,673評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,424評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,762評論 2 372