【我的Android進階之旅】解決重寫onTouch事件提示的警告:onTouch should call View#performClick when a click is detected

歐陽鵬發表於2018-09-10

一、問題描述

當你對一個控制元件(例如ImageView)使用setOnTouchListener() 或者是對你的自定義控制元件重寫onTouchEvent方法時會出現這個警告,警告內容全文如下:

MyImageOnTouchListener#onTouch should call View#performClick when a click is detected less… (Ctrl+F1)
If a View that overrides onTouchEvent or uses an OnTouchListener does not also implement performClick and call it when clicks are detected, the View may not handle accessibility actions properly. Logic handling the click actions should ideally be placed in View#performClick as some accessibility services invoke performClick when a click action should occur.

這裡寫圖片描述

這段英文翻譯過來,大致意思如下:

如果覆蓋onTouchEvent或使用OnTouchListener的View沒有實現performClick方法,並且在檢測到click事件時呼叫它,則View可能無法正確地處理可訪問性操作。處理單擊操作的邏輯理想情況下應該放在View#performClick中,因為某些可訪問性服務在應該發生單擊操作時呼叫performClick。

這段中文翻譯好繞,意思可能不太明瞭。

這裡簡單說明一下:當你新增了一些點選操作,例如像setOnClickListener這樣的,它會呼叫performClick才可以完成操作,但你重寫了onTouch,就有可能把performClick給遮蔽了,這樣這些點選操作就沒辦法完成了,所以就會有了這個警告。如下所示:

這裡寫圖片描述

這個imageView,呼叫了setOnTouchListener方法和setOnClickListener方法。
一般情況下我們很少會在重寫了onTouchEvent後再使用setOnClickListener。

二、探索原理:為什麼會出現這個警告?

我們來探索下,為什麼會出現這個警告?

2.1 View.onTouch原始碼

我們開啟onTouch方法的定義原始碼,如下所示:

這裡寫圖片描述

/**
     * Interface definition for a callback to be invoked when a touch event is
     * dispatched to this view. The callback will be invoked before the touch
     * event is given to the view.
     */
    public interface OnTouchListener {
        /**
         * Called when a touch event is dispatched to a view. This allows listeners to
         * get a chance to respond before the target view.
         *
         * @param v The view the touch event has been dispatched to.
         * @param event The MotionEvent object containing full information about
         *        the event.
         * @return True if the listener has consumed the event, false otherwise.
         */
        boolean onTouch(View v, MotionEvent event);
    }

那這個onTouch方法在什麼地方被呼叫呢?這就得考慮到 Android事件分發機制了,這個 Android事件分發機制太複雜,這裡就不著重去講解了,簡單的講解一下我們這個問題。

2.2 View.dispatchTouchEvent原始碼

先看下View.dispatchTouchEvent方法,程式碼如下:

/**
     * Pass the touch screen motion event down to the target view, or this
     * view if it is the target.
     *
     * @param event The motion event to be dispatched.
     * @return True if the event was handled by the view, false otherwise.
     */
    public boolean dispatchTouchEvent(MotionEvent event) {
        // If the event should be handled by accessibility focus first.
        if (event.isTargetAccessibilityFocus()) {
            // We don't have focus or no virtual descendant has it, do not handle the event.
            if (!isAccessibilityFocusedViewOrHost()) {
                return false;
            }
            // We have focus and got the event, then use normal event dispatch.
            event.setTargetAccessibilityFocus(false);
        }

        boolean result = false;

        if (mInputEventConsistencyVerifier != null) {
            mInputEventConsistencyVerifier.onTouchEvent(event, 0);
        }

        final int actionMasked = event.getActionMasked();
        if (actionMasked == MotionEvent.ACTION_DOWN) {
            // Defensive cleanup for new gesture
            stopNestedScroll();
        }

        if (onFilterTouchEventForSecurity(event)) {
            //noinspection SimplifiableIfStatement
            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;
            }
        }

        if (!result && mInputEventConsistencyVerifier != null) {
            mInputEventConsistencyVerifier.onUnhandledEvent(event, 0);
        }

        // Clean up after nested scrolls if this is the end of a gesture;
        // also cancel it if we tried an ACTION_DOWN but we didn't want the rest
        // of the gesture.
        if (actionMasked == MotionEvent.ACTION_UP ||
                actionMasked == MotionEvent.ACTION_CANCEL ||
                (actionMasked == MotionEvent.ACTION_DOWN && !result)) {
            stopNestedScroll();
        }

        return result;
    }

這裡寫圖片描述

由上面的View.dispatchTouchEvent方法的原始碼,我們可以看到如下的結論:

onTouchListener的介面的優先順序是要高於onTouchEvent的,假若onTouchListener中的onTouch方法返回true, 表示此次事件已經被消費了,那onTouchEvent是接收不到訊息的。

比如,將ImageView設定一個onTouchListener並且重寫onTouch方法,返回值為true, 此時的ImageView設定的OnClickListener還會處理點選事件嗎?

這裡寫圖片描述

這裡寫圖片描述

執行下程式,發現點選圖片,點選事件不響應,如下所示:

這裡寫圖片描述

由上面的測試,我們發現ImageView設定的OnClickListener不會處理點選事件了。
原因是:ImageView的performClick是利用onTouchEvent實現,假若onTouchEvent沒有被呼叫到,那麼ImageView的Click事件也無法響應。

2.3 View.onTouchEvent原始碼

繼續深究View.onTouchEvent原始碼,如下所示:

   /**
     * Implement this method to handle touch screen motion events.
     * <p>
     * If this method is used to detect click actions, it is recommended that
     * the actions be performed by implementing and calling
     * {@link #performClick()}. This will ensure consistent system behavior,
     * including:
     * <ul>
     * <li>obeying click sound preferences
     * <li>dispatching OnClickListener calls
     * <li>handling {@link AccessibilityNodeInfo#ACTION_CLICK ACTION_CLICK} when
     * accessibility features are enabled
     * </ul>
     *
     * @param event The motion event.
     * @return True if the event was handled, false otherwise.
     */
    public boolean onTouchEvent(MotionEvent event) {
        final float x = event.getX();
        final float y = event.getY();
        final int viewFlags = mViewFlags;
        final int action = event.getAction();

        if ((viewFlags & ENABLED_MASK) == DISABLED) {
            if (action == MotionEvent.ACTION_UP && (mPrivateFlags & PFLAG_PRESSED) != 0) {
                setPressed(false);
            }
            // A disabled view that is clickable still consumes the touch
            // events, it just doesn't respond to them.
            return (((viewFlags & CLICKABLE) == CLICKABLE
                    || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
                    || (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE);
        }

        if (mTouchDelegate != null) {
            if (mTouchDelegate.onTouchEvent(event)) {
                return true;
            }
        }

        if (((viewFlags & CLICKABLE) == CLICKABLE ||
                (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE) ||
                (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE) {
            switch (action) {
                case MotionEvent.ACTION_UP:
                    boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0;
                    if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {
                        // take focus if we don't have it already and we should in
                        // touch mode.
                        boolean focusTaken = false;
                        if (isFocusable() && isFocusableInTouchMode() && !isFocused()) {
                            focusTaken = requestFocus();
                        }

                        if (prepressed) {
                            // The button is being released before we actually
                            // showed it as pressed.  Make it show the pressed
                            // state now (before scheduling the click) to ensure
                            // the user sees it.
                            setPressed(true, x, y);
                       }

                        if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
                            // This is a tap, so remove the longpress check
                            removeLongPressCallback();

                            // Only perform take click actions if we were in the pressed state
                            if (!focusTaken) {
                                // Use a Runnable and post this rather than calling
                                // performClick directly. This lets other visual state
                                // of the view update before click actions start.
                                if (mPerformClick == null) {
                                    mPerformClick = new PerformClick();
                                }
                                if (!post(mPerformClick)) {
                                    performClick();
                                }
                            }
                        }

                        if (mUnsetPressedState == null) {
                            mUnsetPressedState = new UnsetPressedState();
                        }

                        if (prepressed) {
                            postDelayed(mUnsetPressedState,
                                    ViewConfiguration.getPressedStateDuration());
                        } else if (!post(mUnsetPressedState)) {
                            // If the post failed, unpress right now
                            mUnsetPressedState.run();
                        }

                        removeTapCallback();
                    }
                    mIgnoreNextUpEvent = false;
                    break;

                case MotionEvent.ACTION_DOWN:
                    mHasPerformedLongPress = false;

                    if (performButtonActionOnTouchDown(event)) {
                        break;
                    }

                    // Walk up the hierarchy to determine if we're inside a scrolling container.
                    boolean isInScrollingContainer = isInScrollingContainer();

                    // For views inside a scrolling container, delay the pressed feedback for
                    // a short period in case this is a scroll.
                    if (isInScrollingContainer) {
                        mPrivateFlags |= PFLAG_PREPRESSED;
                        if (mPendingCheckForTap == null) {
                            mPendingCheckForTap = new CheckForTap();
                        }
                        mPendingCheckForTap.x = event.getX();
                        mPendingCheckForTap.y = event.getY();
                        postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
                    } else {
                        // Not inside a scrolling container, so show the feedback right away
                        setPressed(true, x, y);
                        checkForLongClick(0);
                    }
                    break;

                case MotionEvent.ACTION_CANCEL:
                    setPressed(false);
                    removeTapCallback();
                    removeLongPressCallback();
                    mInContextButtonPress = false;
                    mHasPerformedLongPress = false;
                    mIgnoreNextUpEvent = false;
                    break;

                case MotionEvent.ACTION_MOVE:
                    drawableHotspotChanged(x, y);

                    // Be lenient about moving outside of buttons
                    if (!pointInView(x, y, mTouchSlop)) {
                        // Outside button
                        removeTapCallback();
                        if ((mPrivateFlags & PFLAG_PRESSED) != 0) {
                            // Remove any future long press/tap checks
                            removeLongPressCallback();

                            setPressed(false);
                        }
                    }
                    break;
            }

            return true;
        }

        return false;
    }

這裡寫圖片描述

如上圖所示,在onTouchEvent(MotionEvent event)方法中,處理MotionEvent.ACTION_UP事件的時候,會去呼叫**performClick()**方法。

PerformClick 原始碼

   private final class PerformClick implements Runnable {
        @Override
        public void run() {
            performClick();
        }
    }

這裡寫圖片描述

performClick() 原始碼

  /**
     * Call this view's OnClickListener, if it is defined.  Performs all normal
     * actions associated with clicking: reporting accessibility event, playing
     * a sound, etc.
     *
     * @return True there was an assigned OnClickListener that was called, false
     *         otherwise is returned.
     */
    public boolean performClick() {
        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);
        return result;
    }

這裡寫圖片描述

如上所示,performClick()呼叫你在setOnClickListener時重寫的onClick()方法。

  • 結論

在onTouchEvent的ACTION_UP過程中啟用了一個新的執行緒來呼叫performClick(),而performClick()呼叫你在setOnClickListener時重寫的onClick()方法。

2.4 總結

  • onTouchListener的onTouch方法優先順序比onTouchEvent高,會先觸發。

  • 假如onTouch方法返回false會接著觸發onTouchEvent,反之onTouchEvent方法不會被呼叫。

  • 內建諸如click事件的實現等等都基於onTouchEvent,假如onTouch返回true,這些事件將不會被觸發。
  • 順序為: onTouch—–>onTouchEvent—>onClick

三、解決警告

為了解決這個警告,我們應該在重寫onTouch的時候,在合適的時機處呼叫一下 View#performClick方法,如下所示:

示例程式碼:

public boolean onTouch(View v, MotionEvent event) {
    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:
        //some code....
        break;
    case MotionEvent.ACTION_UP:
        v.performClick();
        break;
    default:
        break;
    }
    return true;
}

下面是我自己專案中的處理邏輯,如下所示:

    class MyImageOnTouchListener implements View.OnTouchListener {
        @Override
        public boolean onTouch(View v, MotionEvent event) {
//          onTouch是優先於onClick的,
//          並且執行了兩次,一次是ACTION_DOWN,一次是ACTION_UP(可能還會有多次ACTION_MOVE),
//          因此事件傳遞的順序是先經過OnTouch,再傳遞給onClick
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN://手指按下
                    Log.d(TAG, "onTouch==手指按下");
                    mNoLeakHandler.removeCallbacksAndMessages(null);
                    break;
                case MotionEvent.ACTION_MOVE://手指在這個控制元件上移動
                    break;
                case MotionEvent.ACTION_CANCEL://手指在這個控制元件上移動
                    Log.d(TAG, "onTouch==事件取消");
                    break;
                case MotionEvent.ACTION_UP://手指離開
                    Log.d(TAG, "onTouch==手指離開");
                    mNoLeakHandler.removeCallbacksAndMessages(null);
                    mNoLeakHandler.sendEmptyMessageDelayed(0, 4000);
                    //如果底下的返回值為true,則需要呼叫performClick()方法,否則OnClick事件無效
                    //如果底下的返回值為false,則不一定需要呼叫performClick()方法
                    v.performClick();
                    break;
            }
            return true;
        }
    }

這裡寫圖片描述

這樣在合適的時機處呼叫一下 View#performClick方法之後,錯誤的警告提示也不存在了。

我們繼續思考下,這一次將ImageView設定一個onTouchListener並且重寫onTouch方法,返回值為true, 此時的ImageView設定的OnClickListener還會處理點選事件嗎?

這裡寫圖片描述

現在再重新執行程式,試一試,如下所示:

這裡寫圖片描述

四、總結

當對一個View既設定了setOnTouchListener()方法,又設定了setOnClickListener()方法的時候,記得在OnTouchListener的onTouch()方法裡呼叫一下performClick()方法。
因為如果你重寫了onTouch,並且返回值返回true的話,就有可能把performClick()方法給遮蔽了,這樣這些點選操作就沒辦法完成了。

五、參考連結


作者:歐陽鵬 歡迎轉載,與人分享是進步的源泉!
轉載請保留原文地址:https://blog.csdn.net/ouyang_peng/article/details/82563779

☞ 本人QQ: 3024665621
☞ QQ交流群: 123133153
github.com/ouyangpeng
oypcz@foxmail.com

如果本文對您有所幫助,歡迎您掃碼下圖所示的支付寶和微信支付二維碼對本文進行打賞。

這裡寫圖片描述

相關文章