接手了这个需求,正好趁热把当时封装圆形进度条的完整思路整理出来,从UI结构到绘制原理再到接口设计一次说清,WebView部分兼顾了不同版本的兼容处理,已经跑过真机验证。
1. 从业务需求到控件拆解:圆形进度条到底在做什么
先说清楚圆形进度条这个控件的本质,它在界面层干的事其实只有三件:画一个环形底轨、画一个弧形进度、在中心显示一段文字。听上去简单,但真正到封装环节,你会发现每件事都有不少细节要决定,比如弧形的起始角度怎么定、进度更新时如何避免重绘闪烁、文字内容和进度状态怎么解耦。
我接到这个需求时,业务方的要求很明确:多个页面都要用,样式略有不同(有的要百分比文字,有的只要图形),希望一行代码就能调用。这就逼着我把控件的样式和行为全部参数化,而不是写死某个业务场景里的特殊逻辑。
在iOS原生开发里实现圆形进度,主流方案就两个:Core Graphics手绘路径(drawRect/CAShapeLayer)或者切图方案(背景图+遮罩旋转)。切图方案的优点是实现快、不需要关心绘制细节,但缺点是适配不同尺寸要出多套图、颜色变化没法动态调整、扩展性很差。我选择用CAShapeLayer加UIBezierPath的组合,原因很简单:矢量绘制,尺寸随便定义,颜色进度动画全部代码可控,后续维护成本最低。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 圆形进度条的绘制原理与关键数学点
2.1 圆弧路径的起点和终点怎么定
绘制圆形进度的核心就是一个带stroke的贝塞尔圆弧路径。UIBezierPath的bezierArcWithCenter:radius:startAngle:endAngle:clockwise:方法里,起点和终点的角度单位是弧度,且“0度”的位置是水平向右的3点钟方向,不是我们直觉里的12点钟方向。
这就带来一个最常见的坑:如果我们直接让startAngle从0开始、endAngle从2π结束,画出来的圆环是完整闭合的,但进度从顶部开始动态生长时,起始点却跑到了右侧,视觉上很别扭。要做出从12点方向开始转圈的效果,需要把起始角度手动减去π/2(90度)。
objectivec复制- (void)drawProgressArc {
// 0度是3点钟方向,顺时针旋转M_PI_2就是6点钟方向,
// 用 -M_PI_2 把起点拨到12点钟方向
CGFloat startAngle = -M_PI_2;
CGFloat endAngle = startAngle + M_PI * 2 * self.progressValue;
// 顺时针绘制,clockwise传YES
}
这个修正几乎是所有圆形进度控件都默认处理的,但很多新手会在这一步栽跟头,画出来的进度条永远从右侧开始,怎么调都不对。
2.2 圆形路径的半径到底应该减多少
绘制圆环时,还有一个极易踩的坑:radius应该取多少。如果我们直接把控件宽高的一半传进去,会发现圆环被裁剪掉了边缘一圈。原因在于CAShapeLayer的stroke会以路径为中心向内外两侧各扩展线宽的一半。
code复制外边缘超出范围 = self.lineWidth / 2
所以正确的计算方式是:
objectivec复制- (CGFloat)arcRadius {
CGFloat minSide = MIN(self.bounds.size.width, self.bounds.size.height);
CGFloat radius = minSide / 2.0 - self.lineWidth / 2.0;
return radius;
}
如果不做这个减半处理,进度条的12点、3点、6点、9点四个方向的外侧会被masksToBounds裁剪平,看起来圆环被切了一刀,整个控件的精致感全毁。
2.3 两个图层为什么要各管各的
既然要画底轨和进度两层圆弧,很多人的第一反应是在同一个drawRect:里画两次。这种做法有致命问题:进度每次变化都会触发整个控件的重绘,包括底轨也要重画,浪费性能不说,稍不注意还会出现绘制闪烁。
我的做法是创建两个CAShapeLayer,底轨一个、进度一个,分开管理:
| 图层 | 职责 | 关键属性 |
|---|---|---|
| trackLayer | 绘制完整的230度/360度底轨 | strokeColor、lineWidth |
| progressLayer | 绘制实际进度弧段 | strokeStart、strokeEnd、lineWidth |
| 文字Label | 展示百分比或自定义文案 | 独立子视图,与图层无耦合 |
进度图层更新时,我只需要修改strokeEnd或者重新赋一个endAngle路径,Core Animation会自动完成过渡,底轨完全不需要参与重绘。这也是CAShapeLayer相对drawRect方案的核心优势:动画过程发生在GPU层,不阻塞主线程。
3. 接口设计与数据传递:封装的边界在哪里
3.1 对外暴露的最小接口
封装的本质是把复杂度关在门里,只给调用方开一扇干净的门。我在设计这个控件的对外接口时,遵循的准则是:能用参数解决的不用方法,能用属性解决的不用协议。
objectivec复制@interface ZYCProgressRingView : UIView
/// 进度值,范围0.0~1.0
@property (nonatomic, assign) CGFloat progressValue;
/// 轨道颜色,默认浅灰
@property (nonatomic, strong) UIColor *trackColor;
/// 进度弧颜色,默认主题蓝
@property (nonatomic, strong) UIColor *progressColor;
/// 轨道宽度
@property (nonatomic, assign) CGFloat trackWidth;
/// 是否显示中心文字
@property (nonatomic, assign) BOOL showCenterText;
/// 中心文字字体
@property (nonatomic, strong) UIFont *centerTextFont;
/// 中心文字颜色
@property (nonatomic, strong) UIColor *centerTextColor;
/// 设置进度并自动执行动画
- (void)setProgress:(CGFloat)progress animated:(BOOL)animated duration:(CGFloat)duration;
@end
这个接口设计有几个隐藏的考虑。第一,进度值统一收口到0.0~1.0的浮点数,业务方不需要关心弧度、角度这些绘制层概念;第二,setProgress:animated:duration:作为唯一的更新入口,内部统一处理校验、动画、文字更新,避免外部通过改属性绕开动画逻辑;第三,文字是否显示做成开关,让需要纯图形的页面不必传入空字符串这种hack方案。
3.2 KVC与KVO在进度更新中的角色
进度条最常见的业务场景是绑定下载进度、上传进度、播放进度这些异步数据源。如果每处业务代码都写一遍progressView.progressValue = xxx,那这个控件就不能算真正封装完成。
我在控件内部对progressValue做了KVO监听,外部直接赋值即可自动刷新UI,业务方不需要关心赋值之后发生了什么。同时对外预留了一个progressChangedBlock,让使用方可以拦截进度变化做一些额外逻辑。
objectivec复制- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary<NSKeyValueChangeKey,id> *)change
context:(void *)context {
if ([keyPath isEqualToString:@"progressValue"]) {
CGFloat newProgress = [change[NSKeyValueChangeNewKey] floatValue];
[self updateProgressLayerWithValue:newProgress];
[self updateCenterLabelWithProgress:newProgress];
}
}
不过这里要提醒一句:KVO回调里尽量不要做耗时操作,
