渐变色圆弧进度条实现:UIBezierPath与图层蒙版实践
这是一篇最近在项目里折腾出来的东西趁着热乎劲儿把整个实现过程捋一遍。先说明白项目用的是 Objective-C核心就是做一个带渐变色的圆弧进度条。网上现成的轮子不少但大多要么是纯色要么封装太重改起来费劲。我这回的诉求很明确轻量、可复用、渐变颜色能随意调、动画别掉链子。实际上手搞完之后发现这个活儿本质上是把UIBezierPath、CAShapeLayer、CAGradientLayer和CALayer的mask机制这四样老伙计组合在一起思路通了代码量其实很小。这篇就把拆解过程、踩坑记录和最终可用的实现全部抖出来。1. 方案选型为什么是“图层叠加”而不是“drawRect重绘”1.1 核心思路拆解先把需求翻译成人话屏幕上需要一个圆环样子的进度条圆弧本身从起点到终点是平滑过渡的渐变色比如从浅蓝渐变到深紫而且还要跟着进度值动起来。第一反应可能是继承UIView在drawRect:里用Core Graphics画。这条路不是不能走但有几个绕不开的坑。首先是性能drawRect:每次进度更新尤其是通过CADisplayLink驱动逐帧动画时都会触发CPU绘制一旦视图层级复杂或者同时存在多个进度条主线程压力会很快上来。其次drawRect:画渐变圆弧要么用CGGradient配合裁剪路径去切要么自己逐点插值计算颜色代码量大且不好维护。更要命的是动画做起来麻烦——重绘模式下要做进度动画你得让进度值变化去触发setNeedsDisplay帧率和动画曲线都得自己控制很难做到像Core Animation那样流畅的隐式动画效果。所以在实际选择时我最终走的是“图层组合”这条路核心思想是用**CAShapeLayer当“蒙版”用CAGradientLayer当“画布”**。思路是这样先创建一个CAGradientLayer它的范围就是整个进度条视图的bounds给它设置好起始色和结束色让整个矩形区域充满渐变。然后创建一个CAShapeLayer把它的path设置为一条圆弧路径。关键一步来了——把shapeLayer赋给gradientLayer的mask属性。这样渐变层就只会在圆弧路径覆盖的那一圈区域显示出来圆弧之外的地方全部透明。因为遮罩层的透明度决定了被遮罩层的可见度CAShapeLayer的路径内部默认是填充状态所以渐变图层上就“印”出了一个圆弧形状的渐变带。这个思路的巧妙之处在于渐变方向和圆弧路径互不影响。你想让渐变从圆心向外辐射就调gradientLayer的startPoint和endPoint你想让渐变沿圆弧周向流动就调整两个点的笛卡尔坐标映射想换成多段渐变往colors数组里塞多个色值就行。圆弧本身想要多粗、缺口多大、起始角度在哪全都由shapeLayer的path控制。分工明确互不干扰这才是组合方案的最大红利。1.2 图层结构解析拆开来看整个控件从下到上分四层背景轨道层可选一个底色CAShapeLayer同样画一个圆弧但用的是浅灰/浅白这类低调颜色用来衬托进度位置。它不需要渐变因为纯色直接设置strokeColor就行。渐变进度层一个CAGradientLayer承载色彩一个CAShapeLayer作为mask两者配合形成渐变圆弧。中心文案层按需如果是带百分比的进度条加一个UILabel子视图或者直接用CATextLayer。手势交互层扩展如果是可拖拽调节的进度条可以在顶层挂UIPanGestureRecognizer。这里有个认知层面的东西想强调一下蒙版不是“盖上去”的而是“露出来”的。mask的透明像素会让被遮罩的图层内容不可见不透明像素才显示所以遮罩里路径的填充区域就是最终可见区域。理解这点之后就能推导出很多变体玩法——比如想让进度条是虚线的只需要给shapeLayer设置lineDashPattern想让两端变成圆头设置lineCap为kCALineCapRound。这些都是改一个属性的事完全不需要动框架。图层关系理清之后代码结构和后续调试会轻松很多。遇到显示不出来、颜色不对这类问题第一反应就应该是先检查图层层级是否对而不是一头扎进坐标计算里。2. 核心细节解析路径、渐变与蒙版的三方协奏2.1 UIBezierPath画圆弧的几个关键参数圆弧本身没啥花头难点在于参数必须一次写对。UIBezierPath画圆弧用的是addArcWithCenter:radius:startAngle:endAngle:clockwise:这个全家桶有四个参数决定生死中心点、半径、起始弧度、结束弧度外加一个方向标识。先说弧度。开发者最容易炸的地方是角度和弧度搞混。计算机图形学里0弧度对应的是3点钟方向不是12点钟方向。如果想把圆弧的起点放在正上方起始角度必须是-M_PI_2也就是-90度。顺时针方向为正所以从-90度开始顺时针画到270度M_PI_2 * 3正好一个整圆。再一个容易出问题的是半径的计算。如果视图bounds是100x100lineWidth是10圆弧半径该给多少是50吗如果路径半径给50圆弧中心在(50,50)那么stroke绘制时线条是沿路径中心线两侧各扩展5pt圆弧最外沿到了55最内沿到了45距离视图边缘有5pt的留白。想让圆弧正好紧贴视图边缘内切半径应该给(100 - lineWidth) / 2即45。这个细节很多人忽略导致圆弧画出来之后“发虚”或者不对称视觉上离边框忽远忽近。CGFloat lineWidth 10.0f; CGFloat radius (MIN(self.bounds.size.width, self.bounds.size.height) - lineWidth) / 2.0f; CGPoint center CGPointMake(self.bounds.size.width / 2.0f, self.bounds.size.height / 2.0f); CGFloat startAngle -M_PI_2; CGFloat endAngle -M_PI_2 M_PI * 2 * progress; // progress: 0.0 ~ 1.0 UIBezierPath *path [UIBezierPath bezierPathWithArcCenter:center radius:radius startAngle:startAngle endAngle:endAngle clockwise:YES];这里多嘴一句progress为0时endAngle等于startAngle路径长度为0画不出来任何进度是正常的。如果你需要进度为0时也显示一个“圆点”或“小帽檐”就得单独做描边处理或者在path里加一个半径极小的第二段弧。这个属于产品细节看需求定。2.2 CAGradientLayer的坐标系陷阱CAGradientLayer有两个属性最容易混淆startPoint和endPoint。这两个点的取值范围是0到1是单位坐标空间里的相对位置。默认值是(0.5, 0.0)和(0.5, 1.0)表示从上到下渐变。这里有个和很多人的直觉冲突的点**这个坐标是相对于layer的bounds的跟渐变色在圆弧上的实际视觉方向不是一回事。**因为最终只有蒙版遮出来的那一条圆弧是可见的渐变层的颜色分布是“铺满整个矩形”的所以控制渐变沿圆弧“走”的方向核心在于利用矩形对角位置的映射。打个比方整个渐变层是一块矩形彩色画布圆弧蒙版相当于在这块画布上抠出了一条环形缝隙。画布左上角到右下角涂的是从红色渐变到蓝色那么在这条环形缝隙上靠近左上角的弧段呈现红色靠近右下角的弧段呈现蓝色中间就是平滑过渡。要让渐变色沿圆弧“转圈”走比如起点红色、终点蓝色沿顺时针过渡你需要让起点色落在圆弧起始角附近终点色落在圆弧结束角附近。直白做法进度条圆弧是3点钟到9点钟方向时startPoint给(0.0, 0.0)endPoint给(1.0, 1.0)能凑合出对角渐变但圆弧是12点钟方向起笔时这个映射关系就需要微调。提供一个通用经验想省事就做圆心到边缘的径向渐变也就是startPoint (0.5, 0.5)endPoint (0.5, 0.0)这种布局下无论圆弧缺口在哪边视觉效果都是内浅外深或者内深外浅不容易穿帮。若必须做沿圆周的首尾渐变建议先画好圆弧看看起点和终点分别落在bounds的哪个方位再反推startPoint和endPoint。两个点都是相对坐标和实际像素无关调起来多用几次就熟了。2.3 让mask图层真正可变形的关键点CAShapeLayer作为蒙版时很多人会遇到一个诡异现象改它的path属性界面纹丝不动。原因很简单**蒙版图层默认关闭了隐式动画但它的动画事务并不总是跟随主图层。**直接改path确实改了但渲染没有及时刷新。实际开发里我会为蒙版shapeLayer单独开一个sublayer的隐式动画开关[CATransaction begin]; [CATransaction setDisableActions:YES]; // 关闭隐式动画手动控制刷新 self.maskShapeLayer.path path.CGPath; [CATransaction commit];setDisableActions:YES这个操作之后后续对path的赋值会立刻同步到渲染树不会产生莫名其妙的动画残留。这条经验适用于所有动态调整蒙版路径的场景记下来能少踩很多坑。还有一点CAShapeLayer的fillColor、strokeColor在充当蒙版时并不是控制显示颜色而是影响遮罩的透明区域。蒙版本身不参与颜色渲染它的strokeColor和fillColor只决定了哪些像素区域是“透”出来的。如果蒙版图层只设置path而忘了设置fillColor默认是nil也就是完全没有填充遮罩就会整体透明结果就是渐变层完全不可见。这俩属性在蒙版场景下要反着理解不需要在意颜色值是多少只要确保填充或描边走的是不透明通道就行。3. 实操过程从零搭建一个可复用的渐变色圆弧进度条3.1 搭建进度条视图骨架有了理论基础上代码。封装一个GradientArcProgressView对外暴露progress属性和gradientColors数组就行。先看完整实现再逐段解释。// GradientArcProgressView.h #import UIKit/UIKit.h interface GradientArcProgressView : UIView /// 进度值取值范围 0.0 ~ 1.0 property (nonatomic, assign) CGFloat progress; /// 渐变色数组元素为UIColor property (nonatomic, strong) NSArrayUIColor * *gradientColors; /// 轨道背景色 property (nonatomic, strong) UIColor *trackColor; /// 圆弧线宽 property (nonatomic, assign) CGFloat arcLineWidth; - (void)setProgress:(CGFloat)progress animated:(BOOL)animated; end// GradientArcProgressView.m #import GradientArcProgressView.h interface GradientArcProgressView () property (nonatomic, strong) CAShapeLayer *trackLayer; property (nonatomic, strong) CAGradientLayer *gradientLayer; property (nonatomic, strong) CAShapeLayer *maskLayer; end implementation GradientArcProgressView - (instancetype)initWithFrame:(CGRect)frame { self [super initWithFrame:frame]; if (self) { [self setupLayers]; } return self; } - (void)setupLayers { // 1. 轨道层 _trackLayer [CAShapeLayer layer]; [self.layer addSublayer:_trackLayer]; // 2. 渐变层 _gradientLayer [CAGradientLayer layer]; [self.layer addSublayer:_gradientLayer]; // 3. 蒙版层 _maskLayer [CAShapeLayer layer]; _gradientLayer.mask _maskLayer; // 默认配置 self.backgroundColor [UIColor clearColor]; self.arcLineWidth 10.0f; self.trackColor [UIColor colorWithWhite:0.0 alpha:0.1]; self.gradientColors [ [UIColor colorWithRed:0.22 green:0.71 blue:0.88 alpha:1.0], [UIColor colorWithRed:0.54 green:0.36 blue:0.91 alpha:1.0] ]; }两个细节提一下。轨道层可以用strokeColor直接画纯色圆弧没必要套渐变。渐变层的mask是弱引用maskLayer全局留着是被self强持有避免被提前释放导致图层不起作用。3.2 layoutSubviews中的关键布局CAShapeLayer和CAGradientLayer虽然是图层但它们的frame、path等几何属性必须依赖视图的bounds。所以布局逻辑要放在layoutSubviews里而不是init里。这地方是很多新手的坑在init里按frame计算坐标等外部用Auto Layout更新了视图尺寸圆弧还是老位置老大小或者干脆走样。正确做法尺寸一变所有图层重新布局。- (void)layoutSubviews { [super layoutSubviews]; CGFloat width CGRectGetWidth(self.bounds); CGFloat height CGRectGetHeight(self.bounds); CGFloat minSide MIN(width, height); CGPoint center CGPointMake(width / 2.0f, height / 2.0f); // 圆弧半径让stroke中心线位于视图内切圆上 CGFloat radius (minSide - self.arcLineWidth) / 2.0f; // 轨道层路径 self.trackLayer.frame self.bounds; self.trackLayer.path [self arcPathWithCenter:center radius:radius startAngle:-M_PI_2 endAngle:M_PI_2 * 3 lineWidth:self.arcLineWidth]; // 渐变层尺寸 self.gradientLayer.frame self.bounds; // 蒙版层路径先默认全圆 self.maskLayer.frame self.bounds; self.maskLayer.path [self arcPathWithCenter:center radius:radius startAngle:-M_PI_2 endAngle:M_PI_2 * 3 lineWidth:self.arcLineWidth]; // 设置完路径后重新应用进度 [self refreshProgressWithAnimation:NO]; }layoutSubviews里每次重新设置path之后必须手动把当前progress对应的圆弧重新画一遍。不然视图尺寸一变蒙版路径变成整圆而进度值没动画面就会显示满进度——这是另一个需要踩过才会记住的细节。所以我把刷新逻辑单独抽成了一个方法任何影响路径的因素变化后统一走这个入口。- (UIBezierPath *)arcPathWithCenter:(CGPoint)center radius:(CGFloat)radius startAngle:(CGFloat)startAngle endAngle:(CGFloat)endAngle lineWidth:(CGFloat)lineWidth { UIBezierPath *path [UIBezierPath bezierPathWithArcCenter:center radius:radius startAngle:startAngle endAngle:endAngle clockwise:YES]; return path; }需要明确的是轨道圆弧是整圆蒙版圆弧是当前进度对应的那一段。别的属性可以共用但路径终点要区分开。3.3 进度刷新与动画处理进度刷新是这个控件的灵魂也是动画表现的关键。先看普通赋值和带动画的两种刷新方式。- (void)setProgress:(CGFloat)progress { [self setProgress:progress animated:NO]; } - (void)setProgress:(CGFloat)progress animated:(BOOL)animated { // 做个简单保护 progress MAX(0.0, MIN(1.0, progress)); _progress progress; CGFloat width CGRectGetWidth(self.bounds); CGFloat height CGRectGetHeight(self.bounds); CGFloat minSide MIN(width, height); CGPoint center CGPointMake(width / 2.0f, height / 2.0f); CGFloat radius (minSide - self.arcLineWidth) / 2.0f; CGFloat startAngle -M_PI_2; CGFloat endAngle startAngle M_PI * 2 * progress; UIBezierPath *maskPath [UIBezierPath bezierPathWithArcCenter:center radius:radius startAngle:startAngle endAngle:endAngle clockwise:YES]; if (animated) { // 核心让蒙版路径的变化自动生成动画 CABasicAnimation *pathAnimation [CABasicAnimation animationWithKeyPath:path]; pathAnimation.duration 0.5f; pathAnimation.timingFunction [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseOut]; pathAnimation.fromValue (id)self.maskLayer.path; pathAnimation.toValue (id)maskPath.CGPath; self.maskLayer.path maskPath.CGPath; [self.maskLayer addAnimation:pathAnimation forKey:progressAnimation]; } else { [CATransaction begin]; [CATransaction setDisableActions:YES]; self.maskLayer.path maskPath.CGPath; [CATransaction commit]; } }动画实现就一行核心代码CABasicAnimation作用于keyPath为path。从当前的蒙版路径动画过渡到目标路径。需要点明的是fromValue用了当前maskLayer.path所以动画的起点是动态的连续多次调用动画不会出现跳动。一个我反复提醒自己的细节**动画在图层树上修改的是modelLayer的path而实际渲染依赖presentationLayer。**如果动画期间你对maskLayer.path进行了额外修改动画会被打断。所以如果是连续进度更新比如下载进度在1秒内更新几十次建议只调用setProgress:animated:NO用高频刷新替代动画如果低频更新一次3%、5%再用动画。3.4 渐变色数组的完整配置gradientColors属性内部要映射到CAGradientLayer的colors属性。这里牵扯到一个“颜色空间”匹配问题CAGradientLayer的colors要求是CGColorRef不是UIColor这个容易漏漏了直接黑屏。- (void)setGradientColors:(NSArrayUIColor * *)gradientColors { _gradientColors [gradientColors copy]; NSMutableArray *cgColors [NSMutableArray array]; for (UIColor *color in gradientColors) { [cgColors addObject:(id)color.CGColor]; } self.gradientLayer.colors cgColors; }渐变方向通过startPoint和endPoint控制我在这个项目里用的是对角渐变self.gradientLayer.startPoint CGPointMake(0.0, 0.0); self.gradientLayer.endPoint CGPointMake(1.0, 1.0);这种配置下渐变色是从左上角往右下角方向铺的。如果你想做得更精细比如三色渐变浅蓝 - 蓝紫 - 深紫只需要在colors数组里多加一个元素并且设置locations——这是CAGradientLayer里控制每个颜色停止位置的数组取值范围0到1长度必须和colors一致。self.gradientLayer.colors [ (id)[UIColor colorWithRed:0.27 green:0.76 blue:0.95 alpha:1.0].CGColor, (id)[UIColor colorWithRed:0.44 green:0.39 blue:0.91 alpha:1.0].CGColor, (id)[UIColor colorWithRed:0.67 green:0.29 blue:0.85 alpha:1.0].CGColor ]; self.gradientLayer.locations [0.0, 0.5, 1.0];重点来了如果你设置了locations但不设置startPoint/endPoint渐变默认是上下方向的三色分布就成了顶部一个色、中间一个色、底部一个色。当圆弧跨度大时这条“三色渐变带”会被蒙版切成几段视觉上可能会出现明显的色块跳跃。要平滑过渡建议让渐变方向与圆弧的走向大致对齐必要时把startPoint设为(0.0, 1.0)、endPoint设为(1.0, 0.0)相当于从左下到右上的对角渐变。底色到高光的过渡会显得自然很多。4. 项目落地中的细节与避坑实践4.1 绘制刷新时机的血泪教训处理完主体逻辑后我对一个现象印象特别深**进度条在某些页面上显示不出来但Debug视图层级里明明存在。**排查了很久最后定位到原因是layoutSubviews的执行时机。如果视图在加入窗口之前bounds还是zero所有基于bounds计算的路径都会是空路径蒙版自然什么也遮不出来。而等视图真正布局完成时layoutSubviews也没有被再次调用——那画面就是空白。解决方式是人为兜底。在didMoveToWindow里触发一次重布局- (void)didMoveToWindow { [super didMoveToWindow]; if (self.window) { [self setNeedsLayout]; [self layoutIfNeeded]; } }这样当视图真正进入窗口、拿到正确尺寸后路径会重新计算一次进度条按时出现。这个坑在纯代码创建视图、用Auto Layout约束的场景下最容易触发尤其是控件在ViewController的viewDidLoad里创建但约束到viewDidAppear才最终确定的情况下。4.2 深色模式与背景透明的处理圆弧进度条通常用在卡片、列表头这种半透明区域控件的backgroundColor必须保持透明。如果视图本身设置了不透明的背景色圆弧周围会出一块方形的色块非常突兀。所以组件内部默认把backgroundColor设为[UIColor clearColor]同时保证gradientLayer的opaque属性默认就是NO图层默认透明不需要额外设置但trackLayer和maskLayer不需要背景色它们只关心路径。深色模式下轨道颜色如果写死白色或者纯黑色适配会翻车。我习惯用动态颜色[UIColor colorWithDynamicProvider:]在light下给浅灰white:0 alpha:0.1在dark下给半透明白white:1 alpha:0.15。这样进度条在明暗两种界面下都有边界感不会糊成一片。- (UIColor *)trackColor { if (!_trackColor) { _trackColor [UIColor colorWithDynamicProvider:^UIColor *(UITraitCollection *traitCollection) { if (traitCollection.userInterfaceStyle UIUserInterfaceStyleDark) { return [UIColor colorWithWhite:1.0 alpha:0.15]; } else { return [UIColor colorWithWhite:0.0 alpha:0.1]; } }]; } return _trackColor; }4.3 内存与渲染性能表现实测下来用这套组合方案的性能表现相当不错。图层都在GPU上合成不涉及CPU逐帧绘制。一个进度条视图整体图层树不超过5个节点基本不会对离屏渲染产生压力。需要留个心眼的是**遮罩本身会引入一层额外的离屏渲染。**也就是说gradientLayer在mask存在时不能直接在屏幕上合成必须先把渐变渲染到一张离屏缓冲里再根据遮罩计算出最终可见像素。如果页面上同时存在十几个这样的进度条GPU内存和混合开销会上升。解决方案也不复杂尽量复用图层、避免透明图层重叠或者把多个进度条合并到一个视图里绘制。关于“避免透明图层重叠”这点展开说一下。如果渐变层下面叠了一个半透明的背景层那么结合遮罩生成最终画面的过程中合成器需要做更多工作。实际项目中若进度条要叠在复杂背景上可以考虑给最外层视图设置一个shouldRasterize属性并配合rasterizationScale让图层栅格化为位图、避免每次帧渲染都重新合成。但注意栅格化会让动态进度动画时的每一次更新都要重新栅格化如果动画频繁优化反而变成负担。我的建议是进度条数量少1-3个、进度更新不频繁秒级更新时用栅格化频繁更新比如拖动滑块时别开。4.4 常见问题与排查方法速查实践过程中遇到的问题整理一个表格方便对照排查。现象可能原因解决方案圆弧显示不出来蒙版层未设置fillColor或strokeColor蒙版层设置strokeColor为[UIColor whiteColor].CGColorlineWidth正常设置渐变颜色方向不对startPoint/endPoint设置反了明确起点与终点对应视觉上的位置用0-1相对坐标反复调试进度条进度为0时整体消失圆弧路径起点等于终点stroke没画出来业务上单独处理0进度时的视觉表现或设置lineDashPattern等兜底样式视图尺寸变化后进度满格layoutSubviews中重新设置了蒙版路径但没有应用进度在布局完成之后调用refreshProgress方法动画中途卡顿或跳动动画过程中频繁修改modelLayer.path动画期间禁止二次路径赋值统一走fromValue/toValue机制进度条整体模糊视图尺寸是小数路径计算精度丢失布局时保证bounds为整数或使用CGRectIntegral取整圆弧粗细和设计图不一致lineWidth设置错误或半径计算未扣除线宽半径 (视图边长 - 线宽) / 2圆弧端点有缺口或圆角不对lineCap设置不符合预期设置lineCap kCALineCapRound或kCALineCapButt图层加了Mask后渐变层消失mask的path为nil或者mask未加到gradientLayer上把maskLayer赋给gradientLayer.mask比对图层层级这里面有几个坑值得再说透一点。lineCap这个属性默认是kCALineCapButt也就是圆角线段的两端是平头。如果进度条是从0开始增长的那种半圆弧平头会导致起点处出现折线感视觉上不柔和。改成kCALineCapRound之后线段两端变成半圆头进度增加时像“胶囊拉长”观感好很多。CAShapeLayer的strokeColor在蒙版场景下要用白色还是不透明色严格来说只要Alpha通道是1.0的任何颜色都行颜色本身的RGB值不影响遮罩效果。建议统一用[UIColor whiteColor].CGColor至少不会在调试时产生“为什么蒙版淡化”的疑惑。还有一点需要特别提醒mask的frame和gradientLayer的frame必须一致。如果蒙版的frame是CGRectZero路径就算画得再对也是白搭——路径是相对mask自身的坐标系来计算的坐标原点的位置变了圆弧就不在所期望的位置上。实测中常见做法是让maskLayer.frame gradientLayer.bounds并且在layoutSubviews里同步更新。4.5 常用接口设计支持任意场景的渐进式扩展做组件的终极需求是接入成本低。这套控件对外暴露的接口不大但足够覆盖绝大多数场景progress、gradientColors、trackColor、arcLineWidth、以及带动画的setProgress:animated:。再往下就看你自己的业务想象力了。比如下载进度页我一般搭配一个UILabel显示百分比布局时放在进度条视图上方居中用NSLayoutConstraint或者center对齐都行。再比如打卡圆环、步数环时长、颜色全部外部传入内部类只需要维护一个通用的“圆弧渐变”核心。接口设计有一个值得养成的习惯所有setter方法里改变外观属性的都应该在内部触发一次图层刷新。比如外部修改了arcLineWidth如果不刷新路径圆弧粗细不变修改了gradientColors如果不刷新colors颜色也不变。把这些副作用内聚在setter里外部的调用方只需要关心业务不需要知道内部实现。我用这个组件的方式是在具体业务页面里再包一层比如“环状目标进度卡片”里面持有三四个GradientArcProgressView每个视图代表一个目标项卡片负责处理数据格式化、文案颜色等。这样不仅让组件保持单一职责复用度也大幅提升。5. 动画表现打磨与高级扩展5.1 从弧线动画到仿“扫光”效果的探索基础进度动画就是path的过渡动画但做产品时单纯的进度条有时显得单调。我后来给它加了一个“扫光”效果——就是那条渐变圆弧上叠加一道高亮的移动光带模拟扫描动态视觉效果很抓人眼球。实现思路也不复杂在渐变层之上再叠加一层CAGradientLayer同样用蒙版但这层gradientLayer只包含白色到透明的渐变宽度很窄。通过CABasicAnimation不断修改gradientLayer.startPoint和endPoint让高光沿路径方向来回“扫”。代价是又增加了一层图层离屏渲染叠加更明显。要控制节奏只在页面展示时扫两三次不要做成永恒循环动画。再说一句凡是涉及无限循环的位移动画强烈建议在页面disappear时停止否则presentationLayer会一直提交渲染树更新电量消耗立竿见影。5.2 通过strokeEnd动画实现进度条增长除了用path控制蒙版形状还有一个备选方案直接用CAShapeLayer的strokeEnd。strokeEnd取值范围0到1表示路径描边绘制到哪一截从0开始增长到0.8就表示只画了整条路径的80%。这个方案的好处是代码更简单不需要来回改path动画可以直接对strokeEnd做CABasicAnimation性能与效果都不错。缺点是对“进度条初始就有完整路径”的产品需求不友好——strokeEnd从0起步时起点处不显示任何内容如果产品要求起点处有小圆点或标记还得额外加一个静态圆点。实现方式如下self.maskLayer.strokeStart 0.0f; self.maskLayer.strokeEnd progress; CABasicAnimation *strokeAnim [CABasicAnimation animationWithKeyPath:strokeEnd]; strokeAnim.fromValue (self.maskLayer.presentationLayer ?? self.maskLayer).strokeEnd; strokeAnim.toValue (progress); strokeAnim.duration 0.5f; [self.maskLayer addAnimation:strokeAnim forKey:strokeEndAnim];s strokeStart也可以配合使用用来做“某一段进度条加粗高亮”的效果让strokeStart从0.2开始、strokeEnd到0.8只高亮中间一段。对目标达成类业务比如完成度超过80%时把高亮区域从70%~100%弹出来这个技巧很实用。5.3 多段渐变的进阶玩法渐变数组不止两个颜色时最终呈现出的圆弧会出现多个色带。理解locations的重要性后再做多色渐变就有底气了。举一个真实场景0到25%是红25%到75%是黄75%到100%是绿——类似仪表盘预警的含义。实现上反而是把“多段”塞进GradientArcProgressView内部的渐变层里外部只需要传三色数组locations给[0.0, 0.25, 1.0]。因为颜色在整个矩形渐变层上分布圆弧上的色段会按照圆弧跨越的空间占比来呈现所以效果并不完全等同于按进度值划分三段。严格按进度值分段的方案需要动态调整locations这属于高级玩法度量和换算要多花心思。一般产品场景下多色数组加固定locations足够应付。要提醒的是颜色越多渐变过渡越可能产生“脏”的中间色。比如红到黄中间会产生橙色过渡这可能不是UI想要的“红黄两色硬切”效果。想要硬切效果可以让两个相邻颜色在同一个locations值上“折返”比如locations [0.0, 0.25, 0.25, 1.0]此时0.25位置出现两次渐变会在这个位置瞬间跳变达成硬切目标。这是设计渐变的小技巧做数据可视化的时候非常有用。6. 我对这类控件的长期实践心得组件做完之后回头复盘整个交付过程。我最大的体会是凡是涉及Core Animation的控件开发90%的难点都在“图层关系的理解”而不是代码量多少。很多人写进度条时一头扎进坐标计算、角度换算在我看是走偏了方向。真正该先想清楚的是图层之间的父子关系、遮罩的显隐逻辑、以及图层动画的渲染机制这三个底座。其次组件化思维要在写第一行代码之前就立起来。progress怎样设置、动画如何触发、颜色数组如何传递、外部刷新时机是什么这些接口设计直接决定项目里能不能顺畅使用。后面的开发中我遇到不少同事拿着这个控件去适配新的产品需求——从横屏电量环到圆形倒计时从目标完成度到仪表盘——基本都是直接传参就能上不需要改内部结构。再有就是更新的维护成本。iOS 13以后深色模式成为必选项UIColor动态颜色在组件里预留了扩展位后续接暗黑主题时连业务层都不用动。这一点的价值在设计初就体会到组件把环境适配从“发生问题再改”变成了“声明式常态支持”省下大量回归测试时间。最后分享一个实际使用中的心态别指望一套代码满足所有设计稿。圆弧渐变进度条看似简单但不同设计师对缺口角度、粗细、圆头、渐变方向甚至动画曲线的审美差异极大。组件的稳定边界是提供“圆弧渐变蒙版”这个核心机制把外观参数全部开放剩下的让使用方按需调整。这样才能真正沉淀出有生命力的组件而不是成为一个只在本项目里跑得通的一次性代码。把原理、实现、踩坑都串起来之后以后再做类似的圆环进度、多段仪表盘心里就完全有底了。核心的这套“图层组合拳”换汤不换药怎么变都能接得住。