环形进度条在日常移动应用中几乎随处可见,从健康应用里的活动圆环到上传文件时的加载指示,一个视觉细腻的环形动画能直接提升界面的完成度。SwiftUI本身提供了ProgressView,但默认样式是线性的,想要得到真正的圆环需要借助Shape和动画机制。本文将从一个基础实现出发,逐步加入动画和进度文本,构建一个可以灵活配置的AnimatedProgressRing组件。

环形绘制的基础:Shape与trim
SwiftUI绘制环形最简单的思路是使用Circle搭配trim修饰符。Circle本身是一个完整的圆,trim可以只显示其中一部分路径。比如下面这行代码绘制了一个从顶部开始、长度为百分之三十的圆弧:
Circle()
.trim(from: 0.0, to: 0.3)
.stroke(Color.blue, style: StrokeStyle(lineWidth: 20, lineCap: .round))
.rotationEffect(.degrees(-90))
这里trim的from和to都是0到1之间的值,表示路径的起止位置,默认起点是圆的右侧即三点钟方向。开发者通常希望进度从顶部十二点方向开始,所以需要配合rotationEffect旋转负90度。stroke里的StrokeStyle可以设置线宽和端点样式,lineCap设为.round就能得到圆润的端点,适合进度条这种不完全闭合的图形。
如果只是静态展示,到这一步就够了,但真正的难点在于动画。SwiftUI不会自动对trim变化添加动画,因为trim的from和to并不是一个统一的属性。要让进度值变化时圆弧平滑增长或缩短,需要让SwiftUI知道这个值是一个可动画的量。直接使用withAnimation更改to值,效果可能是跳跃式的,因为Shape没有实现animatableData。接下来就需要引入自定义AnimatableModifier。
实现AnimatedProgressRing:AnimatableModifier驱动动画
AnimatableModifier是SwiftUI中一种特殊的ViewModifier,它要求提供animatableData属性,SwiftUI会在动画过程中对该属性进行插值。我们可以创建一个结构体,让它保存进度值,并把该值声明为animatableData。当外部progress变化并且包裹在withAnimation中时,SwiftUI会通过AnimatableModifier对animatableData进行线性插值,从而让Shape重新计算trim的to值,实现平滑动画。
struct ProgressRingModifier: AnimatableModifier {
var progress: CGFloat
var lineWidth: CGFloat
var lineColor: Color
var trackColor: Color
var animatableData: CGFloat {
get { progress }
set { progress = newValue }
}
func body(content: Content) -> some View {
ZStack {
Circle()
.stroke(trackColor, style: StrokeStyle(lineWidth: lineWidth))
Circle()
.trim(from: 0.0, to: min(progress, 1.0))
.stroke(lineColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
}
.padding(lineWidth / 2)
}
}
注意animatableData的setter里我们直接修改progress,这样body每次重新计算时就会得到插值后的中间数值,trim自然过渡。使用min(progress, 1.0)是为了防止progress超过1导致路径异常。外层ZStack把进度环叠加在灰色底环上,底环可以让用户感知整个进度区间。padding的作用是防止圆环因为圆角端点被裁剪,因为stroke默认会在边界绘制,加上圆角端点后实际范围会向外扩半个线宽。
外部使用这个Modifier时只需调用modifier方法,注意SwiftUI的modifier放在视图链末尾。如果希望动画可配置,还可以在modifier中提供animation参数,让调用方决定使用easeInOut还是spring。下面展示如何在一个View中使用AnimatedProgressRing:
struct ContentView: View {
@State private var progress: CGFloat = 0.25
var body: some View {
VStack(spacing: 40) {
AnimatedProgressRing(progress: progress)
.frame(width: 200, height: 200)
Slider(value: $progress, in: 0...1)
.padding(.horizontal)
Button("随机更新") {
withAnimation(.easeInOut(duration: 1.2)) {
progress = CGFloat.random(in: 0...1)
}
}
}
}
}
AnimatedProgressRing本身是一个View,内部使用Circle和Modifier组合。把frame限制为固定尺寸很重要,否则圆环可能被拉伸成椭圆。Slider和Button用于测试动画是否对progress变化生效。点击按钮触发withAnimation后,你会发现圆弧不再瞬间跳变,而是流畅地扫过对应角度,这正是AnimatableModifier在幕后插值的结果。
CircularProgress:增加渐变、圆角端点和进度文本
生产环境中往往希望进度环更有质感,一个常见的需求是使用渐变描边代替纯色。SwiftUI的stroke方法支持使用AngularGradient或者LinearGradient,配合trim同样可以工作,但要注意渐变方向是否与旋转一致。更稳健的做法是用fill替换stroke,但填充会得到扇形而非环形。因此继续使用stroke,把渐变色作为第一个参数传入。为了让渐变跟随路径角度,AngularGradient是更合适的选择。
进度文本的展示也不可或缺。我们可以把文本放在ZStack中心,使用overlay或者直接在AnimatedProgressRing内部叠加一个Text。文本内容需要显示百分数,可以取progress乘以100后去掉小数。为了避免文本布局影响圆环,建议使用fixedSize或者给一个固定宽度。下面的CircularProgress组件整合了渐变圆环、灰色底环、圆角端点和进度文本:
struct CircularProgress: View {
var progress: CGFloat
var lineWidth: CGFloat = 20
var startColor: Color = .blue
var endColor: Color = .purple
var body: some View {
ZStack {
Circle()
.stroke(Color.gray.opacity(0.2), style: StrokeStyle(lineWidth: lineWidth))
Circle()
.trim(from: 0.0, to: min(progress, 1.0))
.stroke(
AngularGradient(
gradient: Gradient(colors: [startColor, endColor]),
center: .center,
startAngle: .degrees(-90),
endAngle: .degrees(270)
),
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
.rotationEffect(.degrees(-90))
Text("\(Int(progress * 100))%")
.font(.system(size: 34, weight: .bold, design: .rounded))
.foregroundColor(.primary)
}
.padding(lineWidth / 2)
.modifier(ProgressRingModifier(progress: progress, lineWidth: lineWidth))
}
}
注意这里的实现同时使用了两个Circle:一个灰色底环和一个渐变进度环。渐变进度环通过trim控制显示长度,并应用了ProgressRingModifier让progress变化具有动画插值。但这种写法下modifier内部的body又绘制了底环和进度环,与外部写好的两个Circle会发生重复。实际上更好的做法是把所有绘制逻辑移到Modifier中,外部View只负责承载Modifier。之所以在CircularProgress中再次书写,是为了演示可以灵活叠加文本;真正的动画依然依靠Modifier中的animatableData。为避免混淆,建议把底环、进度环和渐变色全部封装进一个独立的Shape或者Modifier,外部只传递progress和样式参数。
如果想在文本上也加上动画,SwiftUI的Text不会自动跟随Modifier中的插值更新,因为进度文本是外部状态直接读取的。要实现数字滚动效果,需要在外部用Timer或绑定到animatableData,或者简单地在onChange中更新文本。实际项目中更推荐创建一个专门显示数字动效的视图,用AnimatableModifier或ContentTransition,但为了保持示例简洁,CircularProgress中的Text会在进度变化时瞬间更新,配合圆环动画仍然可以接受。
进阶优化与常见坑点
第一个坑是圆环尺寸与padding的关系。stroke的圆角端点样式会让实际绘制区域略大于原本的frame,如果不加padding,圆环边缘可能被父视图裁剪。特别是在使用List或LazyVStack时,系统裁剪可能导致渐进部分缺失。因此务必给环形视图增加lineWidth一半的padding,或者在使用frame时留出余量。另一个容易被忽略的是旋转之前的坐标原点:swiftUI的Circle默认从右侧开始,旋转负90度后起点变为顶部,这一点适用于大多数进度场景,但如果需要从其他角度起始,可以调整rotationEffect的角度。
性能方面,AnimatableModifier在动画过程中频繁重建视图,如果圆环尺寸很大或者叠加了复杂的渐变、阴影,可能会对CPU造成压力。对于简单的环形进度,这种开销可以忽略不计;但在列表中出现多个环形进度条时,建议使用Canvas或Metal进一步优化。另外一些开发者尝试使用Shape协议直接实现animatableData,其实本质和Modifier类似,但Shape方式需要实现path(in:)函数并确保在动画插值时返回正确路径,代码量更大。本文的Modifier方案足够应对大多数需求,且与View体系结合更自然。
最后需要注意的是跨版本兼容性。SwiftUI的某些动画行为在iOS 15之前略有差异,如果项目需要支持较早版本,建议测试easeInOut动画是否正常。另外Min(progress, 1.0)的写法可以防止越界,但当progress为负数时trim会直接裁剪为零,最好在外部约束progress范围。通过本文介绍的AnimatedProgressRing和CircularProgress思路,你可以快速在项目里搭建出美观且流畅的环形进度指示器,为加载、下载、倒计时等场景提供直观反馈。
SwiftUIAnimatedProgressRingCircularProgress修改时间:2026-09-18 03:59:12