Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
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