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;
    }
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容