使用顏色漸變圖片自定義條形ProgressBar

使用顏色漸變圖片自定義條形ProgressBar

用過系統原生的ProgressBar的開發者都知道, 其造型是極其醜陋的, 做個demo都會嫌棄.所以通常情況下,我們需要自定義, 在實際項目中, 有UI設計師給我們出好設計圖或者給好顏色值, 然後我們來替換掉原生的即可, 在一般情況下能很容易滿足需求, 網上大部分博客也有介紹詳細步驟, 很簡單.
但是, 如果UI設計師給我們的是顏色漸變的圖片資源, 這種情況下, 一般是直接給普通的png, 而不會去製作成NinePatch--也就是可以自適應寬度高度的圖片, 而在界面佈局裏, ProgressBar的寬度高度又不一定能完全與所給的圖片大小一直(由於手機型號衆多, 設計師不可能給每種分辨率手機切一套圖), 所以這種情況就會出現圖片拉伸的情況, 這個時候用上面的簡單的自定義ProgressBar的方法就不能實現需求了.原因和解決方案在下面將進行詳細介紹:
  • 通常的自定義條形ProgressBar方法介紹
  • 使用常規方法來實現用漸變圖片自定義ProgressBar的問題
  • 解決方法
  • 一些思考

通常的自定義條形ProgressBar方法介紹

一般情況下, 我們如果要在佈局文件裏使用原生的ProgressBar, 要加上相應的style.如下:

style=android:Widget.ProgressBar.Horizontal

而我們知道修改原生控件樣式的一種高效的方法就是直接繼承其Style,並更改我們需要修改的地方即可.這裏也一樣, 我們通過繼承android:Widget.ProgressBar.Horizontal樣式來實現條形ProgressBar自定義. 現在我們追蹤該style的代碼裏看看.

<style name="Widget.ProgressBar.Horizontal">
   <item name="android:indeterminateOnly">false</item>
   <item name="android:progressDrawable">@android:drawable/progress_horizontal</item>
   <item name="android:indeterminateDrawable">
    @android:drawable/progress_indeterminate_horizontal
   </item>
   <item name="android:minHeight">20dip</item>
   <item name="android:maxHeight">20dip</item>
   <item name="android:mirrorForRtl">true</item>
</style>

其中,

<item name="android:progressDrawable">@android:drawable/progress_horizontal</item>

便是我們需要修改的地方, 當然這裏指的是修改progressDrawable的樣式, 如果修改高度寬度找到相應的屬性修改即可. 繼續跟進android:drawable/progress_horizontal, 看看其樣式是什麼.

<layer-list xmlns:android="http://schemas.android.com/apk/res/android">

    <item android:id="@android:id/background">
        <shape>
            <corners android:radius="5dip" />
            <gradient
                    android:startColor="#ff9d9e9d"
                    android:centerColor="#ff5a5d5a"
                    android:centerY="0.75"
                    android:endColor="#ff747674"
                    android:angle="270"
            />
        </shape>
    </item>

    <item android:id="@android:id/secondaryProgress">
        <clip>
            <shape>
                <corners android:radius="5dip" />
                <gradient
                        android:startColor="#80ffd300"
                        android:centerColor="#80ffb600"
                        android:centerY="0.75"
                        android:endColor="#a0ffcb00"
                        android:angle="270"
                />
            </shape>
        </clip>
    </item>

    <item android:id="@android:id/progress">
        <clip>
            <shape>
                <corners android:radius="5dip" />
                <gradient
                        android:startColor="#ffffd300"
                        android:centerColor="#ffffb600"
                        android:centerY="0.75"
                        android:endColor="#ffffcb00"
                        android:angle="270"
                />
            </shape>
        </clip>
    </item>

</layer-list>

我們可以看到, 這裏使用的是layer-list的配置資源, 其最後構造出來的是一個LayerDrawable, 顧名思義, 就是有層次感的Drawable. 按照從最底層到最上層, 該樣式使用了三層, 分別是 背景層, 第二進度條層, 和主進度條層. 其中第二進度條用在如在線視頻播放時緩衝的進度. 一般場景下我們不需要使用這個.
所以, 接下來, 我們只需要修改第一層和第三層的代碼(替換顏色或者圖片, 看具體需求). 例如:

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">

    <item android:id="@android:id/background" android:drawable="@drawable/progress_bar_back">
    </item>

    <!--<item android:id="@android:id/secondaryProgress">-->
    <!--<clip>-->
    <!--<shape>-->
    <!--<corners android:radius="5dip" />-->
    <!--<gradient-->
    <!--android:startColor="#80ffd300"-->
    <!--android:centerColor="#80ffb600"-->
    <!--android:centerY="0.75"-->
    <!--android:endColor="#a0ffcb00"-->
    <!--android:angle="270"-->
    <!--/>-->
    <!--</shape>-->
    <!--</clip>-->
    <!--</item>-->

    <item android:id="@android:id/progress" android:drawable="@drawable/progress_bar_hover">
    </item>

</layer-list>

按圖片資源給定ProgressBar相同的width 和 height,
ProgressBar的資源配置代碼爲:

<ProgressBar
        android:id="@+id/downloadProgressBar"
        android:layout_width="328dp"
        android:layout_height="10dp"
        android:layout_marginLeft="16dp"
        android:layout_marginRight="16dp"
        style="@style/my_progressbar_style"
        android:progress="35"
        android:indeterminate="false" />

最後出來效果如下
這裏寫圖片描述

當然, 也可以不使用圖片資源, 而直接給定漸變顏色值.例如:

<layer-list xmlns:android="http://schemas.android.com/apk/res/android">

    <item android:id="@android:id/background">
        <shape>
            <corners android:radius="5dip" />
            <gradient
                android:startColor="#ffffff9d"
                android:centerColor="#ffff5d5a"
                android:centerY="0.75"
                android:endColor="#ffff7674"
                android:angle="270"
                />
        </shape>
    </item>

    <!--<item android:id="@android:id/secondaryProgress">-->
    <!--<clip>-->
    <!--<shape>-->
    <!--<corners android:radius="5dip" />-->
    <!--<gradient-->
    <!--android:startColor="#80ffd300"-->
    <!--android:centerColor="#80ffb600"-->
    <!--android:centerY="0.75"-->
    <!--android:endColor="#a0ffcb00"-->
    <!--android:angle="270"-->
    <!--/>-->
    <!--</shape>-->
    <!--</clip>-->
    <!--</item>-->

    <item android:id="@android:id/progress" >
        <clip>
            <shape>
                <corners android:radius="5dip" />
                <gradient
                    android:startColor="#7d3c"
                    android:centerColor="#803c"
                    android:centerY="0.75"
                    android:endColor="#4c23"
                    android:angle="270"
                    />
            </shape>
        </clip>
    </item>

</layer-list>

運行效果截圖如下
這裏寫圖片描述

使用常規方法來實現用漸變圖片自定義ProgressBar的問題

在上面的所講的例子中, 第一種情況, 我們使用了漸變圖片來自定義ProgressBar, 運行結果是正常的, 但是前提是我們指定了ProgressBar的width 和 height 的dp值與圖片的一致. 如果我們給定一個寬度值超過圖片本身的(這種情況很常見), ProgressBar的width改爲Match_Parent, 那麼就會出現下面這種情況.
這裏寫圖片描述
我們發現, 在超出圖片寬度以外的部分, 系統是使用循環填充的方式來截取ProgressBar的前半部分來填滿剩餘的寬度. 這是怎麼導致的呢? 我們跟進ProgressBar源碼, 一看究竟.

public ProgressBar(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);

        mUiThreadId = Thread.currentThread().getId();
        initProgressBar();

        final TypedArray a = context.obtainStyledAttributes(
                attrs, R.styleable.ProgressBar, defStyleAttr, defStyleRes);

        mNoInvalidate = true;

        final Drawable progressDrawable = a.getDrawable(R.styleable.ProgressBar_progressDrawable);
        if (progressDrawable != null) {
            // Calling this method can set mMaxHeight, make sure the corresponding
            // XML attribute for mMaxHeight is read after calling this method
            setProgressDrawableTiled(progressDrawable);
        }

    ... ...
}

從代碼中可以看到, 第一步先從資源xml文件裏, 獲取到progressDrawable. 由於我們使用的tag是layer-list, 所以這個它是一個LayerDrawable對象(具體解析過程見Drawable.java, 這裏我們截取關鍵代碼)

 public static Drawable createFromXmlInner(Resources r, XmlPullParser parser, AttributeSet attrs,
            Theme theme) throws XmlPullParserException, IOException {
        final Drawable drawable;

        final String name = parser.getName();
        switch (name) {
            case "selector":
                drawable = new StateListDrawable();
                break;
            case "animated-selector":
                drawable = new AnimatedStateListDrawable();
                break;
            case "level-list":
                drawable = new LevelListDrawable();
                break;
            case "layer-list":
                drawable = new LayerDrawable();
                break;
            case "transition":
                drawable = new TransitionDrawable();
                break;
            case "ripple":
                drawable = new RippleDrawable();
                break;
            case "color":
                drawable = new ColorDrawable();
                break;
            case "shape":
                drawable = new GradientDrawable();
                break;
            case "vector":
                drawable = new VectorDrawable();
                break;
            case "animated-vector":
                drawable = new AnimatedVectorDrawable();
                break;
            case "scale":
                drawable = new ScaleDrawable();
                break;
            case "clip":
                drawable = new ClipDrawable();
                break;

        ... ...
}

構造出我們的progressDrawable之後, 回到上面ProgressBar代碼中, 繼續往下執行, 即

if (progressDrawable != null) {
            // Calling this method can set mMaxHeight, make sure the corresponding
            // XML attribute for mMaxHeight is read after calling this method
            setProgressDrawableTiled(progressDrawable);
        }

這裏調用了, setProgressDrawableTiled(progressDrawable)函數, 跟進這個函數, 看看實現:

public void setProgressDrawableTiled(Drawable d) {
        if (d != null) {
            d = tileify(d, false);
        }

        setProgressDrawable(d);
    }

其中, 調用了 tileify(d, false)函數,

/**
     * Converts a drawable to a tiled version of itself. It will recursively
     * traverse layer and state list drawables.
     */
    private Drawable tileify(Drawable drawable, boolean clip) {

        if (drawable instanceof LayerDrawable) {
            LayerDrawable background = (LayerDrawable) drawable;
            final int N = background.getNumberOfLayers();
            Drawable[] outDrawables = new Drawable[N];

            for (int i = 0; i < N; i++) {
                int id = background.getId(i);
                outDrawables[i] = tileify(background.getDrawable(i),
                        (id == R.id.progress || id == R.id.secondaryProgress));
            }

            LayerDrawable newBg = new LayerDrawable(outDrawables);

            for (int i = 0; i < N; i++) {
                newBg.setId(i, background.getId(i));
            }

            return newBg;

        } else if (drawable instanceof StateListDrawable) {
            StateListDrawable in = (StateListDrawable) drawable;
            StateListDrawable out = new StateListDrawable();
            int numStates = in.getStateCount();
            for (int i = 0; i < numStates; i++) {
                out.addState(in.getStateSet(i), tileify(in.getStateDrawable(i), clip));
            }
            return out;

        } else if (drawable instanceof BitmapDrawable) {
            final BitmapDrawable bitmap = (BitmapDrawable) drawable;
            final Bitmap tileBitmap = bitmap.getBitmap();
            if (mSampleTile == null) {
                mSampleTile = tileBitmap;
            }

            final ShapeDrawable shapeDrawable = new ShapeDrawable(getDrawableShape());
            final BitmapShader bitmapShader = new BitmapShader(tileBitmap,
                    Shader.TileMode.REPEAT, Shader.TileMode.CLAMP);
            shapeDrawable.getPaint().setShader(bitmapShader);

            // Ensure the tint and filter are propagated in the correct order.
            shapeDrawable.setTintList(bitmap.getTint());
            shapeDrawable.setTintMode(bitmap.getTintMode());
            shapeDrawable.setColorFilter(bitmap.getColorFilter());

            return clip ? new ClipDrawable(
                    shapeDrawable, Gravity.LEFT, ClipDrawable.HORIZONTAL) : shapeDrawable;
        }

        return drawable;
    }

這個函數很簡單, 看上面的函數功能註釋, 即可知道, 該函數主要實現將drawable轉化成一個可以tiled的對象, tiled是拼接的意思, 由於我們的條形ProgressBar是根據狀態不停的改變圖片顯示的, 在條形進度條裏, 圖片不停的變長, 可以理解爲有裁剪成一段段的圖片不停的拼接而成.
該函數主要做了以下事情:
一, 如果該drawable對象是LayerDrawable類型, 那麼就對其每一個layer的drawable對象進行遞歸調用;
二, 如果該drawable對象是BitmapDrawable類型, 那麼就將其轉化成一個ShaperDrawable 或者 ClipDrawable.

注意第二步, 這裏可以找到我們前面的圖片最後變成
這裏寫圖片描述
這個樣子的原因.
原因分析如下:
一, 我們在layer-list裏面是如下方式引用我們的圖片資源的

<layer-list xmlns:android="http://schemas.android.com/apk/res/android">

    <item android:id="@android:id/background" android:drawable="@drawable/progress_bar_back">
    </item>
    <item android:id="@android:id/progress" android:drawable="@drawable/progress_bar_hover">
    </item>
</layer-list>

所以導致, 在構造出我們的LayerDrawable對象時, 它的每一層的drawable對象爲 Bitmap類型(具體從xml解析出Bitmap的過程, 感興趣的可以看源碼).

二, 根據上面tileify函數, 我們知道它會對LayerDrawable的每一層的drawable對象進行遞歸調用該函數, 所以當drawable對象爲Bitmap類型的時候, 就會執行上述所說的, 將其轉化成ShapeDrawable或者ClipDrawable, 在這裏clip爲false. 所以均會轉化成ShapeDrawable類型.

三, 在將BitmapDrawable轉化成ShapeDrawable時, 它做了以下處理.

final ShapeDrawable shapeDrawable = new ShapeDrawable(getDrawableShape());
            final BitmapShader bitmapShader = new BitmapShader(tileBitmap,
                    Shader.TileMode.REPEAT, Shader.TileMode.CLAMP);
            shapeDrawable.getPaint().setShader(bitmapShader);

具體解釋爲, 它給ShapeDrawable設置了Shader(關於Shader是什麼, 這裏不展開說明). 重點是, TileMode.REPEAT. 它的定義爲:

public enum TileMode {
        /**
         * replicate the edge color if the shader draws outside of its
         * original bounds
         */
        CLAMP   (0),
        /**
         * repeat the shader's image horizontally and vertically
         */
        REPEAT  (1),
        /**
         * repeat the shader's image horizontally and vertically, alternating
         * mirror images so that adjacent images always seam
         */
        MIRROR  (2);

        TileMode(int nativeInt) {
            this.nativeInt = nativeInt;
        }
        final int nativeInt;
    }

看註釋即知道, 當tileMode設置爲REPEAT之後, 它會水平或垂直的重複圖片!
這就是爲什麼當我們上面將ProgressBar的長度設置爲超過圖片本身的長度的時候, 它是以repeat的方式來填充多餘的部分.

解決方法

根據上面的分析, 我們知道根本原因之後, 要找到解決方案就知道從哪裏去突破了. 顯然, 我們在定義layer-list的時候, 不應該直接以

<layer-list xmlns:android="http://schemas.android.com/apk/res/android">

    <item android:id="@android:id/background" android:drawable="@drawable/progress_bar_back">
    </item>
    <item android:id="@android:id/progress" android:drawable="@drawable/progress_bar_hover">
    </item>
</layer-list>

在這裏, 爲了防止在執行tileify函數時, 將我們的drawable轉換了, 可以將圖片直接定義爲clip類型.
修改代碼如下:

<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:id="@android:id/background"
        >
        <clip android:drawable="@drawable/progress_bar_back" >

        </clip>
    </item>
    <item android:id="@android:id/progress"
        >
        <clip android:drawable="@drawable/progress_bar_hover">
        </clip>
    </item>
</layer-list>

一樣, 我們將ProgressBar的width定義爲match_parent, 爲了體現效果, 把progress預設爲95

<ProgressBar
        android:id="@+id/downloadProgressBar"
        android:layout_width="match_parent"
        android:layout_height="10dp"
        android:layout_marginLeft="16dp"
        android:layout_marginRight="16dp"
        style="@style/my_progressbar_style"
        android:progress="95"
        android:indeterminate="false" />

我們運行看看是否能解決以上問題. 運行截圖爲:
這裏寫圖片描述

我們看到, progressBar的上層的圖片, 即:

<item android:id="@android:id/progress"
        >
        <clip android:drawable="@drawable/progress_bar_hover">
        </clip>
    </item>

可以正常顯示, 而並沒有出現圖片的重複, 如果圖片重複, 應該顯示成這樣:
這裏寫圖片描述
但是, progressBar的背景層圖片並沒有顯示出來.
這裏由於我們使用ClipDrawable. 跟進ClipDrawable源碼變很容易發現原因;

 @Override
    public void draw(Canvas canvas) {

        if (mState.mDrawable.getLevel() == 0) {
            return;
        }

         ... ...
    }

以上是ClipDrawable重寫的draw函數. 可以看到如果getLevel == 0 時, 就直接返回, 並不繪製圖片.那麼getLevel是什麼呢. 這個返回的是Drawable定義的mLevel字段, 該字段用來是Drawable在不同的level顯示不同的樣子, 應用在如 電池電量狀態, 音量狀態, 或者progressBar的進度等場景.
而上面運行結果, 我們可以看到progressBar的上層的圖片能正常顯示, 是因爲, progressBar 在setProgress的時候會調用該drawable的setLevel方法, 是的該ClipDrawable的mLevel值不爲零, 所以可以成功繪製.

那接下來我們該怎麼辦呢?
有兩種方法:
一, 很直接的, 我們可以想到將上述的layer-list中第一層ClipDrawable即

<item android:id="@android:id/background"
        >
        <clip android:drawable="@drawable/progress_bar_back" >
        </clip>
    </item>

的mLevel置爲大於零. 使其能功能的繪製出來. 但這種方式, 還要在代碼裏面動態的去設置, 很繁瑣. 這裏我想到了另一種替代方案.即第二種方案.

二, 在layer-list裏面, 使用secondProgress來充當背景, 怎麼做呢?
1. 修改layer-list如下,

<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:id="@android:id/background" 
        >
        <clip android:drawable="@drawable/progress_bar_back" >
        </clip>
    </item>
    <item android:id="@android:id/secondaryProgress">
        <clip android:drawable="@drawable/progress_bar_back">
        </clip>
    </item>
    <item android:id="@android:id/progress"
        >
        <clip android:drawable="@drawable/progress_bar_hover">
        </clip>
    </item>
</layer-list>

可以看到, 我們把secondProgress設置爲與background一樣, 即讓它充當progressbar 的第一層
2. 在ProgressBar的xml配置裏, 將secondProgress 置爲 100(即, 讓它全部畫出來, 這樣就能成功的充當背景層了)
修改如下:

<ProgressBar
        android:id="@+id/downloadProgressBar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginLeft="16dp"
        android:layout_marginRight="16dp"
        style="@style/my_progressbar_style"
        android:progress="90"
        android:secondaryProgress="100"
        android:indeterminate="false" />

就這兩部就完成了, 下面運行看下結果是否跟想象的一致, 截圖如下:
這裏寫圖片描述
很激動, 可以看到, 結果跟我們預想的一致!
這樣, 就成功的解決了上述的問題了.

一些思考

最後, 總結下整個問題分析與解決的經驗, 首先, 在想到要自定義progressBar的時候, 採用了網上所講的通用的方法來實現, 這一點幾乎大家都能做到. 但是, 有時候並不能解決我們特定的需求. 在特定需求下會出現特殊的問題, 而這些問題需要我們自己去解決. 這裏, 我解決的思路, 是先通過問題的現象, 到源碼裏找到原因, 然後結合原因, 採取相應的方案. 當然, 這個過程還會結合到很多實驗. 但是一步步的, 不停的實驗 和 不停的到源碼中去找答案, 最終問題肯定是能夠解決的. 這個過程會比較繁瑣, 有些人會選擇迴避, 比如, 爲了實現圖片自定義progressbar, 我完全可以選擇, 三個ImageView, 採用遮蓋的方法實現這個效果, 但我沒這麼做. 因爲這樣即使完成了需求, 也不會學到更多的東西, 相反, 直面問題, 根據閱讀源碼和動手實踐, 最後不僅能很好的解決這個問題, 同時還能加深對其原理的理解, 一舉多得.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章