summaryrefslogtreecommitdiffhomepage
path: root/ios/MullvadVPN/Views/SpinnerActivityIndicatorView.swift
blob: 5d58db2b91be2b554585fcfeda0d7e5789466400 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
//
//  SpinnerActivityIndicatorView.swift
//  MullvadVPN
//
//  Created by pronebird on 15/05/2019.
//  Copyright © 2025 Mullvad VPN AB. All rights reserved.
//

import UIKit

@MainActor
class SpinnerActivityIndicatorView: UIView {
    private static let rotationAnimationKey = "rotation"
    private static let animationDuration = 0.6

    @MainActor
    enum Style {
        case small, medium, large, custom

        var intrinsicSize: CGSize {
            switch self {
            case .small:
                return CGSize(width: 16, height: 16)
            case .medium:
                return CGSize(width: 20, height: 20)
            case .large:
                return CGSize(width: 60, height: 60)
            case .custom:
                return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
            }
        }
    }

    private let imageView = UIImageView(image: .spinner)

    private(set) var isAnimating = false
    private(set) var style = Style.large

    private var sceneActivationObserver: Any?

    override var intrinsicContentSize: CGSize {
        style.intrinsicSize
    }

    init(style: Style) {
        self.style = style

        let size = style == .custom ? .zero : style.intrinsicSize

        super.init(frame: CGRect(origin: .zero, size: size))

        addSubview(imageView)
        isHidden = true
        backgroundColor = UIColor.clear
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    deinit {
        MainActor.assumeIsolated {
            unregisterSceneActivationObserver()
        }
    }

    override func didMoveToWindow() {
        super.didMoveToWindow()

        if window == nil {
            unregisterSceneActivationObserver()
        } else {
            registerSceneActivationObserver()
            restartAnimationIfNeeded()
        }
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        let size = style == .custom ? frame.size : style.intrinsicSize

        imageView.frame = CGRect(origin: .zero, size: size)
    }

    func startAnimating() {
        guard !isAnimating else { return }
        isAnimating = true

        isHidden = false
        addAnimation()
    }

    func stopAnimating() {
        guard isAnimating else { return }
        isAnimating = false

        isHidden = true
        removeAnimation()
    }

    private func addAnimation() {
        layer.add(createAnimation(), forKey: Self.rotationAnimationKey)
    }

    private func removeAnimation() {
        layer.removeAnimation(forKey: Self.rotationAnimationKey)
    }

    private func registerSceneActivationObserver() {
        unregisterSceneActivationObserver()

        sceneActivationObserver = NotificationCenter.default.addObserver(
            forName: UIScene.willEnterForegroundNotification,
            object: window?.windowScene,
            queue: .main,
            using: { [weak self] _ in
                MainActor.assumeIsolated {
                    self?.restartAnimationIfNeeded()
                }
            }
        )
    }

    private func unregisterSceneActivationObserver() {
        if let sceneActivationObserver {
            NotificationCenter.default.removeObserver(sceneActivationObserver)
            self.sceneActivationObserver = nil
        }
    }

    private func restartAnimationIfNeeded() {
        let animation = layer.animation(forKey: Self.rotationAnimationKey)

        if isAnimating, animation == nil {
            removeAnimation()
            addAnimation()
        }
    }

    private func createAnimation() -> CABasicAnimation {
        let animation = CABasicAnimation(keyPath: "transform.rotation")
        animation.toValue = NSNumber(value: Double.pi * 2)
        animation.duration = Self.animationDuration
        animation.repeatCount = Float.infinity
        animation.timingFunction = CAMediaTimingFunction(name: .linear)
        animation.timeOffset = layer.convertTime(CACurrentMediaTime(), from: nil)

        return animation
    }
}