使用颜色渐变图片自定义条形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, 采用遮盖的方法实现这个效果, 但我没这么做. 因为这样即使完成了需求, 也不会学到更多的东西, 相反, 直面问题, 根据阅读源码和动手实践, 最后不仅能很好的解决这个问题, 同时还能加深对其原理的理解, 一举多得.

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