1. 项目概述:投票进度条的实用价值
在社交类App和论坛系统中,投票功能是用户互动的重要形式。传统简单的数字统计方式(如"赞成:153票")缺乏直观性,而采用双色进度条能同时展示正反双方的百分比数据,让结果一目了然。这种可视化方案在知乎、贴吧等平台的投票模块中已有成熟应用,但Android原生控件并未提供开箱即用的实现。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心需求拆解
2.1 基础功能要求
- 双色条带显示(通常用绿色/红色代表赞成/反对)
- 动态百分比数值标注
- 支持数据动态更新时的平滑过渡动画
- 自适应不同屏幕尺寸和分辨率
2.2 进阶交互需求
- 点击区域高亮反馈
- 长按显示详细数据弹窗
- 支持横竖屏切换时的布局保持
3. 技术实现方案
3.1 自定义View基础结构
kotlin复制class VoteProgressBar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
// 测量参数
private var viewWidth = 0
private var viewHeight = 0
// 绘制工具
private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG)
// 数据状态
private var agreeRatio = 0.5f
private var disagreeRatio = 0.5f
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// 实现测量逻辑
}
override fun onDraw(canvas: Canvas) {
// 实现绘制逻辑
}
}
3.2 属性初始化配置
在res/values/attrs.xml中定义自定义属性:
xml复制<declare-styleable name="VoteProgressBar">
<attr name="agreeColor" format="color|reference" />
<attr name="disagreeColor" format="color|reference" />
<attr name="textColor" format="color|reference" />
<attr name="textSize" format="dimension" />
<attr name="cornerRadius" format="dimension" />
</declare-styleable>
初始化代码示例:
kotlin复制init {
val typedArray = context.obtainStyledAttributes(
attrs,
R.styleable.VoteProgressBar,
defStyleAttr,
0
)
progressPaint.color = typedArray.getColor(
R.styleable.VoteProgressBar_agreeColor,
Color.GREEN
)
textPaint.textSize = typedArray.getDimension(
R.styleable.VoteProgressBar_textSize,
12f.dpToPx()
)
typedArray.recycle()
}
4. 核心绘制逻辑实现
4.1 进度条绘制
kotlin复制private fun drawProgressBars(canvas: Canvas) {
// 计算各区域宽度
val agreeWidth = viewWidth * agreeRatio
val disagreeWidth = viewWidth * disagreeRatio
// 绘制赞成条
canvas.drawRoundRect(
0f, 0f,
agreeWidth, viewHeight.toFloat(),
cornerRadius, cornerRadius,
agreePaint
)
// 绘制反对条
canvas.drawRoundRect(
agreeWidth, 0f,
viewWidth.toFloat(), viewHeight.toFloat(),
cornerRadius, cornerRadius,
disagreePaint
)
}
4.2 文字标注绘制
kotlin复制private fun drawPercentageText(canvas: Canvas) {
val agreeText = "${(agreeRatio * 100).toInt()}%"
val disagreeText = "${(disagreeRatio * 100).toInt()}%"
// 赞成百分比文字居中绘制
val agreeTextWidth = textPaint.measureText(agreeText)
canvas.drawText(
agreeText,
(viewWidth * agreeRatio / 2) - (agreeTextWidth / 2),
viewHeight / 2 + textPaint.textSize / 3,
textPaint
)
// 反对百分比文字居中绘制
val disagreeTextWidth = textPaint.measureText(disagreeText)
canvas.drawText(
disagreeText,
viewWidth * agreeRatio + (viewWidth * disagreeRatio / 2) - (disagreeTextWidth / 2),
viewHeight / 2 + textPaint.textSize / 3,
textPaint
)
}
5. 动画效果实现
5.1 ValueAnimator平滑过渡
kotlin复制fun setVoteData(agreeCount: Int, disagreeCount: Int, animate: Boolean = true) {
val total = agreeCount + disagreeCount
val targetAgreeRatio = if (total > 0) agreeCount.toFloat() / total else 0f
if (animate) {
ValueAnimator.ofFloat(agreeRatio, targetAgreeRatio).apply {
duration = 300
interpolator = AccelerateDecelerateInterpolator()
addUpdateListener { animation ->
agreeRatio = animation.animatedValue as Float
disagreeRatio = 1 - agreeRatio
invalidate()
}
start()
}
} else {
agreeRatio = targetAgreeRatio
disagreeRatio = 1 - agreeRatio
invalidate()
}
}
6. 性能优化要点
6.1 避免过度绘制
- 使用canvas.clipRect()限制绘制区域
- 对静态元素使用bitmap缓存
- 减少onDraw中的对象创建
6.2 内存管理
kotlin复制override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
// 清除动画资源
animator?.cancel()
}
7. 实际应用案例
7.1 XML布局使用
xml复制<com.example.customview.VoteProgressBar
android:layout_width="match_parent"
android:layout_height="24dp"
app:agreeColor="#4CAF50"
app:disagreeColor="#F44336"
app:textColor="#FFFFFF"
app:textSize="12sp"
app:cornerRadius="4dp" />
7.2 动态数据更新
kotlin复制binding.voteProgressBar.setVoteData(agreeCount = 75, disagreeCount = 25)
// 带动画效果更新
binding.voteProgressBar.setVoteData(
agreeCount = 120,
disagreeCount = 80,
animate = true
)
8. 扩展功能实现
8.1 点击区域检测
kotlin复制override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
val x = event.x
if (x < width * agreeRatio) {
// 点击了赞成区域
showAgreeHighlight()
} else {
// 点击了反对区域
showDisagreeHighlight()
}
return true
}
}
return super.onTouchEvent(event)
}
8.2 详细数据弹窗
kotlin复制private fun showDetailPopup(anchor: View) {
PopupMenu(context, anchor).apply {
menu.add("总票数:${agreeCount + disagreeCount}")
menu.add("赞成:$agreeCount (${(agreeRatio*100).toInt()}%)")
menu.add("反对:$disagreeCount (${(disagreeRatio*100).toInt()}%)")
show()
}
}
9. 测试验证方案
9.1 单元测试用例
kotlin复制@Test
fun testRatioCalculation() {
val view = VoteProgressBar(ApplicationProvider.getApplicationContext())
view.setVoteData(3, 1, false)
assertEquals(0.75f, view.getAgreeRatio())
view.setVoteData(1, 1, false)
assertEquals(0.5f, view.getAgreeRatio())
view.setVoteData(0, 0, false)
assertEquals(0f, view.getAgreeRatio())
}
9.2 UI自动化测试
使用Espresso进行交互测试:
kotlin复制@RunWith(AndroidJUnit4::class)
class VoteProgressBarTest {
@Test
fun testAnimationPerformance() {
val startTime = System.currentTimeMillis()
onView(withId(R.id.voteProgressBar))
.perform(setVoteData(100, 100))
val duration = System.currentTimeMillis() - startTime
assertThat(duration, lessThan(350L))
}
}
10. 常见问题解决方案
10.1 文字显示不全
问题现象:百分比文字被截断
解决方案:
- 增加View的padding
- 动态调整textSize
- 使用getTextBounds()精确测量文字区域
10.2 动画卡顿
优化方案:
- 使用硬件加速层
- 降低动画帧率
- 采用属性动画代替自定义动画
10.3 横竖屏切换数据丢失
处理方法:
- 在onSaveInstanceState中保存关键数据
- 配置android:configChanges属性
- 使用ViewModel保存状态
在实现过程中发现,当进度条宽度较小时,圆角效果会出现渲染异常。解决方案是通过Path自定义绘制路径,先绘制整个圆角矩形背景,再用clipPath分离两种颜色区域。这种方案相比直接绘制两个圆角矩形,能完美保持圆角一致性。
