Skip to content

Instantly share code, notes, and snippets.

@Koshimizu-Takehito
Last active April 5, 2025 16:14
Show Gist options
  • Save Koshimizu-Takehito/f589657bcf26474bff1a840455b5c695 to your computer and use it in GitHub Desktop.
Save Koshimizu-Takehito/f589657bcf26474bff1a840455b5c695 to your computer and use it in GitHub Desktop.
ProgressRing
import SwiftUI
// MARK: - ProgressRingScreen
/// A screen demonstrating a circular progress ring with a slider and control panel.
/// Uses a `ProgressRingViewModel` to track and animate the progress state.
struct ProgressRingScreen: View {
/// A view model that tracks and animates the progress value.
@State var viewModel = ProgressRingViewModel()
var body: some View {
GeometryReader { geometry in
let width = min(geometry.size.width, geometry.size.height)
VStack(spacing: 0) {
rings(width: width)
controls(width: width)
}
}
}
/// Creates a single `ProgressRing` that displays the current progress,
/// using `ProgressText` to overlay the numeric percentage.
///
/// - Parameter width: The smallest dimension of the available space.
/// - Returns: A view containing the progress ring sized appropriately.
private func rings(width: Double) -> some View {
ProgressRing(value: viewModel.progress, text: ProgressText.init)
.scaledToFit()
.padding()
.frame(width: width * 0.5)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
/// Renders a slider and control panel for managing the progress, adapting layout for various screen sizes.
///
/// - Parameter width: The smallest dimension of the available space.
/// - Returns: A view containing the slider and control panel in an adaptive layout.
private func controls(width: Double) -> some View {
ViewThatFits {
// Horizontal layout for larger screens
HStack {
// Use the slider to manually set or pause progress.
ProgressSlider(viewModel: viewModel)
.frame(minWidth: width * 0.5)
.padding()
// A floating control panel with "Start", "Pause/Resume", and "Reset" actions.
ProgressControl(viewModel: viewModel)
.fixedSize()
.padding()
}
// Vertical layout if horizontal space is constrained
VStack {
ProgressSlider(viewModel: viewModel)
.padding(.horizontal)
// Let the control panel float to the bottom/trailing edge.
ProgressControl(viewModel: viewModel)
.fixedSize()
.frame(maxWidth: .infinity, alignment: .bottomTrailing)
.padding(.horizontal)
}
}
}
}
// MARK: - ProgressSlider
/// A slider view for manually adjusting the progress value.
/// Pauses the animation while the slider is dragged, avoiding conflicts with ongoing animation.
private struct ProgressSlider: View {
var viewModel: ProgressRingViewModel
/// Binds to the `progress` property of the view model, pausing animation on user input.
private var progressBinding: Binding<Double> {
Binding {
viewModel.progress
} set: { newProgress in
viewModel.pause()
viewModel.progress = newProgress
}
}
var body: some View {
@Bindable var viewModel = viewModel
Slider(value: progressBinding.animation(), in: 0...1)
.padding(12)
.background(.ultraThinMaterial)
.clipShape(.rect(cornerRadius: 12))
}
}
// MARK: - ProgressControl
/// A simple control panel for managing the progress state,
/// providing buttons to Start, Pause/Resume, or Reset.
private struct ProgressControl: View {
var viewModel: ProgressRingViewModel
var body: some View {
HStack(spacing: 12) {
let canStart = viewModel.progress <= 0 || viewModel.progress >= 1
let isPaused = viewModel.isPaused
let canResume = (!canStart && isPaused)
// Start / Pause / Resume Button
Button {
canStart
? viewModel.start()
: isPaused
? viewModel.resume()
: viewModel.pause()
} label: {
ZStack {
// A hidden label to reserve layout space
// so the button won't resize when toggling text.
PlaceholderLabel()
Label(action: canStart ? .start : canResume ? .resume : .pause)
}
}
Divider()
// Reset Button
Button {
viewModel.reset()
} label: {
ZStack {
// A hidden label to reserve layout space
// so the button won't resize when toggling text.
PlaceholderLabel()
Label(action: .reset)
}
}
.disabled(canStart)
}
.font(.body.bold())
.padding()
.background(.ultraThinMaterial)
.clipShape(.rect(cornerRadius: 12))
}
}
// MARK: - ProgressRing
/// A customizable circular progress ring that animates its trim value based on `value`.
/// - `value` is clamped to 0.0...1.0
/// - `text` is a ViewBuilder that receives the current progress value (0.0...1.0) and returns any overlay View.
///
/// Conforms to `Animatable` so SwiftUI can smoothly animate changes to `value`.
private struct ProgressRing<ProgressText: View>: Animatable {
/// The current progress value (0.0 to 1.0).
var value = 0.0
/// A closure that creates an overlay (e.g., text) based on the current progress.
var text: (Double) -> ProgressText
/// The property used by SwiftUI to animate `value`.
/// This getter/setter enforces the valid range [0,1].
var animatableData: Double {
get { max(min(value, 1), 0) }
set { value = max(min(newValue, 1), 0) }
}
/// Creates a `ProgressRing`.
/// - Parameters:
/// - value: The initial progress value. Defaults to 0.0.
/// - text: A ViewBuilder closure that renders the overlay given the current progress.
init(value: Double = 0.0, @ViewBuilder text: @escaping (Double) -> ProgressText) {
self.value = value
self.text = text
}
}
extension ProgressRing: View {
/// Draws a circular progress indicator with a background circle,
/// a foreground arc showing the current progress,
/// and an overlaid content view (e.g., progress text).
var body: some View {
ZStack {
// Background ring
Circle()
.stroke(lineWidth: 15)
.foregroundStyle(.gray.opacity(0.3))
// Animated progress arc
Circle()
.trim(from: 0, to: value)
.stroke(
style: StrokeStyle(lineWidth: 18, lineCap: .round, lineJoin: .round)
)
.foregroundStyle(
LinearGradient(
colors: [.cyan, .blue],
startPoint: .top,
endPoint: .bottom
)
)
// Rotate so that 0% is at the top (not the trailing side).
.rotationEffect(.degrees(-90.0 + (360.0 * value)))
}
.overlay {
// Overlaid content, e.g., a text label with the current progress
text(value)
.bold()
.monospacedDigit()
.fixedSize()
}
}
}
// MARK: - ProgressText
/// A simple text view that displays the progress as a percentage, e.g., "85%".
/// Uses an `HStack` with `.bottom` alignment to keep the '%' sign lower than the number.
private struct ProgressText: View {
/// The current progress value, from 0.0 to 1.0.
/// Multiplied by 100 to display a percentage.
var progressValue = 0.0 // renamed from `value` for clarity (optional)
var body: some View {
HStack(alignment: .bottom, spacing: 0) {
Text("\(Int(progressValue * 100))")
.font(.largeTitle)
Text("%")
// Shift the percent sign slightly downward to visually align with the number
.padding(.bottom, 5)
}
}
}
// MARK: - Label.Action
extension Label<Text, Image> {
fileprivate enum Action: CaseIterable {
case start
case reset
case resume
case pause
var title: String {
switch self {
case .start:
return "Start"
case .reset:
return "Reset"
case .resume:
return "Resume"
case .pause:
return "Pause"
}
}
var symbol: String {
switch self {
case .start:
return "play"
case .reset:
return "arrow.trianglehead.counterclockwise"
case .resume:
return "forward.frame"
case .pause:
return "pause"
}
}
}
/// Creates a `Label` with a title and system image based on the given action type.
fileprivate init(action: Action) {
self = Label(action.title, systemImage: action.symbol)
}
}
// MARK: - PlaceholderLabel
/// A hidden label to reserve layout space so the button won't resize when toggling text.
private struct PlaceholderLabel: View {
var body: some View {
ZStack {
ForEach(Label.Action.allCases, id: \.self) { action in
Label(action: action)
}
}
.hidden()
}
}
#Preview {
ProgressRingScreen()
}
import Foundation
import Observation
import SwiftUI
/// A view model that manages the progress state and its animation lifecycle.
@MainActor
@Observable
final class ProgressRingViewModel {
/// The current progress value (from 0.0 to 1.0).
var progress: Double = 0
/// Indicates whether the progress animation is paused.
/// If `task` is `nil`, the animation is considered paused.
var isPaused: Bool {
task == nil
}
/// The internal task responsible for driving the progress animation.
/// Automatically cancels any previous task when reassigned (via `didSet`).
private var task: Task<Void, Never>? {
didSet { oldValue?.cancel() }
}
/// Starts the progress animation from 0. Resets `progress` before resuming.
func start() {
progress = 0
resume()
}
/// Stops the animation and resets progress to 0.
func reset() {
pause()
progress = 0
}
/// Pauses the current animation by canceling the internal Task.
func pause() {
task = nil
}
/// Resumes the animation from the current `progress` value up to 1.0.
/// Uses a linear animation and sleeps briefly between increments for a smooth progression.
func resume() {
task = Task {
// Convert current progress to a starting integer, then go up to 1000 (representing 100% in 0.1% steps).
for i in Int(progress * 1000)...1000 {
if Task.isCancelled {
break
}
withAnimation(.linear) {
progress = Double(i) / 1000.0
}
try? await Task.sleep(for: .milliseconds(3))
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment