Skip to content

Animatable Ring

A progress ring whose arc animates through the @Animatable macro.

Animatable Ring, playing
Download .swift
Origin
Original work
Found by
progress ring · ring · arc · animatable · custom shape · progress · gauge · circular progress · anillo de progreso · anillo · arco · forma animada · progreso · medidor · progreso circular

A progress ring whose arc animates through the shape's own animatableData.

With Reduce Motion on, the ring jumps to the new value.

@available(iOS 26, *)
struct AnimatableRingDemo: View {
@State private var progress = 0.72
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
VStack(spacing: 24) {
AnimatableRing(progress: progress, title: "Daily goal")
.frame(width: 260, height: 260)
Text("Daily goal")
.font(.title2.bold())
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.task {
guard !reduceMotion else { return }
let values = [0.28, 0.91, 0.55, 0.72]
while !Task.isCancelled {
for value in values where !Task.isCancelled {
try? await Task.sleep(for: .seconds(1))
progress = value
}
}
}
}
}
@available(iOS 26, *)
ParameterTypeDefault
progressDoubleRequired
titleLocalizedStringKey"Progress"
colors[Color][.mint, .cyan, .blue, .mint]

Copy this code into your project, or download the file.

AnimatableRing.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// An arc from `start` to `end` degrees. `@Animatable` writes its `animatableData`.
@available(iOS 26, *)
@Animatable
nonisolated struct AnimatableRingArc: Shape {
var start: Double
var end: Double
@AnimatableIgnored var clockwise = false
func path(in rect: CGRect) -> Path {
Path { path in
path.addArc(
center: CGPoint(x: rect.midX, y: rect.midY),
radius: min(rect.width, rect.height) / 2,
startAngle: .degrees(start - 90),
endAngle: .degrees(end - 90),
clockwise: clockwise
)
}
}
}
/// A progress ring whose arc animates through the shape's own `animatableData`.
///
/// With Reduce Motion on, the ring jumps to the new value.
@available(iOS 26, *)
struct AnimatableRing: View {
let progress: Double
var title: LocalizedStringKey = "Progress"
var colors: [Color] = [.mint, .cyan, .blue, .mint]
@ScaledMetric(relativeTo: .largeTitle) private var lineWidth = 26.0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var clamped: Double { min(max(progress, 0), 1) }
var body: some View {
ZStack {
Circle()
.stroke(.quaternary, lineWidth: lineWidth)
AnimatableRingArc(start: 0, end: clamped * 360)
.stroke(
AngularGradient(colors: colors, center: .center),
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
Text(clamped, format: .percent.precision(.fractionLength(0)))
.font(.system(.largeTitle, design: .rounded, weight: .bold))
.contentTransition(.numericText(value: clamped))
}
.padding(lineWidth / 2)
.animation(reduceMotion ? nil : .smooth(duration: 0.8), value: clamped)
.accessibilityElement(children: .ignore)
.accessibilityLabel(title)
.accessibilityValue(Text(clamped, format: .percent.precision(.fractionLength(0))))
}
}
// MARK: - Demo
@available(iOS 26, *)
struct AnimatableRingDemo: View {
@State private var progress = 0.72
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
VStack(spacing: 24) {
AnimatableRing(progress: progress, title: "Daily goal")
.frame(width: 260, height: 260)
Text("Daily goal")
.font(.title2.bold())
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.task {
guard !reduceMotion else { return }
let values = [0.28, 0.91, 0.55, 0.72]
while !Task.isCancelled {
for value in values where !Task.isCancelled {
try? await Task.sleep(for: .seconds(1))
progress = value
}
}
}
}
}
@available(iOS 26, *)
#Preview {
AnimatableRingDemo()
}