近两年做社交、会议、调研类App,投票进度条几乎是标配功能。用户投完票,光告诉“已提交”太骨感,只有立刻看到赞成和反对的实时占比,用户才对结果有感知。这个需求听起来不复杂,真正落地时却有一堆细节:双色数据怎么在同一进度条上比例绘制、百分比文字怎么居中不抖动、动画怎么做到平滑不抢戏。这篇把我自己实现投票进度条的过程完整拆开,从需求分析、自定义View绘制到数据绑定、坑位排查,都给你过一遍,代码可直接抄进工程。
1. 方案选型:为什么我放弃了三方库,选择自绘
1.1 这类进度条常见的三种实现路线
投票类进度条市面上确实有几种现成思路,我把它归纳成三种流派,方便你对号入座选方案。
第一种是用原生View组合搭积木,核心是两根横向渐变或纯色的矩形View按比例撑开,比如LinearLayout加layout_weight,或者ConstraintLayout用GuideLine控制两边宽度比例。优点是零成本、改起来直观,缺点是两端衔接处的圆角、缝隙、动画同步很难做到精细,做出来的东西总有点“玩具感”。
第二种是用第三方开源库,比如一些圆形进度条库、动画依赖库,稍作封装也能实现双色圆环。优点是见效快,缺点是一旦涉及定制,比如加一条中轴线、让反对的一端在鼠标悬停时高亮,就要去翻库源码重写,扩展成本比想象中高。
第三种就是自己写Custom View,用Canvas按百分比绘制左右两段圆弧或矩形,所有视觉细节都握在自己手里。这也是我今天重点展开的方案。它是三种方案中唯一让我能做到“动画时间线完全可控、颜色变化自然、后期加需求不抠脑壳”的路径,所以直接锁定。
1.2 自绘方案的真实收益与代价
选自定义View不是因为它更高级,而是因为投票进度条这种小控件,恰恰踩中了通用控件的盲区:它需要同时展示两个数据维度,而且这两个维度的视觉权重随时会变。
如果只画一个纯色的圆形进度条,随便什么库都能干。但“赞成”和“反对”是两个独立量,要在一个视图中表达比例关系,还得把中间的分界线挤出来,这已经超出了多数存量View的语义边界,与其报个第三方库再魔改,不如从零写一个。
代价是代码量略涨,大概多出三百多行。但你换来的是一个从数据到视觉完全自控的组件。实际维护过程中,一旦产品经理提出“加一个平票高亮”“把百分比从整数改成一位小数”,你在自己的代码里改十分钟就能上线,不用去翻仓库找更新日志。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心绘制逻辑:先把静态版本画干净,再谈动画
2.1 自定义View的基础骨架
我建议你从静态绘制开始,先把View的样子固定住,再叠加动画,否则很容易在调试动效的时候被绘制逻辑分心。新建一个类,比如VoteProgressView,继承View。先定义必须的属性参数,我通常会配置十几个可调节项,但从最小可运行版本出发,下面几个是核心。
基础属性大致是:赞成票颜色、反对票颜色、进度条宽度、百分比文字颜色、文字大小、圆角参数等。在init里通过TypedArray读取xml自定义属性,并给每个值设好默认。这一步看起来繁琐,但千万别省略,否则后续换肤、换主题时会想骂人。
接下来在onMeasure里处理wrap_content。自定义View一个常见的坑是:不处理测量逻辑就直接在xml里写死dp,这样在不同屏幕密度下容易出问题。我的经验是给这个进度条设置一个合理的默认宽度,比如dp2px(300),高度根据进度条厚度加文字高度自适应,不能让高度写死。
2.2 双色扇形/矩形区域的核心绘制命令
这次实现我选了半圆形为主视觉,因为投票比例天然适合用半圆来展示,左右各占一半,视觉上与“赞成/反对”的二元语义天然匹配。
关键Canvas绘制逻辑如下。
半圆底座的圆心在View宽度的二分之一处,半径取宽度的一半减掉边框留白。先用canvas.drawArc画一个半圆的底部背景色,这里通常用一个浅灰或深灰的描边,作为“未参与投票”的底色。
然后分别绘制赞成和反对的弧线。这里有一个极其重要的细节:Canvas的drawArc起始角度方向与常规认知不太一样,0度在正右方,负角度才是逆时针方向。画赞成票那一段时,起始角度应该算成-90度(正上方),终止角度从-90度往正方向扫过赞成比例 * 180度;反对票则从90度(正下方)往负方向扫过反对比例 * 180度。如果你顺手把角度搞反,用户看到的进度条就像镜像了一样,左和右完全颠倒。
为此我封装了一个方法,确保角度计算只在一处维护:
java复制private void drawProportionArc(Canvas canvas, RectF arcRect, float startAngle, float sweepAngle, Paint paint) {
if (sweepAngle <= 0) return;
canvas.drawArc(arcRect, startAngle, sweepAngle, false, paint);
}
sweepAngle小于0时不绘制,是为了避开“赞成0票、反对100票”时绘制的弧段方向反转问题。
2.3 中间分界线和两边百分比文字的绘制细节
半圆成形后,还有两个细节决定精致度。一是中轴线,二是文字位置。
中轴线是一根从圆心垂直向下的细线,颜色通常是白色或半透明白,宽度根据View大小动态设为strokeWidth = progressWidth / 6。这根线的作用是强化“赞成/反对两个阵营”的视觉割裂,没有这根线时,两种颜色连接处会有明显的接缝感,有了一根干净的分隔线后,感官上就立起来了。
百分比文字的绘制,不能用canvas.drawText的默认坐标随手丢。drawText的坐标不是文字左上角,而是文字基线。如果直接按View高度的一半去放,文字会偏上,越小的字号越明显。我通常先用Paint.FontMetrics计算文字baseline偏移,再把文字中心对准弧面正中央。
java复制float textCenterX = halfRectF.centerX();
float textCenterY = halfArcCenterY + (fontMetrics.bottom - fontMetrics.top) / 2 - fontMetrics.bottom;
canvas.drawText(percentText, textCenterX, textCenterY, percentPaint);
这样文字无论显示“50%”还是“100%”,垂直方向始终稳定,不跳跃。另一个小细节是,百分比数字改变时宽度会变化,比如从“9%”变到“10%”,水平方向的抖动也难免。解决方法是给文字区域设置一个固定宽度,用StaticLayout或者TextPaint.measureText先量出最大字符串宽度,再居中对齐,而不是简单地把x坐标固定在圆心。
3. 数据驱动与状态管理:别让View直接碰数据源
3.1 暴露数据入口:setData和setResult
投票进度条本质上是结果型控件,不应该由View自己拉数据。我在实现中只暴露出一个方法:setData(approveCount, opposeCount),内部把票数转成百分比,再触发重绘。这样上层是拿LiveData、Flow还是直接回调里更新,都与View无关。
这里有一个隐藏的坑点:百分比在多个线程间变化时,UI状态一定要回主线程更新。Custom View的postInvalidate()虽然可以在非UI线程调用,但如果数据本身由线程池计算,同时又涉及动画,稳妥的做法是回调到主线程再调setData。我早期偷懒直接在回调里更新,上线后偶现View闪烁,后来排查就是线程竞态引起的绘制状态错乱。
java复制public void setData(int approveCount, int opposeCount) {
int total = approveCount + opposeCount;
if (total <= 0) {
this.approvePercent = 0f;
this.opposePercent = 0f;
} else {
this.approvePercent = approveCount * 1.0f / total;
this.opposePercent = opposeCount * 1.0f / total;
}
startAnimator();
}
如果总票数为0,必须做保护,直接强制两个百分比为0,避免出现“0/0=NaN”的情况。
3.2 用ValueAnimator把进度条“摇”起来
静态的进度条虽然信息完整,但投票结果页需要一点动态反馈。这里不只是为了炫技,更是为了引导用户视线:人的视觉对“移动”天然敏感,动画结束后视线会停留在比例悬殊的一方,这个心理效应在投票场景很管用。
我用一个ValueAnimator控制从0到最终百分比的插值。动画时长通常在800ms到1200ms之间。太短显得仓促,太长用户会不耐烦。我实测下来900ms在多数投票结果页观感最佳。
在动画更新回调里,把实时百分比存入currentApprovePercent和currentOpposePercent,再调用invalidate()。每次动画只改变一个float,绘制逻辑不用感知动画是否存在,这样动画结束后的静态渲染和动画过程中的渲染走同一套代码,不会出现动画前后两套视觉不一致的问题。
特别提醒:如果用户在动画过程中又收到了新的数据,要记得mAnimator.cancel()再重启。否则上一次动画的回调会覆盖新数据的最终值,导致进度条停在错误的位置。
3.3 百分比舍入策略:整数还是小数
投票结果页对精度的要求,取决于受众。通常用户只看整数百分比,但后端返回的可能是精确的浮点比例。我的处理方案是:组件内部保留两位小数用于绘制动画的精细度,展示文案时按场景决定。
如果产品要求“百分比相加严格等于100%”,就要避开直接四舍五入:赞成和反对各自四舍五入后可能相加得到99%或101%。这是一种需要向产品提前解释的“精确舍入问题”。实现上可以先计算三方的浮点比例,再通过调整最大的余数项使得总和恒定为100%。投票只有两方时简单很多,只要保证approvePercent + opposePercent = 1.0即可,用1.0f - approvePercent来算反对比例,而不是单独算一次除法。
4. 细节打磨:文字、颜色与圆角如何控制不留死角
4.1 百分比文字位置的可配置化方案
不同产品对“百分比显示在哪里”的要求不一样。有些要在半圆内部集中显示两行,比如赞成60% / 反对40%,有些则要求把百分比放在弧形两端外侧,以便更直观地与颜色区域对应。
我做了一个可配置项,用来切换文字位置。默认是居中显示两行,一行赞成,一行反对。若自定义属性app:percentPosition设为outside,则左右两端各显示一个百分比。该模式适合宽度留白较多的场景。
文字位置切换时,绘制逻辑根据当前属性分别计算x坐标:内部模式就是圆心下方叠加两行文字,外部模式则把文字对齐到弧线的两端。注意外部模式要预留文字与View边界的距离,否则“100%”出现时容易被裁切,我在onMeasure里给外部模式的左右padding额外增加了dp2px(12)。
4.2 颜色搭配与圆角的审美经验
投票进度条的颜色,核心原则是对比度充足但不过分刺眼。我见过不少项目直接用纯绿和纯红,视觉上像交通信号灯,放在商务App里很违和。建议采用带一点灰度的主色,比如赞成用#3EB575、反对用#F56C6C,整体观感柔和,且对红绿色盲用户也相对友好。
圆角处理上,推荐给两个色段内部加微小的圆头效果。具体来说,绘制弧段时使用Paint.Cap.ROUND,这样每段弧的两端会带上圆形帽头,让连接处不再生硬。但这会带来一个问题:当反对比例为100%时,反对弧段整个铺满半圆,两端圆帽会让端部多出一点头,视觉上超出底座。我要在绘制时根据百分比动态决定是否启用圆帽,比如当段比例大于等于99%时,关闭圆帽效果。
4.3 让文字与进度条同频变化的技巧
进度条动画是平滑的,但文字如果直接从“12%”跳变到“56%”,观感会断裂。我让文字跟随动画的浮点值实时变化,即每次都把currentApprovePercent乘以100后再格式化,而不是等动画结束才更新文字。
这里需要额外处理格式化性能。动画一帧执行一次String.format,通常没问题,但如果设备很老,或者页面中有多个进度条实例,建议把这部分计算放到ValueAnimator的onAnimationUpdate里直接做,避免每帧在onDraw中重复计算。onDraw应该保持极简,只干绘制这一件事,所有状态都应该在更新方法里预计算好。
5. 常见问题排查:从布局跑飞到数据不刷新
5.1 为什么进度条画出来是空的或只显示一半
这类问题的元凶,九成集中在测量阶段。自定义View的onMeasure里,如果直接使用MeasureSpec.AT_MOST分支且没给定具体尺寸,View的宽高在部分布局中可能被压缩成0。比如放在ConstraintLayout里,如果只给了match_parent而没有约束宽度,View实际拿到的测量宽度可能是0,导致RectF构建失败,画出来一片空白。
排查方法很简单:在onDraw第一行临时打Log输出getWidth()与getHeight(),如果宽高为0,基本就是测量问题。这时回到xml里检查父布局约束,或者保证onMeasure中AT_MOST分支有兜底宽度。
5.2 动画结束后仍出现“闪烁”或“重影”
如果我连续调用两次setData,比如投票后服务端推送更新,而第二次触发的动画还未结束时第三次又来了,就容易出现“闪烁”。原因是多次cancel和start时,旧动画的onAnimationEnd回调会执行,把最终的百分比强写到当前View上,刚好覆盖新动画的中间值。
正确做法是在onAnimationEnd里加一个token判断,或者每次startAnimator()时用一个int自增标记来标识当前动画ID,只有最新动画的结束回调才能写终值。
java复制animatorId++;
final int currentId = animatorId;
intValueAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
if (currentId == animatorId) {
approvePercent = targetApprovePercent;
opposePercent = targetOpposePercent;
invalidate();
}
}
});
5.3 旋转屏幕或者切换Fragment时崩溃
屏幕旋转会销毁View,但ValueAnimator持有View引用时如果没有在onDetachedFromWindow中清理,轻则内存泄漏,重则在某些厂商ROM上直接抛异常。
我的标准处理方式是重写onDetachedFromWindow,在这里统一animator.cancel(),并且把动画回调置空。这个习惯对所有自定义动画View都适用,不只是投票进度条。刚开始写自定义View时,我经常漏掉这一步,后来所有动画相关View都默认补上,这个坑从此绝迹。
6. 扩展思路:从半圆进度条到更丰富的投票交互
6.1 双圆环与三态支持
当你掌握了基础的半圆绘制后,扩展方向是灵活的。最近在做的一个需求里,用户不仅能投赞成和反对,还能投“弃权”。我在原有基础上加了一个“弃权”百分比,绘制时把半圆切成三段,中间用固定宽度的小缝隙隔开,看起来像三个连续弧段。
实现并不复杂,核心就是把弧度分成三段来扫,中间预留固定的间距,间距颜色用底色或透明色填充。代码上只要把drawArc拆成三段即可,难点只在于每段弧的sweepAngle要减去间距对应的角度,否则三段弧加起来会超过180度。这个方法同样适用于表达“剩余票数”等三态场景。
6.2 异步加载数据和骨架屏状态
投票结果页打开时,数据通常从网络加载。在数据显示之前,进度条应该有一个骨架屏状态,避免直接显示空的无色半圆。我的做法是给VoteProgressView增加一个loading状态,在该状态下用进度条底色绘制整个半圆,中间显示“投票加载中”的文案。
加载完成后,调用setData进入动画状态。这么做不仅提升了页面观感,也减少了从“空View”到“有数据”之间的生硬切换。
6.3 多语言与超大字体模式适配
如果App需要支持阿拉伯语等从右往左排版的地区,投票进度条的方向也要随之反转。这个不能靠绘制时判断语言,因为Canvas绘制不受系统布局方向自动影响,必须在setLayoutDirection回调或onDraw里手动判断getLayoutDirection(),当为RTL时,镜像整个绘制坐标。
超大字体模式下,百分比文字会放大,默认的折行策略可能导致文字重叠。我在onDraw开头通过对文字高度的测量,动态调整弧线的半径和文字区域的高度,简单说就是当字体变大时,弧线整体下移并缩小,给文字留出空间,保证不重叠。
6.4 无障碍与点击反馈的补充
投票进度条通常还要支持点击“重新投票”或者查看详情。我在View上增加了performClick的合规处理,在onTouchEvent中处理点击手势时,必须调用performClick(),否则无障碍服务无法播报,这是Google Play审核时重点检查的点。同时给进度条区域加上contentDescription,把“赞成率62%,反对率38%”等关键信息暴露给无障碍服务。
实现到了这个阶段,投票进度条已经不只是“一根画了颜色的弧线”,而是一个有数据支撑、有状态管理、有交互反馈的完整组件。梳理下来,核心工作其实不在写Canvas那几行命令,而是在于状态切换、线程管理、测量与绘制的边界划分,这些才是真正决定这个组件稳不稳、好不好扩展的地方。
