capacitor-lottie-splash 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CapacitorLottieSplash.podspec +19 -0
- package/Package.swift +30 -0
- package/README.md +120 -0
- package/android/build.gradle +36 -0
- package/android/settings.gradle +7 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/headcount/plugins/lottiesplash/LottieSplashCoordinator.kt +498 -0
- package/android/src/main/java/com/headcount/plugins/lottiesplash/LottieSplashLaunch.kt +72 -0
- package/android/src/main/java/com/headcount/plugins/lottiesplash/LottieSplashPlugin.kt +121 -0
- package/android/src/main/res/drawable-nodpi/headcount_splash_poster.png +0 -0
- package/dist/esm/definitions.d.ts +65 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/index.d.ts +5 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/state-machine.d.ts +19 -0
- package/dist/esm/state-machine.js +79 -0
- package/dist/esm/web.d.ts +31 -0
- package/dist/esm/web.js +175 -0
- package/dist/plugin.cjs.js +17305 -0
- package/dist/plugin.js +17308 -0
- package/ios/Sources/LottieSplashPlugin/LottieSplashCoordinator.swift +501 -0
- package/ios/Sources/LottieSplashPlugin/LottieSplashPlugin.swift +71 -0
- package/package.json +78 -0
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
import WebKit
|
|
3
|
+
|
|
4
|
+
public enum LottieSplashTheme: String {
|
|
5
|
+
case light
|
|
6
|
+
case dark
|
|
7
|
+
|
|
8
|
+
public var gradient: [CGColor] {
|
|
9
|
+
switch self {
|
|
10
|
+
case .light: return [UIColor(red: 240/255, green: 240/255, blue: 240/255, alpha: 1).cgColor, UIColor(red: 245/255, green: 245/255, blue: 245/255, alpha: 1).cgColor]
|
|
11
|
+
case .dark: return [UIColor(red: 30/255, green: 30/255, blue: 30/255, alpha: 1).cgColor, UIColor(red: 37/255, green: 37/255, blue: 37/255, alpha: 1).cgColor]
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
public var backgroundColor: UIColor {
|
|
16
|
+
switch self {
|
|
17
|
+
case .light: return UIColor(red: 240/255, green: 240/255, blue: 240/255, alpha: 1)
|
|
18
|
+
case .dark: return UIColor(red: 30/255, green: 30/255, blue: 30/255, alpha: 1)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A host-defined Capacitor Preferences value available before the WebView starts. */
|
|
24
|
+
public struct LottieSplashThemePreference {
|
|
25
|
+
public let key: String
|
|
26
|
+
public let darkValue: String
|
|
27
|
+
public let lightValue: String
|
|
28
|
+
public let storagePrefix: String
|
|
29
|
+
|
|
30
|
+
public init(key: String, darkValue: String, lightValue: String, storagePrefix: String = "CapacitorStorage.") {
|
|
31
|
+
self.key = key
|
|
32
|
+
self.darkValue = darkValue
|
|
33
|
+
self.lightValue = lightValue
|
|
34
|
+
self.storagePrefix = storagePrefix
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public func resolvedTheme() -> LottieSplashTheme? {
|
|
38
|
+
switch UserDefaults.standard.string(forKey: storagePrefix + key) {
|
|
39
|
+
case darkValue: return .dark
|
|
40
|
+
case lightValue: return .light
|
|
41
|
+
default: return nil
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private final class LottieSplashCoverView: UIView {
|
|
47
|
+
private let gradient = CAGradientLayer()
|
|
48
|
+
|
|
49
|
+
init(theme: LottieSplashTheme) {
|
|
50
|
+
super.init(frame: .zero)
|
|
51
|
+
backgroundColor = theme.backgroundColor
|
|
52
|
+
gradient.colors = theme.gradient
|
|
53
|
+
gradient.startPoint = CGPoint(x: 0, y: 0.5)
|
|
54
|
+
gradient.endPoint = CGPoint(x: 1, y: 0.5)
|
|
55
|
+
layer.insertSublayer(gradient, at: 0)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
required init?(coder: NSCoder) { nil }
|
|
59
|
+
|
|
60
|
+
override func layoutSubviews() {
|
|
61
|
+
super.layoutSubviews()
|
|
62
|
+
gradient.frame = bounds
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func apply(theme: LottieSplashTheme) {
|
|
66
|
+
backgroundColor = theme.backgroundColor
|
|
67
|
+
gradient.colors = theme.gradient
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A quiet, indeterminate loading rail that does not obscure the completed mark. */
|
|
72
|
+
private final class LottieSplashLoadingRail: UIView {
|
|
73
|
+
private let track = UIView()
|
|
74
|
+
private let segment = UIView()
|
|
75
|
+
private var animationStarted = false
|
|
76
|
+
|
|
77
|
+
init(theme: LottieSplashTheme) {
|
|
78
|
+
super.init(frame: .zero)
|
|
79
|
+
isAccessibilityElement = true
|
|
80
|
+
accessibilityLabel = "Application is still loading"
|
|
81
|
+
track.layer.cornerRadius = 1.5
|
|
82
|
+
track.clipsToBounds = true
|
|
83
|
+
segment.layer.cornerRadius = 1.5
|
|
84
|
+
addSubview(track)
|
|
85
|
+
track.addSubview(segment)
|
|
86
|
+
apply(theme: theme)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
required init?(coder: NSCoder) { nil }
|
|
90
|
+
|
|
91
|
+
override func layoutSubviews() {
|
|
92
|
+
super.layoutSubviews()
|
|
93
|
+
track.frame = bounds
|
|
94
|
+
let width = max(28, bounds.width * 0.36)
|
|
95
|
+
segment.frame = CGRect(x: -width, y: 0, width: width, height: bounds.height)
|
|
96
|
+
startAnimatingIfNeeded()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func apply(theme: LottieSplashTheme) {
|
|
100
|
+
track.backgroundColor = theme == .dark
|
|
101
|
+
? UIColor.white.withAlphaComponent(0.18)
|
|
102
|
+
: UIColor.black.withAlphaComponent(0.14)
|
|
103
|
+
segment.backgroundColor = UIColor(red: 1, green: 0.30, blue: 0.30, alpha: 0.9)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private func startAnimatingIfNeeded() {
|
|
107
|
+
guard !animationStarted, bounds.width > 0 else { return }
|
|
108
|
+
animationStarted = true
|
|
109
|
+
let sweep = CABasicAnimation(keyPath: "transform.translation.x")
|
|
110
|
+
sweep.fromValue = -segment.bounds.width
|
|
111
|
+
sweep.toValue = bounds.width + segment.bounds.width
|
|
112
|
+
sweep.duration = 1.15
|
|
113
|
+
sweep.repeatCount = .infinity
|
|
114
|
+
sweep.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
|
115
|
+
segment.layer.add(sweep, forKey: "lottieSplashLoadingRail")
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private final class LottieSplashMessageBridge: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
|
|
120
|
+
weak var coordinator: LottieSplashCoordinator?
|
|
121
|
+
|
|
122
|
+
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
123
|
+
guard message.name == "lottieSplash", let payload = message.body as? [String: Any] else { return }
|
|
124
|
+
coordinator?.receiveWebAnimationEvent(
|
|
125
|
+
outcome: payload["outcome"] as? String,
|
|
126
|
+
reason: payload["reason"] as? String
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
131
|
+
coordinator?.webRendererDidLoad()
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
public struct LottieSplashConfiguration {
|
|
136
|
+
public let animationName: String
|
|
137
|
+
public let theme: LottieSplashTheme
|
|
138
|
+
public let themePreference: LottieSplashThemePreference?
|
|
139
|
+
|
|
140
|
+
public var resolvedTheme: LottieSplashTheme {
|
|
141
|
+
themePreference?.resolvedTheme() ?? theme
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
public init(animationName: String = "animatedLogo", theme: LottieSplashTheme = .dark, themePreference: LottieSplashThemePreference? = nil) {
|
|
145
|
+
self.animationName = animationName
|
|
146
|
+
self.theme = theme
|
|
147
|
+
self.themePreference = themePreference
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
public enum LottieSplashPhase: String {
|
|
152
|
+
case preparing, playing, holding, hiding, hidden, recovery
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
public final class LottieSplashCoordinator {
|
|
156
|
+
public static let shared = LottieSplashCoordinator()
|
|
157
|
+
|
|
158
|
+
/// A web bundle from before this plugin existed cannot adopt the native cover. Do not
|
|
159
|
+
/// permanently obscure that bundle during an over-the-air/native rollout mismatch.
|
|
160
|
+
private let webAdoptionGracePeriod: TimeInterval = 10
|
|
161
|
+
private let progressDelay: TimeInterval = 1
|
|
162
|
+
private let recoveryDelay: TimeInterval = 15
|
|
163
|
+
|
|
164
|
+
public private(set) var presentationId: String?
|
|
165
|
+
public private(set) var phase: LottieSplashPhase = .hidden
|
|
166
|
+
public private(set) var playbackSettled = false
|
|
167
|
+
public private(set) var appReady = false
|
|
168
|
+
public private(set) var theme: LottieSplashTheme = .dark
|
|
169
|
+
public var onStateChange: ((LottieSplashCoordinator) -> Void)?
|
|
170
|
+
public var onRecoveryRequested: (() -> Void)?
|
|
171
|
+
|
|
172
|
+
private weak var hostController: UIViewController?
|
|
173
|
+
private var cover: LottieSplashCoverView?
|
|
174
|
+
private var animationWebView: WKWebView?
|
|
175
|
+
private var animationMessageBridge: LottieSplashMessageBridge?
|
|
176
|
+
private var loadingRail: LottieSplashLoadingRail?
|
|
177
|
+
private var recoveryWorkItem: DispatchWorkItem?
|
|
178
|
+
private var progressWorkItem: DispatchWorkItem?
|
|
179
|
+
private var adoptionWorkItem: DispatchWorkItem?
|
|
180
|
+
private var adoptedByWeb = false
|
|
181
|
+
private var webRendererLoaded = false
|
|
182
|
+
private var progressRemaining = TimeInterval(1)
|
|
183
|
+
private var recoveryRemaining = TimeInterval(15)
|
|
184
|
+
private var waitingClockStartedAt: Date?
|
|
185
|
+
private var playbackCompletion: ((String, String?) -> Void)?
|
|
186
|
+
private let queue = DispatchQueue.main
|
|
187
|
+
|
|
188
|
+
private init() {}
|
|
189
|
+
|
|
190
|
+
/// Call from a host CAPBridgeViewController subclass in capacitorDidLoad().
|
|
191
|
+
/// The view exists at this point but the WebView has not loaded yet.
|
|
192
|
+
public func installInitialCover(on controller: UIViewController, configuration: LottieSplashConfiguration = .init()) {
|
|
193
|
+
let install = { [weak self, weak controller] in
|
|
194
|
+
guard let self, let controller else { return }
|
|
195
|
+
guard self.cover == nil else { return }
|
|
196
|
+
self.hostController = controller
|
|
197
|
+
self.presentationId = UUID().uuidString
|
|
198
|
+
self.phase = .preparing
|
|
199
|
+
self.playbackSettled = false
|
|
200
|
+
self.appReady = false
|
|
201
|
+
self.theme = configuration.resolvedTheme
|
|
202
|
+
self.adoptedByWeb = false
|
|
203
|
+
self.buildCover(in: controller, configuration: configuration)
|
|
204
|
+
self.prepareWebRenderer(configuration: configuration)
|
|
205
|
+
self.scheduleUnadoptedFallback(for: self.presentationId)
|
|
206
|
+
self.emitState()
|
|
207
|
+
}
|
|
208
|
+
if Thread.isMainThread { install() } else { queue.async(execute: install) }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
public func show(configuration: LottieSplashConfiguration = .init()) -> (id: String, adopted: Bool) {
|
|
212
|
+
if let presentationId, cover != nil {
|
|
213
|
+
adoptedByWeb = true
|
|
214
|
+
adoptionWorkItem?.cancel()
|
|
215
|
+
adoptionWorkItem = nil
|
|
216
|
+
return (presentationId, true)
|
|
217
|
+
}
|
|
218
|
+
guard let controller = hostController else {
|
|
219
|
+
let id = UUID().uuidString
|
|
220
|
+
presentationId = id
|
|
221
|
+
phase = .hidden
|
|
222
|
+
return (id, false)
|
|
223
|
+
}
|
|
224
|
+
installInitialCover(on: controller, configuration: configuration)
|
|
225
|
+
// Cold launch playback starts from the host controller's viewDidAppear. A later
|
|
226
|
+
// JavaScript show() has no second view-appearance callback, so it must start its
|
|
227
|
+
// own presentation here. If the renderer is still loading, webRendererDidLoad()
|
|
228
|
+
// completes this hand-off once it is ready.
|
|
229
|
+
startPlaybackIfVisible()
|
|
230
|
+
return (presentationId ?? UUID().uuidString, false)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
public func waitForCompletion(id: String, completion: @escaping (String, String?) -> Void) {
|
|
234
|
+
guard id == presentationId else { completion("static-fallback", "Stale splash presentation"); return }
|
|
235
|
+
if playbackSettled { completion("completed", nil); return }
|
|
236
|
+
playbackCompletion = completion
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
public func hide(id: String, completion: @escaping (Error?) -> Void) {
|
|
240
|
+
guard id == presentationId else { completion(LottieSplashError.stalePresentation); return }
|
|
241
|
+
appReady = true
|
|
242
|
+
if playbackSettled { dismiss(completion: completion) } else { emitState() }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
public func startPlaybackIfVisible(configuration: LottieSplashConfiguration = .init()) {
|
|
246
|
+
guard phase == .preparing, animationWebView != nil else { return }
|
|
247
|
+
phase = .playing
|
|
248
|
+
emitState()
|
|
249
|
+
guard !UIAccessibility.isReduceMotionEnabled else {
|
|
250
|
+
settle(outcome: "static-fallback", reason: "Reduced motion")
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
beginWebAnimationIfReady()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private func prepareWebRenderer(configuration: LottieSplashConfiguration) {
|
|
257
|
+
guard let animationWebView else { return }
|
|
258
|
+
guard let publicDirectory = Bundle.main.resourceURL?.appendingPathComponent("public", isDirectory: true),
|
|
259
|
+
let animationURL = Bundle.main.url(forResource: configuration.animationName, withExtension: "json"),
|
|
260
|
+
let animationData = try? String(contentsOf: animationURL, encoding: .utf8),
|
|
261
|
+
let lottieRuntimeURL = Bundle(for: LottieSplashCoordinator.self).url(forResource: "lottie.min", withExtension: "js"),
|
|
262
|
+
let lottieRuntime = try? String(contentsOf: lottieRuntimeURL, encoding: .utf8) else {
|
|
263
|
+
settle(outcome: "static-fallback", reason: "Bundled animation unavailable")
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
let document = Self.webAnimationDocument
|
|
267
|
+
.replacingOccurrences(of: "__LOTTIE_RUNTIME__", with: lottieRuntime)
|
|
268
|
+
.replacingOccurrences(of: "__ANIMATION_DATA__", with: animationData)
|
|
269
|
+
animationWebView.loadHTMLString(document, baseURL: publicDirectory)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
func webRendererDidLoad() {
|
|
273
|
+
webRendererLoaded = true
|
|
274
|
+
beginWebAnimationIfReady()
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private func beginWebAnimationIfReady() {
|
|
278
|
+
guard phase == .playing, webRendererLoaded else { return }
|
|
279
|
+
// The approved composition starts its scanner sequence at frame zero. Do not retain
|
|
280
|
+
// the former wordmark's fixed offset here, or the visible playback will skip the
|
|
281
|
+
// scanner and reveal stages even when the renderer was initialized at frame zero.
|
|
282
|
+
animationWebView?.evaluateJavaScript("window.__headcountSplashAnimation?.goToAndPlay(0, true)")
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
public func applicationDidEnterBackground() {
|
|
286
|
+
animationWebView?.evaluateJavaScript("window.__headcountSplashAnimation?.pause()")
|
|
287
|
+
if phase == .holding { pauseWaitingClock() }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
public func applicationDidBecomeActive() {
|
|
291
|
+
if phase == .playing {
|
|
292
|
+
animationWebView?.evaluateJavaScript("window.__headcountSplashAnimation?.play()")
|
|
293
|
+
}
|
|
294
|
+
else if phase == .holding, let presentationId { scheduleWaitingIndicators(for: presentationId) }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private func buildCover(in controller: UIViewController, configuration: LottieSplashConfiguration) {
|
|
298
|
+
let cover = LottieSplashCoverView(theme: configuration.resolvedTheme)
|
|
299
|
+
cover.translatesAutoresizingMaskIntoConstraints = false
|
|
300
|
+
cover.isAccessibilityElement = true
|
|
301
|
+
cover.accessibilityLabel = "Loading application"
|
|
302
|
+
cover.accessibilityViewIsModal = true
|
|
303
|
+
let contentController = WKUserContentController()
|
|
304
|
+
let messageBridge = LottieSplashMessageBridge()
|
|
305
|
+
messageBridge.coordinator = self
|
|
306
|
+
contentController.add(messageBridge, name: "lottieSplash")
|
|
307
|
+
let webConfiguration = WKWebViewConfiguration()
|
|
308
|
+
webConfiguration.userContentController = contentController
|
|
309
|
+
let animation = WKWebView(frame: .zero, configuration: webConfiguration)
|
|
310
|
+
animation.navigationDelegate = messageBridge
|
|
311
|
+
animation.isOpaque = false
|
|
312
|
+
animation.backgroundColor = .clear
|
|
313
|
+
animation.scrollView.backgroundColor = .clear
|
|
314
|
+
animation.scrollView.isScrollEnabled = false
|
|
315
|
+
animation.translatesAutoresizingMaskIntoConstraints = false
|
|
316
|
+
animation.isAccessibilityElement = false
|
|
317
|
+
cover.addSubview(animation)
|
|
318
|
+
controller.view.addSubview(cover)
|
|
319
|
+
let preferredAnimationWidth = animation.widthAnchor.constraint(equalTo: cover.widthAnchor)
|
|
320
|
+
preferredAnimationWidth.priority = .defaultHigh
|
|
321
|
+
NSLayoutConstraint.activate([
|
|
322
|
+
cover.leadingAnchor.constraint(equalTo: controller.view.leadingAnchor), cover.trailingAnchor.constraint(equalTo: controller.view.trailingAnchor),
|
|
323
|
+
cover.topAnchor.constraint(equalTo: controller.view.topAnchor), cover.bottomAnchor.constraint(equalTo: controller.view.bottomAnchor),
|
|
324
|
+
animation.centerXAnchor.constraint(equalTo: cover.centerXAnchor), animation.centerYAnchor.constraint(equalTo: cover.centerYAnchor),
|
|
325
|
+
// The current composition is square. Give it the full portrait width so artwork
|
|
326
|
+
// designed near its 512 px edges is not cropped by the legacy wordmark-sized host.
|
|
327
|
+
// On a short landscape surface, the lower-priority width constraint yields to the
|
|
328
|
+
// height safety limit while the animation remains centered and fully visible.
|
|
329
|
+
preferredAnimationWidth,
|
|
330
|
+
animation.heightAnchor.constraint(equalTo: animation.widthAnchor),
|
|
331
|
+
animation.heightAnchor.constraint(lessThanOrEqualTo: cover.heightAnchor, multiplier: 0.75)
|
|
332
|
+
])
|
|
333
|
+
self.cover = cover
|
|
334
|
+
self.animationWebView = animation
|
|
335
|
+
self.animationMessageBridge = messageBridge
|
|
336
|
+
self.webRendererLoaded = false
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private func apply(theme: LottieSplashTheme) {
|
|
340
|
+
self.theme = theme
|
|
341
|
+
cover?.apply(theme: theme)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
func receiveWebAnimationEvent(outcome: String?, reason: String?) {
|
|
345
|
+
guard phase == .playing else { return }
|
|
346
|
+
settle(outcome: outcome == "completed" ? "completed" : "static-fallback", reason: reason)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private static let webAnimationDocument = """
|
|
350
|
+
<!doctype html>
|
|
351
|
+
<html><head><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
|
352
|
+
<style>html,body{width:100%;height:100%;margin:0;background:transparent;overflow:hidden}body{display:flex;align-items:center;justify-content:center}#animation{width:35%;height:35%;overflow:hidden}canvas{width:100%!important;height:100%!important;display:block}</style>
|
|
353
|
+
</head><body><div id="animation"></div><script>__LOTTIE_RUNTIME__</script><script>
|
|
354
|
+
(() => {
|
|
355
|
+
const report = (outcome, reason) => window.webkit.messageHandlers.lottieSplash.postMessage({ outcome, reason });
|
|
356
|
+
window.addEventListener('error', event => report('static-fallback', event.message));
|
|
357
|
+
try {
|
|
358
|
+
const animationData = __ANIMATION_DATA__;
|
|
359
|
+
const animation = window.lottie.loadAnimation({
|
|
360
|
+
container: document.getElementById('animation'), animationData, renderer: 'canvas', loop: false, autoplay: false,
|
|
361
|
+
rendererSettings: { clearCanvas: true, progressiveLoad: false }
|
|
362
|
+
});
|
|
363
|
+
window.__headcountSplashAnimation = animation;
|
|
364
|
+
// The approved host composition begins its scanner sequence at frame zero. The
|
|
365
|
+
// former HeadCount wordmark was blank until frame 70, but retaining that legacy
|
|
366
|
+
// offset skips the new animation's opening motion.
|
|
367
|
+
animation.goToAndStop(0, true);
|
|
368
|
+
animation.addEventListener('complete', () => report('completed'));
|
|
369
|
+
animation.addEventListener('data_failed', () => report('static-fallback', 'Unable to parse the bundled animation'));
|
|
370
|
+
} catch (error) { report('static-fallback', error.message); }
|
|
371
|
+
})();
|
|
372
|
+
</script></body></html>
|
|
373
|
+
"""
|
|
374
|
+
|
|
375
|
+
private func settle(outcome: String, reason: String?) {
|
|
376
|
+
guard let id = presentationId, !playbackSettled else { return }
|
|
377
|
+
playbackSettled = true
|
|
378
|
+
phase = appReady ? .hiding : .holding
|
|
379
|
+
playbackCompletion?(outcome, reason)
|
|
380
|
+
playbackCompletion = nil
|
|
381
|
+
if appReady { dismiss(completion: { _ in }) } else { resetWaitingIndicators(for: id) }
|
|
382
|
+
emitState()
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private func scheduleWaitingIndicators(for id: String) {
|
|
386
|
+
guard waitingClockStartedAt == nil, presentationId == id, !appReady, phase == .holding else { return }
|
|
387
|
+
waitingClockStartedAt = Date()
|
|
388
|
+
let progress = DispatchWorkItem { [weak self] in
|
|
389
|
+
guard let self, self.presentationId == id, !self.appReady, self.phase == .holding else { return }
|
|
390
|
+
self.progressRemaining = 0
|
|
391
|
+
let rail = LottieSplashLoadingRail(theme: self.theme)
|
|
392
|
+
rail.translatesAutoresizingMaskIntoConstraints = false
|
|
393
|
+
self.cover?.addSubview(rail)
|
|
394
|
+
NSLayoutConstraint.activate([
|
|
395
|
+
rail.centerXAnchor.constraint(equalTo: self.cover!.centerXAnchor),
|
|
396
|
+
// The Lottie composition occupies the center. Keep the loading cue just
|
|
397
|
+
// below its finished tally rather than placing it on top of the artwork.
|
|
398
|
+
rail.centerYAnchor.constraint(equalTo: self.cover!.centerYAnchor, constant: 100),
|
|
399
|
+
rail.widthAnchor.constraint(equalToConstant: 104),
|
|
400
|
+
rail.heightAnchor.constraint(equalToConstant: 3)
|
|
401
|
+
])
|
|
402
|
+
self.loadingRail = rail
|
|
403
|
+
}
|
|
404
|
+
let recovery = DispatchWorkItem { [weak self] in
|
|
405
|
+
self?.recoveryRemaining = 0
|
|
406
|
+
self?.showRecovery(for: id)
|
|
407
|
+
}
|
|
408
|
+
progressWorkItem = progress
|
|
409
|
+
recoveryWorkItem = recovery
|
|
410
|
+
if self.loadingRail == nil { queue.asyncAfter(deadline: .now() + progressRemaining, execute: progress) }
|
|
411
|
+
queue.asyncAfter(deadline: .now() + recoveryRemaining, execute: recovery)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private func resetWaitingIndicators(for id: String) {
|
|
415
|
+
cancelWaitingTasks()
|
|
416
|
+
progressRemaining = progressDelay
|
|
417
|
+
recoveryRemaining = recoveryDelay
|
|
418
|
+
scheduleWaitingIndicators(for: id)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private func pauseWaitingClock() {
|
|
422
|
+
guard let waitingClockStartedAt else { return }
|
|
423
|
+
let elapsed = Date().timeIntervalSince(waitingClockStartedAt)
|
|
424
|
+
progressRemaining = max(0, progressRemaining - elapsed)
|
|
425
|
+
recoveryRemaining = max(0, recoveryRemaining - elapsed)
|
|
426
|
+
cancelWaitingTasks()
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private func cancelWaitingTasks() {
|
|
430
|
+
progressWorkItem?.cancel()
|
|
431
|
+
recoveryWorkItem?.cancel()
|
|
432
|
+
progressWorkItem = nil
|
|
433
|
+
recoveryWorkItem = nil
|
|
434
|
+
waitingClockStartedAt = nil
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private func scheduleUnadoptedFallback(for id: String?) {
|
|
438
|
+
adoptionWorkItem?.cancel()
|
|
439
|
+
guard let id else { return }
|
|
440
|
+
let fallback = DispatchWorkItem { [weak self] in
|
|
441
|
+
guard let self, self.presentationId == id, !self.adoptedByWeb else { return }
|
|
442
|
+
self.dismiss(completion: { _ in })
|
|
443
|
+
}
|
|
444
|
+
adoptionWorkItem = fallback
|
|
445
|
+
queue.asyncAfter(deadline: .now() + webAdoptionGracePeriod, execute: fallback)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private func showRecovery(for id: String) {
|
|
449
|
+
guard presentationId == id, !appReady else { return }
|
|
450
|
+
cancelWaitingTasks()
|
|
451
|
+
phase = .recovery
|
|
452
|
+
loadingRail?.removeFromSuperview()
|
|
453
|
+
loadingRail = nil
|
|
454
|
+
let stack = UIStackView()
|
|
455
|
+
stack.axis = .vertical
|
|
456
|
+
stack.spacing = 12
|
|
457
|
+
stack.alignment = .center
|
|
458
|
+
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
459
|
+
let label = UILabel()
|
|
460
|
+
label.text = "Still starting…"
|
|
461
|
+
label.accessibilityTraits = .updatesFrequently
|
|
462
|
+
let retry = UIButton(type: .system)
|
|
463
|
+
retry.setTitle("Try again", for: .normal)
|
|
464
|
+
retry.addAction(UIAction { [weak self] _ in self?.onRecoveryRequested?() }, for: .primaryActionTriggered)
|
|
465
|
+
stack.addArrangedSubview(label)
|
|
466
|
+
stack.addArrangedSubview(retry)
|
|
467
|
+
cover?.addSubview(stack)
|
|
468
|
+
if let cover {
|
|
469
|
+
NSLayoutConstraint.activate([stack.centerXAnchor.constraint(equalTo: cover.centerXAnchor), stack.bottomAnchor.constraint(equalTo: cover.safeAreaLayoutGuide.bottomAnchor, constant: -48)])
|
|
470
|
+
}
|
|
471
|
+
UIAccessibility.post(notification: .announcement, argument: "Still starting")
|
|
472
|
+
emitState()
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private func dismiss(completion: @escaping (Error?) -> Void) {
|
|
476
|
+
guard phase != .hidden, let cover, let id = presentationId else { completion(nil); return }
|
|
477
|
+
phase = .hiding
|
|
478
|
+
cancelWaitingTasks()
|
|
479
|
+
adoptionWorkItem?.cancel()
|
|
480
|
+
adoptionWorkItem = nil
|
|
481
|
+
loadingRail?.removeFromSuperview()
|
|
482
|
+
loadingRail = nil
|
|
483
|
+
UIView.animate(withDuration: UIAccessibility.isReduceMotionEnabled ? 0 : 0.5, animations: { cover.alpha = 0 }) { [weak self] _ in
|
|
484
|
+
cover.removeFromSuperview()
|
|
485
|
+
self?.cover = nil
|
|
486
|
+
self?.phase = .hidden
|
|
487
|
+
self?.presentationId = nil
|
|
488
|
+
self?.animationWebView?.configuration.userContentController.removeScriptMessageHandler(forName: "lottieSplash")
|
|
489
|
+
self?.animationWebView = nil
|
|
490
|
+
self?.animationMessageBridge = nil
|
|
491
|
+
self?.emitState()
|
|
492
|
+
_ = id
|
|
493
|
+
completion(nil)
|
|
494
|
+
}
|
|
495
|
+
emitState()
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
private func emitState() { onStateChange?(self) }
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
public enum LottieSplashError: Error { case stalePresentation }
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import Capacitor
|
|
2
|
+
|
|
3
|
+
@objc(LottieSplashPlugin)
|
|
4
|
+
public class LottieSplashPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
5
|
+
public let identifier = "LottieSplashPlugin"
|
|
6
|
+
public let jsName = "LottieSplash"
|
|
7
|
+
public let pluginMethods: [CAPPluginMethod] = [
|
|
8
|
+
CAPPluginMethod(name: "show", returnType: CAPPluginReturnPromise),
|
|
9
|
+
CAPPluginMethod(name: "setLaunchTheme", returnType: CAPPluginReturnPromise),
|
|
10
|
+
CAPPluginMethod(name: "waitForCompletion", returnType: CAPPluginReturnPromise),
|
|
11
|
+
CAPPluginMethod(name: "hide", returnType: CAPPluginReturnPromise),
|
|
12
|
+
CAPPluginMethod(name: "getState", returnType: CAPPluginReturnPromise),
|
|
13
|
+
CAPPluginMethod(name: "getCapabilities", returnType: CAPPluginReturnPromise)
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
private let coordinator = LottieSplashCoordinator.shared
|
|
17
|
+
|
|
18
|
+
override public func load() {
|
|
19
|
+
coordinator.onStateChange = { [weak self] coordinator in self?.notifyListeners("stateChange", data: self?.stateData(coordinator) ?? [:]) }
|
|
20
|
+
coordinator.onRecoveryRequested = { [weak self] in self?.bridge?.webView?.reload() }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@objc public func show(_ call: CAPPluginCall) {
|
|
24
|
+
DispatchQueue.main.async {
|
|
25
|
+
let result = self.coordinator.show(configuration: self.configuration(from: call))
|
|
26
|
+
call.resolve(["presentationId": result.id, "renderer": "ios", "adopted": result.adopted])
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@objc public func setLaunchTheme(_ call: CAPPluginCall) {
|
|
31
|
+
// iOS launch storyboard appearance is selected by UIKit; there is no persistent
|
|
32
|
+
// per-application night-mode override equivalent to Android's UiModeManager.
|
|
33
|
+
call.resolve()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@objc public func waitForCompletion(_ call: CAPPluginCall) {
|
|
37
|
+
guard let id = call.getString("presentationId") else { call.reject("presentationId is required"); return }
|
|
38
|
+
coordinator.waitForCompletion(id: id) { outcome, reason in
|
|
39
|
+
var result: PluginCallResultData = ["presentationId": id, "outcome": outcome]
|
|
40
|
+
if let reason { result["reason"] = reason }
|
|
41
|
+
call.resolve(result)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@objc public func hide(_ call: CAPPluginCall) {
|
|
46
|
+
guard let id = call.getString("presentationId") else { call.reject("presentationId is required"); return }
|
|
47
|
+
DispatchQueue.main.async { self.coordinator.hide(id: id) { error in error.map { call.reject($0.localizedDescription) } ?? call.resolve() } }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@objc public func getState(_ call: CAPPluginCall) { call.resolve(stateData(coordinator)) }
|
|
51
|
+
|
|
52
|
+
@objc public func getCapabilities(_ call: CAPPluginCall) {
|
|
53
|
+
call.resolve(["apiVersion": 1, "pluginVersion": "0.1.0", "coldStartInstalled": coordinator.presentationId != nil])
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private func configuration(from call: CAPPluginCall) -> LottieSplashConfiguration {
|
|
57
|
+
let theme: LottieSplashTheme
|
|
58
|
+
if let darkMode = call.getBool("darkMode") {
|
|
59
|
+
theme = darkMode ? .dark : .light
|
|
60
|
+
} else {
|
|
61
|
+
// Accept already-delivered web bundles while the host upgrades to the explicit
|
|
62
|
+
// darkMode contract.
|
|
63
|
+
theme = call.getString("theme").flatMap(LottieSplashTheme.init(rawValue:)) ?? .dark
|
|
64
|
+
}
|
|
65
|
+
return LottieSplashConfiguration(theme: theme)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private func stateData(_ coordinator: LottieSplashCoordinator) -> PluginCallResultData {
|
|
69
|
+
["presentationId": coordinator.presentationId ?? NSNull(), "phase": coordinator.phase.rawValue, "visible": coordinator.phase != .hidden, "playbackSettled": coordinator.playbackSettled, "appReady": coordinator.appReady, "theme": coordinator.theme.rawValue]
|
|
70
|
+
}
|
|
71
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "capacitor-lottie-splash",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Native-first Lottie splash presentation for Capacitor 8",
|
|
5
|
+
"main": "dist/plugin.cjs.js",
|
|
6
|
+
"module": "dist/esm/index.js",
|
|
7
|
+
"types": "dist/esm/index.d.ts",
|
|
8
|
+
"unpkg": "dist/plugin.js",
|
|
9
|
+
"files": [
|
|
10
|
+
"android/src/main/",
|
|
11
|
+
"android/build.gradle",
|
|
12
|
+
"android/settings.gradle",
|
|
13
|
+
"dist/*.js",
|
|
14
|
+
"dist/esm/*.js",
|
|
15
|
+
"dist/esm/*.d.ts",
|
|
16
|
+
"ios/Sources/",
|
|
17
|
+
"Package.swift",
|
|
18
|
+
"CapacitorLottieSplash.podspec"
|
|
19
|
+
],
|
|
20
|
+
"author": "HeadCount CMMS",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/Head-Count-CMMS/CapacitorLottieSplash.git"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/Head-Count-CMMS/CapacitorLottieSplash/issues"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"capacitor",
|
|
31
|
+
"plugin",
|
|
32
|
+
"lottie",
|
|
33
|
+
"splash-screen",
|
|
34
|
+
"native"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "rimraf dist && tsc && rollup -c rollup.config.mjs",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"test:watch": "vitest",
|
|
40
|
+
"docgen": "docgen --api LottieSplashPlugin --output-readme README.md --output-json dist/docs.json",
|
|
41
|
+
"verify:web": "npm run build && npm test",
|
|
42
|
+
"verify:android": "gradle -p android compileDebugKotlin --no-daemon",
|
|
43
|
+
"verify:ios": "xcodebuild -scheme CapacitorLottieSplash -destination generic/platform=iOS build",
|
|
44
|
+
"verify": "npm run verify:web && npm run verify:android && npm run verify:ios",
|
|
45
|
+
"prepublishOnly": "npm run build && npm test"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@capacitor/core": "^8.0.0"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"lottie-web": "5.13.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@capacitor/android": "8.2.0",
|
|
55
|
+
"@capacitor/core": "8.2.0",
|
|
56
|
+
"@capacitor/docgen": "0.3.1",
|
|
57
|
+
"@capacitor/ios": "8.2.0",
|
|
58
|
+
"@rollup/plugin-commonjs": "28.0.8",
|
|
59
|
+
"@rollup/plugin-node-resolve": "16.0.3",
|
|
60
|
+
"@rollup/plugin-typescript": "12.1.4",
|
|
61
|
+
"rimraf": "6.1.2",
|
|
62
|
+
"rollup": "4.53.2",
|
|
63
|
+
"typescript": "5.8.3",
|
|
64
|
+
"vitest": "3.2.4"
|
|
65
|
+
},
|
|
66
|
+
"capacitor": {
|
|
67
|
+
"pluginId": "capacitor-lottie-splash",
|
|
68
|
+
"name": "LottieSplash",
|
|
69
|
+
"ios": {
|
|
70
|
+
"src": "ios",
|
|
71
|
+
"pluginClass": "LottieSplashPlugin"
|
|
72
|
+
},
|
|
73
|
+
"android": {
|
|
74
|
+
"src": "android",
|
|
75
|
+
"pluginClass": "LottieSplashPlugin"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|