Drawable 着色的后向兼容方案


看到 Android Weekly 最新一期有一篇文章:Tinting drawables,使用 ColorFilter 手動打造了一個TintBitmapDrawable,之前也看到有些文章使用這種方式來實現 Drawable 着色或者實現類似的功能。但是,這種方案並不完善,本文將介紹一個完美的后向兼容方案。

解決方案

其實在 Android Support V4 的包中提供了 DrawableCompat 類,我們很容易寫出如下的輔助方法來實現 Drawable 的着色,如下:

public static Drawable tintDrawable(Drawable drawable, ColorStateList colors) {
    final Drawable wrappedDrawable = DrawableCompat.wrap(drawable);
    DrawableCompat.setTintList(wrappedDrawable, colors);
    return wrappedDrawable;
}

使用例子:

EditText editText1 = (EditText) findViewById(R.id.edit_1);
final Drawable originalDrawable = editText1.getBackground();
final Drawable wrappedDrawable = tintDrawable(originalDrawable, ColorStateList.valueOf(Color.RED));
editText1.setBackgroundDrawable(wrappedDrawable);

EditText editText2 = (EditText) findViewById(R.id.edit_2);
editText2.setBackgroundDrawable(tintDrawable(editText2.getBackground(),
        ColorStateList.valueOf(Color.parseColor("#03A9F4"))));

效果如下:

這種方式支持幾乎所有的 Drawable 類型,並且能夠完美兼容幾乎所有的 Android 版本。

優化

使用 ColorStateList 着色

這種方式支持使用 ColorStateList 着色,這樣我們還可以根據 View 的狀態着色成不同的顏色。
對於上面的 EditText 的例子,我們就可以優化一下,根據它是否獲得焦點,設置成不同的顏色。我們新建一個res/color/edittext_tint_colors.xml 如下:

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:color="@color/red" android:state_focused="true" />
    <item android:color="@color/gray" />
</selector>

代碼改成這樣:

editText2.setBackgroundDrawable(tintDrawable(editText2.getBackground(),
    getResources().getColorStateList(R.color.edittext_tint_colors)));

BitmapDrawable 的優化

首先來看一下問題。原始的 Icon 如下圖所示:

我們使用兩個 ImageView,一個不做任何處理,一個使用如下代碼着色:

ImageView imageView = (ImageView) findViewById(R.id.image_1);
final Drawable originalBitmapDrawable = getResources().getDrawable(R.drawable.icon);
imageView.setImageDrawable(tintDrawable(originalBitmapDrawable, ColorStateList.valueOf(Color.MAGENTA)));

效果如下:

 

怎么回事?我明明只給后面的一個設置了着色的 Drawable,為什么兩個都被着色了?這是因為 Android 為了優化系統性能,資源 Drawable 只有一份拷貝,你修改了它,等於所有的都修改了。如果你給兩個 View 設置同一個資源,它的狀態是這樣的:

也是就是他們是共享狀態的。幸運的是,Drawable 提供了一個方法 mutate(),來打破這種共享狀態,等於就是要告訴系統,我要修改(mutate)這個 Drawable。給 Drawable 調用 mutate() 方法以后。他們的關系就變成如下的圖所示:

我們修改一下代碼:

ImageView imageView = (ImageView) findViewById(R.id.image_1);
final Drawable originalBitmapDrawable = getResources().getDrawable(R.drawable.icon).mutate();
imageView.setImageDrawable(tintDrawable(originalBitmapDrawable, ColorStateList.valueOf(Color.MAGENTA)));

非常完美,達到了我們之前想要的效果。

你可能會有這樣的擔心,調用 mutate() 是不是在內存中把 Bitmap 拷貝了一份?其實不是這樣的,還是公用的 Bitmap,只是拷貝了一份狀態值,這個數據量很小,所以不用擔心。詳細情況可以參考這篇文章:Drawable mutations

EditText 光標着色

通過前面的方法,我們已經可以把 EditText 的背景着色(Tint)成了任意想要的顏色。但是仔細一看,還有點問題,輸入的時候,光標的顏色還是原來的顏色,如下圖所示:

在 Android 3.1 (API 12) 開始就支持了 textCursorDrawable,也就是可以自定義光標的 Drawable。遺憾的是,這個方法只能在 xml 中使用,這和本文沒有啥關系,具體使用可以參考這個回答,並沒有提供接口來動態修改。

我們有一個比較折中的方案,就是通過反射機制,來獲得 CursorDrawable,然后通過本文的方法,來對這個 Drawable 着色。

public static void tintCursorDrawable(EditText editText, int color) {
    try {
        Field fCursorDrawableRes = TextView.class.getDeclaredField("mCursorDrawableRes");
        fCursorDrawableRes.setAccessible(true);
        int mCursorDrawableRes = fCursorDrawableRes.getInt(editText);
        Field fEditor = TextView.class.getDeclaredField("mEditor");
        fEditor.setAccessible(true);
        Object editor = fEditor.get(editText);
        Class<?> clazz = editor.getClass();
        Field fCursorDrawable = clazz.getDeclaredField("mCursorDrawable");
        fCursorDrawable.setAccessible(true);

        if (mCursorDrawableRes <= 0) {
            return;
        }

        Drawable cursorDrawable = editText.getContext().getResources().getDrawable(mCursorDrawableRes);
        if (cursorDrawable == null) {
            return;
        }

        Drawable tintDrawable  = tintDrawable(cursorDrawable, ColorStateList.valueOf(color));
        Drawable[] drawables = new Drawable[] {tintDrawable, tintDrawable};
        fCursorDrawable.set(editor, drawables);
    } catch (Throwable ignored) {
    }
}

原理比較簡單,就是直接獲得到 EditText 的 mCursorDrawableRes,然后通過這個 id 獲取到對應的 Drawable,調用我們的着色函數 tintDrawable,然后設置進去。效果如下:

原理分析

上面就是我們的全部的解決方案,我們接下來分析一下 DrawableCompat 着色相關的源碼,理解其中的原理。再來回顧一下我們寫的 tintDrawable 函數,里面只調用了 DrawableCompat 的兩個方法。下面我們詳細分析這兩個方法。

首先通過 DrawableCompat.wrap() 獲得一個封裝的 Drawable:

// android.support.v4.graphics.drawable.DrawableCompat.java
public static Drawable wrap(Drawable drawable) {
    return IMPL.wrap(drawable);
}

調用了 IMPL 的 wrap 函數,IMPL 的實現如下:

/**
 * Select the correct implementation to use for the current platform.
 */
static final DrawableImpl IMPL;
static {
    final int version = android.os.Build.VERSION.SDK_INT;
    if (version >= 23) {
        IMPL = new MDrawableImpl();
    } else if (version >= 22) {
        IMPL = new LollipopMr1DrawableImpl();
    } else if (version >= 21) {
        IMPL = new LollipopDrawableImpl();
    } else if (version >= 19) {
        IMPL = new KitKatDrawableImpl();
    } else if (version >= 17) {
        IMPL = new JellybeanMr1DrawableImpl();
    } else if (version >= 11) {
        IMPL = new HoneycombDrawableImpl();
    } else {
        IMPL = new BaseDrawableImpl();
    }
}

很明顯,這是根據不同的 API Level 選擇不同的實現類,再往下看一點,發現 API Level 大於等於 22 的繼承於LollipopMr1DrawableImpl,我們來看一下它的 wrap() 的實現:

static class LollipopMr1DrawableImpl extends LollipopDrawableImpl {
    @Override
    public Drawable wrap(Drawable drawable) {
        return DrawableCompatApi22.wrapForTinting(drawable);
    }
}

class DrawableCompatApi22 {

    public static Drawable wrapForTinting(Drawable drawable) {
        // We don't need to wrap anything in Lollipop-MR1
        return drawable;
    }

}

因為 API 22 開始 Drwable 本來就支持了 Tint,不需要做任何封裝了。
我們來看一下它的 wrap() 都是返回一個封裝了一層的 Drawable,我們以 BaseDrawableImpl 為例分析:

static class BaseDrawableImpl implements DrawableImpl {
    ...
    @Override
    public Drawable wrap(Drawable drawable) {
        return DrawableCompatBase.wrapForTinting(drawable);
    }
    ...
}

這里調用了 DrawableCompatBase.wrapForTinting(),實現如下:

class DrawableCompatBase {
    ...
    public static Drawable wrapForTinting(Drawable drawable) {
        if (!(drawable instanceof DrawableWrapperDonut)) {
           return new DrawableWrapperDonut(drawable);
        }
        return drawable;
    }
}

實際上這里是返回了一個 DrawableWrapperDonut 的封裝對象。同理分析其他 API Level 小於 22 的最后實現,發現最后都是返回一個繼承於 DrawableWrapperDonut 的對象。

回到最開始的代碼,我們分析 DrawableCompat.setTintList() 的實現,其實是調用了 IMPL.setTintList(),通過前面的分析我們知道,只有 API Level 小於 22 的才要做特殊的處理,我們還是以 BaseDrawableImpl 為例分析:

static class BaseDrawableImpl implements DrawableImpl {
    ...
    @Override
    public void setTintList(Drawable drawable, ColorStateList tint) {
        DrawableCompatBase.setTintList(drawable, tint);
    }
    ...
}

這里調用了 DrawableCompatBase.setTintList()

class DrawableCompatBase {
    ...
    public static void setTintList(Drawable drawable, ColorStateList tint) {
        if (drawable instanceof DrawableWrapper) {
            ((DrawableWrapper) drawable).setTintList(tint);
        }
    }
}

通過前面的分析,我們知道,這里傳入的 Drawable 都是 DrawableWrapperDonut 的子類,所以實際上就是調用了DrawableWrapperDonut 的 setTintList():

@Override
public void setTintList(ColorStateList tint) {
    mTintList = tint;
    updateTint(getState());
}
    
private boolean updateTint(int[] state) {
    if (mTintList != null && mTintMode != null) {
        final int color = mTintList.getColorForState(state, mTintList.getDefaultColor());
        final PorterDuff.Mode mode = mTintMode;
        if (!mColorFilterSet || color != mCurrentColor || mode != mCurrentMode) {
            setColorFilter(color, mode);
            mCurrentColor = color;
            mCurrentMode = mode;
            mColorFilterSet = true;
            return true;
        }
    } else {
        mColorFilterSet = false;
        clearColorFilter();
    }
    return false;
}

看到這里最終是調用了 Drawable 的 setColorFilter() 方法。可以看到,這里和最開始提到的那篇文章的原理是一致的,但是這里處理更加細致,考慮更加全面。

通過源碼分析,感覺到可能這才是做 Android 后向兼容庫的正確姿勢吧。轉自http://www.race604.com/tint-drawable/


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM