最近的项目需求中,有一个显示下载进度的需求。大概是这样子的,一个圆角矩形ImageView作为背景图,以这个矩形的中心作为圆心,圆角矩形的外切圆上的某个点为起点,进度为矩形上某个点与起点的角度。位于进度范围内的圆角矩形部分为透明,其它部分为半透明遮罩。效果如下图所示(因为背景图片并不是规则矩形,所以效果有点差):
如上图,剩余进度是半透明遮罩,完成进度部分为透明,可以设置进度,圆角,半透明遮罩的颜色,还有一个起始角度。相对来说需求很简单,和iOS的app升级时桌面上的升级进度效果基本一致。完成这个需求,自然而然地想到了自定义View,自定义一个遮罩层View,覆盖在ImageView上就可以了。
<declare-styleable name="CircleProgress">
<attr name="circleProgress" format="integer"/>
<attr name="startAngle" format="integer"/>
<attr name="circleCorner" format="dimension" />
<attr name="backgroundColor" format="color"/>
declare-styleable>
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PointF;
import android.graphics.RectF;
import android.graphics.Region;
import android.util.AttributeSet;
import android.view.View;
import androidx.annotation.Nullable;
import com.bottle.app.R;
/**
* 原理就是利用一个圆角矩形A,和一个扇形B,裁切一块区域(A-B),然后把颜色绘制在这块区域,
* 被裁掉区域的中心角度为进度。
* 难度就在于如何裁剪出这一块区域:
* 1. 圆角矩形背景A:取View 的 padding 范围内的View空间即可;
* 2. 进度扇形背景B:以View的中心为圆心,矩形对角线为直径,起始角度为起点a,
* 得到圆上起点,进度角度b,得到圆上第二点,然后画弧度得到扇形;
* 3. 取A-B得到背景,然后画一个矩形即可。
*/
public class CircleProgress extends View {
public static final int PI_RADIUS = 180; // pi弧度对应的角度
private int mProgress; // 进度,取值范围: 0-100
private int mCorner; // 圆角,如果是矩形,取一半的话可以是圆
private int mStartAngle; // 百分比进度的起始值,0-n,其中0度与x轴方向一致
private int mBackgroundColor; // 覆盖部分,也就是除进度外部分的颜色
private int width;
private int height;
private PointF mCenter; // View的中心
private PointF mStart; // 起始点角度在圆上对应的横坐标
private float mRadius; // View的外切圆的半径
private RectF mBackground; // 被裁剪的底层圆角矩形
private Path mClipArcPath = new Path(); // 要裁剪掉的扇形部分 B
private Path mClipBgPath = new Path(); // 整个View的背景 A,绘制部分为: A-B
private RectF mEnclosingRectF; // 这是整个View的外切圆的外切矩形,忽略padding的话它比View的尺寸大
private Paint mPaint = new Paint();
public void setProgress(int progress) {
mProgress = progress;
invalidate();
}
public CircleProgress(Context context) {
super(context);
}
public CircleProgress(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(context, attrs);
}
public CircleProgress(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context, attrs);
}
public CircleProgress(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
init(context, attrs);
}
private void init(Context context, @Nullable AttributeSet attrs) {
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CircleProgress);
mProgress = typedArray.getInt(R.styleable.CircleProgress_circleProgress, 0);
mCorner = typedArray.getDimensionPixelOffset(R.styleable.CircleProgress_circleCorner, 0);
mStartAngle = typedArray.getInt(R.styleable.CircleProgress_startAngle, 315);
mBackgroundColor = typedArray.getColor(R.styleable.CircleProgress_backgroundColor,
Color.argb(90, 90, 90, 90));
typedArray.recycle();
mPaint.setStyle(Paint.Style.FILL);
mPaint.setAntiAlias(true);
mPaint.setColor(mBackgroundColor);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
width = w;
height = h;
float rw = (width - getPaddingStart() - getPaddingEnd()) / 2f;
float rh = (height - getPaddingTop() - getPaddingBottom()) / 2f;
mRadius = (float) Math.sqrt(rw * rw + rh * rh);
mCenter = new PointF(getPaddingStart() + rw, getPaddingTop() + rh);
mStart = new PointF((float) (mCenter.x + mRadius * Math.cos(mStartAngle * Math.PI / PI_RADIUS)),
(float) (mCenter.y + mRadius * Math.sin(mStartAngle * Math.PI / PI_RADIUS)));
mBackground = new RectF(getPaddingStart(),
getPaddingTop(),
width - getPaddingEnd(),
height - getPaddingBottom());
mEnclosingRectF = new RectF(mCenter.x - mRadius, mCenter.y - mRadius,
mCenter.x + mRadius, mCenter.y + mRadius);
mClipBgPath.reset();
mClipBgPath.addRoundRect(mBackground, mCorner, mCorner, Path.Direction.CW);
}
@Override
public void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.save();
canvas.clipPath(mClipBgPath);
canvas.clipPath(getSectorClip(360 * mProgress / 100f + mStartAngle), Region.Op.DIFFERENCE);
canvas.drawRoundRect(mBackground, mCorner, mCorner, mPaint);
canvas.restore();
}
private Path getSectorClip(float sweepAngle) {
mClipArcPath.reset();
mClipArcPath.moveTo(mCenter.x, mCenter.y);
mClipArcPath.lineTo(mStart.x, mStart.y);
mClipArcPath.lineTo((float) (mCenter.x + mRadius * Math.cos(sweepAngle * Math.PI / PI_RADIUS)),
(float) (mCenter.y + mRadius * Math.sin(sweepAngle * Math.PI / PI_RADIUS)));
mClipArcPath.close();
mClipArcPath.addArc(mEnclosingRectF, mStartAngle, sweepAngle - mStartAngle);
return mClipArcPath;
}
}
<FrameLayout
android:layout_width="150dp"
android:layout_height="150dp">
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:src="@mipmap/ic_launcher"/>
<com.bottle.app.widget.CircleProgress
android:id="@+id/circleProgress"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:backgroundColor="#9000ee00"
app:startAngle="315"
app:circleProgress="10"
app:circleCorner="60dp"/>
FrameLayout>
在Activity中实例化后,模拟动态改变进度
private CircleProgress mCircleProgress;
private int power;
private Runnable mAction = new Runnable() {
@Override
public void run() {
int pow = power++ % 100;
mCircleProgress.setProgress(pow);
mBatteryView.postDelayed(mAction, 50);
}
};
原理就是利用一个圆角矩形A,和一个扇形B,裁切一块区域(A-B),然后把颜色绘制在这块区域,被裁掉区域的中心角度为进度。难度就在于如何裁剪出这一块区域: