尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Android自定义圆环进度条实战:从MeasureSpec到动画的完整指南

Android自定义圆环进度条实战:从MeasureSpec到动画的完整指南 1. 为什么原生 ProgressBar 扛不住实际需求从一段踩坑说起提示如果你还没在项目里写过自定义进度条只是看标题觉得“这玩意不就是一个圈圈转吗”那这篇文章刚好适合你如果你已经被原生 ProgressBar 逼到改了三版需求那你直接看第 3、4、5 章就行。几个月前我做一款运动打卡应用首页需要展示“今日目标完成度”产品经理给的效果图是一个渐变色圆环圆环内部正中间显示 68%右上角还有一个小小的火焰图标。我当时心想这不就是 ProgressBar 改个颜色吗于是打开布局文件把android:progressTint一改运行一看——原生的ProgressBar在style?android:attr/progressBarStyleHorizontal下确实能改颜色但它本质上是一个带圆角的水平矩形条根本不可能变成环形更不用提渐变、扇形缺口、内部文字这些需求了。去网上搜了一圈大部分方案是“切两张图一张灰色底图、一张彩色进度图用layer-list叠加”这种方案能解决 80% 的需求但有两个硬伤第一图片资源在不同分辨率下会糊第二如果你需要在进度条上叠加动画、旋转、渐变、动态变更的颜色切图方案就非常难受。所以最终的落地方案是直接继承View重写onDraw自己画。这篇文章不打算只给你一段能跑的代码而是把这类自定义控件的完整思考链路讲清楚MeasureSpec 决定控件尺寸Canvas 和 Paint 决定外观属性动画驱动进度值的变化自定义属性让控件可以复用。每一步我都把当时的取舍和解法写出来都是可以直接抄进项目里的经验。2. 动手前的关键准备MeasureSpec 和 Canvas 这两个“地基”先搞明白2.1 MeasureSpec控件到底该画多大很多人写自定义 View 喜欢直接跳过onMeasure这在小尺寸控件上问题不大但一旦你的控件要放进RecyclerView的 item 里或者要配合ConstraintLayout的match_parent使用跳过测量逻辑就会出现“进度条只画了一个角”或者“控件直接占满全屏”的诡异情况。MeasureSpec是一个 32 位的 int 值高两位代表模式低 30 位代表尺寸。模式有三种EXACTLY父布局已经确定了具体的尺寸比如设置了match_parent或者固定 dp 值。AT_MOST父布局给了你一个上限你自己决定要多大但不能超过这个上限比如wrap_content。UNSPECIFIED父布局没有给你任何限制你可以随意画常见于ScrollView内部。我做的圆环进度条处理方式如下override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val widthMode MeasureSpec.getMode(widthMeasureSpec) val heightMode MeasureSpec.getMode(heightMeasureSpec) val widthSize MeasureSpec.getSize(widthMeasureSpec) val heightSize MeasureSpec.getSize(heightMeasureSpec) // 默认取宽度作为基准保证圆环是正圆 val defaultSize dp2px(120f) val resultWidth when (widthMode) { MeasureSpec.EXACTLY - widthSize MeasureSpec.AT_MOST - minOf(defaultSize, widthSize) else - defaultSize } val resultHeight when (heightMode) { MeasureSpec.EXACTLY - heightSize MeasureSpec.AT_MOST - minOf(defaultSize, heightSize) else - defaultSize } // 取较小值保证正方形 val side minOf(resultWidth, resultHeight) setMeasuredDimension(side, side) }注意最后一行我强制把宽高设为相等。因为圆环进度条的视觉焦点是正圆如果父布局把它拉成一个椭圆后续画出来的环就会变形。如果产品经理就是要一个椭圆环那你需要在onDraw里根据width和height分别计算椭圆的两条轴而不是用RectF直接套。这一段代码看起来简单但它是后续所有绘制逻辑的地基。很多自定义 View 跑起来发现“画出来的东西缺了一块”十有八九就是测量环节没处理好。2.2 画布的坐标系和 Paint 的三个关键参数Canvas的坐标系不是数学里的笛卡尔坐标系——y轴方向是向下的。这意味着如果你用正的角度去画弧它会从 3 点钟方向开始顺时针旋转。圆环进度条通常从 12 点钟方向开始画所以你需要对起始角度做偏移// 从12点钟方向开始顺时针-90度 val startAngle -90f val sweepAngle 360f * currentProgress / maxProgressPaint是绘制的最小单元三个最影响外观的参数是styleSTROKE画线框FILL填充内部一个进度条通常两样都要用。strokeCap线帽ROUND能让进度条两端变成圆头视觉上更柔和。strokeWidth线条宽度也就是进度环的厚度。这里有一个细节strokeCap设为ROUND后进度条两端会超出圆弧本身的路径超出量是strokeWidth / 2。如果你的进度达到 100%首尾两个圆帽会重叠在一起形成一个“小疙瘩”。我当时就踩了这个坑解决方案是sweepAngle最大值不要给 360而是给360 - 一个很小的角度偏移比如 359.2f或者用strokeCap BUTT同时手动在两头各画一个圆形节点。3. 核心实现从“画一个圈”到“进度动起来”3.1 基础版重写 onDraw 画出圆环底层和进度层自定义进度条的核心绘制逻辑就三步画底层圆环、画进度圆弧、画中间文字。这三步的顺序不能乱否则进度会被底图盖住。override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val center width / 2f val radius center - strokeWidth / 2f val rectF RectF( center - radius, center - radius, center radius, center radius ) // 1. 底层圆环 progressPaint.color trackColor progressPaint.style Paint.Style.STROKE canvas.drawArc(rectF, 0f, 360f, false, progressPaint) // 2. 进度层 progressPaint.color progressColor val startAngle -90f val sweep 360f * progress / maxProgress.coerceAtLeast(1f) canvas.drawArc(rectF, startAngle, sweep, false, progressPaint) // 3. 中间文字 if (showText) { textPaint.textSize textSize textPaint.color textColor val percent (progress / maxProgress * 100).toInt() val text $percent% val textWidth textPaint.measureText(text) val baseline center - (textPaint.descent() textPaint.ascent()) / 2f canvas.drawText(text, center - textWidth / 2f, baseline, textPaint) } }这里有两个小细节值得展开。第一个是radius center - strokeWidth / 2f为什么不能直接用center因为drawArc的线条路径是以矩形边框的内切路径来计算的STROKE模式下手绘的线条会以路径为中心向两侧扩散strokeWidth / 2。如果RectF直接贴到控件边缘圆环最粗的地方会直接被裁掉一半视觉上是一个被切开的圆。第二个是文字的垂直居中Paint.FontMetrics的ascent和descent加起来除以 2 才是文字的视觉中心直接用center - textSize / 2画出来的文字会偏上。3.2 让进度动起来属性动画比线程更新优雅得多网上一搜“ProgressBar 动起来”大多数老教程会告诉你用Handler或者ThreadpostInvalidate()。这两个方案在简单场景下能跑但有两个致命问题第一慢速循环更新会丢帧第二非 UI 线程调用invalidate()会直接崩溃而postInvalidate()如果调用频率过高会在系统消息队列里堆积大量重绘请求造成 UI 卡顿。我用的是ValueAnimator它本身就是一个时间插值器回调的value是从起点到终点的平滑中间值fun animateProgress(target: Float, duration: Long 800L) { animator?.cancel() val current currentProgress animator ValueAnimator.ofFloat(current, target).apply { this.duration duration interpolator DecelerateInterpolator() addUpdateListener { currentProgress it.animatedValue as Float invalidate() } start() } }这里有一个经验如果不设置interpolator默认是AccelerateDecelerateInterpolator它是先加速后减速视觉上进度条前后变慢、中间变快看久了会觉得“有点死”。换成DecelerateInterpolator后进度条像是在“冲向终点”完成度越高越有成就感特别适合用在打卡、任务完成的反馈场景。如果只更新进度数字而不要动画直接改progress字段再调invalidate()就行。注意invalidate()只能在 UI 线程调用ValueAnimator的回调就在主线程所以这里不会出问题。3.3 进阶渐变、阴影、多个进度叠加的组合效果产品经理的第二个需求是把圆环从纯色改成渐变色。直接用canvas.drawArc只能画单色渐变需要借助SweepGradient——它专门用于围绕某个中心点旋转的颜色渐变正好适配圆环。val shader SweepGradient( centerX, centerY, intArrayOf(startColor, endColor), floatArrayOf(0f, 1f) ) progressPaint.shader shader canvas.drawArc(rectF, startAngle, sweepAngle, false, progressPaint)这里有个容易忽略的点SweepGradient是从 3 点钟方向开始扫的默认 0 度对应屏幕右方。如果你的起始角度是 12 点钟方向即 -90 度颜色的起点位置就不会出现在预期位置。解决办法是给颜色数组的分布位置加偏移或者直接把起始角度改成-90后重新调整颜色的位置分布。我当时懒得调直接用了第二种方案把起始角度设置为0然后给整个画布canvas.rotate(-90f, centerX, centerY)。这个旋转操作只影响后续绘制不影响已经画好的内容很干净。canvas.save() canvas.rotate(-90f, centerX, centerY) // 先画底层再画带渐变的进度层 canvas.restore()如果你还想让进度条有“发光”效果可以用setShadowLayer但要注意setShadowLayer在硬件加速下有一部分兼容性问题同时对性能有额外损耗建议只在小尺寸控件上使用。4. 细节雕琢一个“有质感”的进度条光会画是不够的4.1 支持多状态加载中、完成、失败三种状态自由切换真实项目里进度条往往不只有一种状态。比如我那个运动打卡 App目标完成时圆环要变成绿色高亮还要有一个弹跳动画未完成时是灰蓝渐变加载数据时圆环显示不确定状态也就是所谓的“转圈等待”。多状态不能靠if-else在onDraw里堆而应该抽象成枚举再配合自定义属性或者 setterenum class ProgressState { LOADING, NORMAL, SUCCESS, FAIL }不同状态的绘制差异主要在三处进度颜色progressColor不同。是否显示文字加载中显示“加载中”或“99%”成功显示“完成”。动画行为加载中是一个不会停止的转圈动画正常是单向推进。我在实现时把动画拆成了两个独立部分进度推进动画和状态切换动画。进度推进动画用ValueAnimator状态切换动画则用AnimatorSet做一个“回弹”效果——先把进度条反方向拉回一点再正向冲过去视觉上就像有弹性一样完成感特别强。4.2 自定义属性让控件在 XML 里就可以配置一个控件如果只能通过代码调用setXxx()来配置复用性会大打折扣。标准做法是自定义属性这样布局文件里直接写app:progressWidth8dp就行。先创建attrs.xmlresources declare-styleable nameCircleProgressBar attr nameprogress formatfloat / attr namemaxProgress formatfloat / attr namestrokeWidth formatdimension / attr nametrackColor formatcolor / attr nameprogressColor formatcolor / attr namestartColor formatcolor / attr nameendColor formatcolor / attr nameshowText formatboolean / attr nametextSize formatdimension / attr nametextColor formatcolor / /declare-styleable /resources然后在自定义 View 的初始化中读取这些属性private fun init(attrs: AttributeSet?) { attrs?.let { val ta context.obtainStyledAttributes(it, R.styleable.CircleProgressBar) progress ta.getFloat(R.styleable.CircleProgressBar_progress, 0f) maxProgress ta.getFloat(R.styleable.CircleProgressBar_maxProgress, 100f) strokeWidth ta.getDimension(R.styleable.CircleProgressBar_strokeWidth, dp2px(10f)) trackColor ta.getColor(R.styleable.CircleProgressBar_trackColor, Color.GRAY) progressColor ta.getColor(R.styleable.CircleProgressBar_progressColor, Color.BLUE) // ... ta.recycle() } }这里有一个新手容易忽略的坑obtainStyledAttributes用完之后必须调recycle()。虽然 Android 的TypedArray内部有 pool 机制不回收也能用但你的代码要是跑在低版本系统上或者同一个 Activity 里创建了大量进度条控件就可能出现资源泄漏严重时直接 OOM。如果项目里用DataBinding或者Compose自定义属性的读取逻辑依然保留因为 XML 布局里配置属性的值是可以直接被dataBinding动态设置的读取逻辑完全兼容。4.3 布局适配wrap_content、padding 和 RTL 布局写完自定义 View 后你可能会直接把它放到LinearLayout里测试结果发现指定wrap_content时控件面积是 0指定dp又硬邦邦。原因就是第 2 章说的onMeasure没写好。除了测量之外还有一个常见的边界情况是padding。如果你在布局里给控件设置了paddingLeft而onDraw里的RectF是从0, 0开始的那被padding挤掉的区域就会被画到 padding 外面直接重叠到别的 View 上面。处理方式有两种要么在onMeasure里就加上 padding 的计算要么在onDraw里把所有坐标都偏移到paddingLeft strokeWidth / 2。我更倾向于后者因为改测量逻辑往往会影响父布局的尺寸判断不够稳定。RTL从右到左布局在中文项目里遇到得少但一旦产品要做阿拉伯语或者希伯来语版本进度条的起始角度就要反过来。很多自定义 View 的onDraw没考虑layoutDirection导致 RTL 下进度条依然从左边往右边生长。要兼容可以在onDraw开头判断val startAngle if (layoutDirection View.LAYOUT_DIRECTION_RTL) 90f else -90f这个细节在 Google Play 上架应用时会遇到如果是国内应用可以先放着但心里要有个数。5. 踩坑实录运行起来才发现诡异问题的地方5.1 尺寸为 0 的经典翻车现场为什么我画了但是什么都看不见如果你是照着网上的远古博客比如 2015 年那批写的代码大概率会看到这种写法canvas.drawArc(rectF, startAngle, sweepAngle, false, paint);但你在项目里运行后进度条就是消失不见。排查思路是这样的先在构造器里打印width和height你会发现是-1或者0。原因在于布局文件里你写的是wrap_content而onMeasure里没有做处理系统默认一个控件在没有测量结果时会按 0 处理。解决办法就是第 2 章里写的onMeasure那段确保wrap_content时给一个默认尺寸或者干脆把布局文件改成100dp。如果已经写了onMeasure但还是看不见那就要检查绘制逻辑里是否用了Float类型做取整运算是否strokeWidth设置的太大导致整个圆环被挤出画布。我当时差点被这个问题折磨疯——边框宽度写成了20dp控件的测量尺寸只有10dp画布的RectF中心加上半径已经超过控件宽度结果一大半圆环被裁剪掉。5.2 进度条“打转”postInvalidate 跑不到 UI 线程有一次我把ValueAnimator的addUpdateListener里直接写了postInvalidate()结果进度条没有任何动画效果看起来好像卡住了一样。排查后发现是线程问题ValueAnimator默认在主线程回调但我是在一个后台协程里启动动画回调的时候就变成了后台线程而invalidate()是只能在 UI 线程调用的postInvalidate()虽然可以在后台线程调但它内部其实还是通过ViewRootImpl把这个重绘请求 post 回主线程如果不小心 post 的次数太多就会变成一次“抖动式”的进度条闪烁。正确的做法是动画的更新回调里直接用invalidate()并且确保动画实例是在主线程创建的。如果你一定要在后台线程触发动画启动可以用view.post { animator.start() }不要跨线程裸奔。5.3 内存抖动和性能瓶颈避免在 onDraw 里做多余的分配onDraw方法每帧都会被调用在动画运行的几十帧里如果你反复执行new RectF(...)、new Paint(...)、SweepGradient的创建GC 会非常频繁。跑在低端机上标志就是进度条转动时掉帧明显同时 Logcat 里出现Gc频繁回收的日志。优化方案把能复用的对象提前在构造器里创建好。例如RectF和Paint都作为成员变量在onDraw里只修改它的属性不重新创建ValueAnimator也同样复用只有一个实例反复start()。上面代码里我用了currentProgress这个字段来保存动画插值它本身是Float类型在 Kotlin 里不会频繁装箱但如果你的进度值需要转换成Int来显示就把toInt()的计算放在onDraw里做不要放在动画回调里做否则中间值会被截断视觉上进度条会“一格一格跳”而不是平滑推进。5.4 硬件加速的坑setShadowLayer 的兼容性这个坑是后面加阴影效果时踩到的。paint.setShadowLayer(radius, dx, dy, shadowColor)在 API 21 以下不是很好用尤其当Canvas被 disable 掉硬件加速时阴影效果直接消失。我要兼容 Android 6.0 的设备最后用的是最土的办法在底层圆环外面再画一层半透明圆环模拟“发光”。虽然效果没有真正的模糊阴影那么细腻但胜在稳定不用考虑硬件加速的开关。6. 进阶方向如果这些还不够用还能往哪走到这里一个能打的进度条已经完成了。不过如果你的需求也是那种“三天一小改、五天一大改”的类型还可以考虑下面几条路。第一把进度条封装成组件库。不要把它写死在一个 Activity 里抽到单独的 Module 里提供progress、animateTo、setProgressColor等公开方法团队其他人接入时只需要一行布局加一行调用。第二接入RecyclerView复用机制。如果你的进度条要呈现在列表里注意不要在onBindViewHolder里频繁触发动画。正确做法是如果状态没有变化直接设置progress不带动画或者在onDetachedFromWindow里取消动画否则下拉滑动时动画会堆积体验会非常糟糕。第三如果项目已经全面转向Compose用CanvasanimateFloatAsState完全可以实现同样的效果。Compose 的自定义绘制方式更现代状态管理也更方便。但我个人还是建议团队里保留一个自定义 View 版本的组件因为老业务里的 XML 布局不会一夜之间全部改完两边并行的过渡期里两个版本都要跑得通。最后回到本文的起点一个进度条真的需要自定义吗如果你的需求就是产品经理随手画了个圆形 文字那你改改原生的ProgressBar也就够了但如果它需要渐变、动效、多状态、反 RTL 兼容、以及随时调整的视觉细节那就值得花半天时间自己写一个。我在这篇文章里提供的思路和坑都是实际项目中验证过的你照着抄一遍大概率能少走我走过的弯路。
返回列表