根據評論反饋,整理成了一個系列,三種解決方案,文章3 應該是三個中最合理的方案。這三篇依次看下來,可以看到解決一個問題走過的彎路:
本文是接著上一篇文章 文章1 ,繼續聊聊關于按鈕的按下狀態的問題。
上一篇文章中,關于不規則形狀圖片按鈕在運行時設置pressed狀態的問題,提出了一種思路:用 PorterDuff.Mode 的 SRC_IN 模式裁剪一張半透明灰度蒙層圖來生成與按鈕的normal狀態圖形狀一致的蒙層,然后,再疊加兩張圖生成pressed狀態下的圖,最后組合成 StateListDrawable 來解決 pressed state 的問題。
文章發出后,評論區李 @我是李小平 提示說這種方式“曲線救國”,并提示了可以用著色的方式來簡單實現需求,具體可以見他的評論。其實道理很簡單,就是用ColorFilter對圖片進行著色,產生pressed的效果。但是著色的方式,相對于StateListDrawable(或selector)的方式,需要自己監聽touch事件并判斷動作類型來進行著色操作。感謝 @我是李小平 的提示,我具體實現了一下,把實現中遇到的著色時機的細節問題,在這篇文章中記錄一下。
什么時候顯示pressed狀態?
由于我們的頁面是一個可以滑動并且可以下拉刷新的頁面,因此,其頁面上的按鈕在處理touch事件時就需要考慮區分點擊事件和滑動事件。具體到這里說的按鈕的點擊狀態的問題,我們給按鈕設置了OnTouchListener,肯定不能在收到ACTION_DOWN事件后立刻就設置著色(即設置為pressed狀態),因為此時用戶手剛接觸屏幕,接下來可能是短點擊,也可能是滑動,所以需要有一個延時來判斷具體是哪種動作,這個延時的時長,系統有一個特定的值,可以通過 ViewConfiguration 獲取。
跟進查看 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的,則判定為滑動:
所以,在收到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源碼,發現果然是這樣的:
注釋說的很清楚:*** 按鈕在還沒來得及顯示為pressed狀態之前就被release了,那么就現在(在觸發click之前)再來顯示為pressed狀態,確保用戶看到效果。***
再往下一點點,還是在這個ACTION_UP的處理中,有取消pressed狀態的代碼:
其中 ViewConfiguration.getPressedStateDuration()
就是表示應該顯示多久的pressed狀態。
這樣就全部明朗了,我們也按照這種方式處理即可。文章最后貼上完整代碼。
扯遠一點
注意上面View源碼片段中的 if (prepressed)
,這個prepressed 局部變量是怎么回事。我們看它的賦值:
boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0; ```
可以猜測這個prepressed就表示上面提到的沒來的及顯示pressed狀態就ACTION_UP了,需要“補救”。在搜索 ``` PFLAG_PREPRESSED ``` 這個常量的用處,發現只有一個地方它被賦給了 ``` mPrivateFlags ``` :

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

就是沿著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;
}
}
}
}