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