Android 自定义View之BounceProgressBar

这是csdn上的一篇博客,写的比较详细,特别是在不使用图片的基础上绘制心形的细节非常到位。以下是原文:

之前几天下载了很久没用了的桌面版酷狗来用用的时候,发现其中加载歌曲的等待进度条的效果不错(个人感觉),如下:

然后趁着这周末两天天气较冷,窝在宿舍放下成堆的操作系统作业(目测要抄一节多课的一堆堆文字了啊...啊..)毅然决定把它鼓捣出来,最终的效果如下(总感觉有点不和谐啊·):

对比能看出来的就是多了形状的选择还有使用图片了,那么接下来就是它的实现过程。

对自定义View实现还不明白的建议看下郭神的博客(View系列4篇):Android LayoutInflater原理分析,带你一步步深入了解View(一) 和大苞米的这篇:ANDROID自定义视图——onMeasure,MeasureSpec源码 流程 思路详解

自定义属性

自定义View一般都要用到view本身的属性了,重写现有的控件则不用。额,然后我们的这个BounceProgressBar需要什么特有的属性呢?首先要明确的是这里BounceProgressBar没有提供具体进度表现的实现的。再具体想想:它需要每个图像的大小,叫singleSrcSize,类型就是dimension了;上下跳动的速度,叫speed,类型为integer;形状,叫shape,类型为枚举类型,提供这几个形状的实现,original、circle、pentagon、rhombus、heart都是见名知意的了;最后是需要的图片资源,叫src,类型为reference|color,即可以是drawable里的图片或颜色值。

有了需要的属性后,在values文件夹下建个资源文件(名字随意,见名知意就好)来定义这些属性了,如下,代码可能有些英文,而且水平有些渣,不过一般前面都会解释了的:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="BounceProgressBar">
        <!-- the single child size -->
        <attr name="singleSrcSize" format="dimension" />
        <!-- the bounce animation one-way duration -->
        <attr name="speed" format="integer" />
        <!-- the child count ,本来还想能自定义个数的,但是暂时个人实现起来有些麻烦,所以先不加这个-->
        <!-- <attr name="count" format="integer" min="1" /> -->
        <!-- the progress child shape -->
        <attr name="shape" format="enum">
            <enum name="original" value="0" />
            <enum name="circle" value="1" />
            <enum name="pentagon" value="2" />
            <enum name="rhombus" value="3" />
            <enum name="heart" value="4" />
        </attr>
        <!-- the progress drawable resource -->
        <attr name="src" format="reference|color"></attr>
    </declare-styleable>
</resources>

然后先把BounceProgressBar类写出来如下:

public class BounceProgressBar extends View {
    //...
}

现在就可以在布局里用我们的BounceProgressBar了,这里需要注意的是,我们需要加上下面代码第二行命名空间才能使用我们的属性,也可以把它放到根元素的属性里。

<org.roc.bounceprogressbar.BounceProgressBar
    xmlns:bpb="http://schemas.android.com/apk/res-auto"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_centerHorizontal="true"
    android:layout_centerVertical="true"
    bpb:shape="circle"
    bpb:singleSrcSize="8dp"
    bpb:speed="250"
    bpb:src="#6495ED" />

自定义了属性最后我们要做的就是在代码里去获取它了,在哪里获取呢,当然是BounceProgressBar类的构造方法里了,相关代码如下:

public BounceProgressBar(Context context) {
    this(context, null, 0);
}
public BounceProgressBar(Context context, AttributeSet attrs) {
    this(context, attrs, 0);
}
public BounceProgressBar(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    init(attrs);
}
private void init(AttributeSet attrs) {
    if (null == attrs) {
        return;
    }
    TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable.BounceProgressBar);
    speed = a.getInt(R.styleable.BounceProgressBar_speed, 250);
    size = a.getDimensionPixelSize(R.styleable.BounceProgressBar_singleSrcSize, 50);
    shape = a.getInt(R.styleable.BounceProgressBar_shape, 0);
    src = a.getDrawable(R.styleable.BounceProgressBar_src);
    a.recycle();
}

得到属性还是比较简单的,记得把TypedArray回收掉。首先是获得我们定义的TypedArray,然后是一个一个的去get属性值。然后可能有人要说了,我明明没定义R.styleable.BounceProgressBar_xxx这些东西啊,其实呢这是Android自动给我们生成的declare-styleable里的每个属性的在TypedArray里的index对应位置的,你是找不到类似R.styleable.speed这种东西存在的,它又是怎么对应的呢,点进去看一下R文件就知道了,R.styleable.BounceProgressBar_speed的值是1,因为speed是第2个属性(0,1..),所以你确定属性的位置直接写a.getInt(1, 250)也是可以的。第二个参数是默认值。

图形的形状

得到属性值后,我们就可以去做相应的处理操作了,这里是图形形状的获取,用到了shapesrcsize属性,speed和size在下一点中也会讲到。

首先我们观察到三个图片是有些渐变的效果的,我这里只是简单地做透明度处理,即一次变透明,效果是可以在处理好一点,可能之后再优化了。从src得到的图片资源是Drawable的,无论是ColorDrawable或是BitmapDrawable。我们需要先把它转换成size大小的Bitmap,再用canvas对它进行形状裁剪操作。至于为什么要先转Bitmap呢,这是我的做法,再看完下面的操作后如果有更好的方式希望可以交流一下。

/**
 * Drawable → Bitmap(the size is "size")
 */
private Bitmap drawable2Bitmap(Drawable drawable) {
    Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
    Canvas canvas = new Canvas(bitmap);
    drawable.setBounds(0, 0, size, size);
    drawable.draw(canvas);
    return bitmap;
}

Bitmap得到了,形状呢我们就可以进行操作了,我们先说圆形circle、菱形rhombus、五角星pentagon,再说心形heart,因为处理方式有些不同。像其它ShapeImageView我看到好像喜欢用svg来处理,看了他们的代码,例如这个:https://github.com/siyamed/android-shape-imageview  貌似有些麻烦,相比之下我的处理比较简单。

圆形circle、菱形rhombus、五角星pentagon

这些形状都可以使用ShapeDrawable来得到。我们需要BitmapShader渲染器,这是ShapeDrawable的Paint画笔需要的,再需要一个空的位图Bitmap,再一个 Canvas。如下:

BitmapShader bitmapShader = new BitmapShader(srcBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
Path path;
ShapeDrawable shapeDrawable = new ShapeDrawable();
shapeDrawable.getPaint().setAntiAlias(true);
shapeDrawable.getPaint().setShader(bitmapShader);
shapeDrawable.setBounds(0, 0, size, size);
shapeDrawable.setAlpha(alpha);

Canvas是ShapeDrawable上的画布,BitmapShader是ShapeDrawable画笔Paint的的渲染器,用来渲染处理图形(由src的drawable转换得到的bitmap),渲染模式选用了CLAMP,意思是 如果渲染器超出原始边界范围,会复制范围内边缘染色。

圆形呢,我们直接用现成的就可以:

shapeDrawable.setShape(new OvalShape());

这个ShapeDrawable画出来的就是圆形了,当然要调用shapeDrawable.draw(canvas);方法了,这样bitmap就会变成圆形的srcBitmap(方法传进的参数)了,这方法的完整代码后面给出。

菱形呢,我们则这样子:

path = new Path();
path.moveTo(size / 2, 0);
path.lineTo(0, size / 2);
path.lineTo(size / 2, size);
path.lineTo(size, size / 2);
path.close();
shapeDrawable.setShape(new PathShape(path, size, size));

就是边长为size的正方形,取每条边的中点,四个点连起来就是了。我们知道Android的坐标一般都是屏幕左上角顶点为坐标原点的,坐标点找到了我们把path连接起来即close。这样PathShape就是一个菱形了。多边形差不多都可以这么画的,下面的五角形也是一样。说明:这里所有图形的绘制都是在边长size的正方形里。

五角形的原理也是用PathShape,只是它需要的坐标点有点多啊,需要仔细计算慢慢调试。

path = new Path();
// The Angle of the pentagram
float radian = (float) (Math.PI * 36 / 180);
float radius = size / 2;
// In the middle of the radius of the pentagon
float radius_in = (float) (radius * Math.sin(radian / 2) / Math.cos(radian));
// The starting point of the polygon
path.moveTo((float) (radius * Math.cos(radian / 2)), 0);
path.lineTo((float) (radius * Math.cos(radian / 2) + radius_in * Math.sin(radian)),
        (float) (radius - radius * Math.sin(radian / 2)));
path.lineTo((float) (radius * Math.cos(radian / 2) * 2),
        (float) (radius - radius * Math.sin(radian / 2)));
path.lineTo((float) (radius * Math.cos(radian / 2) + radius_in * Math.cos(radian / 2)),
        (float) (radius + radius_in * Math.sin(radian / 2)));
path.lineTo((float) (radius * Math.cos(radian / 2) + radius * Math.sin(radian)),
        (float) (radius + radius * Math.cos(radian)));
path.lineTo((float) (radius * Math.cos(radian / 2)), (float) (radius + radius_in));
path.lineTo((float) (radius * Math.cos(radian / 2) - radius * Math.sin(radian)),
        (float) (radius + radius * Math.cos(radian)));
path.lineTo((float) (radius * Math.cos(radian / 2) - radius_in * Math.cos(radian / 2)),
        (float) (radius + radius_in * Math.sin(radian / 2)));
path.lineTo(0, (float) (radius - radius * Math.sin(radian / 2)));
path.lineTo((float) (radius * Math.cos(radian / 2) - radius_in * Math.sin(radian)),
        (float) (radius - radius * Math.sin(radian / 2)));
path.close();// Make these points closed polygons
shapeDrawable.setShape(new PathShape(path, size, size));

连线果然有点多啊。。这里的绘制五角形是先根据指定的五角形的角的角度还有半径,然后确定连线起点,再连下一点...最后封闭,一不小心就不知道连到哪去了。。

心形heart

path来画心形就不能连直线实现了,刚开始是使用path的quadTo(x1, y1, x2, y2)方法来画贝塞尔曲线来实现的,发现画出来的形状不饱满,更像一个锥形(脑补),所以就放弃这种方式了。然后找到了这篇关于画心形的介绍Heart Curve,然后就采用他的第四种方法(如下图),即采用两个椭圆形状来裁剪实现。

1、画一个椭圆形状

//canvas bitmap bitmapshader等,上面代码已有
path = new Path();
Paint paint = new Paint();
paint.setAntiAlias(true);
paint.setShader(bitmapShader);
Matrix matrix = new Matrix(); //控制旋转
Region region = new Region();//裁剪一段图形区域
RectF ovalRect = new RectF(size / 4, 0, size - (size / 4), size);
path.addOval(ovalRect, Path.Direction.CW);

2、旋转图形,大概45度左右

matrix.postRotate(42, size / 2, size / 2);
path.transform(matrix, path);

3、选取旋转后的右半部分图形,并用cancas画出这半边的心形

path.transform(matrix, path);
region.setPath(path, new Region((int) size / 2, 0, (int) size, (int) size));
canvas.drawPath(region.getBoundaryPath(), paint);

4、重复1、2、3同时改变方向角度和裁剪的区域

matrix.reset();
path.reset();
path.addOval(ovalRect, Path.Direction.CW);
matrix.postRotate(-42, size / 2, size / 2);
path.transform(matrix, path);
region.setPath(path, new Region(0, 0, (int) size / 2, (int) size));
canvas.drawPath(region.getBoundaryPath(), paint);

这样我们便完成心形图片的裁剪工作了,得到的bitmap就变成心形了:

这个心可以见人了。。
画完心就该下一步了。

View的绘制

说到view的绘制过程就需要下面三部曲了:

  • 测量——onMeasure():决定View的大小

  • 布局——onLayout():决定View在ViewGroup中的位置

  • 绘制——onDraw():如何绘制这个View。

    测量

    对于BounceProgressBar控件的测量还是比较简单的,当wrap_content时高度和宽度分别为size的5倍和4倍,其它情况时就指定宽高为具体测量到的值就好。然后决定三个图形在控件之中的水平位置:

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int sizeWidth = MeasureSpec.getSize(widthMeasureSpec);
        int sizeHeight = MeasureSpec.getSize(heightMeasureSpec);
        int modeWidth = MeasureSpec.getMode(widthMeasureSpec);
        int modeHeight = MeasureSpec.getMode(heightMeasureSpec);
        setMeasuredDimension((modeWidth == MeasureSpec.EXACTLY) ? mWidth = sizeWidth : mWidth,
                (modeHeight == MeasureSpec.EXACTLY) ? mHeight = sizeHeight : mHeight);
        firstDX = mWidth / 4 - size / 2;//第一个图形的水平位置
        secondDX = mWidth / 2 - size / 2;//...
        thirdDX = 3 * mWidth / 4 - size / 2;//...
    }
    

    当有指定了具体值的宽高时,mWidth和mHeight也设置应为测量到的sizeWidth和sizeHeight。

    布局

    说到布局时先明确一点的是图像的跳动是通过属性动画来控制的,属性动画是什么?我一句话说一下就是:可以以动画的效果形式去更改一个对象的某个属性。还不太了解的可以先找找资料看一下。

    布局这里就决定视图里的各种位置的操作了,作为单个控件时一般不怎么用到,我在这里进行动画的初始化并开始的操作了。可以看到我们的BounceProgressBar是三个图形在跳动的。

    三个属性的封装如下:

/**
 * firstBitmapTop's Property. The change of the height through canvas is
 * onDraw() method.
 */
private Property<BounceProgressBar, Integer> firstBitmapTopProperty = new Property<BounceProgressBar, Integer>(
        Integer.class, "firstDrawableTop") {
    @Override
    public Integer get(BounceProgressBar obj) {
        return obj.firstBitmapTop;
    }
    public void set(BounceProgressBar obj, Integer value) {
        obj.firstBitmapTop = value;
        invalidate();
    };
};
/**
 * secondBitmapTop's Property. The change of the height through canvas is
 * onDraw() method.
 */
private Property<BounceProgressBar, Integer> secondBitmapTopProperty = new Property<BounceProgressBar, Integer>(
        Integer.class, "secondDrawableTop") {
    @Override
    public Integer get(BounceProgressBar obj) {
        return obj.secondBitmapTop;
    }
    public void set(BounceProgressBar obj, Integer value) {
        obj.secondBitmapTop = value;
        invalidate();
    };
};
/**
 * thirdBitmapTop's Property. The change of the height through canvas is
 * onDraw() method.
 */
private Property<BounceProgressBar, Integer> thirdBitmapTopProperty = new Property<BounceProgressBar, Integer>(
        Integer.class, "thirdDrawableTop") {
    @Override
    public Integer get(BounceProgressBar obj) {
        return obj.thirdBitmapTop;
    }
    public void set(BounceProgressBar obj, Integer value) {
        obj.thirdBitmapTop = value;
        invalidate();
    };
};

onLayout部分的代码如下:

@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    super.onLayout(changed, left, top, right, bottom);
    if (bouncer == null || !bouncer.isRunning()) {
        ObjectAnimator firstAnimator = initDrawableAnimator(firstBitmapTopProperty, speed, size / 2,
                mHeight - size);
        ObjectAnimator secondAnimator = initDrawableAnimator(secondBitmapTopProperty, speed, size / 2,
                mHeight - size);
        secondAnimator.setStartDelay(100);
        ObjectAnimator thirdAnimator = initDrawableAnimator(thirdBitmapTopProperty, speed, size / 2,
                mHeight - size);
        thirdAnimator.setStartDelay(200);
        bouncer = new AnimatorSet();
        bouncer.playTogether(firstAnimator, secondAnimator, thirdAnimator);
        bouncer.start();
    }
}
private ObjectAnimator initDrawableAnimator(Property<BounceProgressBar, Integer> property, int duration,
        int startValue, int endValue) {
    ObjectAnimator animator = ObjectAnimator.ofInt(this, property, startValue, endValue);
    animator.setDuration(duration);
    animator.setRepeatCount(Animation.INFINITE);
    animator.setRepeatMode(ValueAnimator.REVERSE);
    animator.setInterpolator(new AccelerateInterpolator());
    return animator;
}

动画的值变换是从size到mHeight-size的,要减去size的原因是在canvas中,大于(mHeight, mHeight)的左边已经view本身的大小范围了。

绘制

绘制这里做的工作不是很多,就是根据每个图像的水平位置,和通过属性动画控制的高度来去绘制bitmap在画布上。

@Override
protected synchronized void onDraw(Canvas canvas) {
    /* draw three bitmap */
    firstBitmapMatrix.reset();
    firstBitmapMatrix.postTranslate(firstDX, firstBitmapTop);
    secondBitmapMatrix.reset();
    secondBitmapMatrix.setTranslate(secondDX, secondBitmapTop);
    thirdBitmapMatrix.reset();
    thirdBitmapMatrix.setTranslate(thirdDX, thirdBitmapTop);
    canvas.drawBitmap(firstBitmap, firstBitmapMatrix, mPaint);
    canvas.drawBitmap(secondBitmap, secondBitmapMatrix, mPaint);
    canvas.drawBitmap(thirdBitmap, thirdBitmapMatrix, mPaint);
}

位置是通过Matrix来控制的,因为当时还考虑到落地的变形,但现在给去掉先了。

总的来说绘制的流程是通过属性动画来控制每个图像在画布上的位置,在属性更改时调用invalidate()方法去通知重绘就行了,看起来就是跳动的效果了,跳动速度的变化则是给动画设置插值器来完成。


这篇文章就写到这里了,完整的源码我放到我的github上了(https://github.com/zhengxiaopeng/BounceProgressBar),欢迎大家star、fork一起完善它。      

转自 【Rocko's blogAndroid 自定义View之BounceProgressBar