Android 按鈕 pressed 狀態的顯示時機 (附少許源碼分析)

根據評論反饋,整理成了一個系列,三種解決方案,文章3 應該是三個中最合理的方案。這三篇依次看下來,可以看到解決一個問題走過的彎路:

  1. Android 運行時給動態加載的圖標按鈕添加點擊效果
  2. 本文
  3. Android Drawable / DrawableCompat # setTintList( ) 使用時一個值得注意的問題

本文是接著上一篇文章 文章1 ,繼續聊聊關于按鈕的按下狀態的問題。

上一篇文章中,關于不規則形狀圖片按鈕在運行時設置pressed狀態的問題,提出了一種思路:用 PorterDuff.Mode 的 SRC_IN 模式裁剪一張半透明灰度蒙層圖來生成與按鈕的normal狀態圖形狀一致的蒙層,然后,再疊加兩張圖生成pressed狀態下的圖,最后組合成 StateListDrawable 來解決 pressed state 的問題。

文章發出后,評論區李 @我是李小平 提示說這種方式“曲線救國”,并提示了可以用著色的方式來簡單實現需求,具體可以見他的評論。其實道理很簡單,就是用ColorFilter對圖片進行著色,產生pressed的效果。但是著色的方式,相對于StateListDrawable(或selector)的方式,需要自己監聽touch事件并判斷動作類型來進行著色操作。感謝 @我是李小平 的提示,我具體實現了一下,把實現中遇到的著色時機的細節問題,在這篇文章中記錄一下。

什么時候顯示pressed狀態?

由于我們的頁面是一個可以滑動并且可以下拉刷新的頁面,因此,其頁面上的按鈕在處理touch事件時就需要考慮區分點擊事件和滑動事件。具體到這里說的按鈕的點擊狀態的問題,我們給按鈕設置了OnTouchListener,肯定不能在收到ACTION_DOWN事件后立刻就設置著色(即設置為pressed狀態),因為此時用戶手剛接觸屏幕,接下來可能是短點擊,也可能是滑動,所以需要有一個延時來判斷具體是哪種動作,這個延時的時長,系統有一個特定的值,可以通過 ViewConfiguration 獲取。

ViewConfiguration.java 源碼截圖

跟進查看 TAP_TIMEOUT 這個值是100(毫秒),注釋說明,如果在這個時長之內用戶沒有移動,就判定為一次點擊,否則判定為scroll。

所以,在我們的實現中,也應該用這個值。在OnTouchListener的onTouch( )方法中:

switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:
        downX = event.getX();
        downY = event.getY();

        // 發送延時消息,進行著色(即設置為pressed狀態)
        handler.sendEmptyMessageDelayed(MSG_TINT, ViewConfiguration.getTapTimeout());
        break;

        ...
}

如果用戶在這個時間段內有移動,則要取消這個消息。如何判定為移動,ViewConfiguration也有個可以獲取 ** touchSlop ** 值的方法,大于這個touchSlop的,則判定為滑動:

ViewConfiguration.java 源碼截圖

所以,在收到ACTION_MOVE事件時,按這個標準判定是否移動:

...

case MotionEvent.ACTION_MOVE:
    float dx = event.getX() - downX;
    float dy = event.getY() - downY;
    if (touchSlop == 0) {
        touchSlop = ViewConfiguration.get(target.getContext()).getScaledTouchSlop();
    }

    // 如果判定為移動,在handler中remove掉進行著色的消息
    if ((dx * dx) + (dy * dy) > (touchSlop * touchSlop) ) {
        handler.removeMessages(MSG_TINT);
    }
    break;

case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
    // 動作結束時,清除著色,按鈕由pressed狀態恢復為normal狀態
    clearTint();
    handler.removeMessages(MSG_TINT);
    break;

...

還有一個小問題

實現到這一步,還有個問題:當點擊稍微快一些的時候,經常是看不到按鈕的pressed狀態,即著色后的效果的。原因稍一想也很明顯:前面講到,為了區分滑動和點擊,我們并沒有在ACTION_DOWN的時候立刻著色,而是有一個延時,那么如果點擊的時候從ACTION_DOWN到ACTION_UP的時間小于這個延時,就沒有觸發著色。

怎么解決?測試發現用StateListDrawable(即selector)的方式是沒問題的,點擊再快也有pressed效果。而且既然這個時延是從系統獲得,那么我們不妨看看源碼中是怎么解決這個問題的。開始我猜測源碼應該是在ACTION_UP時候做了一次置為pressed狀態的動作,然后一定短時間后再取消狀態。這樣視覺上可以達到效果。StateListDrawable(即selector)的方式,依賴于把View setPressed(true), 那我們就搜索這個方法的調用。查看TextView源碼未發現,再到View源碼,發現果然是這樣的:

View.java 源碼截圖

注釋說的很清楚:*** 按鈕在還沒來得及顯示為pressed狀態之前就被release了,那么就現在(在觸發click之前)再來顯示為pressed狀態,確保用戶看到效果。***

再往下一點點,還是在這個ACTION_UP的處理中,有取消pressed狀態的代碼:

View.java 源碼截圖

其中 ViewConfiguration.getPressedStateDuration() 就是表示應該顯示多久的pressed狀態。

這樣就全部明朗了,我們也按照這種方式處理即可。文章最后貼上完整代碼。

扯遠一點

注意上面View源碼片段中的 if (prepressed),這個prepressed 局部變量是怎么回事。我們看它的賦值:

 boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0; ``` 

可以猜測這個prepressed就表示上面提到的沒來的及顯示pressed狀態就ACTION_UP了,需要“補救”。在搜索 ``` PFLAG_PREPRESSED ``` 這個常量的用處,發現只有一個地方它被賦給了 ``` mPrivateFlags ``` :

![View.java 源碼截圖](http://upload-images.jianshu.io/upload_images/71249-cdc3970f3c27fb55.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

可以看到,這段代碼就是說在ACTION_DOWN的時候,判斷一下,當前控件如果是一個可滾動布局的子View,那么就延遲設置pressed狀態;否則直接設置 ``` setPressed(true, x, y) ``` 。

看一下 ``` isInScrollingContainer ``` :

![View.java 源碼截圖](http://upload-images.jianshu.io/upload_images/71249-44c3d3ef5e2bb236.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

就是沿著View的層級結構一層層往上找,如果有一層的父布局是可滾動的,那么就return true;如果所有層級的父布局都不可滾動,才return false。

再看看 ``` ViewGroup ``` 中 ``` shouldDelayChildPressedState( ) ``` 這個方法在各個子類的覆寫。發現像 ``` LinearLayout ``` 
、``` FrameLayout ``` 這樣的不可滾動的Layout,返回 false;而 ``` ScrollView ``` 這樣可滾動的則返回 true。

而其實在我的需求中,我們的頁面是已知可以滾動的,所以,如果只考慮在這種場景下使用的話,可以省略這一判斷。

下面是我寫的一個PressTintedDrawableViewWrapper類,可以支持為ImageView或TextView里的Drawable設置pressed效果,我們的按鈕是用TextView的CompoundDrawable實現的,因此用法如下:

```java
new PressTintedDrawableViewWrapper(Color.parseColor("#4C333333")).wrap(someTextView).apply();

附上 PressTintedDrawableViewWrapper 類完整代碼:

public class PressTintedDrawableViewWrapper implements View.OnTouchListener {

    private static final int MSG_TINT = 1;
    private static final long TAP_TIMEOUT = ViewConfiguration.getTapTimeout();

    private View target;
    private Drawable[] drawables;
    private int tintColor;

    private Handler handler;
    private float downX, downY;
    private int touchSlop;

    private boolean tinted = false;

    public PressTintedDrawableViewWrapper(int tintColor) {
        this.tintColor = tintColor;
    }

    public PressTintedDrawableViewWrapper wrap(TextView textView) {
        this.target = textView;
        this.drawables = textView.getCompoundDrawables();
        return this;
    }

    public PressTintedDrawableViewWrapper wrap(ImageView imageView) {
        this.target = imageView;
        this.drawables = new Drawable[]{imageView.getDrawable()};
        return this;
    }

    public boolean apply() {
        if (drawables != null && drawables.length > 0) {
            handler = new TouchHandler(this);
            target.setOnTouchListener(this);

            return true;
        } else {
            return false;
        }
    }

    @Override
    public boolean onTouch(View v, MotionEvent event) {

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                downX = event.getX();
                downY = event.getY();

                handler.sendEmptyMessageDelayed(MSG_TINT, TAP_TIMEOUT);
                break;
            case MotionEvent.ACTION_MOVE:
                float dx = event.getX() - downX;
                float dy = event.getY() - downY;
                if (touchSlop == 0) {
                    touchSlop = ViewConfiguration.get(target.getContext()).getScaledTouchSlop();
                }
                if ((dx * dx) + (dy * dy) > (touchSlop * touchSlop) ) {
                    handler.removeMessages(MSG_TINT);
                }
                break;
            case MotionEvent.ACTION_UP:
                if (!tinted) {
                    if (handler.hasMessages(MSG_TINT)) {
                        handler.removeMessages(MSG_TINT);
                        applyTint();
                        target.postDelayed(new Runnable() {
                            @Override
                            public void run() {
                                clearTint();
                            }
                        }, ViewConfiguration.getPressedStateDuration());
                    }
                } else {
                    clearTint();
                }
                break;
            case MotionEvent.ACTION_CANCEL:
                clearTint();
                handler.removeMessages(MSG_TINT);
                break;
        }

        return false;
    }

    private void applyTint() {
        ColorFilter colorFilter = new PorterDuffColorFilter(tintColor, PorterDuff.Mode.SRC_ATOP);
        for (Drawable drawable : drawables) {
            if (drawable != null) {
                drawable.mutate().setColorFilter(colorFilter);
            }
        }
        tinted = true;
    }

    private void clearTint() {
        if (tinted) {
            for (Drawable drawable : drawables) {
                if (drawable != null) {
                    drawable.mutate().clearColorFilter();
                }
            }
            tinted = false;
        }
    }

    private static class TouchHandler extends Handler {
        WeakReference<PressTintedDrawableViewWrapper> ref;

        public TouchHandler(PressTintedDrawableViewWrapper view) {
            this.ref = new WeakReference<>(view);
        }

        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
                case MSG_TINT:
                    PressTintedDrawableViewWrapper view = ref.get();
                    if (view != null) {
                        view.applyTint();
                    }
                    break;
            }
        }
    }

}
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容