@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.
Files changed (36) hide show
  1. package/CapgoNativeNavigation.podspec +17 -0
  2. package/LICENSE +373 -0
  3. package/Package.swift +28 -0
  4. package/README.md +858 -0
  5. package/android/build.gradle +61 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/java/app/capgo/nativenavigation/NativeNavigation.java +8 -0
  8. package/android/src/main/java/app/capgo/nativenavigation/NativeNavigationPlugin.java +1322 -0
  9. package/android/src/main/res/.gitkeep +0 -0
  10. package/dist/docs.json +1369 -0
  11. package/dist/esm/components.d.ts +1 -0
  12. package/dist/esm/components.js +159 -0
  13. package/dist/esm/components.js.map +1 -0
  14. package/dist/esm/definitions.d.ts +470 -0
  15. package/dist/esm/definitions.js +2 -0
  16. package/dist/esm/definitions.js.map +1 -0
  17. package/dist/esm/index.d.ts +19 -0
  18. package/dist/esm/index.js +40 -0
  19. package/dist/esm/index.js.map +1 -0
  20. package/dist/esm/plugin.d.ts +2 -0
  21. package/dist/esm/plugin.js +2 -0
  22. package/dist/esm/plugin.js.map +1 -0
  23. package/dist/esm/web.d.ts +17 -0
  24. package/dist/esm/web.js +90 -0
  25. package/dist/esm/web.js.map +1 -0
  26. package/dist/plugin.cjs.js +310 -0
  27. package/dist/plugin.cjs.js.map +1 -0
  28. package/dist/plugin.js +313 -0
  29. package/dist/plugin.js.map +1 -0
  30. package/docs/demo-navigation.webp +0 -0
  31. package/docs/demo-options.webp +0 -0
  32. package/docs/demo-svg-icons.webp +0 -0
  33. package/ios/Sources/NativeNavigationPlugin/NativeNavigation.swift +7 -0
  34. package/ios/Sources/NativeNavigationPlugin/NativeNavigationPlugin.swift +1580 -0
  35. package/ios/Tests/NativeNavigationPluginTests/NativeNavigationTests.swift +19 -0
  36. 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
+ }