Android 運行時給動態加載的圖標按鈕添加點擊效果

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

  1. 本文
  2. Android 按鈕 pressed 狀態的顯示時機 (附少許源碼分析)
  3. Android Drawable / DrawableCompat # setTintList( ) 使用時一個值得注意的問題

原文:
大家都知道,要在Android中添加一個帶圖標的按鈕,一般是聲明一個ImageView,設置clickable=true,然后設置src為"@drawable/xxx_selector"。或者是聲明一個TextView,然后設置它的compoundDrawables為一個xxx_selector。其中xxx_selector是一個在xml中定義的圖片選擇器,里邊有各種狀態,其中最多用到的就是 android:state_pressed="true" 這個狀態。本文就只以這個按下狀態為例,其他狀態同理。一個普通的selector如下:

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@drawable/xxx_button_pressed"
          android:state_pressed="true" />
    <item android:drawable="@drawable/xxx_button_normal" />
</selector>

這沒問題,很簡單。但是如果需求是按鈕的圖標資源不能在客戶端編譯時寫死,而是要在運行時動態去服務器獲取,而且如果獲取來的圖形狀是不確定的,這種情況下應該怎么添加按鈕的按下態,也就是它的點擊效果呢?

本文結合我們項目中的實踐講一種思路,供參考,如有更好的方案請觀眾們賜教。

一、固定形狀圖標

我們APP的主頁面頂部類似美團外賣,是幾排不固定數量的圖標,表示應用的各個功能入口。第一版時,產品的需求和UI設計出來后,確定這些圖標一定圓形圖標,而且近幾版內不會變為其他形狀。那么一個圖標的normal狀態和pressed狀態大概如下圖所示,按下時稍有變暗。下面以微信的圖標為例。

round_normal.png
round_pressed.png

這時,完成這個需求就有三種思路:

  1. 兩種狀態的圖都從服務器獲取
    normal和pressed兩張圖都從服務器動態獲取,然后在客戶端拼成一個 StateListDrawable

  2. 只從服務器獲取normal狀態的圖,有客戶端動態生成pressed狀態的圖,然后拼成一個 StateListDrawable

  3. 熱更新,動態加載資源包。

第1種思路優點是pressed狀態可以隨時動態調整;缺點是增加網絡操作,增大流量消耗,增大時延,也增大圖片加載失敗的風險。第2種思路的優缺點正好相反。如果需求是:正常狀態下顯示一個微信圖標,按下之后要變成一個QQ圖標(只是舉例),那第2種思路顯然不能滿足。不過產品和設計都確定不會有這種需求(如有可以考慮第3種思路),于是我們采用了第2種方法。第3種是一種涉及熱更新、插件化的思路,本文暫不涉及。

下面來看第2種思路的具體實現。

由于圖標固定是圓形的,那么我們只需要一張半透明的灰色蒙層圖片疊加在normal狀態的圖片之上就可以生成pressed狀態下的圖。蒙層如下圖所示:

round_press_mask.png

即,從效果上: *** round_normal.png + round_press_mask.png = round_pressed.png *** 。疊加兩張圖的具體代碼如下:

       // overlay bm2 on top of bm1
       public static Bitmap overlayBitmaps(Context context, Bitmap bmp1, Bitmap bmp2,
                int drawableWidth, int drawableHeight, Rect destRect) {

        try {
            int maxWidth = Math.max(bmp1.getWidth(), bmp2.getWidth());
            int maxHeight = Math.max(bmp1.getHeight(), bmp2.getHeight());
            maxWidth = Math.max(maxWidth, drawableWidth);
            maxHeight = Math.max(maxHeight, drawableHeight);

            Bitmap bmOverlay;
            if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN_MR1) {
                bmOverlay = Bitmap.createBitmap(context.getResources().getDisplayMetrics(),
                        maxWidth, maxHeight, bmp1.getConfig());
            } else {
                bmOverlay = Bitmap.createBitmap(maxWidth, maxHeight, bmp1.getConfig());
            }

            Canvas canvas = new Canvas(bmOverlay);
            canvas.drawBitmap(bmp1, null, destRect, null);
            canvas.drawBitmap(bmp2, null, destRect, null);

            return bmOverlay;
        } catch (Exception e) {
            e.printStackTrace();
            return bmp1;
        }
    }

normal狀態的圖片資源從服務器下載而來。關于圖片下載,我們沒有采用時下流行的Picasso、Glide或Fresco等,自己實現了一個輕量的 ** ImageLoader **。ImageLoader 的實現與本文無關,就不介紹了,總之,它從一個image url 加載回來一個 ** Bitmap **, 并做了緩存的相關工作。

注意上邊的 overlayBitmaps() 方法只是生成了pressed狀態下的圖,我們還要用normal狀態的圖一起來生成一個包含normal和pressed兩種狀態的StateListDrawable 。實現很簡單,new 一個 StateListDrawable ,添加各個狀態,注意 ** setBounds() ** 。代碼:

    public static StateListDrawable makeStateListDrawable(final Context context, Drawable normal, Bitmap pressedMask,
                                                    int drawableWidth, int drawableHeight) {
        if (pressedMask == null || normal == null) {
            return null;
        }

        int pressedWidth = Math.max(drawableWidth, Math.max(normal.getIntrinsicWidth(), pressedMask.getWidth()));
        int pressedHeight = Math.max(drawableHeight, Math.max(normal.getIntrinsicHeight(), pressedMask.getHeight()));

        StateListDrawable stateListDrawable = new StateListDrawable();

        normal.setBounds(0, 0, drawableWidth, drawableHeight);
        Bitmap normalBm = ((BitmapDrawable)normal).getBitmap();

        Rect destRect = new Rect(0, 0, pressedWidth, pressedHeight);//normal.copyBounds();
        Bitmap pressedBitmap = overlayBitmaps(context, normalBm, tailoredMask, drawableWidth, drawableHeight, destRect);

        BitmapDrawable pressed = new BitmapDrawable(context.getResources(), pressedBitmap);
        pressed.setBounds(0, 0, pressedWidth, pressedHeight);

        stateListDrawable.addState(new int[] {android.R.attr.state_pressed}, pressed);
        stateListDrawable.addState(new int[] { }, normal);
        stateListDrawable.setBounds(0, 0, drawableWidth, drawableHeight);

        return stateListDrawable;
    }

調用的時候,第三個參數 ** pressedMask ** 傳入的就是上面的圖 ** round_press_mask.png **decode出來的Bitmap:

Bitmap pressedMask = BitmapFactory.decodeResource(context.getResources(), R.drawable.round_press_mask);

至此,滿足需求,一切都很好。巴特,as always,需求是會變的。

二、不定形狀圖標

1. 需求思考

新設計稿一出來,一看,原來乖乖的排排坐吃果果的圓圓的圖標們不見了,滿屏都換成了各有各形狀的圖標。也不用含淚去質問設計師是否還記得當初執手許下的約定了,還是想想怎么改代碼吧。

雖然圖標不是確定的形狀了(例如下面的圖 icon_random_normal.png),按下效果還是一樣,還是稍稍變暗的效果。但是上面的方法就不行了,如果不改,就會變成一個不規則形狀的按鈕按下之后上面蒙了一層圓形的灰色半透明蒙層,應該會很難看。我們需要根據圖標的形狀,對應地動態生成一個一樣形狀的蒙層,然后再套用上面的方法,就可以達到效果。就是說,如果按鈕A是三角形的,那么就要生成一個三角形的蒙層,然后疊加生成一個pressed狀態的圖;如果按鈕B是任何不規則形狀,同理。

icon_random_normal.png

這時候是不是想,如果能把round_press_mask.png 裁剪成需要的形狀就好了(需要保證 round_press_mask.png 尺寸足夠大)。

2. PorterDuff.Mode

說到這,應該忽然想起來 ** PorterDuff ** 這個東西了。PorterDuff這個單詞查詞典基本查不到,其實是關于圖像處理的一篇論文的兩個作者Thomas Porter 和 Tom Duff 的名字的合成詞。定義了一系列處理圖像的方式,感興趣可以查看這篇文章,當然,如果對學術有興趣的話,也可以看原論文 (在下是不敢看的 -_-)。安卓中源碼:

    public static enum Mode {
        ADD,
        CLEAR,
        DARKEN,
        DST,
        DST_ATOP,
        DST_IN,
        DST_OUT,
        DST_OVER,
        LIGHTEN,
        MULTIPLY,
        OVERLAY,
        SCREEN,
        SRC,
        SRC_ATOP,
        SRC_IN,
        SRC_OUT,
        SRC_OVER,
        XOR;
    }

對應的效果如下圖(圖片來自Google搜索):

porter_duff_modes.png

有幾種都有“裁剪”的效果,其中 ** SRC_IN ** 是滿足我們需求的,可以從一張大的灰色半透明mask圖上裁剪下來一片跟normal圖標形狀一致的子集。

到這兒,原理就清楚了,只需要在上面所說的方法的基礎上增加 ** “裁剪” ** 這一步即可。

我們修改 overlayBitmaps() 方法,使之增加裁剪的功能,修改后代碼如下:

    // 增加 isTailoringMask參數,為 true 時表示是在進行裁剪,false 表示是在進行普通的疊加操作
    public static Bitmap overlayBitmaps(Context context, Bitmap bmp1, Bitmap bmp2,
                int drawableWidth, int drawableHeight, Rect destRect, boolean isTailoringMask) {

        try {
            int maxWidth = Math.max(bmp1.getWidth(), bmp2.getWidth());
            int maxHeight = Math.max(bmp1.getHeight(), bmp2.getHeight());
            maxWidth = Math.max(maxWidth, drawableWidth);
            maxHeight = Math.max(maxHeight, drawableHeight);

            Bitmap bmOverlay;
            if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN_MR1) {
                bmOverlay = Bitmap.createBitmap(context.getResources().getDisplayMetrics(), maxWidth, maxHeight, bmp1.getConfig());
            } else {
                bmOverlay = Bitmap.createBitmap(maxWidth, maxHeight, bmp1.getConfig());
            }

            Canvas canvas = new Canvas(bmOverlay);
            canvas.drawBitmap(bmp1, null, destRect, null);

/*******************************************************************/
            // 這里指定一個paint,并設置 PorterDuff.Mode 為 SRC_IN,已達到裁剪效果
            Paint paint = null;
            if (isTailoringMask) {
                paint = new Paint();
                PorterDuff.Mode mode = PorterDuff.Mode.SRC_IN;
                paint.setXfermode(new PorterDuffXfermode(mode));
            }
            canvas.drawBitmap(bmp2, null, destRect, paint);
/*******************************************************************/

            return bmOverlay;
        } catch (Exception e) {
            e.printStackTrace();
            return bmp1;
        }
    }

好,給 overlayBitmaps() 方法增加了一項裁剪技能之后,現在來改一下 makeStateListDrawable() 方法,改后如下:

    public static StateListDrawable makeStateListDrawable(final Context context, Drawable normal, Bitmap pressedMask,
                                                    int drawableWidth, int drawableHeight) {
        if (pressedMask == null || normal == null) {
            return null;
        }

        int pressedWidth = Math.max(drawableWidth, Math.max(normal.getIntrinsicWidth(), pressedMask.getWidth()));
        int pressedHeight = Math.max(drawableHeight, Math.max(normal.getIntrinsicHeight(), pressedMask.getHeight()));

        StateListDrawable stateListDrawable = new StateListDrawable();

        normal.setBounds(0, 0, drawableWidth, drawableHeight);
        Bitmap normalBm = ((BitmapDrawable)normal).getBitmap();
        
        Rect destRect = new Rect(0, 0, pressedWidth, pressedHeight);//normal.copyBounds();

/*******************************************************************/
        // 先調用一次overlayBitmaps(), isTailoringMask 傳 true,這一步只是裁剪出符合形狀的 mask
        Bitmap tailoredMask = overlayBitmaps(context, normalBm, pressedMask, drawableWidth, drawableHeight, destRect, true);
        
        // 再調用一次,isTailoringMask 傳 false,這一步是將裁剪好的 mask 疊加到 normal 圖上, 生成 pressed 狀態的圖
        Bitmap pressedBm = overlayBitmaps(context, normalBm, tailoredMask, drawableWidth, drawableHeight, destRect, false);
/*******************************************************************/

        BitmapDrawable pressed = new BitmapDrawable(context.getResources(), pressedBm);
        pressed.setBounds(0, 0, pressedWidth, pressedHeight);

        stateListDrawable.addState(new int[] {android.R.attr.state_pressed}, pressed);
        stateListDrawable.addState(new int[] { }, normal);
        stateListDrawable.setBounds(0, 0, drawableWidth, drawableHeight);

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

推薦閱讀更多精彩內容