1. 投票进度条:一个看起来简单、做起来全是细节的控件
做Android开发这几年,我接到过不少“看起来很简单”的需求,投票进度条绝对能排进前三。乍一看就是两个色块按比例排开,上面加个百分比数字,但真动手做了,你会发现里面藏着不少坑:数据怎么传、比例怎么算、动画怎么加、极端值怎么处理、横竖屏切换怎么不丢状态……一个不留神,做出来的东西要么比例对不上,要么动画卡顿,要么在真机上显示效果稀烂。
这篇文章就围绕“Android投票进度条,支持显示赞成和反对的百分比”这个需求,把完整实现思路、核心代码、踩坑记录都梳理一遍。适合刚接触自定义View的初学者,也适合想快速把投票类控件落地到项目里的中级开发者。我会尽量少说废话,直接给结论、给代码、给原因。
先说清楚这个控件的核心诉求:输入赞成票数和反对票数,输出一个两端对齐、中间带分隔线、两端带百分比文字的进度条。听起来很简单,但实际要考虑的点有:
- 百分比如何计算,总票数为0时怎么办;
- 两端百分比文字长度不一样,怎么布局才不遮挡;
- 要不要动画,动画时间多长,打断动画怎么办;
- 颜色和主题怎么适配,深色模式下是否可用;
- 数据变化时如何刷新,是重建还是局部更新。
把这些想清楚了,再动手写代码,你会发现整个实现路径非常清晰。下面我按自己的实际开发顺序来讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体设计与方案选型
2.1 为什么选择自定义View而不是组合控件
接到这个需求,第一反应可能是用两个LinearLayout加Weight来做,左边红色占width * ratio,右边绿色占width * (1 - ratio),中间再插一个View做分割线。我刚开始也是这么想的,但仔细一想就放弃了,原因有三。
第一,使用Weight布局虽然能实现比例分配,但当某一方的票数为0时,Weight分配会出现一个很尴尬的情况:宽度为0的View虽然在测量时不会显示,但它的存在依然会影响其他子控件的测量和绘制,尤其是当你需要在比例变化时做动画,Weight动画的插值计算非常别扭,得手动控制两个方向的宽度变化,很容易出现中间分割线跳变的问题。
第二,百分比文字的位置很难精确控制。如果用两个TextView放在两端,当反对票百分比特别大(比如99%)而赞成票只有1%时,左边的TextView会和右边的TextView重叠,或者被压缩到看不见,必须额外做minWidth限制或者动态调整文字位置,逻辑复杂度反而上去了。
第三,动画效果不好做。投票进度条最常见的交互是数据刷新时比例棒滑动切换,用Weight布局做这个动画需要同时操作两个子View的宽度参数,每次都走requestLayout,性能损耗大,而且动画过程中的视觉连续性差。
所以最终我选择了自定义View的方案,重写onDraw方法,直接用Canvas绘制背景条、比例块、分隔线和文字。这样做的好处是可以完全控制绘制逻辑,包括文字位置的动态计算、动画插值、状态缓存,而且避免了布局嵌套带来的性能问题。整个控件的对外调用方式也很干净:一行代码设置数据,内部自己处理重绘。
2.2 方案选型背后的取舍逻辑
自定义View并不是没有缺点,最大的问题就是开发量比其他方案大,需要自己处理测量逻辑、动画逻辑、触摸事件(如果有的话)。但在这个需求里,自定义View的收益是远大于成本的。
具体来说,我选择重写onDraw而不是基于已有控件做扩展,还考虑到了一个问题:投票进度条往往不止一个颜色展示,后期可能会加“平票”的高亮状态,加“已投票”后的置灰状态,加无动画的静态展示模式。这些扩展在自定义View上只需要增加几个分支判断,但在组合控件方案里,需要改动大量布局文件,维护成本根本不在一个量级。
另外,自定义View还有一个隐性的好处:可以在一个类里把渲染逻辑全部封装好,便于做单元测试。比如百分比计算、文字位置适配、动画插值这些纯逻辑,可以抽成方法直接测试,这在组合控件方案里很难做到。
在API级别上,这个控件只需要兼容到API 21(Android 5.0)就够用了,因为涉及的都是基础绘制API,不需要太新的特性,Canvas、Paint、ValueAnimator在旧版本上都能正常工作。当然,如果你的项目minSdk更低,也没关系,这些API从API 1就有了。
2.3 对外接口设计
自定义View做得好不好,接口设计占一半。我这个投票进度条的对外接口采用了链式调用的方式,让使用方一行代码就能完成配置。
java复制public class VoteProgressBar extends View {
public VoteProgressBar(Context context) {
this(context, null);
}
public VoteProgressBar(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public VoteProgressBar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
}
提供给外界的核心方法就是设置数据:
java复制/**
* 设置投票数据
*
* @param favorCount 赞成票数
* @param opposeCount 反对票数
* @param animated 是否需要播放切换动画
*/
public void setVoteData(int favorCount, int opposeCount, boolean animated) {
if (favorCount < 0 || opposeCount < 0) {
throw new IllegalArgumentException("投票数不能为负数");
}
int total = favorCount + opposeCount;
float newFavorRatio = total == 0 ? 0.5f : (float) favorCount / total;
float newOpposeRatio = total == 0 ? 0.5f : (float) opposeCount / total;
if (animated && mCurrentFavorRatio != newFavorRatio) {
animateTo(newFavorRatio, newOpposeRatio);
} else {
mCurrentFavorRatio = newFavorRatio;
mCurrentOpposeRatio = newOpposeRatio;
invalidate();
}
}
这里有个细节值得说明:当总票数为0时,我看过很多实现直接把比例设置为0和0,但实际显示效果是左边完全空白,右边也是完全空白,整个进度条看起来像一条灰线,很丑。我的处理方式是:总票数为0时,两边比例各占50%,显示效果就是一条对称的进度条,中间一条分隔线,两端的百分比都显示0%,用户一看就明白“还没有人投票”。这比完全空白要直观得多。
总票数为0时各占50%还有一个附加好处:后续如果有动画,从0投票状态切到第一次有投票状态时,动画是从中间向两边展开的,视觉效果非常自然,不会出现从空白“唰”一下跳出来的突兀感。
3. 核心细节解析与实操要点
3.1 初始化与画笔配置
自定义View的第一步是初始化画笔,这里有非常多的细节坑,我挨个说明。
java复制private Paint mLeftPaint;
private Paint mRightPaint;
private Paint mTextPaint;
private Paint mDividerPaint;
private void init() {
// 左侧赞成色块
mLeftPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mLeftPaint.setColor(ContextCompat.getColor(getContext(), R.color.vote_favor_color));
// 右侧反对色块
mRightPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mRightPaint.setColor(ContextCompat.getColor(getContext(), R.color.vote_oppose_color));
// 中间分隔线
mDividerPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mDividerPaint.setStrokeWidth(dp2px(2f));
mDividerPaint.setColor(Color.WHITE);
// 文本画笔
mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mTextPaint.setTextSize(sp2px(12f));
mTextPaint.setColor(Color.WHITE);
mTextPaint.setFakeBoldText(true);
mCurrentFavorRatio = 0.5f;
mCurrentOpposeRatio = 0.5f;
}
几个容易出问题的点:
第一,Paint.ANTI_ALIAS_FLAG一定要加。不加的话,色块边缘会出现明显的锯齿,尤其是进度条宽度较窄、颜色饱和度高的时候,锯齿非常扎眼。我自己就吃过这个亏,在低分辨率的测试机上效果惨不忍睹。
第二,文本画笔和色块画笔要分开创建,不要复用。因为它们的用途不同,属性设置也不同,混用会导致状态污染。比如色块画笔设置了StrokeWidth,文本画笔又需要这个属性,但色块画笔可能设置了Style为FILL,文本画笔需要的是FILL还是STROKE状态就混乱了。分开创建,各管各的,一劳永逸。
第三,颜色使用资源文件管理,不要硬编码到代码里。因为这个控件未来大概率要适配主题切换、深色模式,颜色资源化之后,后续适配只需要增加values-night里的对应颜色即可,改一个资源文件,全App生效。
第四,dp2px和sp2px的转换工具类不要用错了。进度条的圆角半径、分隔线宽度这些用dp2px,文字大小用sp2px,因为sp会跟随系统字体缩放。如果你用dp2px设置文字大小,用户在系统里把字体调到最大时,你的文字不会变大,就会和整体UI风格脱节。如果用户把字体调到最小,也是一样的问题。
3.2 测量逻辑:高度自适应,宽度填满
投票进度条的高度一般不需要固定,我给它设置了最小高度,并且支持在布局里通过layout_height控制。这里的测量逻辑采用了自适应权重方案。
java复制@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int desiredWidth = getSuggestedMinimumWidth() + getPaddingLeft() + getPaddingRight();
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int width;
if (widthMode == MeasureSpec.EXACTLY) {
width = widthSize;
} else if (widthMode == MeasureSpec.AT_MOST) {
width = Math.min(desiredWidth, widthSize);
} else {
width = desiredWidth;
}
int minHeight = dp2px(36f);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int height;
if (heightMode == MeasureSpec.EXACTLY) {
height = heightSize;
} else if (heightMode == MeasureSpec.AT_MOST) {
height = Math.min(minHeight, heightSize);
} else {
height = minHeight;
}
setMeasuredDimension(width, height);
}
要注意的是,如果高度模式是AT_MOST(对应布局里的wrap_content),我使用了Math.min而不是直接取minHeight。原因是如果父布局限制了最大高度,控件就应该遵从这个限制,不能强行撑出去。但这里也有一个视觉问题:如果高度被压缩得很小,比如只有10dp,文本就画不下了,需要在onDraw里做文字是否显示的判断。我的方案是:当高度小于dp2px(24f)时,不绘制文字,只画色块。
3.3 绘制逻辑:Text的基线、分隔线、百分比的真实处理
接下来是重头戏onDraw。这里我先放完整代码,再一个一个讲重点。
java复制@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
float contentLeft = getPaddingLeft();
float contentRight = getWidth() - getPaddingRight();
float contentTop = getPaddingTop();
float contentBottom = getHeight() - getPaddingBottom();
float contentHeight = contentBottom - contentTop;
if (contentHeight <= 0 || contentRight <= contentLeft) {
return;
}
float barWidth = contentRight - contentLeft;
// 1. 计算左右色块的宽度(考虑分隔线宽度)
float dividerWidth = mDividerPaint.getStrokeWidth();
float favorWidth = barWidth * mCurrentFavorRatio;
float opposeWidth = barWidth * mCurrentOpposeRatio;
// 2. 绘制左侧赞成色块
float favorLeft = contentLeft;
float favorTop = contentTop;
float favorRight = favorLeft + favorWidth;
float favorBottom = contentBottom;
canvas.drawRect(favorLeft, favorTop, favorRight, favorBottom, mLeftPaint);
// 3. 绘制右侧反对色块
float opposeRight = contentRight;
float opposeLeft = opposeRight - opposeWidth;
canvas.drawRect(opposeLeft, contentTop, opposeRight, contentBottom, mRightPaint);
// 4. 绘制中间分隔线
float dividerX = contentLeft + favorWidth;
if (dividerX >= contentLeft + dp2px(1f) && dividerX <= contentRight - dp2px(1f)) {
canvas.drawLine(dividerX, contentTop, dividerX, contentBottom, mDividerPaint);
}
// 5. 绘制百分比文字
if (contentHeight >= dp2px(24f)) {
drawPercentText(canvas, contentLeft, contentRight, contentTop, contentBottom);
}
}
3.3.1 文本基线的计算
绘制文字最烦人的一点就是Android的drawText方法的Y坐标不是文字的顶部,也不是底部,而是文字的基底线(baseline)。搞不清楚这个,你画出来的文字位置总会偏上或偏下,怎么调都不对。
文字位置的计算公式如下:
java复制private void drawPercentText(Canvas canvas, float contentLeft, float contentRight,
float contentTop, float contentBottom) {
String favorText = String.format(Locale.getDefault(), "赞成 %.0f%%", mCurrentFavorRatio * 100);
String opposeText = String.format(Locale.getDefault(), "反对 %.0f%%", mCurrentOpposeRatio * 100);
Paint.FontMetrics fm = mTextPaint.getFontMetrics();
// 文字垂直居中:以基线为基准计算
float baseline = contentTop + (contentBottom - contentTop) / 2f
- (fm.descent - fm.ascent) / 2f - fm.ascent;
float dividerX = contentLeft + (contentRight - contentLeft) * mCurrentFavorRatio;
// 绘制左侧文本:左对齐,但要保证不被分隔线遮挡
float favorTextWidth = mTextPaint.measureText(favorText);
float favorTextX = contentLeft + dp2px(8f);
if (favorTextX + favorTextWidth > dividerX - dp2px(4f)) {
favorTextX = dividerX - favorTextWidth - dp2px(4f);
}
canvas.drawText(favorText, favorTextX, baseline, mTextPaint);
// 绘制右侧文本:右对齐
float opposeTextWidth = mTextPaint.measureText(opposeText);
float opposeTextX = contentRight - dp2px(8f) - opposeTextWidth;
if (opposeTextX < dividerX + dp2px(4f)) {
opposeTextX = dividerX + dp2px(4f);
}
canvas.drawText(opposeText, opposeTextX, baseline, mTextPaint);
}
基线的计算公式看起来有点吓人,但拆开理解其实很简单:
fm.ascent是文字从基线到最高点的距离,它是负数(因为Y轴向下为正);fm.descent是文字从基线到最低点的距离,它是正数;- 所以
fm.descent - fm.ascent就是文字的总高度; - 我们想让文字垂直居中,就要让文字的中心点等于
contentTop + contentHeight / 2; - 推导公式就是:
baseline + fm.ascent(文字顶部)距离控件顶部的距离,等于baseline + fm.descent(文字底部)距离控件底部的距离。
化简之后的写法就是我上面那行代码,这里直接记住可以用就行。实际开发中我建议封装一个TextUtil.getBaseline方法,后面所有绘制文字的地方都复用。
3.3.2 文字和分隔线的重叠处理
这是一个我在真机上才发现的坑。
当赞成的比例非常接近100%(比如99.6%),而反对只有0.4%时,右侧的“反对 0%”文字长度会比较长(汉字加数字),而右侧色块宽度很窄,这时如果按照纯右对齐绘制,文字会越过中间分隔线,画到左侧的红色区域里去,视觉上非常奇怪。
我处理这个问题的方式是:先按常规位置绘制文字,然后判断文字是否超出了分隔线的位置,如果超了就强行把文字的起始点往分隔线方向挪,挪到紧贴分隔线加一点间距的位置。同时给文字设置一个maxWidth,如果文字宽度本身超过可用空间,就截断或用省略号。
但这里还有一个问题:如果赞成比例是99.9%,反对比例是0.1%,右侧可用宽度只有barWidth * 0.001,大概只有0.4个像素,根本放不下任何文字。这种情况下我的策略是:不再绘制百分比文字,只保留色块显示。毕竟用户看到一条几乎全红、右边只有一条细线的进度条,就知道结果了,硬塞文字反而是画蛇添足。
为了防止measureText在极端情况下计算出NaN或者其他异常值,我做了一层保护:如果favorTextWidth + opposeTextWidth + 总间距 大于 bar宽度,则只显示比例较大一侧的文字,另一侧不绘制。这个逻辑在实际测试里非常有用,尤其是在平板等宽屏幕上。
3.3.3 分隔线的绘制时机
分隔线不是任何时候都绘制的。当某一边的比例为0时,分隔线如果还画在边界上,会看起来像一根竖线嵌在色块里,很突兀。我的判断条件是:当dividerX在contentLeft + 1dp到contentRight - 1dp范围内才绘制,否则不绘制。
还有一个细节:分隔线的颜色我用了白色,搭配2dp的宽度。在浅色背景下够清晰,在深色模式下也还行,因为两边的色块颜色本身偏深,白色分隔线依然有辨识度。但如果你项目里的色块颜色偏浅,分隔线颜色可能要改成深色,这个要根据实际情况调整,不要死抄代码。
3.4 动画的实现与中断处理
投票进度条如果没有动画,总感觉缺了点什么。数据从旧比例切换到新比例时,如果瞬间跳变,用户会觉得突兀。我用了ValueAnimator配合DecelerateInterpolator来实现平滑过渡。
java复制private ValueAnimator mAnimator;
private void animateTo(float targetFavorRatio, float targetOpposeRatio) {
if (mAnimator != null && mAnimator.isRunning()) {
mAnimator.cancel();
}
float startFavorRatio = mCurrentFavorRatio;
float startOpposeRatio = mCurrentOpposeRatio;
long duration = 600;
mAnimator = ValueAnimator.ofFloat(0f, 1f);
mAnimator.setDuration(duration);
mAnimator.setInterpolator(new DecelerateInterpolator());
mAnimator.addUpdateListener(animation -> {
float fraction = animation.getAnimatedFraction();
mCurrentFavorRatio = startFavorRatio + (targetFavorRatio - startFavorRatio) * fraction;
mCurrentOpposeRatio = startOpposeRatio + (targetOpposeRatio - startOpposeRatio) * fraction;
invalidate();
});
mAnimator.start();
}
这个实现里有几个细节:
第一,动画启动前一定要cancel()之前的动画。如果不取消,两个动画同时运行,会同时回调updateListener,两个回调都在修改mCurrentFavorRatio,最后的值完全取决于哪个动画后回调,会出现比例乱跳的bug。我第一次写的时候没注意这个,测试时快速切换数据,进度条就在 30% 和 70% 之间疯狂抽搐,排查了半天才意识到是动画没取消的原因。
第二,动画时长我设了600ms,这个值是我试出来的。太短了(200ms)看起来像闪烁,太长了(1s以上)用户会等得不耐烦。600ms配合DecelerateInterpolator(先快后慢),视觉上比较自然,用户感觉数据是“流畅地滑过去”,而不是“掉帧地挪过去”。
第三,动画取消时要记得把mCurrentFavorRatio设为最后的目标值。否则你取消动画后,进度条会停留在当前位置,而不是跳到最终值,视觉上会有“卡住”的感觉。我这里的写法是:如果新动画要开始,直接cancel()旧动画,然后把startFavorRatio设为当前实际显示的值,新动画从当前位置继续滑向新目标,这就避免了跳变。
第四,动画更新回调里,我没有直接设置最终值,而是根据fraction插值。这里要注意getAnimatedFraction()和getAnimatedValue()的区别。如果插值器是线性的,两者是一样的;但如果用了DecelerateInterpolator,getAnimatedFraction()返回的是时间进度(0到1),getAnimatedValue()返回的是经过插值后的值(比如0 -> 0.5 -> 0.8 -> 1),用getAnimatedValue()来做插值计算会对不上目标比例。所以正确的做法是用getAnimatedFraction()作为插值因子。
3.5 状态保存:横竖屏切换不会丢状态
如果投票进度条所在页面支持横竖屏切换,那就要实现onSaveInstanceState和onRestoreInstanceState,否则Activity重建后控件里的数据就丢了。虽然数据一般会在ViewModel或数据库里保存,但控件自身的状态保持依然是一个好习惯。
java复制private static final String KEY_FAVOR_RATIO = "favor_ratio";
private static final String KEY_OPPOSE_RATIO = "oppose_ratio";
@Override
protected Parcelable onSaveInstanceState() {
Bundle bundle = new Bundle();
bundle.putFloat(KEY_FAVOR_RATIO, mCurrentFavorRatio);
bundle.putFloat(KEY_OPPOSE_RATIO, mCurrentOpposeRatio);
bundle.putParcelable("super_state", super.onSaveInstanceState());
return bundle;
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
if (state instanceof Bundle) {
Bundle bundle = (Bundle) state;
mCurrentFavorRatio = bundle.getFloat(KEY_FAVOR_RATIO, 0.5f);
mCurrentOpposeRatio = bundle.getFloat(KEY_OPPOSE_RATIO, 0.5f);
super.onRestoreInstanceState(bundle.getParcelable("super_state"));
return;
}
super.onRestoreInstanceState(state);
}
这里有个坑需要注意:如果在onRestoreInstanceState里恢复了比例,而调用方又调用了setVoteData方法,两次设置可能会产生一次不期望的闪烁。我的处理方案是:在恢复状态后,立即用恢复后的比例更新数据源,这样调用方再设置数据时,新值和旧值一致,动画判断会跳过,刷新就是一个静态绘制。
4. 实操过程与核心环节实现
4.1 搭建项目与导入依赖
我们用Android Studio来写这个控件。新建一个空项目后,不需要引入任何第三方库,因为这个控件的所有逻辑都用原生API实现。如果你的项目里原本就有androidx.appcompat依赖,那就更好,ContextCompat.getColor可以直接用。
如果你的minSdk在23以下,建议在build.gradle里开启vectorDrawables.useSupportLibrary = true,否则使用向量图标会有兼容问题。我们这个控件不涉及图片,所以影响不大。
gradle复制android {
compileSdk 34
defaultConfig {
applicationId "com.example.voteprogress"
minSdk 21
targetSdk 34
versionCode 1
versionName "1.0"
}
}
4.2 在布局中使用控件
布局文件中的使用方式很简单,就是一个普通的View:
xml复制<com.example.voteprogress.widget.VoteProgressBar
android:id="@+id/voteProgressBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/titleTextView"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
这里重点说下为什么用match_parent而不是wrap_content。因为进度条的本质就是占满可用宽度,让两端的色块比例视觉上更明确。如果宽度只包裹内容,那进度条就只有色块加上文字那么宽,比例感会大打折扣。实际项目中,进度条宽度应该由父布局决定,所以match_parent或ConstraintLayout里的0dp才是正确的宽度设置。
4.3 数据绑定与刷新
在Activity或Fragment里,通过findViewById拿到控件,然后调用setVoteData方法,传入赞成票数和反对票数。
java复制VoteProgressBar voteProgressBar = findViewById(R.id.voteProgressBar);
// 模拟从网络加载数据
int favorCount = 327;
int opposeCount = 128;
// 第一次加载,不需要动画
voteProgressBar.setVoteData(favorCount, opposeCount, false);
// 用户投票后,数据更新,带动画切换
favorCount = 328;
voteProgressBar.setVoteData(favorCount, opposeCount, true);
如果数据是从LiveData或Flow这类响应式数据源里拿到的,可以在数据回调里直接调用setVoteData。唯一要注意的是不要在主线程之外调用该控件的方法,如果数据源回调在子线程,记得切换回主线程再调用。
java复制viewModel.voteResult.observe(this, result -> {
voteProgressBar.setVoteData(result.getFavorCount(), result.getOpposeCount(), true);
});
这里要说明一下:setVoteData方法内部会做参数合法性检查,如果票数为负数会抛出异常。在实际项目中,网络数据可能因后台bug返回负数,你不希望一个简单的UI控件让整个App崩溃。所以我建议在接数据的地方再包一层脏数据过滤,把负数直接当成0处理。不过控件内部还是要保留异常抛出逻辑,这是因为开发阶段尽早暴露问题比线上静默出错要好得多。
4.4 颜色与主题适配方案
这个控件最有争议的地方可能就是颜色了。赞成用蓝色还是绿色?反对用红色还是灰色?这个没有标准答案,完全看产品需求。我自己的经验是:
- 如果投票主题是“支持/反对”这种二元对立的,用蓝色+红色比较符合直觉;
- 如果投票主题是“好/中/差”这种评价性质的,用绿色+橙色比较柔和;
- 如果投票主题是“确认/取消”这种操作性质的,用主题色+灰色更协调。
为了方便适配,我在资源文件里定义了颜色:
xml复制<resources>
<color name="vote_favor_color">#4CAF50</color>
<color name="vote_oppose_color">#F44336</color>
</resources>
深色模式下,这两种颜色依然有足够对比度,所以不需要额外适配。但如果你的颜色偏浅,就需要在values-night目录下重新定义更深的颜色。
还有一个细节:分隔线的白色在深色模式下可能会显得刺眼。我建议把分隔线颜色也抽成资源文件,深色模式下用半透明白色或者深灰色更好。
4.5 与RecyclerView的配合
投票进度条最常见的应用场景是投票列表,每条数据对应一个进度条。在RecyclerView里使用这个控件,要特别注意复用问题。
RecyclerView的smoothScrollToPosition或notifyDataSetChanged会引起item重绘,如果你的进度条状态没有保存,会出现错位或者闪烁。我的建议是:
在Adapter的onBindViewHolder里,总是调用setVoteData方法,并且把animated参数设为false,因为列表滚动时播动画会非常卡顿,还会干扰用户浏览。
只有在某个item被点击投票后,才让对应item的进度条播动画,而且要用notifyItemChanged(position, payload)这种方式通知局部刷新,避免整个列表重绘。
java复制public void onVoteClick(int position, boolean isFavor) {
VoteItem item = dataList.get(position);
if (isFavor) {
item.setFavorCount(item.getFavorCount() + 1);
} else {
item.setOpposeCount(item.getOpposeCount() + 1);
}
notifyItemChanged(position, "vote_update");
}
@Override
public void onBindViewHolder(ViewHolder holder, int position, List<Object> payloads) {
if (payloads.isEmpty()) {
onBindViewHolder(holder, position);
} else {
// 只更新进度条,不重建整个item
holder.voteProgressBar.setVoteData(item.getFavorCount(), item.getOpposeCount(), true);
}
}
这个局部刷新技巧非常实用,能明显减少列表刷新时的卡顿感。
5. 常见问题与排查技巧实录
5.1 问题速查表
我在开发这个控件的过程中,遇到了不少问题,我把它们整理成一张速查表,方便后来者直接对照排查。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 进度条不显示 | 色块颜色为透明,或控件宽高为0 | 检查布局里宽高设置,检查颜色资源是否存在 |
| 文本位置偏上或偏下 | 基线计算错误 | 用Paint.FontMetrics计算基线,而不是画布的上下居中 |
| 动画结束后比例不对 | 插值器使用不当 | 使用getAnimatedFraction()作为插值因子,不要用getAnimatedValue() |
| 数据快速刷新时比例乱跳 | 旧动画未取消 | 在animateTo开头调用mAnimator.cancel() |
| 左或右侧文字超出边界 | 极端比例下未做宽度限制 | 判段文字宽度是否超过色块宽度,超过则省略绘制 |
| 深色模式下看不清 | 颜色对比度不足 | 使用values-night资源覆盖色值 |
| RecyclerView滑动卡顿 | 滚动时播放动画 | onBindViewHolder里动画参数设为false |
| 横竖屏切换状态丢失 | 未实现状态保存 | 实现onSaveInstanceState和onRestoreInstanceState |
5.2 比例计算的精度问题
一个视觉细节:当赞成票数327,反对票数128时,总票数455,赞成比例是71.868131%,反对比例是28.131869%。如果直接用float计算然后绘制,在边界处可能出现1px的误差,导致色块之间有微小缝隙或者重叠。
这个问题我在前期没注意到,直到测试人员在真机上发现进度条中间分隔线处有一条极细的白线,截图放大了才看清是色块没完全接上。
原因是:Canvas.drawRect绘制的矩形是左闭右开区间,左边矩形从contentLeft画到favorRight,右边矩形从opposeLeft画到contentRight。如果favorRight和opposeLeft不重合,就会露出下面View的背景色,形成白线。
解决方案是在绘制右边反对色块时,把起始点向左偏移0.5px:
java复制float opposeLeft = opposeRight - opposeWidth - 0.5f;
这样可以让两个色块在视觉上无缝拼接。这个0.5px的偏移在绝大多数分辨率下都有效,因为屏幕物理像素的最小单位是1px,偏移0.5px配合抗锯齿,正好可以覆盖边缘半透明像素。
另外,百分比文字的显示值,我用了Math.round配合String.format保留一位小数再转int,避免出现“赞成 71.868131%”这种浮点尾巴。浮点尾巴在视觉上完全是噪音,用户也看不懂。
java复制String favorText = String.format(Locale.getDefault(), "赞成 %d%%", Math.round(mCurrentFavorRatio * 100));
5.3 性能优化:避免不必要的重绘
投票进度条的核心重绘方法是invalidate(),它会触发onDraw。频繁调invalidate()不会有生命危险,但会消耗CPU资源。我的优化策略有三个:
第一,setVoteData里对比新旧比例,如果比例没变,直接返回,不触发重绘。比例不变可能是数据刷新但票数没变,或者两次设置的是同样的数据。
java复制if (Math.abs(newFavorRatio - mCurrentFavorRatio) < 0.0001f
&& Math.abs(newOpposeRatio - mCurrentOpposeRatio) < 0.0001f) {
return;
}
第二,不要在onDraw里创建对象。String.format其实会在内部创建对象,在动画回调里频繁调用会有一定的GC压力。我的优化方案是:在setVoteData时就把文本字符串构造好,缓存在成员变量里,onDraw只用drawText直接绘制。
java复制private String mFavorText;
private String mOpposeText;
private void updateTexts() {
mFavorText = String.format(Locale.getDefault(), "赞成 %d%%", Math.round(mCurrentFavorRatio * 100));
mOpposeText = String.format(Locale.getDefault(), "反对 %d%%", Math.round(mCurrentOpposeRatio * 100));
}
第三,如果进度条高度不足以显示文字,mFavorText和mOpposeText就不会被绘制,此时也不需要计算measureText。所以我在判断高度小于dp2px(24f)时,直接跳过了文字绘制的整个流程。
5.4 离线数据的边界情况
投票进度条存在一种离线数据或缓存数据的场景:用户断网后,本地缓存里存了一条“赞成0票,反对0票”的数据。如果按照正常逻辑,进度条会显示为左右各50%,看起来像平票。但“平票”和“无人投票”在语义上完全不同,我建议数据层区分这两种情况。
如果你需要区分,可以再给控件增加一个setEmptyViewMode(boolean)方法,当它为true时,进度条居中画一条浅灰色提示线,不画色块。但考虑到这个需求本身很轻量,大多数场景下“0票时各50%”已经足够直观,我就没有把这个逻辑做进控件里。如果你有需要,可以参考这个思路自己扩展。
5.5 文案显示位置的自适应策略
百分比文字的显示位置,很多人会直接左对齐和右对齐。但这在极端比例下会踩坑:左边文字长度超过左色块宽度时,文字会被中间分隔线截断。
我的文字显示策略分为三个优先级:
- 第一优先级:尽量在色块内部显示完整文字;
- 第二优先级:如果色块宽度不够,文字优先向色块外侧延伸,但不超过控件边界;
- 第三优先级:如果两边色块都太窄,只显示比例大的一侧文字,另一侧不显示。
简化版的判断逻辑如下:
java复制private void drawPercentText(Canvas canvas, ...) {
// 先判断两侧色块是否足够宽
float favorBlockWidth = favorRight - favorLeft - dp2px(16f);
float opposeBlockWidth = opposeRight - opposeLeft - dp2px(16f);
if (favorBlockWidth >= mTextPaint.measureText(mFavorText)) {
// 左侧完整绘制
canvas.drawText(mFavorText, favorLeft + dp2px(8f), baseline, mTextPaint);
} else if (favorBlockWidth < dp2px(20f)) {
// 左侧太窄,不绘制
} else {
// 文字缩小字体再试一次
mTextPaint.setTextSize(sp2px(10f));
canvas.drawText(mFavorText, favorLeft + dp2px(4f), baseline, mTextPaint);
}
// 右侧同理
}
这里需要说明,缩小字体这个方案我是有保留的。因为一个进度条里两侧文字大小不一致,视觉上会很奇怪。我更推荐的做法是:当某一侧太窄时,直接用省略号或者不显示,而不是缩小字体。只有两侧都太窄时,才整体缩小字体,保证一致性。
我这里展示完整方案是为了让你知道有这些选择,实际使用时根据自己的审美和技术要求取舍。
5.6 点击交互扩展
原生投票进度条通常不具备点击能力,但产品经理偶尔会提“点击进度条上的赞成区域就是投赞成票”这种需求。如果你需要增加点击交互,只需要在自定义View里设置setOnClickListener,然后在onTouchEvent里判断触摸点落在哪个色块区域。
java复制@Override
public boolean onTouchEvent(MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_UP) {
float x = event.getX();
boolean isFavorZone = x <= getWidth() * mCurrentFavorRatio;
if (mOnVoteClickListener != null) {
mOnVoteClickListener.onVoteClick(isFavorZone);
}
return true;
}
return true;
}
这里有个交互陷阱:如果这个控件本身设置了点击监听,用户在非点击区域触摸也会触发事件。要给控件增加点击反馈,比如点击赞成区域时,进度条快速闪一下,让用户知道自己的点击被识别了。否则用户会认为App卡住了。
5.7 可访问性支持
很多开发者会忽略自定义View的无障碍支持,但对某些用户来说,自定义View缺乏无障碍描述就是“读屏软件静默区域”。
给自定义View加无障碍支持其实很简单,只需要在onInitializeAccessibilityEvent和onInitializeAccessibilityNodeInfo里增加文字描述和方法:
java复制@Override
public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(info);
info.setContentDescription(String.format(Locale.getDefault(),
"投票结果,赞成百分之%d,反对百分之%d",
Math.round(mCurrentFavorRatio * 100),
Math.round(mCurrentOpposeRatio * 100)));
}
这样读屏软件就能读出投票结果,帮助视障用户了解当前信息。这个操作的成本极低,但价值很高,建议所有自定义View都顺手做了。
6. 进阶:从固定控件到通用组件的路径
投票进度条做完之后,我并没有停在这里,而是思考了一下这个控件如果要扩展成通用组件,还能加哪些能力。这里分享几个思路,供你参考。
第一,把“赞成”、“反对”这两个文案标签做成可配置的接口,甚至可以把“赞成”换成赞、UP、喜欢,“反对”换成踩、DOWN、不喜欢。这些业务词汇不应该写死在控件内部,而是由调用方传入。具体的做法是抽出一个setLabels(String leftLabel, String rightLabel)方法,内部存储字符串,绘制时直接使用。
第二,增加圆角绘制方式。目前进度条是直角矩形,如果产品的设计稿是圆角,你需要用drawRoundRect替换drawRect。圆角矩形绘制时,两侧的色块需要各自画半圆角,这样拼接处才不会出现重叠的圆角弧线。具体做法是:左色块用左右圆角半径radius,但右边只圆右上角和右下角;右色块只圆左边两个角。这个用Path来构建比较复杂,如果需要可以参考GradientDrawable的withRadius效果来做。
第三,把动画时长做成接口参数。不同场景下动画时长需求不同:首次进入页面时可能希望长一点(800ms)吸引注意力,点击投票后希望短一点(300ms)反馈迅速。暴露一个setAnimationDuration(int)方法,调用方自己控制。
第四,增加阴影效果。某些设计稿里,进度条会有很浅的阴影或描边。使用Paint.setShadowLayer可以实现,但要注意阴影会影响性能,而且要避免在onDraw里重复调用,否则会卡顿。
第五,支持自定义装饰图案。比如在某些投票活动中,赞成区域右下角放一个星星图标,反对区域左下角放一个叉图标。这些装饰最好用Drawable作为接口传入,控件内部在onDraw里把Drawable绘制在指定区域。这样一来,控件的渲染层和业务层就彻底解耦了。
做完这些扩展后,这个投票进度条就可以作为一个VoteWidget组件沉淀到基础库里了。我自己的做法是:先在新版本里用这个控件跑通业务,确认稳定后,再把它抽象成单独模块,然后编写文档、示例代码、放GitLab内部库。后续其他项目需要使用投票类组件,直接依赖这个模块,一行代码接入,不用再造轮子。
这个路径你也可以复制粘贴。任何一个看起来简单的自定义View,只要按“核心需求 → 边界情况 → 通用封装 → 组件化沉淀”的顺序推进,你不仅能攒一仓库的优质组件,还能在团队里建立技术口碑。
最后再分享一个小技巧:自定义View开发时,强烈建议打开开发者选项里的“显示布局边界”和“显示Surface更新”。这两项可以直观地看到控件的绘制区域和重绘范围,帮你快速定位“文字位置不对”和“动画卡顿”这两类问题的根源。我最初调试文字基线的时候,就是靠“显示布局边界”才发现文字顶部多出了2dp的空隙,原因是FontMetrics里ascent的负数绝对值比预期大。有了这个工具,排查效率高得多。
