Path: blob/main/SignalUI/Views/CircularProgressView.swift
1 views
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
public import UIKit
public class CircularProgressView: UIView {
// MARK: - Progress
private var _progress: Float = 0
private enum IndeternimateAnimationState: Equatable {
/// Not in "indeternimate animation" state.
case notAnimating
/// Speeding up.
case phase1(CFTimeInterval)
/// Infinite spin..
case phase2
/// Transitioning to determinate progress.
case phase3
}
private enum Animation {
enum Indeternimate {
// How long does it take for the spinner stroke to grow and do initial rotation.
static let phaseOneDuration: CFTimeInterval = 1
// How long does it take for the spinner to do once full rotation.
static let phaseTwoDuration: CFTimeInterval = 1
static let strokeGrow = "indeterminate.strokeGrow"
static let strokeSpin = "indeterminate.strokeSpin"
static let infiniteSpin = "indeterminate.infiniteSpin"
}
enum Determinate {
static let duration: CFTimeInterval = 0.2
static let strokeResize = "determinate.strokeResize"
}
}
public var progress: Float {
get { _progress }
set { setProgress(newValue, animated: false) }
}
public func setProgress(_ newValue: Float, animated: Bool) {
// Can switch to determinate if indeterminate is starting or running.
if isAnimating {
switchToDeterminate(progress: newValue, animated: animated)
return
}
_progress = newValue.clamp01()
// If state is `phase3` we want to preserve the progress but not interfere with the animation.
// Instead, once animation finishes, the view will update to match current `progress`.
guard case .notAnimating = animationState else {
return
}
let progress = CGFloat(progress)
if animated {
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = progressLayer.presentation()?.strokeEnd ?? progressLayer.strokeEnd
animation.toValue = progress
animation.duration = Animation.Determinate.duration
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
progressLayer.add(animation, forKey: Animation.Determinate.strokeResize)
}
CATransaction.begin()
CATransaction.setDisableActions(true) // suppress implicit animation
progressLayer.strokeEnd = progress
CATransaction.commit()
}
private var animationState: IndeternimateAnimationState = .notAnimating
public var isAnimating: Bool {
switch animationState {
case .notAnimating:
false
case .phase1(_), .phase2:
true
case .phase3:
false
}
}
public func startAnimating() {
guard animationState == .notAnimating else { return }
_progress = 0
// Reset to default state.
CATransaction.begin()
CATransaction.setDisableActions(true)
progressLayer.strokeEnd = 0
progressLayer.transform = CATransform3DIdentity
CATransaction.commit()
// Phase 1 animations.
let strokeGrow = CABasicAnimation(keyPath: "strokeEnd")
strokeGrow.fromValue = 0
strokeGrow.toValue = 0.5
strokeGrow.duration = Animation.Indeternimate.phaseOneDuration
strokeGrow.timingFunction = CAMediaTimingFunction(name: .easeIn)
strokeGrow.fillMode = .forwards
strokeGrow.isRemovedOnCompletion = false
let initialSpin = CABasicAnimation(keyPath: "transform.rotation.z")
initialSpin.fromValue = 0
initialSpin.toValue = 3 * CGFloat.halfPi
initialSpin.duration = Animation.Indeternimate.phaseOneDuration
initialSpin.timingFunction = CAMediaTimingFunction(name: .easeIn)
initialSpin.fillMode = .forwards
initialSpin.isRemovedOnCompletion = false
let animationStartTime = CACurrentMediaTime()
animationState = .phase1(animationStartTime)
CATransaction.begin()
CATransaction.setCompletionBlock { [weak self] in
guard let self else { return }
// Make sure `startIndeterminate()` wasn't called in between.
guard animationState == .phase1(animationStartTime) else { return }
self.beginInfiniteSpin()
}
progressLayer.add(strokeGrow, forKey: Animation.Indeternimate.strokeGrow)
progressLayer.add(initialSpin, forKey: Animation.Indeternimate.strokeSpin)
CATransaction.commit()
}
private func beginInfiniteSpin() {
// Necessary in case animation was interrupted during phase 1.
guard case .phase1 = animationState else { return }
// Bake state at end of phase 1 into the model layer before removing animations.
CATransaction.begin()
CATransaction.setDisableActions(true)
progressLayer.strokeEnd = 0.5
progressLayer.transform = CATransform3DMakeRotation(3 * .halfPi, 0, 0, 1)
CATransaction.commit()
progressLayer.removeAnimation(forKey: Animation.Indeternimate.strokeGrow)
progressLayer.removeAnimation(forKey: Animation.Indeternimate.strokeSpin)
animationState = .phase2
// Phase 2 animation: one full circle spin, repeaded indefinitely.
let infiniteSpin = CABasicAnimation(keyPath: "transform.rotation.z")
infiniteSpin.byValue = 2 * CGFloat.pi
infiniteSpin.duration = Animation.Indeternimate.phaseTwoDuration
infiniteSpin.repeatCount = .infinity
infiniteSpin.timingFunction = CAMediaTimingFunction(name: .linear)
progressLayer.add(infiniteSpin, forKey: Animation.Indeternimate.infiniteSpin)
}
public func stopAnimating() {
guard isAnimating else { return }
_progress = 0
animationState = .notAnimating
progressLayer.removeAnimation(forKey: Animation.Indeternimate.strokeGrow)
progressLayer.removeAnimation(forKey: Animation.Indeternimate.strokeSpin)
progressLayer.removeAnimation(forKey: Animation.Indeternimate.infiniteSpin)
progressLayer.strokeEnd = 0
progressLayer.transform = CATransform3DIdentity
}
private func switchToDeterminate(progress: Float, animated: Bool) {
guard isAnimating else { return }
let targetProgress = CGFloat(progress).clamp01()
// 1. Snapshot current rotation.
let currentRotation = progressLayer.presentation()?.value(forKeyPath: "transform.rotation.z") as? CGFloat ?? 0
// Bring current rotation angle into 0..2pi range.
// `visibleRotation` represents visible amount of rotation that the arc has relative to it's `default` state.
// `visibleRotation` being zero corresponds to beginning of the arc being at 12 o'clock
// and the end of the arc being at 6 o'clock.
let doublePi = 2 * CGFloat.pi
let visibleRotation =
currentRotation.truncatingRemainder(dividingBy: doublePi) + (currentRotation < 0 ? doublePi : 0)
// 2. Remove ALL of the animations, in case switch to deternimate happens before
// spinner starts its infinite spin.
progressLayer.removeAnimation(forKey: Animation.Indeternimate.strokeGrow)
progressLayer.removeAnimation(forKey: Animation.Indeternimate.strokeSpin)
progressLayer.removeAnimation(forKey: Animation.Indeternimate.infiniteSpin)
// Simply update model and state if changes should not be animated.
guard animated else {
animationState = .notAnimating
CATransaction.begin()
CATransaction.setDisableActions(true)
progressLayer.strokeEnd = targetProgress
progressLayer.transform = CATransform3DIdentity
CATransaction.commit()
return
}
// 3. Prepare layer to be animated to it's final state by applying current rotation angle.
CATransaction.begin()
CATransaction.setDisableActions(true)
progressLayer.strokeEnd = 0.5
progressLayer.transform = CATransform3DMakeRotation(visibleRotation, 0, 0, 1)
CATransaction.commit()
// 4. Animate rotation back to 0 and strokeEnd to target progress simultaneously.
let strokeLength = CABasicAnimation(keyPath: "strokeEnd")
strokeLength.fromValue = 0.5
strokeLength.toValue = targetProgress
strokeLength.duration = Animation.Determinate.duration
strokeLength.timingFunction = CAMediaTimingFunction(name: .easeOut)
let finalSpin = CABasicAnimation(keyPath: "transform.rotation.z")
finalSpin.fromValue = visibleRotation
finalSpin.toValue = 0
finalSpin.duration = Animation.Determinate.duration
finalSpin.timingFunction = CAMediaTimingFunction(name: .easeOut)
animationState = .phase3
CATransaction.begin()
CATransaction.setDisableActions(true)
CATransaction.setCompletionBlock { [weak self] in
guard let self else { return }
guard self.animationState == .phase3 else { return }
self.animationState = .notAnimating
CATransaction.begin()
CATransaction.setDisableActions(true)
self.progressLayer.strokeEnd = targetProgress
self.progressLayer.transform = CATransform3DIdentity
CATransaction.commit()
}
progressLayer.add(strokeLength, forKey: Animation.Indeternimate.strokeGrow)
progressLayer.add(finalSpin, forKey: Animation.Indeternimate.strokeSpin)
CATransaction.commit()
}
// MARK: - Appearance
public var progressTintColor: UIColor? {
didSet {
updateColors()
}
}
public var trackTintColor: UIColor? {
didSet {
updateColors()
}
}
public var lineWidth: CGFloat = 2 {
didSet {
trackLayer.lineWidth = lineWidth
progressLayer.lineWidth = lineWidth
}
}
private func updateColors() {
// Use `self.tintColor` if `progressTintColor` isn't set.
let progressColor = progressTintColor ?? tintColor!
progressLayer.strokeColor = progressColor.resolvedColor(with: traitCollection).cgColor
// Reasonable fallback for track color.
let trackColor = trackTintColor ?? UIColor.Signal.MaterialBase.fillSecondary
trackLayer.strokeColor = trackColor.resolvedColor(with: traitCollection).cgColor
}
override public func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
// Use `registerForTraitChanges()` on newer iOS versions.
guard #available(iOS 17, *) else { return }
// Re-resolve dynamic colors when changing between light and dark themes.
if previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle {
updateColors()
}
}
override public func tintColorDidChange() {
super.tintColorDidChange()
// Progress track might be using `self.tintColor` - update if that's the case.
updateColors()
}
// MARK: - UIView
private var didBecomeActiveObservation: NotificationCenter.Observer?
override public init(frame: CGRect) {
super.init(frame: frame)
setupLayers()
// Modern alternative to `traitCollectionDidChange`.
if #available(iOS 17, *) {
registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CircularProgressView, _) in
view.updateColors()
}
}
didBecomeActiveObservation = NotificationCenter.default.addObserver(
name: UIApplication.didBecomeActiveNotification,
) { [weak self] notification in
self?.restartAnimationsIfNeeded()
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
if let didBecomeActiveObservation {
NotificationCenter.default.removeObserver(didBecomeActiveObservation)
}
}
override public func layoutSubviews() {
super.layoutSubviews()
updateGeometryIfNecessary()
}
override public var intrinsicContentSize: CGSize { .square(44) }
// MARK: - Restarting Animations
override public func willMove(toWindow newWindow: UIWindow?) {
super.willMove(toWindow: newWindow)
if newWindow != nil {
restartAnimationsIfNeeded()
}
}
private func restartAnimationsIfNeeded() {
guard animationState == .phase2 else { return }
animationState = .phase1(CACurrentMediaTime())
progressLayer.removeAllAnimations()
beginInfiniteSpin()
}
// MARK: - Layout
private var trackLayer = CAShapeLayer()
private var progressLayer = CAShapeLayer()
private func updatePath(layer: CAShapeLayer) {
let bounds = layer.bounds
guard bounds.isEmpty == false else { return }
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let radius = max(0, min(bounds.width, bounds.height) - lineWidth) / 2
let startAngle = -CGFloat.halfPi // 12 o'clock
let endAngle = startAngle + 2 * .pi
let path = UIBezierPath(
arcCenter: center,
radius: radius,
startAngle: startAngle,
endAngle: endAngle,
clockwise: true,
)
layer.path = path.cgPath
}
private func setupLayers() {
// Track
trackLayer.fillColor = UIColor.clear.cgColor
trackLayer.lineWidth = lineWidth
trackLayer.lineCap = .round
layer.addSublayer(trackLayer)
// Progress
progressLayer.fillColor = UIColor.clear.cgColor
progressLayer.lineWidth = lineWidth
progressLayer.lineCap = .round
progressLayer.strokeEnd = 0 // starts empty
layer.addSublayer(progressLayer)
updateColors()
}
private func updateGeometryIfNecessary() {
let layerBounds = CGRect(origin: .zero, size: layer.bounds.size)
let layerPosition = layer.bounds.center
if layerBounds.size != trackLayer.bounds.size {
trackLayer.bounds = layerBounds
trackLayer.position = layerPosition
updatePath(layer: trackLayer)
}
if layerBounds.size != progressLayer.bounds.size {
progressLayer.bounds = layerBounds
progressLayer.position = layerPosition
updatePath(layer: progressLayer)
}
}
}
#if DEBUG
private class CPVPreviewViewController: UIViewController {
let progressView = CircularProgressView(frame: .init(origin: .zero, size: .square(44)))
var task: Task<Void, Never>?
var cancelButton: UIButton!
init() {
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { owsFail("") }
override func viewDidLoad() {
super.viewDidLoad()
let size: CGFloat = 88
let margin: CGFloat = 4
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemThinMaterial))
blurView.translatesAutoresizingMaskIntoConstraints = false
blurView.layer.cornerRadius = size / 2
blurView.layer.masksToBounds = true
blurView.contentView.addSubview(progressView)
progressView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
progressView.widthAnchor.constraint(equalToConstant: size),
progressView.heightAnchor.constraint(equalToConstant: size),
progressView.centerXAnchor.constraint(equalTo: blurView.centerXAnchor),
progressView.centerYAnchor.constraint(equalTo: blurView.centerYAnchor),
progressView.widthAnchor.constraint(equalTo: blurView.widthAnchor, constant: -2 * margin),
progressView.heightAnchor.constraint(equalTo: blurView.heightAnchor, constant: -2 * margin),
])
let button1 = UIButton(
configuration: .borderedProminent(),
primaryAction: UIAction(
title: "Run Indeterminate",
handler: { [weak self] _ in
self?.runIndeterminateAnimation()
},
),
)
let button2 = UIButton(
configuration: .borderedProminent(),
primaryAction: UIAction(
title: "Run Determinate",
handler: { [weak self] _ in
self?.runDeterminateAnimation()
},
),
)
cancelButton = UIButton(
configuration: .borderedProminent(),
primaryAction: UIAction(
title: "Cancel",
handler: { [weak self] _ in
self?.cancel()
},
),
)
let buttonStack = UIStackView(arrangedSubviews: [blurView, button1, button2, cancelButton])
buttonStack.axis = .vertical
buttonStack.spacing = 20
buttonStack.alignment = .center
buttonStack.distribution = .fillProportionally
buttonStack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(buttonStack)
NSLayoutConstraint.activate([
buttonStack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
buttonStack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
}
func runIndeterminateAnimation() {
reset()
progressView.startAnimating()
}
func runDeterminateAnimation() {
task?.cancel()
task = Task {
var progress: Float = progressView.isAnimating ? 0.1 : 0
while progress < 1 {
let step = Float.random(in: 0.01...0.1)
progress = min(progress + step, 1)
progressView.setProgress(progress, animated: true)
let delay = UInt64.random(in: 150...500) // msec
try? await Task.sleep(nanoseconds: delay * NSEC_PER_MSEC)
}
// Done
}
}
func reset() {
progressView.stopAnimating()
progressView.progress = 0
}
func cancel() {
progressView.stopAnimating()
task?.cancel()
task = nil
}
}
@available(iOS 17, *)
#Preview("CVCircularProgressView") {
CPVPreviewViewController()
}
#endif