一、前言
Android 提供了线头设置的方法线头形状有三种:
BUTT 平头、ROUND 圆头、SQUARE 方头,默认为 BUTT。
而当线条变粗的时候,它们就会表现出不同的样子:
从上图我们能看出一些差别
BUTT: 符合线条的真实长度
ROUND: 线条真实长度 + 半径 (半径理论长度为线宽的一半)
SQUARE: 线条真实长度 + 矩形宽度 (宽度理论上也是线宽的一半)
但是这些并不能满足所有需求方案,如果要绘制下图左下侧和有下侧弧形的起点和终点半圆角的线条,这些显然是不行的
二、解决方案
其实这类问题又2种处理方法,第一种是利用Path路径,构建闭合区间;第二种是线条描边,我们给线条添加 border,boder 宽度 * 2 + 中心线条宽度 = 总宽度。
我们这里以第二种实现,原因是Path会增加一定的计算量和复杂程度、其次还要众所周知的原因,Path存在锯齿问题性能问题,当然最根本的原因还是我想这么写。
1条SQUARE + 2条ROUND,为什么这个组合呢?主要是BUTT长度是真实长度,需要增加一定的便宜,如果直线还好,但是曲线需要更多的计算。这里方式绘制2条ROUND,然后1条SQUARE可以,当然反正顺序也可以,至于要不要clip防止过度绘制,自行决定就行,绘制示意图如下。
统一颜色后的效果
三、案例
本篇三种线头+自定义线头都会用到。本篇以仪表盘为例子,进行绘制,核心逻辑如下。
ini
private void drawArcRoundLine(Canvas canvas, float outArcR, float startAngle, float swipeAngle, float centerLineWidth) {
RectF arcRect = new RectF();
arcRect.left = -outArcR;
arcRect.right = outArcR;
arcRect.top = -outArcR;
arcRect.bottom = outArcR;
if (centerLineWidth <= 0) {
//如果中间线宽为0,那么就没必要绘制圆角了,直接使用ROUND类型
mTextPaint.setStrokeCap(Paint.Cap.ROUND);
canvas.drawArc(arcRect, startAngle, swipeAngle, false, mTextPaint);
} else {
mTextPaint.setStrokeCap(Paint.Cap.SQUARE); //绘制中间圆弧
mTextPaint.setStrokeWidth(centerLineWidth);
canvas.drawArc(arcRect, startAngle, swipeAngle, false, mTextPaint);
//下面绘制两侧圆弧
mTextPaint.setStrokeCap(Paint.Cap.ROUND);
mTextPaint.setStrokeWidth(lineRadius * 2);
RectF oArcRect = new RectF();
oArcRect.left = -outArcR - centerLineWidth / 2;
oArcRect.right = outArcR + centerLineWidth / 2;
oArcRect.top = -outArcR - centerLineWidth / 2;
oArcRect.bottom = outArcR + centerLineWidth / 2;
canvas.drawArc(oArcRect, startAngle, swipeAngle, false, mTextPaint);
RectF iArcRect = new RectF();
iArcRect.left = -outArcR + centerLineWidth / 2;
iArcRect.right = outArcR - centerLineWidth / 2;
iArcRect.top = -outArcR + centerLineWidth / 2;
iArcRect.bottom = outArcR - centerLineWidth / 2;
canvas.drawArc(iArcRect, startAngle, swipeAngle, false, mTextPaint);
}
}
全部代码
ini
public class SectionPointerMeterView extends View implements ValueAnimator.AnimatorUpdateListener {
private final String TAG = "SectionPointerMeterView";
private final boolean isDebug = true; //注意这里是Debug
private TextPaint mTextPaint;
private DisplayMetrics mDisplayMetrics;
private int mContentHeight = 0;
private int mContentWidth = 0;
private final float MIN_ARC_ANGLE = 120f;
private final float MAX_ARC_ANGLE = 360 - MIN_ARC_ANGLE;
private int progress = 0;
private int maxProgress = 100;
private String tagText = "";
private final static String UNIT_PERCENT = "%";
private ValueAnimator animatorProgress = null;
private volatile boolean isRelease = false;
private boolean disableComputeColorBlock = false;
private PorterDuffXfermode MODE_CLEAR = new PorterDuffXfermode(PorterDuff.Mode.CLEAR);
private SweepGradient colorShader = new SweepGradient(0, 0, new int[]{
0x33ffffff,
Color.TRANSPARENT,
0x33ffffff,
Color.WHITE,
0x33ffffff
}, new float[]{
0,
(90) / 360f,
180f / 360f,
270 / 360f,
1.0f
});
private float MIN_PADDING = 0.0f;
private float lineRadius = 0;
private float minLength = 0f;
private float outArcR = 0f;
private float centerX = 0f;
private float centerY = 0f;
private float startAngle = 0f;
private float offsetDegree = 5f;
public static class ColorBlock {
int color;
float ratio;
public ColorBlock(float ratio, int color) {
this.color = color;
this.ratio = ratio;
}
static ColorBlock build(float ratio, int color) {
ColorBlock cb = new ColorBlock(ratio, color);
return cb;
}
}
public final ColorBlock[] colorBlocks = {
ColorBlock.build(0f, 0xffFF1D1D),
ColorBlock.build(0.1f, 0xffFF1D1D),
ColorBlock.build(0.2f, 0xffF04D11),
ColorBlock.build(0.3f, 0xffF04D11),
ColorBlock.build(0.4f, 0xffFEA315),
ColorBlock.build(0.5f, 0xffFEA315),
ColorBlock.build(0.6f, 0xffFEA315),
ColorBlock.build(0.7f, 0xffF8DF38),
ColorBlock.build(0.8f, 0xffF8DF38),
ColorBlock.build(0.9f, 0xff10D659),
ColorBlock.build(1.0f, 0xff10D659),
};
public SectionPointerMeterView(Context context) {
this(context, null);
}
public SectionPointerMeterView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public SectionPointerMeterView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initPaint();
if (isInEditMode() ||isDebug) {
setMaxProgress(100);
setProgress(25);
setTagText("功效");
}
}
public void setMaxProgress(int maxProgress) {
if (maxProgress <= 0) {
throw new IllegalArgumentException(" max progress must be postive num");
}
this.maxProgress = maxProgress;
invalidate();
}
public void setProgress(int progress) {
if (progress < 0) {
throw new IllegalArgumentException(" progress must be no-nagtive num");
}
this.progress = progress;
invalidate();
}
public void setProgress(int progress, boolean isAnimate) {
if (progress < 0) {
throw new IllegalArgumentException(" progress must be no-nagtive num");
}
if (!isAnimate) {
setProgress(progress);
return;
}
int current = this.progress;
int target = progress;
if (animatorProgress != null) {
animatorProgress.cancel();
animatorProgress = null;
}
if (target == current) {
return;
}
startProgressAnimation(current, target);
}
private void startProgressAnimation(int current, int target) {
ValueAnimator animator = ValueAnimator.ofInt(current, target);
animator.setDuration(1100);
animator.setInterpolator(new BounceInterpolator());
animatorProgress = animator;
animatorProgress.addUpdateListener(this);
animator.start();
}
@Override
public void onAnimationUpdate(ValueAnimator animation) {
this.progress = (int) animation.getAnimatedValue();
invalidate();
}
public void setTagText(String tagText) {
this.tagText = tagText;
}
private void initPaint() {
mDisplayMetrics = getResources().getDisplayMetrics();
mTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mTextPaint.setAntiAlias(true);
MIN_PADDING = dp2px(1);
lineRadius = dp2px(5);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDisplayMetrics.widthPixels / 2;
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mContentHeight = (int) (h - MIN_PADDING * 2);
mContentWidth = (int) (w - MIN_PADDING * 2);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (isRelease) {
return;
}
mTextPaint.setStyle(Paint.Style.STROKE);
mTextPaint.setStrokeWidth(dp2px(20));
float arcStrokeWidth = mTextPaint.getStrokeWidth();
if (mContentWidth <= arcStrokeWidth || mContentHeight <= arcStrokeWidth) {
return;
}
minLength = Math.min(mContentWidth, mContentHeight) - arcStrokeWidth;
outArcR = (float) (minLength / (1f + Math.cos(Math.toRadians(MIN_ARC_ANGLE / 2))));
centerX = getWidth() / 2f;
centerY = outArcR + MIN_PADDING + arcStrokeWidth / 2;
startAngle = (180f - MIN_ARC_ANGLE) / 2f + MIN_ARC_ANGLE;
Bitmap targetBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
drawArcSection(new Canvas(targetBitmap), arcStrokeWidth);
RectF rectF = new RectF();
rectF.left = 0;
rectF.right = getWidth();
rectF.top = 0;
rectF.bottom = getHeight();
canvas.drawBitmap(targetBitmap, null, rectF, null);
targetBitmap.recycle();
int saveCount = canvas.save();
canvas.translate(centerX, centerY);
float totalDegree = 360f - (MIN_ARC_ANGLE + offsetDegree * 2);
float perDegree = totalDegree / 10f;
mTextPaint.setStrokeWidth(dp2px(1));
mTextPaint.setColor(Color.WHITE);
mTextPaint.setStrokeCap(Paint.Cap.BUTT);
for (int i = 0; i < 11; i++) {
float angle = (float) Math.toRadians(startAngle + offsetDegree + i * perDegree);
float sx = (float) (Math.cos(angle) * (outArcR - arcStrokeWidth / 2));
float sy = (float) (Math.sin(angle) * (outArcR - arcStrokeWidth / 2));
float ex = (float) (Math.cos(angle) * (outArcR - arcStrokeWidth / 2 - dp2px(5)));
float ey = (float) (Math.sin(angle) * (outArcR - arcStrokeWidth / 2 - dp2px(5)));
canvas.drawLine(sx, sy, ex, ey, mTextPaint);
}
drawPointer(canvas, outArcR);
float innerArcR = outArcR / 2f;
mTextPaint.setStyle(Paint.Style.STROKE);
mTextPaint.setShader(colorShader);
RectF innerArcRect = new RectF();
innerArcRect.left = -innerArcR;
innerArcRect.right = innerArcR;
innerArcRect.top = -innerArcR;
innerArcRect.bottom = innerArcR;
canvas.drawArc(innerArcRect, startAngle + offsetDegree, MAX_ARC_ANGLE - 2 * offsetDegree, false, mTextPaint);
mTextPaint.setShader(null);
canvas.restoreToCount(saveCount);
mTextPaint.setStyle(Paint.Style.FILL_AND_STROKE);
drawTextBlock(canvas, centerX, centerY);
drawTextScale(canvas, centerX, centerY, outArcR - arcStrokeWidth - dp2px(8));
}
private void drawPointer(Canvas canvas, float compassRadius) {
int count = canvas.save();
float ratio = (progress * 1F / maxProgress);
float CONTENT_MAX_ARC_ANGEL = (MAX_ARC_ANGLE - offsetDegree * 2);
float progressAngle = ratio * (MAX_ARC_ANGLE - offsetDegree * 2);
canvas.rotate(-(CONTENT_MAX_ARC_ANGEL) / 2f + progressAngle);
RectF pointerRectF = new RectF();
pointerRectF.left = -dp2px(1);
pointerRectF.right = dp2px(1);
pointerRectF.top = -compassRadius * 2 / 3f;
pointerRectF.bottom = 0;
mTextPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mTextPaint.setColor(0xee5191FF);
canvas.drawRoundRect(pointerRectF, 0, 0, mTextPaint);
canvas.restoreToCount(count);
}
private void drawArcSection(Canvas canvas, float strokeWidth) {
float centerLineWidth = strokeWidth - lineRadius * 2;
float degree = MAX_ARC_ANGLE * 2f / (11f);
int saveCount = canvas.save();
canvas.translate(centerX, centerY);
mTextPaint.setColor(0xffFF1D1D); //圆弧 - 最左侧边缘
drawArcRoundLine(canvas, outArcR, startAngle, degree, centerLineWidth);
mTextPaint.setColor(0xff10D659);//圆弧 - 最右侧边缘
drawArcRoundLine(canvas, outArcR, startAngle + MAX_ARC_ANGLE - degree, degree, centerLineWidth);
mTextPaint.setStrokeWidth(strokeWidth); //圆弧 - 最做侧第二半圆
mTextPaint.setColor(0xffF04D11);
drawArcLine(canvas, outArcR, startAngle + degree, degree);
mTextPaint.setColor(0xffF8DF38); //圆弧 - 最右侧第二半圆
drawArcLine(canvas, outArcR, startAngle + MAX_ARC_ANGLE - degree * 2, degree);
//圆弧 - 顶部半圆
mTextPaint.setColor(0xffFEA315);
drawArcLine(canvas, outArcR, startAngle + degree * 2, degree + degree / 2);
mTextPaint.setXfermode(MODE_CLEAR);
drawSplitLines(canvas, outArcR, startAngle, degree, strokeWidth);
mTextPaint.setXfermode(null);
canvas.restoreToCount(saveCount);
}
private void drawSplitLines(Canvas canvas, float outArcR, float startAngle, float degree, float strokeWith) {
float degreePadding = 5f;
int color = mTextPaint.getColor();
mTextPaint.setColor(Color.MAGENTA);
mTextPaint.setStrokeWidth(strokeWith + dp2px(1));
drawSplitLine(canvas, outArcR, startAngle + degree - degreePadding / 2, degreePadding / 2);
drawSplitLine(canvas, outArcR, startAngle + degree * 2 - degreePadding / 2, degreePadding / 2);
drawSplitLine(canvas, outArcR, (startAngle + degree * 2 + degree + degree / 2), degreePadding / 2);
drawSplitLine(canvas, outArcR, startAngle + MAX_ARC_ANGLE - degree, degreePadding / 2);
mTextPaint.setColor(color);
}
private void drawArcLine(Canvas canvas, float outArcR, float startAngle, float swipeAngle) {
RectF arcRect = new RectF();
arcRect.left = -outArcR;
arcRect.right = outArcR;
arcRect.top = -outArcR;
arcRect.bottom = outArcR;
mTextPaint.setStrokeCap(Paint.Cap.BUTT);
canvas.drawArc(arcRect, startAngle, swipeAngle, false, mTextPaint);
}
private void drawSplitLine(Canvas canvas, float outArcR, float startAngle, float swipeAngle) {
RectF arcRect = new RectF();
arcRect.left = -outArcR;
arcRect.right = outArcR;
arcRect.top = -outArcR;
arcRect.bottom = outArcR;
mTextPaint.setStrokeCap(Paint.Cap.BUTT);
canvas.drawArc(arcRect, startAngle, swipeAngle, false, mTextPaint);
}
private void drawArcRoundLine(Canvas canvas, float outArcR, float startAngle, float swipeAngle, float centerLineWidth) {
RectF arcRect = new RectF();
arcRect.left = -outArcR;
arcRect.right = outArcR;
arcRect.top = -outArcR;
arcRect.bottom = outArcR;
if (centerLineWidth <= 0) {
//如果中间线宽为0,那么就没必要绘制圆角了,直接使用ROUND类型
mTextPaint.setStrokeCap(Paint.Cap.ROUND);
canvas.drawArc(arcRect, startAngle, swipeAngle, false, mTextPaint);
} else {
mTextPaint.setStrokeCap(Paint.Cap.SQUARE); //绘制中间圆弧
mTextPaint.setStrokeWidth(centerLineWidth);
canvas.drawArc(arcRect, startAngle, swipeAngle, false, mTextPaint);
//下面绘制两侧圆弧
mTextPaint.setStrokeCap(Paint.Cap.ROUND);
mTextPaint.setStrokeWidth(lineRadius * 2);
RectF oArcRect = new RectF();
oArcRect.left = -outArcR - centerLineWidth / 2;
oArcRect.right = outArcR + centerLineWidth / 2;
oArcRect.top = -outArcR - centerLineWidth / 2;
oArcRect.bottom = outArcR + centerLineWidth / 2;
canvas.drawArc(oArcRect, startAngle, swipeAngle, false, mTextPaint);
RectF iArcRect = new RectF();
iArcRect.left = -outArcR + centerLineWidth / 2;
iArcRect.right = outArcR - centerLineWidth / 2;
iArcRect.top = -outArcR + centerLineWidth / 2;
iArcRect.bottom = outArcR - centerLineWidth / 2;
canvas.drawArc(iArcRect, startAngle, swipeAngle, false, mTextPaint);
}
}
private void drawTextScale(Canvas canvas, float centerX, float centerY, float pointerLength) {
int id = canvas.save();
canvas.translate(centerX, centerY);
final String startText = "0%";
final String endText = maxProgress + "%";
mTextPaint.setTextSize(dp2px(12));
float offsetAngle = (180 - MIN_ARC_ANGLE) / 2f; //计算从X轴方向逆时针的角度
float sx = (float) (Math.cos(Math.toRadians((MIN_ARC_ANGLE + offsetAngle + offsetDegree))) * pointerLength);
float sy = (float) (Math.sin(Math.toRadians((MIN_ARC_ANGLE + offsetAngle + offsetDegree))) * pointerLength);
canvas.drawText(startText, sx - mTextPaint.measureText(startText) / 2, sy + getTextPaintBaseline(mTextPaint), mTextPaint);
float x = (float) (Math.cos(Math.toRadians((offsetAngle - offsetDegree))) * pointerLength);
float y = (float) (Math.sin(Math.toRadians((offsetAngle - offsetDegree))) * pointerLength);
canvas.drawText(endText, x - mTextPaint.measureText(endText) / 2, y + getTextPaintBaseline(mTextPaint), mTextPaint);
canvas.restoreToCount(id);
}
private void drawTextBlock(Canvas canvas, float centerX, float centerY) {
float ratio = (progress * 1F / maxProgress * 1f);
int id = canvas.save();
canvas.translate(centerX, centerY);
final String text = "+" + progress;
ColorBlock colorBlock = computeColorBlock(ratio);
if (colorBlock != null) {
mTextPaint.setColor(colorBlock.color);
}
final float textSize = dp2px(40);
final float textUnitSize = dp2px(20);
final float textPadding = dp2px(2);
final float topOffset = dp2px(5) * -1f;
mTextPaint.setTextSize(textSize);
mTextPaint.setFakeBoldText(true);
final float textBaseline = getTextPaintBaseline(mTextPaint);
float textWidth = mTextPaint.measureText(text);
mTextPaint.setTextSize(textUnitSize);
mTextPaint.setFakeBoldText(false);
float textUnitWidth = mTextPaint.measureText(UNIT_PERCENT);
float topTextWidth = textWidth + textUnitWidth + textPadding;
mTextPaint.setTextSize(textSize);
mTextPaint.setFakeBoldText(true);
canvas.drawText(text, -topTextWidth / 2, textBaseline + topOffset, mTextPaint);
mTextPaint.setTextSize(textUnitSize);
mTextPaint.setFakeBoldText(false);
canvas.drawText(UNIT_PERCENT, -topTextWidth / 2 + textWidth + textPadding, textBaseline + topOffset, mTextPaint);
mTextPaint.setTextSize(dp2px(15));
mTextPaint.setFakeBoldText(false);
mTextPaint.setColor(0xddffffff);
float bottomTextWidth = mTextPaint.measureText(tagText);
canvas.drawText(tagText, -bottomTextWidth / 2, topOffset + textBaseline + getTextHeight(mTextPaint) + getTextPaintBaseline(mTextPaint), mTextPaint);
canvas.restoreToCount(id);
}
//根据进度计算颜色范围,实现文字和指针的变色
private ColorBlock computeColorBlock(float ratio) {
if (disableComputeColorBlock) {
return ColorBlock.build(ratio, Color.WHITE);
}
if (ratio <= 0) {
return colorBlocks[0];
}
if (ratio >= 1f) {
return colorBlocks[colorBlocks.length - 1];
}
for (int i = 0; i < colorBlocks.length; i++) {
if (ratio > colorBlocks[i].ratio) {
continue;
}
if (colorBlocks[i].ratio == ratio) {
return colorBlocks[i];
}
int preIndex = i - 1;
float dx = Math.abs(colorBlocks[i].ratio - ratio) - Math.abs(colorBlocks[preIndex].ratio - ratio);
if (dx > 0) {
return colorBlocks[preIndex];
}
return colorBlocks[i];
}
return ColorBlock.build(ratio, Color.WHITE);
}
public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDisplayMetrics);
}
public float sp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dp, mDisplayMetrics);
}
public static int argb(
int alpha,
int red,
int green,
int blue) {
return (alpha << 24) | (red << 16) | (green << 8) | blue;
}
//真实宽度 + 笔画上下两侧间隙(符合文本绘制基线)
private static int getTextHeight(Paint paint) {
Paint.FontMetricsInt fm = paint.getFontMetricsInt();
int textHeight = ~fm.top - (~fm.top - ~fm.ascent) - (fm.bottom - fm.descent);
return textHeight;
}
/**
* 基线到中线的距离=(Descent+Ascent)/2-Descent
* 注意,实际获取到的Ascent是负数。公式推导过程如下:
* 中线到BOTTOM的距离是(Descent+Ascent)/2,这个距离又等于Descent+中线到基线的距离,即(Descent+Ascent)/2=基线到中线的距离+Descent。
*/
public static float getTextPaintBaseline(Paint p) {
Paint.FontMetrics fontMetrics = p.getFontMetrics();
return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent;
}
public void setDisableComputeColorBlock(boolean disableComputeColorBlock) {
this.disableComputeColorBlock = disableComputeColorBlock;
invalidate();
}
}
四、总结
线头看似是简单的问题,但是也是容易被忽略的细节,绘制View时,一定要了解线头的意义,从而实现更复杂的组合,避免出现绘制不理想的情况。