Skip to content

Commit eeea1f9

Browse files
committed
Re-do APIs so the progress indicator is available directly from the DynamicIsland namespace
1 parent 6bca77b commit eeea1f9

4 files changed

+332
-292
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
//
2+
// DynamicIsland+ProgressIndicator.swift
3+
// DynamicIslandUtilities
4+
//
5+
// Created by Suyash Srijan on 01/10/2022.
6+
//
7+
8+
import UIKit
9+
10+
extension DynamicIsland {
11+
public struct ProgressIndicator {
12+
private let progressIndicatorImpl: DynamicIslandProgressIndicatorImplementation
13+
14+
init () {
15+
progressIndicatorImpl = .init()
16+
progressIndicatorImpl.add(toContext: window)
17+
}
18+
19+
/// The window that this progress indicator is attached to.
20+
/// By default, it's added to the key window (or the first window
21+
/// of the first scene), but you can change that by assigning a
22+
/// different window to this property.
23+
public var window: UIWindow = Self.getMainWindow() {
24+
didSet {
25+
progressIndicatorImpl.changeContext(to: window)
26+
}
27+
}
28+
29+
/// The current progress of the progress indicator, between 0 and 100.
30+
/// - Note: This requires `isProgressIndeterminate` to be set to `false`
31+
public var progress: Double {
32+
get { progressIndicatorImpl.progress }
33+
set { progressIndicatorImpl.progress = newValue }
34+
}
35+
36+
/// The color of the progress indicator. The default value is `UIColor.red`.
37+
public var progressColor: UIColor {
38+
get { progressIndicatorImpl.progressColor }
39+
set { progressIndicatorImpl.progressColor = newValue }
40+
}
41+
42+
/// Whether the progress indicator should show indeterminate progress (this is useful when you don't know
43+
/// how long something is going to take). The default value is `true`.
44+
public var isProgressIndeterminate: Bool {
45+
get { progressIndicatorImpl.isProgressIndeterminate }
46+
set { progressIndicatorImpl.isProgressIndeterminate = newValue }
47+
}
48+
49+
/// Shows an indeterminate progress animation indicator on the dynamic island.
50+
/// - Note: This requires `isProgressIndeterminate` to be set to `true`.
51+
public func showIndeterminateProgressAnimation() {
52+
progressIndicatorImpl.showIndeterminateProgressAnimation()
53+
}
54+
55+
/// Hides the progress indicator on the dynamic island.
56+
public func hideProgressIndicator() {
57+
progressIndicatorImpl.hideProgressIndicator()
58+
}
59+
60+
private static func getMainWindow() -> UIWindow {
61+
lazy var keyWindow = UIApplication.shared.windows.first { $0.isKeyWindow }!
62+
63+
if #available(iOS 13.0, *) {
64+
let scenes = UIApplication.shared.connectedScenes
65+
let windowScene = scenes.first as? UIWindowScene
66+
return windowScene?.windows.first ?? keyWindow
67+
}
68+
69+
return keyWindow
70+
}
71+
}
72+
}
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,62 @@
11
//
2-
// File.swift
3-
//
2+
// DynamicIsland.swift
3+
// DynamicIslandUtilities
44
//
55
// Created by Suyash Srijan on 19/09/2022.
66
//
77

88
import UIKit
99

10-
/// A type that provides the size, origin, rect and some other information related to the Dynamic Island.
11-
/// - Note: This only provides the values for a static island, not one that is expanded (while a live activity is running for example).
10+
/// A type that information about the Dynamic Island as well as functionality around it such as a progress indicator.
11+
/// - Note: The information provided (such as size) is for a static island, not one that is expanded (while a live activity is running for example).
1212
public enum DynamicIsland {
13+
14+
/// An object hat provides a progress indicator that shows progress around the dynamic island cutout.
15+
public static let progressIndicator: ProgressIndicator = {
16+
precondition(DynamicIsland.isAvailable,
17+
"Cannot show dynamic island progress indicator on a device that does not support it!")
18+
return .init()
19+
}()
20+
21+
/// The size of the Dynamic Island cutout.
22+
public static let size: CGSize = {
23+
return .init(width: 126.0, height: 37.33)
24+
}()
25+
26+
/// The starting position of the Dynamic Island cutout.
27+
public static let origin: CGPoint = {
28+
return .init(x: UIScreen.main.bounds.midX - size.width / 2, y: 11)
29+
}()
30+
31+
/// A rect that has the size and position of the Dynamic Island cutout.
32+
public static let rect: CGRect = {
33+
return .init(origin: origin, size: size)
34+
}()
35+
36+
/// The corner radius of the Dynamic Island cutout.
37+
public static let cornerRadius: Double = {
38+
return size.width / 2
39+
}()
40+
41+
/// Returns whether this device supports the Dynamic Island.
42+
/// This returns `true` for iPhone 14 Pro and iPhone Pro Max, otherwise returns `false`.
43+
public static let isAvailable: Bool = {
44+
if #unavailable(iOS 16) {
45+
return false
46+
}
1347

14-
/// The size of the Dynamic Island cutout.
15-
public static let size: CGSize = {
16-
return .init(width: 126.0, height: 37.33)
17-
}()
48+
#if targetEnvironment(simulator)
49+
let identifier = ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"]!
50+
#else
51+
var systemInfo = utsname()
52+
uname(&systemInfo)
53+
let machineMirror = Mirror(reflecting: systemInfo.machine)
54+
let identifier = machineMirror.children.reduce("") { identifier, element in
55+
guard let value = element.value as? Int8, value != 0 else { return identifier }
56+
return identifier + String(UnicodeScalar(UInt8(value)))
57+
}
58+
#endif
1859

19-
/// The starting position of the Dynamic Island cutout.
20-
public static let origin: CGPoint = {
21-
return .init(x: UIScreen.main.bounds.midX - size.width / 2, y: 11)
22-
}()
23-
24-
/// A rect that has the size and position of the Dynamic Island cutout.
25-
public static let rect: CGRect = {
26-
return .init(origin: origin, size: size)
27-
}()
28-
29-
/// The corner radius of the Dynamic Island cutout.
30-
public static let cornerRadius: Double = {
31-
return size.width / 2
32-
}()
33-
34-
/// Returns whether this device supports the Dynamic Island.
35-
/// This returns `true` for iPhone 14 Pro and iPhone Pro Max, otherwise returns `false`.
36-
public static let isAvailable: Bool = {
37-
if #unavailable(iOS 16) {
38-
return false
39-
}
40-
41-
#if targetEnvironment(simulator)
42-
let identifier = ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"]!
43-
#else
44-
var systemInfo = utsname()
45-
uname(&systemInfo)
46-
let machineMirror = Mirror(reflecting: systemInfo.machine)
47-
let identifier = machineMirror.children.reduce("") { identifier, element in
48-
guard let value = element.value as? Int8, value != 0 else { return identifier }
49-
return identifier + String(UnicodeScalar(UInt8(value)))
50-
}
51-
#endif
52-
53-
return identifier == "iPhone15,2" || identifier == "iPhone15,3"
54-
}()
60+
return identifier == "iPhone15,2" || identifier == "iPhone15,3"
61+
}()
5562
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
//
2+
// DynamicIslandProgressIndicator.swift
3+
// DynamicIslandUtilities
4+
//
5+
// Created by Suyash Srijan on 18/09/2022.
6+
//
7+
8+
import UIKit
9+
10+
final class DynamicIslandProgressIndicatorImplementation: UIView {
11+
private let tailLayer: CAShapeLayer = CAShapeLayer()
12+
private let partialTailLayer: CAShapeLayer = CAShapeLayer()
13+
private var currentContext: UIWindow!
14+
15+
private enum State {
16+
case ready
17+
case animating
18+
}
19+
20+
private var state: State = .ready
21+
22+
private var isProgressIndicatorHidden: Bool {
23+
return tailLayer.isHidden && partialTailLayer.isHidden
24+
}
25+
26+
@Clamped(between: 0...100) var progress: Double {
27+
didSet {
28+
requiresIndeterminateProgress(equalTo: false)
29+
if isProgressIndicatorHidden {
30+
requiresState(equalTo: .ready)
31+
showProgressIndicator()
32+
state = .animating
33+
}
34+
tailLayer.strokeEnd = progress / 100
35+
}
36+
}
37+
38+
var progressColor: UIColor = .red {
39+
didSet {
40+
tailLayer.strokeColor = progressColor.cgColor
41+
partialTailLayer.strokeColor = progressColor.cgColor
42+
}
43+
}
44+
45+
var isProgressIndeterminate = true {
46+
didSet {
47+
resetProgressIndicator()
48+
}
49+
}
50+
51+
func add(toContext context: UIWindow) {
52+
requiresState(equalTo: .ready)
53+
currentContext = context
54+
createAndAddDynamicIslandBorderLayers()
55+
currentContext.addSubview(self)
56+
currentContext.bringSubviewToFront(self)
57+
}
58+
59+
func changeContext(to newContext: UIWindow) {
60+
requiresState(equalTo: .ready)
61+
removeIndicator()
62+
add(toContext: newContext)
63+
}
64+
65+
66+
func showIndeterminateProgressAnimation() {
67+
requiresIndeterminateProgress(equalTo: true)
68+
requiresState(equalTo: .ready)
69+
70+
resetProgressIndicator()
71+
showProgressIndicator()
72+
tailLayer.add(mainTailAnimation(), forKey: nil)
73+
partialTailLayer.add(partialTailAnimation(), forKey: nil)
74+
state = .animating
75+
}
76+
77+
func hideProgressIndicator() {
78+
tailLayer.isHidden = true
79+
partialTailLayer.isHidden = true
80+
resetProgressIndicator()
81+
state = .ready
82+
}
83+
84+
fileprivate func showProgressIndicator() {
85+
tailLayer.isHidden = false
86+
partialTailLayer.isHidden = false
87+
}
88+
89+
fileprivate func resetProgressIndicator() {
90+
tailLayer.removeAllAnimations()
91+
partialTailLayer.removeAllAnimations()
92+
tailLayer.strokeStart = 0
93+
tailLayer.strokeEnd = 1
94+
partialTailLayer.strokeStart = 0
95+
partialTailLayer.strokeEnd = 0
96+
}
97+
98+
private func removeIndicator() {
99+
resetProgressIndicator()
100+
removeFromSuperview()
101+
tailLayer.removeFromSuperlayer()
102+
partialTailLayer.removeFromSuperlayer()
103+
currentContext = nil
104+
}
105+
106+
private func requiresIndeterminateProgress(equalTo value: Bool) {
107+
precondition(isProgressIndeterminate == value,
108+
"isProgressIndeterminate must be set to '\(value)'!")
109+
}
110+
111+
private func requiresState(equalTo value: State) {
112+
let message: String
113+
switch (value, state) {
114+
case (.ready, .animating):
115+
message = "Cannot show animation because progress indicator is already animating!"
116+
// Handle other cases here if we require them.
117+
default:
118+
message = ""
119+
}
120+
precondition(state == value, message)
121+
}
122+
123+
private func createAndAddDynamicIslandBorderLayers() {
124+
let dynamicIslandPath = UIBezierPath(roundedRect: DynamicIsland.rect,
125+
byRoundingCorners: [.allCorners],
126+
cornerRadii: CGSize(width: DynamicIsland.cornerRadius,
127+
height: DynamicIsland.cornerRadius))
128+
129+
tailLayer.path = dynamicIslandPath.cgPath
130+
partialTailLayer.path = dynamicIslandPath.cgPath
131+
132+
if #available(iOS 16.0, *) {
133+
tailLayer.cornerCurve = .continuous
134+
}
135+
tailLayer.lineCap = .round
136+
tailLayer.fillRule = .evenOdd
137+
tailLayer.strokeColor = progressColor.cgColor
138+
tailLayer.strokeStart = 0
139+
tailLayer.strokeEnd = 1
140+
tailLayer.lineWidth = 5
141+
142+
if #available(iOS 16.0, *) {
143+
partialTailLayer.cornerCurve = .continuous
144+
}
145+
partialTailLayer.lineCap = .round
146+
partialTailLayer.fillRule = .evenOdd
147+
partialTailLayer.strokeColor = progressColor.cgColor
148+
partialTailLayer.strokeStart = 0
149+
partialTailLayer.strokeEnd = 0
150+
partialTailLayer.lineWidth = 5
151+
partialTailLayer.fillColor = UIColor.clear.cgColor
152+
153+
layer.addSublayer(tailLayer)
154+
layer.addSublayer(partialTailLayer)
155+
}
156+
157+
private func mainTailAnimation() -> CAAnimationGroup {
158+
let animationStart = CAKeyframeAnimation(keyPath: #keyPath(CAShapeLayer.strokeStart))
159+
animationStart.values = [0, 0, 0.75]
160+
animationStart.keyTimes = [0, 0.25, 1]
161+
animationStart.duration = 2
162+
163+
let animationEnd = CAKeyframeAnimation(keyPath: #keyPath(CAShapeLayer.strokeEnd))
164+
animationEnd.values = [0, 0.25, 1]
165+
animationEnd.keyTimes = [0, 0.25, 1]
166+
animationEnd.duration = 2
167+
168+
let group = CAAnimationGroup()
169+
group.duration = 2
170+
group.repeatCount = .infinity
171+
group.animations = [animationStart, animationEnd]
172+
return group
173+
}
174+
175+
private func partialTailAnimation() -> CAAnimationGroup {
176+
let animationStart = CABasicAnimation(keyPath: #keyPath(CAShapeLayer.strokeStart))
177+
animationStart.fromValue = 0.75
178+
animationStart.toValue = 1
179+
animationStart.duration = 0.5
180+
181+
let animationEnd = CABasicAnimation(keyPath: #keyPath(CAShapeLayer.strokeEnd))
182+
animationEnd.fromValue = 1
183+
animationEnd.toValue = 1
184+
animationEnd.duration = 0.5
185+
186+
let group = CAAnimationGroup()
187+
group.duration = 2
188+
group.repeatCount = .infinity
189+
group.animations = [animationStart, animationEnd]
190+
return group
191+
}
192+
}
193+
194+
/// A property wrapper that clamps a value between a specified range.
195+
@propertyWrapper
196+
struct Clamped<Value: Comparable> {
197+
private var value: Value
198+
private let range: ClosedRange<Value>
199+
200+
init(between range: ClosedRange<Value>) {
201+
self.value = range.lowerBound
202+
self.range = range
203+
}
204+
205+
var wrappedValue: Value {
206+
get { value }
207+
set { value = min(max(range.lowerBound, newValue), range.upperBound) }
208+
}
209+
}

0 commit comments

Comments
 (0)