@capgo/capacitor-native-navigation 8.0.9
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/CapgoNativeNavigation.podspec +17 -0
- package/LICENSE +373 -0
- package/Package.swift +28 -0
- package/README.md +858 -0
- package/android/build.gradle +61 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/app/capgo/nativenavigation/NativeNavigation.java +8 -0
- package/android/src/main/java/app/capgo/nativenavigation/NativeNavigationPlugin.java +1322 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/dist/docs.json +1369 -0
- package/dist/esm/components.d.ts +1 -0
- package/dist/esm/components.js +159 -0
- package/dist/esm/components.js.map +1 -0
- package/dist/esm/definitions.d.ts +470 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +19 -0
- package/dist/esm/index.js +40 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/plugin.d.ts +2 -0
- package/dist/esm/plugin.js +2 -0
- package/dist/esm/plugin.js.map +1 -0
- package/dist/esm/web.d.ts +17 -0
- package/dist/esm/web.js +90 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +310 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +313 -0
- package/dist/plugin.js.map +1 -0
- package/docs/demo-navigation.webp +0 -0
- package/docs/demo-options.webp +0 -0
- package/docs/demo-svg-icons.webp +0 -0
- package/ios/Sources/NativeNavigationPlugin/NativeNavigation.swift +7 -0
- package/ios/Sources/NativeNavigationPlugin/NativeNavigationPlugin.swift +1580 -0
- package/ios/Tests/NativeNavigationPluginTests/NativeNavigationTests.swift +19 -0
- package/package.json +91 -0
|
@@ -0,0 +1,1580 @@
|
|
|
1
|
+
// swiftlint:disable file_length
|
|
2
|
+
|
|
3
|
+
import Foundation
|
|
4
|
+
import Capacitor
|
|
5
|
+
import UIKit
|
|
6
|
+
|
|
7
|
+
private struct NativeNavigationTransitionContext {
|
|
8
|
+
let webView: UIView
|
|
9
|
+
let snapshot: UIView?
|
|
10
|
+
let id: String
|
|
11
|
+
let direction: String
|
|
12
|
+
let duration: TimeInterval
|
|
13
|
+
let durationMs: Int
|
|
14
|
+
let resolve: ([String: Any]) -> Void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private struct NativeNavigationZoomTransitionContext {
|
|
18
|
+
let transition: NativeNavigationTransitionContext
|
|
19
|
+
let sourceFrame: CGRect?
|
|
20
|
+
let targetFrame: CGRect?
|
|
21
|
+
let cornerRadius: CGFloat
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// swiftlint:disable type_body_length
|
|
25
|
+
@objc(NativeNavigationPlugin)
|
|
26
|
+
public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDelegate {
|
|
27
|
+
public let identifier = "NativeNavigationPlugin"
|
|
28
|
+
public let jsName = "NativeNavigation"
|
|
29
|
+
public let pluginMethods: [CAPPluginMethod] = [
|
|
30
|
+
CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise),
|
|
31
|
+
CAPPluginMethod(name: "setNavbar", returnType: CAPPluginReturnPromise),
|
|
32
|
+
CAPPluginMethod(name: "setTabbar", returnType: CAPPluginReturnPromise),
|
|
33
|
+
CAPPluginMethod(name: "beginTransition", returnType: CAPPluginReturnPromise),
|
|
34
|
+
CAPPluginMethod(name: "finishTransition", returnType: CAPPluginReturnPromise),
|
|
35
|
+
CAPPluginMethod(name: "getPluginVersion", returnType: CAPPluginReturnPromise)
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
private let implementation = NativeNavigation()
|
|
39
|
+
private var navContainer: UIView?
|
|
40
|
+
private var navBlurView: UIVisualEffectView?
|
|
41
|
+
private var navBar: UINavigationBar?
|
|
42
|
+
private var tabContainer: UIView?
|
|
43
|
+
private var tabEffectView: UIVisualEffectView?
|
|
44
|
+
private var tabBar: UITabBar?
|
|
45
|
+
private var navbarHeight: CGFloat = 44
|
|
46
|
+
private var tabbarHeight: CGFloat = 64
|
|
47
|
+
private let floatingTabbarHorizontalMargin: CGFloat = 24
|
|
48
|
+
private let floatingTabbarMaxWidth: CGFloat = 430
|
|
49
|
+
private let floatingTabbarBottomGap: CGFloat = 10
|
|
50
|
+
private var navbarVisible = false
|
|
51
|
+
private var tabbarVisible = false
|
|
52
|
+
private var contentInsetMode = "css"
|
|
53
|
+
private var isEnabled = true
|
|
54
|
+
private var defaultTransitionDuration: TimeInterval = 0.35
|
|
55
|
+
private var navbarItemPlacement: [String: String] = [:]
|
|
56
|
+
private var navbarItemTitle: [String: String] = [:]
|
|
57
|
+
private var tabIds: [String] = []
|
|
58
|
+
private var tabTitles: [String] = []
|
|
59
|
+
private var transitionSnapshot: UIView?
|
|
60
|
+
private var activeTransitionId: String?
|
|
61
|
+
private var activeTransitionDirection = "forward"
|
|
62
|
+
private var activeZoomSourceFrame: CGRect?
|
|
63
|
+
private var activeZoomCornerRadius: CGFloat = 0
|
|
64
|
+
|
|
65
|
+
private var usesSystemLiquidGlass: Bool {
|
|
66
|
+
if #available(iOS 26.0, *) {
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
override public func load() {
|
|
73
|
+
NotificationCenter.default.addObserver(
|
|
74
|
+
self,
|
|
75
|
+
selector: #selector(handleLayoutChange),
|
|
76
|
+
name: UIDevice.orientationDidChangeNotification,
|
|
77
|
+
object: nil
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
deinit {
|
|
82
|
+
NotificationCenter.default.removeObserver(self)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@objc func configure(_ call: CAPPluginCall) {
|
|
86
|
+
DispatchQueue.main.async {
|
|
87
|
+
self.isEnabled = call.getBool("enabled", true)
|
|
88
|
+
self.contentInsetMode = call.getString("contentInsetMode") ?? self.contentInsetMode
|
|
89
|
+
if let duration = call.getDouble("animationDuration") {
|
|
90
|
+
self.defaultTransitionDuration = duration / 1_000
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if !self.isEnabled {
|
|
94
|
+
self.navContainer?.isHidden = true
|
|
95
|
+
self.tabContainer?.isHidden = true
|
|
96
|
+
self.tabBar?.isHidden = true
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
self.updateInsetsAndNotify()
|
|
100
|
+
call.resolve(self.insetsResult())
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@objc func setNavbar(_ call: CAPPluginCall) {
|
|
105
|
+
DispatchQueue.main.async {
|
|
106
|
+
guard self.isEnabled else {
|
|
107
|
+
self.navbarVisible = false
|
|
108
|
+
self.updateInsetsAndNotify()
|
|
109
|
+
call.resolve(self.insetsResult())
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let hidden = call.getBool("hidden", false)
|
|
114
|
+
self.navbarVisible = !hidden
|
|
115
|
+
let animated = call.getBool("animated", false)
|
|
116
|
+
let large = call.getBool("large", false)
|
|
117
|
+
self.navbarHeight = large ? 96 : 44
|
|
118
|
+
|
|
119
|
+
guard !hidden else {
|
|
120
|
+
self.navContainer?.isHidden = true
|
|
121
|
+
self.updateInsetsAndNotify()
|
|
122
|
+
call.resolve(self.insetsResult())
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let navBar = self.ensureNavBar()
|
|
127
|
+
let navItem = UINavigationItem(title: call.getString("title") ?? "")
|
|
128
|
+
navItem.prompt = call.getString("subtitle")
|
|
129
|
+
if #available(iOS 11.0, *) {
|
|
130
|
+
navBar.prefersLargeTitles = large
|
|
131
|
+
navItem.largeTitleDisplayMode = large ? .always : .never
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
self.navbarItemPlacement.removeAll()
|
|
135
|
+
self.navbarItemTitle.removeAll()
|
|
136
|
+
|
|
137
|
+
if let backButton = call.getObject("backButton"), backButton["visible"] as? Bool == true {
|
|
138
|
+
let title = backButton["title"] as? String
|
|
139
|
+
let item = UIBarButtonItem(title: title ?? "Back", style: .plain, target: self, action: #selector(self.handleNavbarBack))
|
|
140
|
+
self.configureGlassBarButtonItem(item, id: "back")
|
|
141
|
+
navItem.leftBarButtonItem = item
|
|
142
|
+
} else {
|
|
143
|
+
navItem.leftBarButtonItems = self.makeBarButtonItems(call.getArray("leftItems") as? [[String: Any]] ?? [], placement: "left")
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
navItem.rightBarButtonItems = self.makeBarButtonItems(call.getArray("rightItems") as? [[String: Any]] ?? [], placement: "right")
|
|
147
|
+
navBar.setItems([navItem], animated: animated)
|
|
148
|
+
self.applyNavBarAppearance(navBar: navBar, options: call)
|
|
149
|
+
self.navContainer?.isHidden = false
|
|
150
|
+
self.layoutChrome()
|
|
151
|
+
self.updateInsetsAndNotify()
|
|
152
|
+
call.resolve(self.insetsResult())
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
@objc func setTabbar(_ call: CAPPluginCall) {
|
|
157
|
+
DispatchQueue.main.async {
|
|
158
|
+
guard self.isEnabled else {
|
|
159
|
+
self.tabbarVisible = false
|
|
160
|
+
self.updateInsetsAndNotify()
|
|
161
|
+
call.resolve(self.insetsResult())
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let hidden = call.getBool("hidden", false)
|
|
166
|
+
self.tabbarVisible = !hidden
|
|
167
|
+
|
|
168
|
+
guard !hidden else {
|
|
169
|
+
self.tabContainer?.isHidden = true
|
|
170
|
+
self.tabBar?.isHidden = true
|
|
171
|
+
self.updateInsetsAndNotify()
|
|
172
|
+
call.resolve(self.insetsResult())
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let tabBar = self.ensureTabBar()
|
|
177
|
+
let tabs = call.getArray("tabs") as? [[String: Any]] ?? []
|
|
178
|
+
let selectedId = call.getString("selectedId")
|
|
179
|
+
let labels = call.getBool("labels", true)
|
|
180
|
+
let labelVisibilityMode = call.getString("labelVisibilityMode") ?? (labels ? "labeled" : "unlabeled")
|
|
181
|
+
let icons = call.getBool("icons", true)
|
|
182
|
+
|
|
183
|
+
let (items, selectedIndex) = self.makeTabBarItems(
|
|
184
|
+
tabs,
|
|
185
|
+
selectedId: selectedId,
|
|
186
|
+
labelVisibilityMode: labelVisibilityMode,
|
|
187
|
+
icons: icons
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
tabBar.items = items
|
|
191
|
+
if let selectedIndex = selectedIndex, selectedIndex < items.count {
|
|
192
|
+
tabBar.selectedItem = items[selectedIndex]
|
|
193
|
+
} else if tabBar.selectedItem == nil {
|
|
194
|
+
tabBar.selectedItem = items.first
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
self.applyTabBarAppearance(tabBar: tabBar, options: call)
|
|
198
|
+
self.tabContainer?.isHidden = false
|
|
199
|
+
tabBar.isHidden = false
|
|
200
|
+
self.layoutChrome()
|
|
201
|
+
self.updateInsetsAndNotify()
|
|
202
|
+
call.resolve(self.insetsResult())
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
@objc func beginTransition(_ call: CAPPluginCall) {
|
|
207
|
+
DispatchQueue.main.async {
|
|
208
|
+
guard let webView = self.webView, let rootView = self.bridge?.viewController?.view else {
|
|
209
|
+
call.reject("WebView unavailable")
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let transitionId = call.getString("id") ?? "transition-\(Int(Date().timeIntervalSince1970 * 1_000))"
|
|
214
|
+
let direction = call.getString("direction") ?? "forward"
|
|
215
|
+
let durationMs = Int((call.getDouble("duration") ?? self.defaultTransitionDuration * 1_000).rounded())
|
|
216
|
+
let zoomSourceRect = direction == "zoom" ? self.transitionRect(call.getObject("sourceRect")) : nil
|
|
217
|
+
let zoomSourceFrame = zoomSourceRect.map { self.rootFrame(for: $0, webView: webView) }
|
|
218
|
+
let cornerRadius = CGFloat(call.getDouble("cornerRadius") ?? 0)
|
|
219
|
+
|
|
220
|
+
self.transitionSnapshot?.removeFromSuperview()
|
|
221
|
+
let snapshot = self.transitionSnapshotView(from: webView, sourceRect: zoomSourceRect)
|
|
222
|
+
snapshot.frame = zoomSourceFrame ?? webView.frame
|
|
223
|
+
snapshot.autoresizingMask = zoomSourceFrame == nil ? [.flexibleWidth, .flexibleHeight] : []
|
|
224
|
+
snapshot.layer.cornerRadius = cornerRadius
|
|
225
|
+
snapshot.clipsToBounds = cornerRadius > 0
|
|
226
|
+
rootView.insertSubview(snapshot, aboveSubview: webView)
|
|
227
|
+
self.bringChromeToFront()
|
|
228
|
+
self.transitionSnapshot = snapshot
|
|
229
|
+
self.activeTransitionId = transitionId
|
|
230
|
+
self.activeTransitionDirection = direction
|
|
231
|
+
self.activeZoomSourceFrame = zoomSourceFrame
|
|
232
|
+
self.activeZoomCornerRadius = cornerRadius
|
|
233
|
+
webView.alpha = 0.01
|
|
234
|
+
|
|
235
|
+
let event: [String: Any] = ["id": transitionId, "direction": direction, "duration": durationMs]
|
|
236
|
+
self.notifyListeners("transitionStart", data: event)
|
|
237
|
+
call.resolve(event)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
@objc func finishTransition(_ call: CAPPluginCall) {
|
|
242
|
+
DispatchQueue.main.async {
|
|
243
|
+
guard let webView = self.webView else {
|
|
244
|
+
call.reject("WebView unavailable")
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let transitionId = call.getString("id") ?? self.activeTransitionId ?? "transition-\(Int(Date().timeIntervalSince1970 * 1_000))"
|
|
249
|
+
let direction = call.getString("direction") ?? self.activeTransitionDirection
|
|
250
|
+
let duration = (call.getDouble("duration") ?? self.defaultTransitionDuration * 1_000) / 1_000
|
|
251
|
+
let durationMs = Int((duration * 1_000).rounded())
|
|
252
|
+
let transition = NativeNavigationTransitionContext(
|
|
253
|
+
webView: webView,
|
|
254
|
+
snapshot: self.transitionSnapshot,
|
|
255
|
+
id: transitionId,
|
|
256
|
+
direction: direction,
|
|
257
|
+
duration: duration,
|
|
258
|
+
durationMs: durationMs,
|
|
259
|
+
resolve: { call.resolve($0) }
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if direction == "zoom" {
|
|
263
|
+
let sourceRect = self.transitionRect(call.getObject("sourceRect"))
|
|
264
|
+
let targetRect = self.transitionRect(call.getObject("targetRect"))
|
|
265
|
+
self.finishZoomTransition(NativeNavigationZoomTransitionContext(
|
|
266
|
+
transition: transition,
|
|
267
|
+
sourceFrame: sourceRect.map { self.rootFrame(for: $0, webView: webView) },
|
|
268
|
+
targetFrame: targetRect.map { self.rootFrame(for: $0, webView: webView) },
|
|
269
|
+
cornerRadius: CGFloat(call.getDouble("cornerRadius") ?? Double(self.activeZoomCornerRadius))
|
|
270
|
+
))
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
self.finishStandardTransition(transition)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private func finishStandardTransition(_ transition: NativeNavigationTransitionContext) {
|
|
279
|
+
let width = transition.webView.bounds.width
|
|
280
|
+
let transforms = standardTransitionTransforms(direction: transition.direction, width: width)
|
|
281
|
+
transition.webView.transform = transforms.start
|
|
282
|
+
transition.webView.alpha = transition.direction == "none" ? 1 : 0.01
|
|
283
|
+
|
|
284
|
+
UIView.animate(
|
|
285
|
+
withDuration: max(transition.duration, 0),
|
|
286
|
+
delay: 0,
|
|
287
|
+
options: [.curveEaseOut, .allowUserInteraction],
|
|
288
|
+
animations: {
|
|
289
|
+
transition.webView.transform = .identity
|
|
290
|
+
transition.webView.alpha = 1
|
|
291
|
+
transition.snapshot?.transform = transforms.snapshotEnd
|
|
292
|
+
transition.snapshot?.alpha = transition.direction == "none" ? 0 : 0.75
|
|
293
|
+
},
|
|
294
|
+
completion: { _ in
|
|
295
|
+
self.finishTransitionCleanup(transition)
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private func standardTransitionTransforms(direction: String, width: CGFloat) -> (start: CGAffineTransform, snapshotEnd: CGAffineTransform) {
|
|
301
|
+
switch direction {
|
|
302
|
+
case "back":
|
|
303
|
+
return (CGAffineTransform(translationX: -width * 0.3, y: 0), CGAffineTransform(translationX: width, y: 0))
|
|
304
|
+
case "tab", "root", "none":
|
|
305
|
+
return (.identity, .identity)
|
|
306
|
+
default:
|
|
307
|
+
return (CGAffineTransform(translationX: width, y: 0), CGAffineTransform(translationX: -width * 0.3, y: 0))
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private func finishZoomTransition(_ zoom: NativeNavigationZoomTransitionContext) {
|
|
312
|
+
let transition = zoom.transition
|
|
313
|
+
let startFrame = zoom.sourceFrame ?? activeZoomSourceFrame ?? transition.webView.frame
|
|
314
|
+
|
|
315
|
+
let finish = {
|
|
316
|
+
self.finishTransitionCleanup(transition)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
guard transition.duration > 0 else {
|
|
320
|
+
finish()
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if let targetFrame = zoom.targetFrame {
|
|
325
|
+
animateZoomToTarget(zoom, startFrame: startFrame, targetFrame: targetFrame, completion: finish)
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
animateZoomToFullScreen(zoom, startFrame: startFrame, completion: finish)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private func animateZoomToTarget(
|
|
333
|
+
_ zoom: NativeNavigationZoomTransitionContext,
|
|
334
|
+
startFrame: CGRect,
|
|
335
|
+
targetFrame: CGRect,
|
|
336
|
+
completion: @escaping () -> Void
|
|
337
|
+
) {
|
|
338
|
+
let transition = zoom.transition
|
|
339
|
+
transition.webView.transform = .identity
|
|
340
|
+
transition.webView.alpha = 0.01
|
|
341
|
+
transition.snapshot?.frame = startFrame
|
|
342
|
+
transition.snapshot?.layer.cornerRadius = zoom.cornerRadius
|
|
343
|
+
transition.snapshot?.clipsToBounds = zoom.cornerRadius > 0
|
|
344
|
+
|
|
345
|
+
UIView.animate(
|
|
346
|
+
withDuration: transition.duration,
|
|
347
|
+
delay: 0,
|
|
348
|
+
options: [.curveEaseInOut, .allowUserInteraction],
|
|
349
|
+
animations: {
|
|
350
|
+
transition.webView.alpha = 1
|
|
351
|
+
transition.snapshot?.frame = targetFrame
|
|
352
|
+
transition.snapshot?.alpha = 0
|
|
353
|
+
},
|
|
354
|
+
completion: { _ in completion() }
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private func animateZoomToFullScreen(
|
|
359
|
+
_ zoom: NativeNavigationZoomTransitionContext,
|
|
360
|
+
startFrame: CGRect,
|
|
361
|
+
completion: @escaping () -> Void
|
|
362
|
+
) {
|
|
363
|
+
let transition = zoom.transition
|
|
364
|
+
let fullFrame = transition.webView.frame
|
|
365
|
+
let scaleX = max(startFrame.width / max(fullFrame.width, 1), 0.01)
|
|
366
|
+
let scaleY = max(startFrame.height / max(fullFrame.height, 1), 0.01)
|
|
367
|
+
let translationX = startFrame.midX - fullFrame.midX
|
|
368
|
+
let translationY = startFrame.midY - fullFrame.midY
|
|
369
|
+
transition.webView.transform = CGAffineTransform(translationX: translationX, y: translationY).scaledBy(x: scaleX, y: scaleY)
|
|
370
|
+
transition.webView.alpha = 1
|
|
371
|
+
transition.webView.layer.cornerRadius = zoom.cornerRadius
|
|
372
|
+
transition.webView.clipsToBounds = zoom.cornerRadius > 0
|
|
373
|
+
transition.snapshot?.frame = startFrame
|
|
374
|
+
|
|
375
|
+
UIView.animate(
|
|
376
|
+
withDuration: transition.duration,
|
|
377
|
+
delay: 0,
|
|
378
|
+
options: [.curveEaseInOut, .allowUserInteraction],
|
|
379
|
+
animations: {
|
|
380
|
+
transition.webView.transform = .identity
|
|
381
|
+
transition.webView.layer.cornerRadius = 0
|
|
382
|
+
transition.snapshot?.frame = fullFrame
|
|
383
|
+
transition.snapshot?.alpha = 0
|
|
384
|
+
},
|
|
385
|
+
completion: { _ in completion() }
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private func finishTransitionCleanup(_ transition: NativeNavigationTransitionContext) {
|
|
390
|
+
transition.snapshot?.removeFromSuperview()
|
|
391
|
+
transition.webView.transform = .identity
|
|
392
|
+
transition.webView.alpha = 1
|
|
393
|
+
transition.webView.layer.cornerRadius = 0
|
|
394
|
+
transition.webView.clipsToBounds = false
|
|
395
|
+
transitionSnapshot = nil
|
|
396
|
+
activeTransitionId = nil
|
|
397
|
+
activeZoomSourceFrame = nil
|
|
398
|
+
let event: [String: Any] = ["id": transition.id, "direction": transition.direction, "duration": transition.durationMs]
|
|
399
|
+
notifyListeners("transitionEnd", data: event)
|
|
400
|
+
transition.resolve(event)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
@objc func getPluginVersion(_ call: CAPPluginCall) {
|
|
404
|
+
call.resolve([
|
|
405
|
+
"version": implementation.getPluginVersion()
|
|
406
|
+
])
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
@objc private func handleNavbarBack() {
|
|
410
|
+
notifyListeners("navbarBack", data: ["source": "navbar"])
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
@objc private func handleNavbarButton(_ sender: UIBarButtonItem) {
|
|
414
|
+
guard let id = sender.accessibilityIdentifier else {
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
notifyListeners("navbarItemTap", data: [
|
|
418
|
+
"id": id,
|
|
419
|
+
"title": navbarItemTitle[id] ?? "",
|
|
420
|
+
"placement": navbarItemPlacement[id] ?? "right"
|
|
421
|
+
])
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
public func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
|
|
425
|
+
let index = item.tag
|
|
426
|
+
guard index >= 0 && index < tabIds.count else {
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
notifyListeners("tabSelect", data: [
|
|
430
|
+
"id": tabIds[index],
|
|
431
|
+
"index": index,
|
|
432
|
+
"title": tabTitles[index]
|
|
433
|
+
])
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
@objc private func handleLayoutChange() {
|
|
437
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
438
|
+
self.layoutChrome()
|
|
439
|
+
self.updateInsetsAndNotify()
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private func ensureNavBar() -> UINavigationBar {
|
|
444
|
+
if let navBar = navBar {
|
|
445
|
+
return navBar
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
let container = NativeNavigationChromeContainer()
|
|
449
|
+
container.hitSlop = UIEdgeInsets(top: 0, left: 0, bottom: 32, right: 0)
|
|
450
|
+
container.isUserInteractionEnabled = true
|
|
451
|
+
container.autoresizingMask = [.flexibleWidth, .flexibleBottomMargin]
|
|
452
|
+
|
|
453
|
+
if !usesSystemLiquidGlass {
|
|
454
|
+
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
|
|
455
|
+
blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
456
|
+
blurView.isUserInteractionEnabled = false
|
|
457
|
+
container.addSubview(blurView)
|
|
458
|
+
self.navBlurView = blurView
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
let bar = NativeNavigationBar()
|
|
462
|
+
bar.hitSlop = UIEdgeInsets(top: 0, left: 0, bottom: 32, right: 0)
|
|
463
|
+
bar.isTranslucent = true
|
|
464
|
+
if !usesSystemLiquidGlass {
|
|
465
|
+
bar.backgroundColor = .clear
|
|
466
|
+
}
|
|
467
|
+
bar.autoresizingMask = [.flexibleWidth, .flexibleBottomMargin]
|
|
468
|
+
container.addSubview(bar)
|
|
469
|
+
|
|
470
|
+
bridge?.viewController?.view.addSubview(container)
|
|
471
|
+
self.navContainer = container
|
|
472
|
+
self.navBar = bar
|
|
473
|
+
return bar
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private func ensureTabBar() -> UITabBar {
|
|
477
|
+
if let tabBar = tabBar {
|
|
478
|
+
return tabBar
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
let container = NativeNavigationChromeContainer()
|
|
482
|
+
container.hitSlop = UIEdgeInsets(top: 32, left: 0, bottom: 24, right: 0)
|
|
483
|
+
container.isUserInteractionEnabled = true
|
|
484
|
+
container.autoresizingMask = [.flexibleWidth, .flexibleTopMargin]
|
|
485
|
+
container.backgroundColor = .clear
|
|
486
|
+
|
|
487
|
+
if !usesSystemLiquidGlass {
|
|
488
|
+
container.layer.shadowColor = UIColor.black.cgColor
|
|
489
|
+
container.layer.shadowOpacity = 0.14
|
|
490
|
+
container.layer.shadowRadius = 18
|
|
491
|
+
container.layer.shadowOffset = CGSize(width: 0, height: 10)
|
|
492
|
+
|
|
493
|
+
let effectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
|
|
494
|
+
effectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
495
|
+
effectView.isUserInteractionEnabled = false
|
|
496
|
+
effectView.clipsToBounds = true
|
|
497
|
+
container.addSubview(effectView)
|
|
498
|
+
self.tabEffectView = effectView
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
let bar = UITabBar()
|
|
502
|
+
bar.isTranslucent = true
|
|
503
|
+
if !usesSystemLiquidGlass {
|
|
504
|
+
bar.backgroundColor = .clear
|
|
505
|
+
bar.backgroundImage = UIImage()
|
|
506
|
+
bar.shadowImage = UIImage()
|
|
507
|
+
bar.clipsToBounds = true
|
|
508
|
+
}
|
|
509
|
+
bar.delegate = self
|
|
510
|
+
bar.autoresizingMask = [.flexibleWidth, .flexibleTopMargin]
|
|
511
|
+
container.addSubview(bar)
|
|
512
|
+
bridge?.viewController?.view.addSubview(container)
|
|
513
|
+
self.tabContainer = container
|
|
514
|
+
self.tabBar = bar
|
|
515
|
+
return bar
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private func makeBarButtonItems(_ rawItems: [[String: Any]], placement: String) -> [UIBarButtonItem] {
|
|
519
|
+
return rawItems.map { rawItem in
|
|
520
|
+
let id = rawItem["id"] as? String ?? UUID().uuidString
|
|
521
|
+
let title = rawItem["title"] as? String
|
|
522
|
+
let image = image(from: rawItem["icon"] as? [String: Any])
|
|
523
|
+
let item = UIBarButtonItem(image: image, style: .plain, target: self, action: #selector(handleNavbarButton(_:)))
|
|
524
|
+
if image == nil {
|
|
525
|
+
item.title = title
|
|
526
|
+
}
|
|
527
|
+
item.isEnabled = rawItem["enabled"] as? Bool ?? true
|
|
528
|
+
item.accessibilityIdentifier = id
|
|
529
|
+
item.accessibilityLabel = title
|
|
530
|
+
configureGlassBarButtonItem(item, id: id)
|
|
531
|
+
navbarItemPlacement[id] = placement
|
|
532
|
+
navbarItemTitle[id] = title ?? ""
|
|
533
|
+
return item
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private func makeTabBarItems(
|
|
538
|
+
_ tabs: [[String: Any]],
|
|
539
|
+
selectedId: String?,
|
|
540
|
+
labelVisibilityMode: String,
|
|
541
|
+
icons: Bool
|
|
542
|
+
) -> ([UITabBarItem], Int?) {
|
|
543
|
+
tabIds = []
|
|
544
|
+
tabTitles = []
|
|
545
|
+
var selectedIndex: Int?
|
|
546
|
+
|
|
547
|
+
let items = tabs.enumerated().map { index, tab -> UITabBarItem in
|
|
548
|
+
let id = tab["id"] as? String ?? "tab-\(index)"
|
|
549
|
+
let title = tabTitle(
|
|
550
|
+
tab["title"] as? String,
|
|
551
|
+
id: id,
|
|
552
|
+
index: index,
|
|
553
|
+
selectedId: selectedId,
|
|
554
|
+
labelVisibilityMode: labelVisibilityMode
|
|
555
|
+
)
|
|
556
|
+
let image = icons ? self.image(from: tab["icon"] as? [String: Any]) : nil
|
|
557
|
+
let selectedImage = icons ? self.image(from: tab["selectedIcon"] as? [String: Any]) : nil
|
|
558
|
+
let item = UITabBarItem(title: title, image: image, selectedImage: selectedImage)
|
|
559
|
+
item.tag = index
|
|
560
|
+
item.isEnabled = tab["enabled"] as? Bool ?? true
|
|
561
|
+
if let badge = tab["badge"] {
|
|
562
|
+
item.badgeValue = String(describing: badge)
|
|
563
|
+
}
|
|
564
|
+
tabIds.append(id)
|
|
565
|
+
tabTitles.append(tab["title"] as? String ?? "")
|
|
566
|
+
if id == selectedId {
|
|
567
|
+
selectedIndex = index
|
|
568
|
+
}
|
|
569
|
+
return item
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return (items, selectedIndex)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private func tabTitle(
|
|
576
|
+
_ title: String?,
|
|
577
|
+
id: String,
|
|
578
|
+
index: Int,
|
|
579
|
+
selectedId: String?,
|
|
580
|
+
labelVisibilityMode: String
|
|
581
|
+
) -> String? {
|
|
582
|
+
let isSelected = id == selectedId || (selectedId == nil && index == 0)
|
|
583
|
+
switch labelVisibilityMode {
|
|
584
|
+
case "unlabeled":
|
|
585
|
+
return nil
|
|
586
|
+
case "selected":
|
|
587
|
+
return isSelected ? title : nil
|
|
588
|
+
case "auto":
|
|
589
|
+
let compact = bridge?.viewController?.traitCollection.horizontalSizeClass == .compact || UIDevice.current.userInterfaceIdiom == .phone
|
|
590
|
+
return compact && !isSelected ? nil : title
|
|
591
|
+
default:
|
|
592
|
+
return title
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private func image(from descriptor: [String: Any]?) -> UIImage? {
|
|
597
|
+
guard let descriptor = descriptor else {
|
|
598
|
+
return nil
|
|
599
|
+
}
|
|
600
|
+
let template = descriptor["template"] as? Bool ?? true
|
|
601
|
+
if let svg = svgMarkup(from: descriptor),
|
|
602
|
+
let image = SVGIconRenderer.render(svg: svg, size: iconSize(from: descriptor)) {
|
|
603
|
+
return template ? image.withRenderingMode(.alwaysTemplate) : image
|
|
604
|
+
}
|
|
605
|
+
if let ios = descriptor["ios"] as? [String: Any] {
|
|
606
|
+
if let symbol = ios["sfSymbol"] as? String, let image = UIImage(systemName: symbol) {
|
|
607
|
+
return template ? image.withRenderingMode(.alwaysTemplate) : image
|
|
608
|
+
}
|
|
609
|
+
if let imageName = ios["image"] as? String, let image = UIImage(named: imageName) {
|
|
610
|
+
return template ? image.withRenderingMode(.alwaysTemplate) : image
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
if let svg = descriptor["svg"] as? String,
|
|
614
|
+
let image = SVGIconRenderer.render(svg: svg, size: iconSize(from: descriptor)) {
|
|
615
|
+
return template ? image.withRenderingMode(.alwaysTemplate) : image
|
|
616
|
+
}
|
|
617
|
+
if let src = descriptor["src"] as? String {
|
|
618
|
+
if let svg = inlineSVG(from: src),
|
|
619
|
+
let image = SVGIconRenderer.render(svg: svg, size: iconSize(from: descriptor)) {
|
|
620
|
+
return template ? image.withRenderingMode(.alwaysTemplate) : image
|
|
621
|
+
}
|
|
622
|
+
if let image = UIImage(named: src) {
|
|
623
|
+
return template ? image.withRenderingMode(.alwaysTemplate) : image
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return nil
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private func svgMarkup(from descriptor: [String: Any]) -> String? {
|
|
630
|
+
if let ios = descriptor["ios"] as? [String: Any],
|
|
631
|
+
let svg = ios["svg"] as? String {
|
|
632
|
+
return svg
|
|
633
|
+
}
|
|
634
|
+
if let svg = descriptor["svg"] as? String {
|
|
635
|
+
return svg
|
|
636
|
+
}
|
|
637
|
+
if let src = descriptor["src"] as? String {
|
|
638
|
+
return inlineSVG(from: src)
|
|
639
|
+
}
|
|
640
|
+
return nil
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
private func inlineSVG(from value: String) -> String? {
|
|
644
|
+
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
645
|
+
if trimmed.hasPrefix("<svg") {
|
|
646
|
+
return trimmed
|
|
647
|
+
}
|
|
648
|
+
let prefix = "data:image/svg+xml"
|
|
649
|
+
guard trimmed.lowercased().hasPrefix(prefix),
|
|
650
|
+
let commaIndex = trimmed.firstIndex(of: ",") else {
|
|
651
|
+
return nil
|
|
652
|
+
}
|
|
653
|
+
let payload = String(trimmed[trimmed.index(after: commaIndex)...])
|
|
654
|
+
if trimmed[..<commaIndex].contains(";base64") {
|
|
655
|
+
guard let data = Data(base64Encoded: payload) else {
|
|
656
|
+
return nil
|
|
657
|
+
}
|
|
658
|
+
return String(data: data, encoding: .utf8)
|
|
659
|
+
}
|
|
660
|
+
return payload.removingPercentEncoding
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
private func iconSize(from descriptor: [String: Any]) -> CGSize {
|
|
664
|
+
let width = number(from: descriptor["width"]) ?? 24
|
|
665
|
+
let height = number(from: descriptor["height"]) ?? width
|
|
666
|
+
return CGSize(width: max(width, 1), height: max(height, 1))
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
private func number(from value: Any?) -> CGFloat? {
|
|
670
|
+
if let value = value as? NSNumber {
|
|
671
|
+
return CGFloat(truncating: value)
|
|
672
|
+
}
|
|
673
|
+
if let value = value as? String,
|
|
674
|
+
let number = Double(value) {
|
|
675
|
+
return CGFloat(number)
|
|
676
|
+
}
|
|
677
|
+
return nil
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
private func transitionRect(_ rawRect: [String: Any]?) -> CGRect? {
|
|
681
|
+
guard let rawRect = rawRect,
|
|
682
|
+
let width = number(from: rawRect["width"]),
|
|
683
|
+
let height = number(from: rawRect["height"]),
|
|
684
|
+
width > 0,
|
|
685
|
+
height > 0 else {
|
|
686
|
+
return nil
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return CGRect(
|
|
690
|
+
x: number(from: rawRect["x"]) ?? 0,
|
|
691
|
+
y: number(from: rawRect["y"]) ?? 0,
|
|
692
|
+
width: width,
|
|
693
|
+
height: height
|
|
694
|
+
)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
private func rootFrame(for viewportRect: CGRect, webView: UIView) -> CGRect {
|
|
698
|
+
return CGRect(
|
|
699
|
+
x: webView.frame.minX + viewportRect.minX,
|
|
700
|
+
y: webView.frame.minY + viewportRect.minY,
|
|
701
|
+
width: viewportRect.width,
|
|
702
|
+
height: viewportRect.height
|
|
703
|
+
)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
private func transitionSnapshotView(from webView: UIView, sourceRect: CGRect?) -> UIView {
|
|
707
|
+
guard let sourceRect = sourceRect else {
|
|
708
|
+
return webView.snapshotView(afterScreenUpdates: false) ?? UIView(frame: webView.frame)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
let cropRect = sourceRect.intersection(webView.bounds)
|
|
712
|
+
guard cropRect.width > 0, cropRect.height > 0 else {
|
|
713
|
+
return webView.snapshotView(afterScreenUpdates: false) ?? UIView(frame: webView.frame)
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
let renderer = UIGraphicsImageRenderer(bounds: webView.bounds)
|
|
717
|
+
let image = renderer.image { _ in
|
|
718
|
+
webView.drawHierarchy(in: webView.bounds, afterScreenUpdates: false)
|
|
719
|
+
}
|
|
720
|
+
let scale = image.scale
|
|
721
|
+
let scaledCropRect = CGRect(
|
|
722
|
+
x: cropRect.minX * scale,
|
|
723
|
+
y: cropRect.minY * scale,
|
|
724
|
+
width: cropRect.width * scale,
|
|
725
|
+
height: cropRect.height * scale
|
|
726
|
+
).integral
|
|
727
|
+
|
|
728
|
+
guard let croppedImage = image.cgImage?.cropping(to: scaledCropRect) else {
|
|
729
|
+
return webView.snapshotView(afterScreenUpdates: false) ?? UIView(frame: webView.frame)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
let imageView = UIImageView(image: UIImage(cgImage: croppedImage, scale: scale, orientation: image.imageOrientation))
|
|
733
|
+
imageView.contentMode = .scaleAspectFill
|
|
734
|
+
return imageView
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
private func applyNavBarAppearance(navBar: UINavigationBar, options call: CAPPluginCall) {
|
|
738
|
+
let appearance = UINavigationBarAppearance()
|
|
739
|
+
let transparent = call.getBool("transparent", false)
|
|
740
|
+
if usesSystemLiquidGlass {
|
|
741
|
+
appearance.configureWithTransparentBackground()
|
|
742
|
+
appearance.backgroundColor = .clear
|
|
743
|
+
appearance.backgroundEffect = nil
|
|
744
|
+
appearance.shadowColor = .clear
|
|
745
|
+
navBlurView?.isHidden = true
|
|
746
|
+
} else if transparent {
|
|
747
|
+
appearance.configureWithTransparentBackground()
|
|
748
|
+
appearance.backgroundColor = .clear
|
|
749
|
+
appearance.shadowColor = .clear
|
|
750
|
+
if let effect = blurEffect(from: call.getString("blurEffect"), fallback: .systemChromeMaterial) {
|
|
751
|
+
navBlurView?.effect = effect
|
|
752
|
+
navBlurView?.isHidden = false
|
|
753
|
+
} else {
|
|
754
|
+
navBlurView?.isHidden = true
|
|
755
|
+
}
|
|
756
|
+
} else {
|
|
757
|
+
appearance.configureWithDefaultBackground()
|
|
758
|
+
navBlurView?.isHidden = true
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if let colors = call.getObject("colors") {
|
|
762
|
+
if let color = colorValue(colors["tint"]) {
|
|
763
|
+
navBar.tintColor = color
|
|
764
|
+
}
|
|
765
|
+
if let color = colorValue(colors["foreground"]) {
|
|
766
|
+
appearance.titleTextAttributes = [.foregroundColor: color]
|
|
767
|
+
appearance.largeTitleTextAttributes = [.foregroundColor: color]
|
|
768
|
+
}
|
|
769
|
+
if let background = colors["background"] as? String,
|
|
770
|
+
let color = colorValue(background),
|
|
771
|
+
!usesSystemLiquidGlass,
|
|
772
|
+
!transparent {
|
|
773
|
+
appearance.backgroundColor = color
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
navBar.standardAppearance = appearance
|
|
778
|
+
navBar.scrollEdgeAppearance = appearance
|
|
779
|
+
navBar.compactAppearance = appearance
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
private func applyTabBarAppearance(tabBar: UITabBar, options call: CAPPluginCall) {
|
|
783
|
+
let appearance = UITabBarAppearance()
|
|
784
|
+
configureTabBarBackground(appearance, options: call)
|
|
785
|
+
applyTabBarColorOptions(appearance, tabBar: tabBar, options: call)
|
|
786
|
+
applyTabBarBadgeOptions(appearance, options: call)
|
|
787
|
+
|
|
788
|
+
tabBar.standardAppearance = appearance
|
|
789
|
+
if #available(iOS 15.0, *) {
|
|
790
|
+
tabBar.scrollEdgeAppearance = appearance
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
private func configureTabBarBackground(_ appearance: UITabBarAppearance, options call: CAPPluginCall) {
|
|
795
|
+
if usesSystemLiquidGlass {
|
|
796
|
+
appearance.configureWithDefaultBackground()
|
|
797
|
+
tabEffectView?.isHidden = true
|
|
798
|
+
} else {
|
|
799
|
+
appearance.configureWithDefaultBackground()
|
|
800
|
+
if let effect = blurEffect(from: call.getString("blurEffect"), fallback: nil) {
|
|
801
|
+
appearance.configureWithTransparentBackground()
|
|
802
|
+
appearance.backgroundColor = .clear
|
|
803
|
+
tabEffectView?.effect = effect
|
|
804
|
+
tabEffectView?.isHidden = false
|
|
805
|
+
} else {
|
|
806
|
+
tabEffectView?.isHidden = true
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
private func applyTabBarColorOptions(
|
|
812
|
+
_ appearance: UITabBarAppearance,
|
|
813
|
+
tabBar: UITabBar,
|
|
814
|
+
options call: CAPPluginCall
|
|
815
|
+
) {
|
|
816
|
+
if let colors = call.getObject("colors") {
|
|
817
|
+
if let color = colorValue(colors["tint"]) {
|
|
818
|
+
tabBar.tintColor = color
|
|
819
|
+
applyTabItemAppearances(appearance) { itemAppearance in
|
|
820
|
+
itemAppearance.selected.iconColor = color
|
|
821
|
+
itemAppearance.selected.titleTextAttributes = [.foregroundColor: color]
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
if let color = colorValue(colors["inactiveTint"]) {
|
|
825
|
+
tabBar.unselectedItemTintColor = color
|
|
826
|
+
applyTabItemAppearances(appearance) { itemAppearance in
|
|
827
|
+
itemAppearance.normal.iconColor = color
|
|
828
|
+
itemAppearance.normal.titleTextAttributes = [.foregroundColor: color]
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
if let color = colorValue(colors["badgeBackground"]) {
|
|
832
|
+
applyTabItemAppearances(appearance) { itemAppearance in
|
|
833
|
+
itemAppearance.normal.badgeBackgroundColor = color
|
|
834
|
+
itemAppearance.selected.badgeBackgroundColor = color
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
if let color = colorValue(colors["badgeText"]) {
|
|
838
|
+
applyTabItemAppearances(appearance) { itemAppearance in
|
|
839
|
+
itemAppearance.normal.badgeTextAttributes = [.foregroundColor: color]
|
|
840
|
+
itemAppearance.selected.badgeTextAttributes = [.foregroundColor: color]
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
if let background = colors["background"] as? String,
|
|
844
|
+
let color = colorValue(background),
|
|
845
|
+
!usesSystemLiquidGlass {
|
|
846
|
+
appearance.backgroundColor = color
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
private func applyTabBarBadgeOptions(_ appearance: UITabBarAppearance, options call: CAPPluginCall) {
|
|
852
|
+
if let color = colorValue(call.getString("badgeBackgroundColor")) {
|
|
853
|
+
applyTabItemAppearances(appearance) { itemAppearance in
|
|
854
|
+
itemAppearance.normal.badgeBackgroundColor = color
|
|
855
|
+
itemAppearance.selected.badgeBackgroundColor = color
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
if let color = colorValue(call.getString("badgeTextColor")) {
|
|
859
|
+
applyTabItemAppearances(appearance) { itemAppearance in
|
|
860
|
+
itemAppearance.normal.badgeTextAttributes = [.foregroundColor: color]
|
|
861
|
+
itemAppearance.selected.badgeTextAttributes = [.foregroundColor: color]
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
private func applyTabItemAppearances(
|
|
867
|
+
_ appearance: UITabBarAppearance,
|
|
868
|
+
update: (UITabBarItemAppearance) -> Void
|
|
869
|
+
) {
|
|
870
|
+
update(appearance.stackedLayoutAppearance)
|
|
871
|
+
update(appearance.inlineLayoutAppearance)
|
|
872
|
+
update(appearance.compactInlineLayoutAppearance)
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
private func layoutChrome() {
|
|
876
|
+
guard let rootView = bridge?.viewController?.view else {
|
|
877
|
+
return
|
|
878
|
+
}
|
|
879
|
+
rootView.layoutIfNeeded()
|
|
880
|
+
let safeInsets = rootView.safeAreaInsets
|
|
881
|
+
let width = rootView.bounds.width
|
|
882
|
+
let height = rootView.bounds.height
|
|
883
|
+
|
|
884
|
+
if let container = navContainer {
|
|
885
|
+
container.frame = CGRect(x: 0, y: 0, width: width, height: safeInsets.top + navbarHeight)
|
|
886
|
+
navBlurView?.frame = container.bounds
|
|
887
|
+
navBar?.frame = CGRect(x: 0, y: safeInsets.top, width: width, height: navbarHeight)
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
if let container = tabContainer {
|
|
891
|
+
if usesSystemLiquidGlass {
|
|
892
|
+
container.frame = CGRect(
|
|
893
|
+
x: 0,
|
|
894
|
+
y: height - safeInsets.bottom - tabbarHeight,
|
|
895
|
+
width: width,
|
|
896
|
+
height: tabbarHeight + safeInsets.bottom
|
|
897
|
+
)
|
|
898
|
+
container.layer.cornerRadius = 0
|
|
899
|
+
container.layer.shadowOpacity = 0
|
|
900
|
+
container.layer.shadowPath = nil
|
|
901
|
+
tabEffectView?.isHidden = true
|
|
902
|
+
tabBar?.frame = container.bounds
|
|
903
|
+
tabBar?.layer.cornerRadius = 0
|
|
904
|
+
} else {
|
|
905
|
+
let availableWidth = max(0, width - (floatingTabbarHorizontalMargin * 2))
|
|
906
|
+
let tabbarWidth = min(availableWidth, floatingTabbarMaxWidth)
|
|
907
|
+
let originX = (width - tabbarWidth) / 2
|
|
908
|
+
let originY = height - safeInsets.bottom - floatingTabbarBottomGap - tabbarHeight
|
|
909
|
+
container.frame = CGRect(x: originX, y: originY, width: tabbarWidth, height: tabbarHeight)
|
|
910
|
+
container.layer.cornerRadius = tabbarHeight / 2
|
|
911
|
+
container.layer.shadowPath = UIBezierPath(roundedRect: container.bounds, cornerRadius: tabbarHeight / 2).cgPath
|
|
912
|
+
tabEffectView?.frame = container.bounds
|
|
913
|
+
tabEffectView?.layer.cornerRadius = tabbarHeight / 2
|
|
914
|
+
tabBar?.frame = container.bounds
|
|
915
|
+
tabBar?.layer.cornerRadius = tabbarHeight / 2
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
bringChromeToFront()
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
private func bringChromeToFront() {
|
|
923
|
+
if let navContainer = navContainer {
|
|
924
|
+
bridge?.viewController?.view.bringSubviewToFront(navContainer)
|
|
925
|
+
}
|
|
926
|
+
if let tabContainer = tabContainer {
|
|
927
|
+
bridge?.viewController?.view.bringSubviewToFront(tabContainer)
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
private func colorValue(_ value: Any?) -> UIColor? {
|
|
932
|
+
guard let value = value as? String else {
|
|
933
|
+
return nil
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
switch value {
|
|
937
|
+
case "ios:label", "system:label":
|
|
938
|
+
return .label
|
|
939
|
+
case "ios:secondaryLabel", "system:secondaryLabel":
|
|
940
|
+
return .secondaryLabel
|
|
941
|
+
case "ios:systemBackground", "system:background":
|
|
942
|
+
return .systemBackground
|
|
943
|
+
case "ios:secondarySystemBackground", "system:secondaryBackground":
|
|
944
|
+
return .secondarySystemBackground
|
|
945
|
+
default:
|
|
946
|
+
return UIColor(hexString: value)
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
private func blurEffect(from value: String?, fallback: UIBlurEffect.Style?) -> UIBlurEffect? {
|
|
951
|
+
guard value != "none" else {
|
|
952
|
+
return nil
|
|
953
|
+
}
|
|
954
|
+
guard let style = blurStyle(from: value) ?? fallback else {
|
|
955
|
+
return nil
|
|
956
|
+
}
|
|
957
|
+
return UIBlurEffect(style: style)
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
private func blurStyle(from value: String?) -> UIBlurEffect.Style? {
|
|
961
|
+
guard let value = value else {
|
|
962
|
+
return nil
|
|
963
|
+
}
|
|
964
|
+
return [
|
|
965
|
+
"extraLight": .extraLight,
|
|
966
|
+
"light": .light,
|
|
967
|
+
"dark": .dark,
|
|
968
|
+
"regular": .regular,
|
|
969
|
+
"prominent": .prominent,
|
|
970
|
+
"systemUltraThinMaterial": .systemUltraThinMaterial,
|
|
971
|
+
"systemThinMaterial": .systemThinMaterial,
|
|
972
|
+
"systemMaterial": .systemMaterial,
|
|
973
|
+
"systemThickMaterial": .systemThickMaterial,
|
|
974
|
+
"systemUltraThinMaterialLight": .systemUltraThinMaterialLight,
|
|
975
|
+
"systemThinMaterialLight": .systemThinMaterialLight,
|
|
976
|
+
"systemMaterialLight": .systemMaterialLight,
|
|
977
|
+
"systemThickMaterialLight": .systemThickMaterialLight,
|
|
978
|
+
"systemUltraThinMaterialDark": .systemUltraThinMaterialDark,
|
|
979
|
+
"systemThinMaterialDark": .systemThinMaterialDark,
|
|
980
|
+
"systemMaterialDark": .systemMaterialDark,
|
|
981
|
+
"systemThickMaterialDark": .systemThickMaterialDark,
|
|
982
|
+
"systemDefault": .systemChromeMaterial,
|
|
983
|
+
"systemChromeMaterial": .systemChromeMaterial,
|
|
984
|
+
"systemChromeMaterialLight": .systemChromeMaterialLight,
|
|
985
|
+
"systemChromeMaterialDark": .systemChromeMaterialDark
|
|
986
|
+
][value]
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
private func configureGlassBarButtonItem(_ item: UIBarButtonItem, id: String) {
|
|
990
|
+
guard #available(iOS 26.0, *) else {
|
|
991
|
+
return
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Keep older SDK builds working while adopting the native iOS 26 bar
|
|
995
|
+
// button Liquid Glass grouping APIs when the runtime exposes them.
|
|
996
|
+
let object = item as NSObject
|
|
997
|
+
if object.responds(to: NSSelectorFromString("setIdentifier:")) {
|
|
998
|
+
object.setValue(id, forKey: "identifier")
|
|
999
|
+
}
|
|
1000
|
+
if object.responds(to: NSSelectorFromString("setSharesBackground:")) {
|
|
1001
|
+
object.setValue(true, forKey: "sharesBackground")
|
|
1002
|
+
}
|
|
1003
|
+
if object.responds(to: NSSelectorFromString("setHidesSharedBackground:")) {
|
|
1004
|
+
object.setValue(false, forKey: "hidesSharedBackground")
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
private func currentInsets() -> [String: Any] {
|
|
1009
|
+
let safeInsets = bridge?.viewController?.view.safeAreaInsets ?? .zero
|
|
1010
|
+
let navHeight = navbarVisible ? navbarHeight + safeInsets.top : 0
|
|
1011
|
+
let tabbarGap = usesSystemLiquidGlass ? 0 : floatingTabbarBottomGap
|
|
1012
|
+
let tabHeight = tabbarVisible ? tabbarHeight + safeInsets.bottom + tabbarGap : 0
|
|
1013
|
+
return [
|
|
1014
|
+
"top": navHeight,
|
|
1015
|
+
"right": safeInsets.right,
|
|
1016
|
+
"bottom": tabHeight,
|
|
1017
|
+
"left": safeInsets.left,
|
|
1018
|
+
"navbarHeight": navHeight,
|
|
1019
|
+
"tabbarHeight": tabHeight
|
|
1020
|
+
]
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
private func insetsResult() -> [String: Any] {
|
|
1024
|
+
return ["insets": currentInsets()]
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
private func updateInsetsAndNotify() {
|
|
1028
|
+
layoutChrome()
|
|
1029
|
+
let insets = currentInsets()
|
|
1030
|
+
notifyListeners("safeAreaChanged", data: ["insets": insets])
|
|
1031
|
+
guard contentInsetMode != "none" else {
|
|
1032
|
+
return
|
|
1033
|
+
}
|
|
1034
|
+
let top = insets["top"] as? CGFloat ?? 0
|
|
1035
|
+
let right = insets["right"] as? CGFloat ?? 0
|
|
1036
|
+
let bottom = insets["bottom"] as? CGFloat ?? 0
|
|
1037
|
+
let left = insets["left"] as? CGFloat ?? 0
|
|
1038
|
+
let navbar = insets["navbarHeight"] as? CGFloat ?? 0
|
|
1039
|
+
let tabbar = insets["tabbarHeight"] as? CGFloat ?? 0
|
|
1040
|
+
let detailJson = jsonString(["insets": insets])
|
|
1041
|
+
let script = """
|
|
1042
|
+
(() => {
|
|
1043
|
+
const root = document.documentElement;
|
|
1044
|
+
root.style.setProperty('--cap-native-navigation-top', '\(top)px');
|
|
1045
|
+
root.style.setProperty('--cap-native-navigation-right', '\(right)px');
|
|
1046
|
+
root.style.setProperty('--cap-native-navigation-bottom', '\(bottom)px');
|
|
1047
|
+
root.style.setProperty('--cap-native-navigation-left', '\(left)px');
|
|
1048
|
+
root.style.setProperty('--cap-native-navbar-height', '\(navbar)px');
|
|
1049
|
+
root.style.setProperty('--cap-native-tabbar-height', '\(tabbar)px');
|
|
1050
|
+
window.dispatchEvent(new CustomEvent('capNativeNavigation:safeAreaChanged', { detail: \(detailJson) }));
|
|
1051
|
+
})();
|
|
1052
|
+
"""
|
|
1053
|
+
bridge?.webView?.evaluateJavaScript(script)
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
private func jsonString(_ value: Any) -> String {
|
|
1057
|
+
guard JSONSerialization.isValidJSONObject(value),
|
|
1058
|
+
let data = try? JSONSerialization.data(withJSONObject: value),
|
|
1059
|
+
let json = String(data: data, encoding: .utf8) else {
|
|
1060
|
+
return "{}"
|
|
1061
|
+
}
|
|
1062
|
+
return json
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
// swiftlint:enable type_body_length
|
|
1066
|
+
|
|
1067
|
+
// swiftlint:disable cyclomatic_complexity function_body_length identifier_name
|
|
1068
|
+
private struct SVGRenderStyle {
|
|
1069
|
+
var fill = true
|
|
1070
|
+
var stroke = false
|
|
1071
|
+
var strokeWidth: CGFloat = 2
|
|
1072
|
+
var lineCap: CGLineCap = .butt
|
|
1073
|
+
var lineJoin: CGLineJoin = .miter
|
|
1074
|
+
var opacity: CGFloat = 1
|
|
1075
|
+
|
|
1076
|
+
mutating func apply(_ attributes: [String: String]) {
|
|
1077
|
+
if let fillValue = attributes["fill"] {
|
|
1078
|
+
fill = fillValue.lowercased() != "none"
|
|
1079
|
+
}
|
|
1080
|
+
if let strokeValue = attributes["stroke"] {
|
|
1081
|
+
stroke = strokeValue.lowercased() != "none"
|
|
1082
|
+
}
|
|
1083
|
+
if let width = SVGIconRenderer.length(attributes["stroke-width"]) {
|
|
1084
|
+
strokeWidth = width
|
|
1085
|
+
}
|
|
1086
|
+
if let opacityValue = SVGIconRenderer.length(attributes["opacity"]) {
|
|
1087
|
+
opacity = max(0, min(opacityValue, 1))
|
|
1088
|
+
}
|
|
1089
|
+
if let cap = attributes["stroke-linecap"]?.lowercased() {
|
|
1090
|
+
switch cap {
|
|
1091
|
+
case "round":
|
|
1092
|
+
lineCap = .round
|
|
1093
|
+
case "square":
|
|
1094
|
+
lineCap = .square
|
|
1095
|
+
default:
|
|
1096
|
+
lineCap = .butt
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
if let join = attributes["stroke-linejoin"]?.lowercased() {
|
|
1100
|
+
switch join {
|
|
1101
|
+
case "round":
|
|
1102
|
+
lineJoin = .round
|
|
1103
|
+
case "bevel":
|
|
1104
|
+
lineJoin = .bevel
|
|
1105
|
+
default:
|
|
1106
|
+
lineJoin = .miter
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
private final class NativeNavigationChromeContainer: UIView {
|
|
1113
|
+
var hitSlop = UIEdgeInsets.zero
|
|
1114
|
+
|
|
1115
|
+
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
|
1116
|
+
let expandedBounds = bounds.inset(by: UIEdgeInsets(
|
|
1117
|
+
top: -hitSlop.top,
|
|
1118
|
+
left: -hitSlop.left,
|
|
1119
|
+
bottom: -hitSlop.bottom,
|
|
1120
|
+
right: -hitSlop.right
|
|
1121
|
+
))
|
|
1122
|
+
return expandedBounds.contains(point)
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
private final class NativeNavigationBar: UINavigationBar {
|
|
1127
|
+
var hitSlop = UIEdgeInsets.zero
|
|
1128
|
+
|
|
1129
|
+
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
|
1130
|
+
let expandedBounds = bounds.inset(by: UIEdgeInsets(
|
|
1131
|
+
top: -hitSlop.top,
|
|
1132
|
+
left: -hitSlop.left,
|
|
1133
|
+
bottom: -hitSlop.bottom,
|
|
1134
|
+
right: -hitSlop.right
|
|
1135
|
+
))
|
|
1136
|
+
return expandedBounds.contains(point)
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
private final class SVGIconRenderer: NSObject, XMLParserDelegate {
|
|
1141
|
+
private let context: CGContext
|
|
1142
|
+
private var styleStack = [SVGRenderStyle()]
|
|
1143
|
+
|
|
1144
|
+
init(context: CGContext) {
|
|
1145
|
+
self.context = context
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
static func render(svg: String, size: CGSize) -> UIImage? {
|
|
1149
|
+
guard let data = svg.data(using: .utf8) else {
|
|
1150
|
+
return nil
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
let viewBox = viewBox(in: svg) ?? CGRect(origin: .zero, size: size)
|
|
1154
|
+
let renderer = UIGraphicsImageRenderer(size: size)
|
|
1155
|
+
return renderer.image { rendererContext in
|
|
1156
|
+
let context = rendererContext.cgContext
|
|
1157
|
+
context.saveGState()
|
|
1158
|
+
context.scaleBy(x: size.width / max(viewBox.width, 1), y: size.height / max(viewBox.height, 1))
|
|
1159
|
+
context.translateBy(x: -viewBox.minX, y: -viewBox.minY)
|
|
1160
|
+
|
|
1161
|
+
let parser = XMLParser(data: data)
|
|
1162
|
+
let delegate = SVGIconRenderer(context: context)
|
|
1163
|
+
parser.delegate = delegate
|
|
1164
|
+
parser.parse()
|
|
1165
|
+
context.restoreGState()
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
func parser(
|
|
1170
|
+
_ parser: XMLParser,
|
|
1171
|
+
didStartElement elementName: String,
|
|
1172
|
+
namespaceURI: String?,
|
|
1173
|
+
qualifiedName qName: String?,
|
|
1174
|
+
attributes attributeDict: [String: String]
|
|
1175
|
+
) {
|
|
1176
|
+
var style = styleStack.last ?? SVGRenderStyle()
|
|
1177
|
+
style.apply(attributeDict)
|
|
1178
|
+
styleStack.append(style)
|
|
1179
|
+
|
|
1180
|
+
switch elementName.lowercased() {
|
|
1181
|
+
case "path":
|
|
1182
|
+
guard let pathData = attributeDict["d"] else {
|
|
1183
|
+
return
|
|
1184
|
+
}
|
|
1185
|
+
draw(SVGPathParser(pathData).parse(), style: style)
|
|
1186
|
+
case "line":
|
|
1187
|
+
let path = UIBezierPath()
|
|
1188
|
+
path.move(to: CGPoint(x: Self.length(attributeDict["x1"]) ?? 0, y: Self.length(attributeDict["y1"]) ?? 0))
|
|
1189
|
+
path.addLine(to: CGPoint(x: Self.length(attributeDict["x2"]) ?? 0, y: Self.length(attributeDict["y2"]) ?? 0))
|
|
1190
|
+
draw(path, style: style)
|
|
1191
|
+
case "polyline", "polygon":
|
|
1192
|
+
let points = Self.points(attributeDict["points"])
|
|
1193
|
+
guard let first = points.first else {
|
|
1194
|
+
return
|
|
1195
|
+
}
|
|
1196
|
+
let path = UIBezierPath()
|
|
1197
|
+
path.move(to: first)
|
|
1198
|
+
for point in points.dropFirst() {
|
|
1199
|
+
path.addLine(to: point)
|
|
1200
|
+
}
|
|
1201
|
+
if elementName.lowercased() == "polygon" {
|
|
1202
|
+
path.close()
|
|
1203
|
+
}
|
|
1204
|
+
draw(path, style: style)
|
|
1205
|
+
case "circle":
|
|
1206
|
+
let cx = Self.length(attributeDict["cx"]) ?? 0
|
|
1207
|
+
let cy = Self.length(attributeDict["cy"]) ?? 0
|
|
1208
|
+
let radius = Self.length(attributeDict["r"]) ?? 0
|
|
1209
|
+
draw(UIBezierPath(ovalIn: CGRect(x: cx - radius, y: cy - radius, width: radius * 2, height: radius * 2)), style: style)
|
|
1210
|
+
case "rect":
|
|
1211
|
+
let rect = CGRect(
|
|
1212
|
+
x: Self.length(attributeDict["x"]) ?? 0,
|
|
1213
|
+
y: Self.length(attributeDict["y"]) ?? 0,
|
|
1214
|
+
width: Self.length(attributeDict["width"]) ?? 0,
|
|
1215
|
+
height: Self.length(attributeDict["height"]) ?? 0
|
|
1216
|
+
)
|
|
1217
|
+
let radius = Self.length(attributeDict["rx"]) ?? Self.length(attributeDict["ry"]) ?? 0
|
|
1218
|
+
let path = radius > 0 ? UIBezierPath(roundedRect: rect, cornerRadius: radius) : UIBezierPath(rect: rect)
|
|
1219
|
+
draw(path, style: style)
|
|
1220
|
+
default:
|
|
1221
|
+
break
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
func parser(
|
|
1226
|
+
_ parser: XMLParser,
|
|
1227
|
+
didEndElement elementName: String,
|
|
1228
|
+
namespaceURI: String?,
|
|
1229
|
+
qualifiedName qName: String?
|
|
1230
|
+
) {
|
|
1231
|
+
if styleStack.count > 1 {
|
|
1232
|
+
styleStack.removeLast()
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
private func draw(_ path: UIBezierPath, style: SVGRenderStyle) {
|
|
1237
|
+
context.saveGState()
|
|
1238
|
+
path.lineWidth = style.strokeWidth
|
|
1239
|
+
path.lineCapStyle = style.lineCap
|
|
1240
|
+
path.lineJoinStyle = style.lineJoin
|
|
1241
|
+
UIColor.black.withAlphaComponent(style.opacity).setFill()
|
|
1242
|
+
UIColor.black.withAlphaComponent(style.opacity).setStroke()
|
|
1243
|
+
if style.fill {
|
|
1244
|
+
path.fill()
|
|
1245
|
+
}
|
|
1246
|
+
if style.stroke {
|
|
1247
|
+
path.stroke()
|
|
1248
|
+
}
|
|
1249
|
+
context.restoreGState()
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
static func length(_ value: String?) -> CGFloat? {
|
|
1253
|
+
guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
1254
|
+
!value.isEmpty else {
|
|
1255
|
+
return nil
|
|
1256
|
+
}
|
|
1257
|
+
let allowed = CharacterSet(charactersIn: "-+.0123456789eE")
|
|
1258
|
+
let prefix = String(value.unicodeScalars.prefix { allowed.contains($0) })
|
|
1259
|
+
guard let double = Double(prefix) else {
|
|
1260
|
+
return nil
|
|
1261
|
+
}
|
|
1262
|
+
return CGFloat(double)
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
static func points(_ value: String?) -> [CGPoint] {
|
|
1266
|
+
let numbers = numbers(in: value ?? "")
|
|
1267
|
+
var points: [CGPoint] = []
|
|
1268
|
+
var index = 0
|
|
1269
|
+
while index + 1 < numbers.count {
|
|
1270
|
+
points.append(CGPoint(x: numbers[index], y: numbers[index + 1]))
|
|
1271
|
+
index += 2
|
|
1272
|
+
}
|
|
1273
|
+
return points
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
private static func viewBox(in svg: String) -> CGRect? {
|
|
1277
|
+
if let rawViewBox = attribute("viewBox", in: svg) {
|
|
1278
|
+
let values = numbers(in: rawViewBox)
|
|
1279
|
+
if values.count >= 4 {
|
|
1280
|
+
return CGRect(x: values[0], y: values[1], width: values[2], height: values[3])
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
guard let width = length(attribute("width", in: svg)),
|
|
1284
|
+
let height = length(attribute("height", in: svg)) else {
|
|
1285
|
+
return nil
|
|
1286
|
+
}
|
|
1287
|
+
return CGRect(x: 0, y: 0, width: width, height: height)
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
private static func attribute(_ name: String, in svg: String) -> String? {
|
|
1291
|
+
let pattern = "\(name)\\s*=\\s*[\"']([^\"']+)[\"']"
|
|
1292
|
+
guard let expression = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
|
|
1293
|
+
return nil
|
|
1294
|
+
}
|
|
1295
|
+
let range = NSRange(svg.startIndex..<svg.endIndex, in: svg)
|
|
1296
|
+
guard let match = expression.firstMatch(in: svg, range: range),
|
|
1297
|
+
let valueRange = Range(match.range(at: 1), in: svg) else {
|
|
1298
|
+
return nil
|
|
1299
|
+
}
|
|
1300
|
+
return String(svg[valueRange])
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
private static func numbers(in value: String) -> [CGFloat] {
|
|
1304
|
+
let pattern = "[-+]?(?:\\d*\\.\\d+|\\d+\\.?)(?:[eE][-+]?\\d+)?"
|
|
1305
|
+
guard let expression = try? NSRegularExpression(pattern: pattern) else {
|
|
1306
|
+
return []
|
|
1307
|
+
}
|
|
1308
|
+
let range = NSRange(value.startIndex..<value.endIndex, in: value)
|
|
1309
|
+
var values: [CGFloat] = []
|
|
1310
|
+
for match in expression.matches(in: value, range: range) {
|
|
1311
|
+
if let valueRange = Range(match.range, in: value),
|
|
1312
|
+
let double = Double(value[valueRange]) {
|
|
1313
|
+
values.append(CGFloat(double))
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
return values
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
private final class SVGPathParser {
|
|
1321
|
+
private let tokens: [String]
|
|
1322
|
+
private var index = 0
|
|
1323
|
+
private var command: Character?
|
|
1324
|
+
private var current = CGPoint.zero
|
|
1325
|
+
private var subpathStart = CGPoint.zero
|
|
1326
|
+
private var lastCubicControl: CGPoint?
|
|
1327
|
+
private var lastQuadControl: CGPoint?
|
|
1328
|
+
|
|
1329
|
+
init(_ data: String) {
|
|
1330
|
+
tokens = Self.tokenize(data)
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
func parse() -> UIBezierPath {
|
|
1334
|
+
let path = UIBezierPath()
|
|
1335
|
+
while index < tokens.count {
|
|
1336
|
+
if let nextCommand = commandToken() {
|
|
1337
|
+
command = nextCommand
|
|
1338
|
+
index += 1
|
|
1339
|
+
}
|
|
1340
|
+
guard let command = command else {
|
|
1341
|
+
break
|
|
1342
|
+
}
|
|
1343
|
+
consume(command, into: path)
|
|
1344
|
+
}
|
|
1345
|
+
return path
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
private func consume(_ command: Character, into path: UIBezierPath) {
|
|
1349
|
+
let relative = command.isLowercase
|
|
1350
|
+
switch command.uppercased() {
|
|
1351
|
+
case "M":
|
|
1352
|
+
guard let firstPoint = point(relative: relative) else {
|
|
1353
|
+
return
|
|
1354
|
+
}
|
|
1355
|
+
path.move(to: firstPoint)
|
|
1356
|
+
current = firstPoint
|
|
1357
|
+
subpathStart = firstPoint
|
|
1358
|
+
while let nextPoint = point(relative: relative) {
|
|
1359
|
+
path.addLine(to: nextPoint)
|
|
1360
|
+
current = nextPoint
|
|
1361
|
+
}
|
|
1362
|
+
resetControls()
|
|
1363
|
+
case "L":
|
|
1364
|
+
while let nextPoint = point(relative: relative) {
|
|
1365
|
+
path.addLine(to: nextPoint)
|
|
1366
|
+
current = nextPoint
|
|
1367
|
+
}
|
|
1368
|
+
resetControls()
|
|
1369
|
+
case "H":
|
|
1370
|
+
while let value = number() {
|
|
1371
|
+
current = CGPoint(x: relative ? current.x + value : value, y: current.y)
|
|
1372
|
+
path.addLine(to: current)
|
|
1373
|
+
}
|
|
1374
|
+
resetControls()
|
|
1375
|
+
case "V":
|
|
1376
|
+
while let value = number() {
|
|
1377
|
+
current = CGPoint(x: current.x, y: relative ? current.y + value : value)
|
|
1378
|
+
path.addLine(to: current)
|
|
1379
|
+
}
|
|
1380
|
+
resetControls()
|
|
1381
|
+
case "C":
|
|
1382
|
+
while let control1 = point(relative: relative),
|
|
1383
|
+
let control2 = point(relative: relative),
|
|
1384
|
+
let end = point(relative: relative) {
|
|
1385
|
+
path.addCurve(to: end, controlPoint1: control1, controlPoint2: control2)
|
|
1386
|
+
current = end
|
|
1387
|
+
lastCubicControl = control2
|
|
1388
|
+
lastQuadControl = nil
|
|
1389
|
+
}
|
|
1390
|
+
case "S":
|
|
1391
|
+
while let control2 = point(relative: relative),
|
|
1392
|
+
let end = point(relative: relative) {
|
|
1393
|
+
let control1 = reflected(lastCubicControl)
|
|
1394
|
+
path.addCurve(to: end, controlPoint1: control1, controlPoint2: control2)
|
|
1395
|
+
current = end
|
|
1396
|
+
lastCubicControl = control2
|
|
1397
|
+
lastQuadControl = nil
|
|
1398
|
+
}
|
|
1399
|
+
case "Q":
|
|
1400
|
+
while let control = point(relative: relative),
|
|
1401
|
+
let end = point(relative: relative) {
|
|
1402
|
+
path.addQuadCurve(to: end, controlPoint: control)
|
|
1403
|
+
current = end
|
|
1404
|
+
lastQuadControl = control
|
|
1405
|
+
lastCubicControl = nil
|
|
1406
|
+
}
|
|
1407
|
+
case "T":
|
|
1408
|
+
while let end = point(relative: relative) {
|
|
1409
|
+
let control = reflected(lastQuadControl)
|
|
1410
|
+
path.addQuadCurve(to: end, controlPoint: control)
|
|
1411
|
+
current = end
|
|
1412
|
+
lastQuadControl = control
|
|
1413
|
+
lastCubicControl = nil
|
|
1414
|
+
}
|
|
1415
|
+
case "A":
|
|
1416
|
+
while let end = arcEndpoint(relative: relative) {
|
|
1417
|
+
path.addLine(to: end)
|
|
1418
|
+
current = end
|
|
1419
|
+
}
|
|
1420
|
+
resetControls()
|
|
1421
|
+
case "Z":
|
|
1422
|
+
path.close()
|
|
1423
|
+
current = subpathStart
|
|
1424
|
+
resetControls()
|
|
1425
|
+
default:
|
|
1426
|
+
index += 1
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
private func point(relative: Bool) -> CGPoint? {
|
|
1431
|
+
guard let x = number(),
|
|
1432
|
+
let y = number() else {
|
|
1433
|
+
return nil
|
|
1434
|
+
}
|
|
1435
|
+
return relative ? CGPoint(x: current.x + x, y: current.y + y) : CGPoint(x: x, y: y)
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
private func arcEndpoint(relative: Bool) -> CGPoint? {
|
|
1439
|
+
guard number() != nil,
|
|
1440
|
+
number() != nil,
|
|
1441
|
+
number() != nil,
|
|
1442
|
+
number() != nil,
|
|
1443
|
+
number() != nil,
|
|
1444
|
+
let x = number(),
|
|
1445
|
+
let y = number() else {
|
|
1446
|
+
return nil
|
|
1447
|
+
}
|
|
1448
|
+
return relative ? CGPoint(x: current.x + x, y: current.y + y) : CGPoint(x: x, y: y)
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
private func number() -> CGFloat? {
|
|
1452
|
+
guard index < tokens.count,
|
|
1453
|
+
commandToken() == nil,
|
|
1454
|
+
let value = Double(tokens[index]) else {
|
|
1455
|
+
return nil
|
|
1456
|
+
}
|
|
1457
|
+
index += 1
|
|
1458
|
+
return CGFloat(value)
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
private func commandToken() -> Character? {
|
|
1462
|
+
guard index < tokens.count,
|
|
1463
|
+
tokens[index].count == 1,
|
|
1464
|
+
let character = tokens[index].first,
|
|
1465
|
+
character.isLetter else {
|
|
1466
|
+
return nil
|
|
1467
|
+
}
|
|
1468
|
+
return character
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
private func reflected(_ point: CGPoint?) -> CGPoint {
|
|
1472
|
+
guard let point = point else {
|
|
1473
|
+
return current
|
|
1474
|
+
}
|
|
1475
|
+
return CGPoint(x: current.x * 2 - point.x, y: current.y * 2 - point.y)
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
private func resetControls() {
|
|
1479
|
+
lastCubicControl = nil
|
|
1480
|
+
lastQuadControl = nil
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
private static func tokenize(_ data: String) -> [String] {
|
|
1484
|
+
var tokens: [String] = []
|
|
1485
|
+
var index = data.startIndex
|
|
1486
|
+
while index < data.endIndex {
|
|
1487
|
+
let character = data[index]
|
|
1488
|
+
if character.isWhitespace || character == "," {
|
|
1489
|
+
index = data.index(after: index)
|
|
1490
|
+
continue
|
|
1491
|
+
}
|
|
1492
|
+
if character.isLetter {
|
|
1493
|
+
tokens.append(String(character))
|
|
1494
|
+
index = data.index(after: index)
|
|
1495
|
+
continue
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
let start = index
|
|
1499
|
+
var end = index
|
|
1500
|
+
var hasDigits = false
|
|
1501
|
+
|
|
1502
|
+
if data[end] == "-" || data[end] == "+" {
|
|
1503
|
+
end = data.index(after: end)
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
while end < data.endIndex, data[end].isNumber {
|
|
1507
|
+
hasDigits = true
|
|
1508
|
+
end = data.index(after: end)
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
if end < data.endIndex, data[end] == "." {
|
|
1512
|
+
end = data.index(after: end)
|
|
1513
|
+
while end < data.endIndex, data[end].isNumber {
|
|
1514
|
+
hasDigits = true
|
|
1515
|
+
end = data.index(after: end)
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
if hasDigits, end < data.endIndex, data[end] == "e" || data[end] == "E" {
|
|
1520
|
+
let exponentStart = end
|
|
1521
|
+
var exponentEnd = data.index(after: end)
|
|
1522
|
+
if exponentEnd < data.endIndex, data[exponentEnd] == "-" || data[exponentEnd] == "+" {
|
|
1523
|
+
exponentEnd = data.index(after: exponentEnd)
|
|
1524
|
+
}
|
|
1525
|
+
var hasExponentDigits = false
|
|
1526
|
+
while exponentEnd < data.endIndex, data[exponentEnd].isNumber {
|
|
1527
|
+
hasExponentDigits = true
|
|
1528
|
+
exponentEnd = data.index(after: exponentEnd)
|
|
1529
|
+
}
|
|
1530
|
+
if hasExponentDigits {
|
|
1531
|
+
end = exponentEnd
|
|
1532
|
+
} else {
|
|
1533
|
+
end = exponentStart
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
if hasDigits, end > start {
|
|
1538
|
+
tokens.append(String(data[start..<end]))
|
|
1539
|
+
index = end
|
|
1540
|
+
} else {
|
|
1541
|
+
index = data.index(after: index)
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
return tokens
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
// swiftlint:enable cyclomatic_complexity function_body_length identifier_name
|
|
1548
|
+
|
|
1549
|
+
private extension UIColor {
|
|
1550
|
+
convenience init?(hexString: String) {
|
|
1551
|
+
var value = hexString.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1552
|
+
if value.hasPrefix("#") {
|
|
1553
|
+
value.removeFirst()
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
var hex: UInt64 = 0
|
|
1557
|
+
guard Scanner(string: value).scanHexInt64(&hex) else {
|
|
1558
|
+
return nil
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
switch value.count {
|
|
1562
|
+
case 6:
|
|
1563
|
+
self.init(
|
|
1564
|
+
red: CGFloat((hex & 0xFF0000) >> 16) / 255,
|
|
1565
|
+
green: CGFloat((hex & 0x00FF00) >> 8) / 255,
|
|
1566
|
+
blue: CGFloat(hex & 0x0000FF) / 255,
|
|
1567
|
+
alpha: 1
|
|
1568
|
+
)
|
|
1569
|
+
case 8:
|
|
1570
|
+
self.init(
|
|
1571
|
+
red: CGFloat((hex & 0x00FF0000) >> 16) / 255,
|
|
1572
|
+
green: CGFloat((hex & 0x0000FF00) >> 8) / 255,
|
|
1573
|
+
blue: CGFloat(hex & 0x000000FF) / 255,
|
|
1574
|
+
alpha: CGFloat((hex & 0xFF000000) >> 24) / 255
|
|
1575
|
+
)
|
|
1576
|
+
default:
|
|
1577
|
+
return nil
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
}
|