@webview-native-ui/engine 1.0.0 → 1.0.2-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/README.md +48 -35
  2. package/WebViewNativeUIControls.podspec +17 -0
  3. package/dist/controls/activation.d.ts +15 -0
  4. package/dist/controls/activation.js +370 -0
  5. package/dist/controls/components/index.d.ts +16 -0
  6. package/dist/controls/components/index.js +38 -0
  7. package/dist/controls/components/ion-back-button.d.ts +4 -0
  8. package/dist/controls/components/ion-back-button.js +16 -0
  9. package/dist/controls/components/ion-button.d.ts +4 -0
  10. package/dist/controls/components/ion-button.js +26 -0
  11. package/dist/controls/components/ion-buttons.d.ts +5 -0
  12. package/dist/controls/components/ion-buttons.js +34 -0
  13. package/dist/controls/components/ion-fab.d.ts +4 -0
  14. package/dist/controls/components/ion-fab.js +164 -0
  15. package/dist/controls/components/ion-menu-button.d.ts +7 -0
  16. package/dist/controls/components/ion-menu-button.js +41 -0
  17. package/dist/controls/components/ion-segment.d.ts +5 -0
  18. package/dist/controls/components/ion-segment.js +24 -0
  19. package/dist/controls/components/ion-tab-bar.d.ts +5 -0
  20. package/dist/controls/components/ion-tab-bar.js +49 -0
  21. package/dist/controls/components/searchable-tabs.d.ts +20 -0
  22. package/dist/controls/components/searchable-tabs.js +525 -0
  23. package/dist/controls/definitions.d.ts +360 -0
  24. package/dist/controls/definitions.js +1 -0
  25. package/dist/controls/extensions.d.ts +44 -0
  26. package/dist/controls/extensions.js +164 -0
  27. package/dist/controls/foldable/fold-state.d.ts +4 -0
  28. package/dist/controls/foldable/fold-state.js +8 -0
  29. package/dist/controls/foldable/types.d.ts +9 -0
  30. package/dist/controls/foldable/types.js +1 -0
  31. package/dist/controls/html.d.ts +8 -0
  32. package/dist/controls/html.js +260 -0
  33. package/dist/controls/index.d.ts +7 -0
  34. package/dist/controls/index.js +4 -0
  35. package/dist/controls/ionic.d.ts +1 -0
  36. package/dist/controls/ionic.js +1 -0
  37. package/dist/controls/legacy-layout.d.ts +7 -0
  38. package/dist/controls/legacy-layout.js +23 -0
  39. package/dist/controls/overlays/content.d.ts +2 -0
  40. package/dist/controls/overlays/content.js +55 -0
  41. package/dist/controls/overlays/controller.d.ts +7 -0
  42. package/dist/controls/overlays/controller.js +369 -0
  43. package/dist/controls/overlays/covered-overlay.d.ts +4 -0
  44. package/dist/controls/overlays/covered-overlay.js +9 -0
  45. package/dist/controls/overlays/dialog-animation.d.ts +7 -0
  46. package/dist/controls/overlays/dialog-animation.js +49 -0
  47. package/dist/controls/overlays/dismiss-animation.d.ts +2 -0
  48. package/dist/controls/overlays/dismiss-animation.js +14 -0
  49. package/dist/controls/overlays/focus.d.ts +2 -0
  50. package/dist/controls/overlays/focus.js +67 -0
  51. package/dist/controls/overlays/popover.d.ts +10 -0
  52. package/dist/controls/overlays/popover.js +117 -0
  53. package/dist/controls/overlays/styles.d.ts +9 -0
  54. package/dist/controls/overlays/styles.js +194 -0
  55. package/dist/controls/prehide.d.ts +6 -0
  56. package/dist/controls/prehide.js +280 -0
  57. package/dist/controls/runtime.d.ts +5 -0
  58. package/dist/controls/runtime.js +1685 -0
  59. package/dist/controls/shared/bridge.d.ts +4 -0
  60. package/dist/controls/shared/bridge.js +15 -0
  61. package/dist/controls/shared/candidate.d.ts +23 -0
  62. package/dist/controls/shared/candidate.js +128 -0
  63. package/dist/controls/shared/clipping.d.ts +1 -0
  64. package/dist/controls/shared/clipping.js +1 -0
  65. package/dist/controls/shared/connection.d.ts +21 -0
  66. package/dist/controls/shared/connection.js +112 -0
  67. package/dist/controls/shared/control-options.d.ts +4 -0
  68. package/dist/controls/shared/control-options.js +14 -0
  69. package/dist/controls/shared/covered-snapshot.d.ts +35 -0
  70. package/dist/controls/shared/covered-snapshot.js +41 -0
  71. package/dist/controls/shared/covered-source.d.ts +22 -0
  72. package/dist/controls/shared/covered-source.js +72 -0
  73. package/dist/controls/shared/crossfade.d.ts +11 -0
  74. package/dist/controls/shared/crossfade.js +71 -0
  75. package/dist/controls/shared/diagnostics.d.ts +2 -0
  76. package/dist/controls/shared/diagnostics.js +16 -0
  77. package/dist/controls/shared/dom.d.ts +45 -0
  78. package/dist/controls/shared/dom.js +258 -0
  79. package/dist/controls/shared/events.d.ts +12 -0
  80. package/dist/controls/shared/events.js +13 -0
  81. package/dist/controls/shared/horizontal-navigation.d.ts +36 -0
  82. package/dist/controls/shared/horizontal-navigation.js +90 -0
  83. package/dist/controls/shared/icons.d.ts +5 -0
  84. package/dist/controls/shared/icons.js +66 -0
  85. package/dist/controls/shared/modal-animation.d.ts +21 -0
  86. package/dist/controls/shared/modal-animation.js +97 -0
  87. package/dist/controls/shared/modal.d.ts +26 -0
  88. package/dist/controls/shared/modal.js +261 -0
  89. package/dist/controls/shared/projection-id.d.ts +2 -0
  90. package/dist/controls/shared/projection-id.js +2 -0
  91. package/dist/controls/shared/scroll-placement.d.ts +1 -0
  92. package/dist/controls/shared/scroll-placement.js +1 -0
  93. package/dist/controls/shared/tab-bar-width.d.ts +7 -0
  94. package/dist/controls/shared/tab-bar-width.js +72 -0
  95. package/dist/controls/shared/toolbar-options.d.ts +10 -0
  96. package/dist/controls/shared/toolbar-options.js +38 -0
  97. package/dist/controls/shared/transition-identity.d.ts +3 -0
  98. package/dist/controls/shared/transition-identity.js +64 -0
  99. package/dist/controls/shared/vertical-bars-geometry.d.ts +8 -0
  100. package/dist/controls/shared/vertical-bars-geometry.js +82 -0
  101. package/dist/controls/shared/vertical-navigation.d.ts +5 -0
  102. package/dist/controls/shared/vertical-navigation.js +39 -0
  103. package/dist/controls/startup.d.ts +7 -0
  104. package/dist/controls/startup.js +72 -0
  105. package/dist/controls/transport.d.ts +10 -0
  106. package/dist/controls/transport.js +1 -0
  107. package/dist/controls/vertical-bars-web-search.d.ts +8 -0
  108. package/dist/controls/vertical-bars-web-search.js +83 -0
  109. package/dist/controls/vertical-bars-web.d.ts +2 -0
  110. package/dist/controls/vertical-bars-web.js +609 -0
  111. package/dist/index.d.ts +1 -0
  112. package/dist/index.js +1 -0
  113. package/dist/integration/index.d.ts +36 -0
  114. package/dist/integration/index.js +25 -0
  115. package/dist/native/bootstrap.js +1 -1
  116. package/dist/native/session.js +14 -8
  117. package/dist/projection/pause.d.ts +10 -0
  118. package/dist/projection/pause.js +58 -0
  119. package/dist/projection/styles.d.ts +1 -1
  120. package/dist/projection/styles.js +0 -1
  121. package/dist/projection/surfaces.js +11 -8
  122. package/dist/projection/web.js +48 -13
  123. package/dist/types.d.ts +5 -1
  124. package/docs/README.md +17 -10
  125. package/docs/concepts.md +39 -14
  126. package/docs/configuration/README.md +4 -2
  127. package/docs/configuration/ionic-overlays.md +36 -2
  128. package/docs/configuration/pause-resume.md +51 -0
  129. package/docs/controls.md +39 -0
  130. package/docs/getting-started/capacitor.md +3 -3
  131. package/docs/getting-started/cordova.md +3 -1
  132. package/docs/getting-started/initialization.md +18 -2
  133. package/docs/reference/platforms.md +2 -2
  134. package/docs/reference/projection.md +1 -1
  135. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIButton.swift +98 -0
  136. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIButtons.swift +24 -0
  137. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIComponents.swift +19 -0
  138. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIFab.swift +80 -0
  139. package/ios/Sources/WebViewNativeUIControls/Components/NativeUISearchController.swift +449 -0
  140. package/ios/Sources/WebViewNativeUIControls/Components/NativeUISegment.swift +91 -0
  141. package/ios/Sources/WebViewNativeUIControls/Components/NativeUITabBar.swift +213 -0
  142. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIToolbarTransition.swift +186 -0
  143. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIVerticalBars.swift +696 -0
  144. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIVerticalSearch.swift +117 -0
  145. package/ios/Sources/WebViewNativeUIControls/Extensions/NativeUI.swift +180 -0
  146. package/ios/Sources/WebViewNativeUIControls/Extensions/NativeUIRendering.swift +38 -0
  147. package/ios/Sources/WebViewNativeUIControls/NativeUIController.swift +801 -0
  148. package/ios/Sources/WebViewNativeUIControls/Overlays/NativeUIOverlayController.swift +131 -0
  149. package/ios/Sources/WebViewNativeUIControls/Overlays/NativeUIOverlayHost.swift +85 -0
  150. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIControlRendering.swift +21 -0
  151. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUICoveredSnapshots.swift +14 -0
  152. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUICrossfade.swift +35 -0
  153. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIHost.swift +8 -0
  154. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIScrollTracker.swift +2 -0
  155. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUISnapshot.swift +206 -0
  156. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIVerticalBarsGeometry.swift +93 -0
  157. package/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +2 -2
  158. package/ios/Sources/WebViewNativeUIEngine/NativeUIProjection.swift +6 -4
  159. package/ios/Sources/WebViewNativeUIEngine/ProjectionOrigins.swift +31 -0
  160. package/ios/Sources/WebViewNativeUIEngine/Resources/native-ui-engine.js +3 -4
  161. package/package.json +30 -5
  162. package/src/controls/activation.ts +427 -0
  163. package/src/controls/components/index.ts +45 -0
  164. package/src/controls/components/ion-back-button.ts +22 -0
  165. package/src/controls/components/ion-button.ts +28 -0
  166. package/src/controls/components/ion-buttons.ts +37 -0
  167. package/src/controls/components/ion-fab.ts +151 -0
  168. package/src/controls/components/ion-menu-button.ts +37 -0
  169. package/src/controls/components/ion-segment.ts +27 -0
  170. package/src/controls/components/ion-tab-bar.ts +55 -0
  171. package/src/controls/components/searchable-tabs.ts +534 -0
  172. package/src/controls/definitions.ts +338 -0
  173. package/src/controls/extensions.ts +198 -0
  174. package/src/controls/foldable/fold-state.ts +10 -0
  175. package/src/controls/foldable/types.ts +4 -0
  176. package/src/controls/html.ts +239 -0
  177. package/src/controls/index.ts +7 -0
  178. package/src/controls/ionic.ts +1 -0
  179. package/src/controls/legacy-layout.ts +21 -0
  180. package/src/controls/overlays/content.ts +51 -0
  181. package/src/controls/overlays/controller.ts +394 -0
  182. package/src/controls/overlays/covered-overlay.ts +11 -0
  183. package/src/controls/overlays/dialog-animation.ts +53 -0
  184. package/src/controls/overlays/dismiss-animation.ts +15 -0
  185. package/src/controls/overlays/focus.ts +64 -0
  186. package/src/controls/overlays/popover.ts +116 -0
  187. package/src/controls/overlays/styles.ts +195 -0
  188. package/src/controls/prehide.ts +285 -0
  189. package/src/controls/runtime.ts +1731 -0
  190. package/src/controls/shared/bridge.ts +18 -0
  191. package/src/controls/shared/candidate.ts +163 -0
  192. package/src/controls/shared/clipping.ts +1 -0
  193. package/src/controls/shared/connection.ts +105 -0
  194. package/src/controls/shared/control-options.ts +20 -0
  195. package/src/controls/shared/covered-snapshot.ts +52 -0
  196. package/src/controls/shared/covered-source.ts +78 -0
  197. package/src/controls/shared/crossfade.ts +70 -0
  198. package/src/controls/shared/diagnostics.ts +13 -0
  199. package/src/controls/shared/dom.ts +310 -0
  200. package/src/controls/shared/events.ts +14 -0
  201. package/src/controls/shared/horizontal-navigation.ts +106 -0
  202. package/src/controls/shared/icons.ts +64 -0
  203. package/src/controls/shared/modal-animation.ts +109 -0
  204. package/src/controls/shared/modal.ts +286 -0
  205. package/src/controls/shared/projection-id.ts +2 -0
  206. package/src/controls/shared/scroll-placement.ts +1 -0
  207. package/src/controls/shared/tab-bar-width.ts +66 -0
  208. package/src/controls/shared/toolbar-options.ts +51 -0
  209. package/src/controls/shared/transition-identity.ts +67 -0
  210. package/src/controls/shared/vertical-bars-geometry.ts +92 -0
  211. package/src/controls/shared/vertical-navigation.ts +34 -0
  212. package/src/controls/startup.ts +74 -0
  213. package/src/controls/transport.ts +11 -0
  214. package/src/controls/vertical-bars-web-search.ts +86 -0
  215. package/src/controls/vertical-bars-web.ts +674 -0
  216. package/src/dom/geometry.ts +24 -0
  217. package/src/dom/overlays.ts +196 -0
  218. package/src/dom/scroll-bindings.ts +66 -0
  219. package/src/dom/scroll-placement.ts +92 -0
  220. package/src/dom/visibility.ts +29 -0
  221. package/src/index.ts +19 -0
  222. package/src/integration/index.ts +64 -0
  223. package/src/ionic.ts +34 -0
  224. package/src/native/bootstrap.ts +132 -0
  225. package/src/native/bridge.ts +18 -0
  226. package/src/native/resource-url.ts +11 -0
  227. package/src/native/session.ts +708 -0
  228. package/src/native/updates.ts +103 -0
  229. package/src/native/values.ts +29 -0
  230. package/src/projection/events.ts +58 -0
  231. package/src/projection/pause.ts +58 -0
  232. package/src/projection/placement.ts +34 -0
  233. package/src/projection/scope.ts +13 -0
  234. package/src/projection/styles.ts +26 -0
  235. package/src/projection/surface.ts +88 -0
  236. package/src/projection/surfaces.ts +119 -0
  237. package/src/projection/web.ts +1013 -0
  238. package/src/types.ts +58 -0
@@ -0,0 +1,801 @@
1
+ import WebViewNativeUIEngine
2
+ import UIKit
3
+ import SwiftUI
4
+ import WebKit
5
+
6
+ /// Shared control host. The embedding adapter owns bridge registration and event delivery.
7
+ public final class NativeUIController: NSObject, UITabBarDelegate {
8
+ private weak var webView: WKWebView?
9
+ private weak var viewController: UIViewController?
10
+ private let notifyListeners: (String, [String: Any]) -> Void
11
+ private let hasListeners: (String) -> Bool
12
+ private let refresh: (Bool) -> Void
13
+
14
+ public init(webView: WKWebView?, viewController: UIViewController?,
15
+ emit: @escaping (String, [String: Any]) -> Void,
16
+ hasListeners: @escaping (String) -> Bool,
17
+ refresh: @escaping (Bool) -> Void) {
18
+ self.webView = webView
19
+ self.viewController = viewController
20
+ self.notifyListeners = emit
21
+ self.hasListeners = hasListeners
22
+ self.refresh = refresh
23
+ super.init()
24
+ }
25
+ private let coveredSnapshots = NativeUICoveredSnapshots()
26
+ private var snapshotControls: [NativeUIControl] = []
27
+ private var overlays: NativeUIOverlayController?
28
+ private var toolbarTransition: NativeUIToolbarTransition?
29
+ private var host: NativeUIHost?
30
+ private var verticalBars: NativeUIVerticalBarsControlling?
31
+ private var controls: [String: UIView] = [:]
32
+ private var searchControllers: [String: NativeUISearchControlling] = [:]
33
+ private var extensionControllers: [String: NativeUIExtensionController] = [:]
34
+ private var extensionSessions: [String: UUID] = [:]
35
+ private let scrollBindings = NativeUIScrollBindings()
36
+ private var extensionNodes: [String: NativeUIControl] = [:]
37
+ private let extensionActions = NativeUIExtensionActions()
38
+ private var fingerprints: [String: NativeUIControl] = [:]
39
+ private let rendering = NativeUIControlRendering()
40
+ private var configurationGeneration = 0
41
+ private var connectionSession = UUID().uuidString
42
+ private var modalAnimationID: Int?
43
+ private var revision = 0
44
+ private var sequence = 0
45
+ private var keyboardVisible = false
46
+ private var pendingTabSelections: [String: NativeUITabBar.PendingSelection] = [:]
47
+ private var pendingTabExpiryWorks: [String: DispatchWorkItem] = [:]
48
+ private var restoreTopEdge: (() -> Void)?
49
+ private var observers: [NSObjectProtocol] = []
50
+ private var lastWebViewRadius: Double?
51
+ private var lastVerticalBarEdge: String?
52
+
53
+ public func load() {
54
+ #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
55
+ if #available(iOS 27.1, *), let webView = webView {
56
+ webView.registerForTraitChanges(UITraitCollection.systemTraitsAffectingVerticalBarEdge) { [weak self] (_: WKWebView, _: UITraitCollection) in
57
+ self?.notifyWebViewMetricsChange()
58
+ }
59
+ }
60
+ #endif
61
+ for name in [UIApplication.didEnterBackgroundNotification, UIResponder.keyboardWillChangeFrameNotification, UIResponder.keyboardWillHideNotification,
62
+ UIDevice.orientationDidChangeNotification] {
63
+ observers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] notification in
64
+ guard let self else { return }
65
+ let keyboard = name == UIResponder.keyboardWillChangeFrameNotification || name == UIResponder.keyboardWillHideNotification
66
+ if keyboard {
67
+ if name == UIResponder.keyboardWillHideNotification { self.keyboardVisible = false } else if let window = self.webView?.window,
68
+ let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
69
+ let local = window.convert(frame, from: window.screen.coordinateSpace)
70
+ let overlap = window.bounds.intersection(local)
71
+ self.keyboardVisible = !overlap.isNull && overlap.width > 0 && overlap.height > 0
72
+ }
73
+ }
74
+ let suspend = name == UIApplication.didEnterBackgroundNotification
75
+ if suspend {
76
+ self.resetCoveredSnapshots()
77
+ self.host?.isHidden = true
78
+ self.verticalBars?.view.isHidden = true
79
+ self.extensionControllers.values.forEach { $0.view.isHidden = true }
80
+ }
81
+ var searchOwnsKeyboard = self.verticalBars?.ownsKeyboardChrome == true
82
+ self.searchControllers.values.forEach { controller in
83
+ if controller.ownsKeyboardChrome { searchOwnsKeyboard = true }
84
+ if suspend { controller.surface.isHidden = true }
85
+ }
86
+ if keyboard {
87
+ if !searchOwnsKeyboard {
88
+ self.refresh(false)
89
+ }
90
+ } else if name == UIDevice.orientationDidChangeNotification {
91
+ self.notifyWebViewMetricsChange()
92
+ }
93
+ })
94
+ }
95
+ for name in [UIApplication.didBecomeActiveNotification, UIResponder.keyboardDidHideNotification,
96
+ UIDevice.orientationDidChangeNotification] {
97
+ observers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
98
+ if name == UIResponder.keyboardDidHideNotification { self?.keyboardVisible = false }
99
+ self?.refresh(name == UIApplication.didBecomeActiveNotification)
100
+ if name == UIApplication.didBecomeActiveNotification { self?.notifyWebViewMetricsChange() }
101
+ })
102
+ }
103
+ }
104
+
105
+ deinit {
106
+ observers.forEach(NotificationCenter.default.removeObserver)
107
+ }
108
+
109
+ public func prepareOverlay(_ options: [String: Any], resolve: @escaping ([String: Any]) -> Void, reject: @escaping (String) -> Void) {
110
+ DispatchQueue.main.async { [weak self] in
111
+ guard let self, let id = (options["id"] as? String),
112
+ let source = self.webView, let owner = self.viewController else {
113
+ reject("Popover source unavailable"); return
114
+ }
115
+ if self.overlays == nil {
116
+ self.overlays = NativeUIOverlayController(source: source, owner: owner) { [weak self] id, action in
117
+ self?.notifyListeners("overlay", ["id": id, "action": action])
118
+ }
119
+ }
120
+ guard self.overlays?.prepare(id) == true else { reject("Popover already pending"); return }
121
+ resolve([:])
122
+ }
123
+ }
124
+
125
+ public func presentOverlay(_ options: [String: Any], resolve: @escaping ([String: Any]) -> Void, reject: @escaping (String) -> Void) {
126
+ DispatchQueue.main.async { [weak self] in
127
+ guard let self, let overlays = self.overlays, let id = (options["id"] as? String) else {
128
+ reject("Popover not prepared"); return
129
+ }
130
+ overlays.present(id, options: (options["presentation"] as? [String: Any]), anchored: { [weak self] anchor, host, completion in
131
+ guard let self, let rail = self.verticalBars,
132
+ rail.presentPopover(anchor: anchor, host: host, completion: completion) else { return false }
133
+ let cancel = host.cancelAnchored
134
+ host.cancelAnchored = { [weak self, weak rail] in
135
+ cancel?()
136
+ // Cancellation removed the rail; invalidate native ownership even
137
+ // if the next DOM snapshot is identical to the previous one.
138
+ if let self, let rail, self.verticalBars === rail { self.verticalBars = nil }
139
+ self?.refresh(false)
140
+ }
141
+ return true
142
+ }) { presented in
143
+ if presented { resolve([:]) } else { reject("Native Vertical Bars anchor unavailable") }
144
+ }
145
+ }
146
+ }
147
+
148
+ public func getConnectionState(_ options: [String: Any], resolve: @escaping ([String: Any]) -> Void, reject: @escaping (String) -> Void) {
149
+ DispatchQueue.main.async { [weak self] in
150
+ guard let self else { reject("Plugin released"); return }
151
+ var state: [String: Any] = [
152
+ "session": self.connectionSession, "revision": self.revision, "sequence": self.sequence,
153
+ "surfaces": ["horizontal": self.host?.window != nil, "vertical": self.verticalBars?.view.window != nil],
154
+ "listeners": ["activate", "search", "overlay", "webViewMetricsChange", "snapshotInteraction"].filter { self.hasListeners($0) }
155
+ ]
156
+ if let overlay = self.overlays?.connectionState { state["overlay"] = overlay }
157
+ resolve(state)
158
+ }
159
+ }
160
+
161
+ public func dismissOverlay(_ options: [String: Any], resolve: @escaping ([String: Any]) -> Void, reject: @escaping (String) -> Void) {
162
+ DispatchQueue.main.async { [weak self] in
163
+ guard let overlays = self?.overlays, let id = (options["id"] as? String) else { resolve([:]); return }
164
+ overlays.dismiss(id, animated: (options["animated"] as? Bool) ?? false) { resolve([:]) }
165
+ }
166
+ }
167
+
168
+ public func closeOverlay(_ options: [String: Any], resolve: @escaping ([String: Any]) -> Void, reject: @escaping (String) -> Void) {
169
+ DispatchQueue.main.async { [weak self] in
170
+ guard let overlays = self?.overlays, let id = (options["id"] as? String) else { resolve([:]); return }
171
+ overlays.close(id) { resolve([:]) }
172
+ }
173
+ }
174
+
175
+ public func stopOverlays(_ options: [String: Any], resolve: @escaping ([String: Any]) -> Void, reject: @escaping (String) -> Void) {
176
+ DispatchQueue.main.async { [weak self] in
177
+ guard let self, let overlays = self.overlays else { resolve([:]); return }
178
+ overlays.stop {
179
+ if self.overlays === overlays { self.overlays = nil }
180
+ resolve([:])
181
+ }
182
+ }
183
+ }
184
+
185
+ private func webViewMetrics() -> [String: Any]? {
186
+ guard #available(iOS 26.0, *), let webView = webView else { return nil }
187
+ webView.layoutIfNeeded()
188
+ var metrics: [String: Any] = ["radius": Double(webView.effectiveRadius(corner: .topLeft)), "verticalBarEdge": NSNull()]
189
+ #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
190
+ if #available(iOS 27.1, *) {
191
+ switch webView.traitCollection.verticalBarEdge {
192
+ case .leading: metrics["verticalBarEdge"] = "leading"
193
+ case .trailing: metrics["verticalBarEdge"] = "trailing"
194
+ default: break
195
+ }
196
+ }
197
+ #endif
198
+ return metrics
199
+ }
200
+
201
+ private func notifyWebViewMetricsChange() {
202
+ let metrics = webViewMetrics() ?? ["radius": 0]
203
+ let radius = metrics["radius"] as? Double ?? 0
204
+ let edge = metrics["verticalBarEdge"] as? String
205
+ guard radius != lastWebViewRadius || edge != lastVerticalBarEdge else { return }
206
+ lastVerticalBarEdge = edge
207
+ lastWebViewRadius = radius
208
+ notifyListeners("webViewMetricsChange", metrics)
209
+ }
210
+
211
+ public func getWebViewMetrics(_ options: [String: Any], resolve: @escaping ([String: Any]) -> Void, reject: @escaping (String) -> Void) {
212
+ DispatchQueue.main.async { [weak self] in
213
+ resolve(self?.webViewMetrics() ?? ["radius": 0])
214
+ }
215
+ }
216
+
217
+ public func configure(_ options: [String: Any], resolve: @escaping ([String: Any]) -> Void, reject: @escaping (String) -> Void) {
218
+ DispatchQueue.main.async { [weak self] in
219
+ guard let self else { resolve(["supported": false]); return }
220
+ self.configurationGeneration += 1
221
+ let generation = self.configurationGeneration
222
+ let configure = { [self] in
223
+ guard generation == self.configurationGeneration else {
224
+ reject("Configuration superseded"); return
225
+ }
226
+ // A new JS context starts revision numbering again (live reload / navigation).
227
+ self.restoreTopEdge?()
228
+ self.restoreTopEdge = nil
229
+ self.resetCoveredSnapshots()
230
+ self.removeControls()
231
+ self.revision = 0
232
+ self.connectionSession = UUID().uuidString
233
+ if #available(iOS 26.0, *) {
234
+ self.webView?.layoutIfNeeded()
235
+ // Ionic already paints the header edge; a second native effect can
236
+ // add a dark scrim when the OS and Web themes differ.
237
+ if (options["suppressScrollEdgeEffect"] as? Bool) == true, let effect = self.webView?.scrollView.topEdgeEffect {
238
+ let hidden = effect.isHidden
239
+ effect.isHidden = true
240
+ self.restoreTopEdge = { [weak effect] in effect?.isHidden = hidden }
241
+ }
242
+ resolve(["supported": true, "extensions": NativeUI.names, "modalAnimation": true, "connectionState": true, "coveredSnapshots": true, "nativeScroll": true])
243
+ } else { resolve(["supported": false]) }
244
+ }
245
+ // A replacement JS context cannot close its predecessor's child window.
246
+ // Finish dismissing it while its rail still owns the SwiftUI presentation.
247
+ if let overlays = self.overlays {
248
+ overlays.stop {
249
+ if self.overlays === overlays { self.overlays = nil }
250
+ configure()
251
+ }
252
+ } else { configure() }
253
+ }
254
+ }
255
+
256
+ private func resetCoveredSnapshots() {
257
+ coveredSnapshots.reset()
258
+ }
259
+
260
+ public func coveredSnapshot(_ options: [String: Any], resolve: @escaping ([String: Any]) -> Void, reject: @escaping (String) -> Void) {
261
+ DispatchQueue.main.async { [weak self] in
262
+ guard let self, let webView = self.webView else { reject("WebView unavailable"); return }
263
+ if (options["action"] as? String) == "prepare", let window = webView.window {
264
+ self.coveredSnapshots.observe(window) { [weak self] active in
265
+ self?.notifyListeners("snapshotInteraction", ["active": active])
266
+ }
267
+ }
268
+ let measure: (Set<String>, Int) -> [String: CGRect] = { [weak self, weak webView] ids, revision in
269
+ guard let self, let webView, revision == self.revision,
270
+ !self.keyboardVisible, self.toolbarTransition == nil else { return [:] }
271
+ let nodes = self.snapshotControls.filter { ids.contains($0.id) }
272
+ guard nodes.count == ids.count else { return [:] }
273
+ let itemIDs = Set(nodes.flatMap { $0.items.map(\.id) } + nodes.filter { $0.extensionContent?.tabBarId != nil }.map(\.id) + ["BackButton", "__tabBar"])
274
+ var measured = self.verticalBars.map { NativeUIVerticalBarsGeometry.measure(in: $0.view, relativeTo: webView, ids: itemIDs) } ?? [:]
275
+ self.verticalBars?.measuredFrames(relativeTo: webView).forEach { measured[$0.key] = $0.value.frame }
276
+ var frames: [String: CGRect] = [:]
277
+ for node in nodes {
278
+ if node.extensionContent?.tabBarId != nil {
279
+ frames[node.id] = measured[node.id]
280
+ } else if node.placement == .verticalBars {
281
+ if node.kind == .tabBar { frames[node.id] = measured["__tabBar"] } else {
282
+ let rects = node.items.compactMap { measured[node.kind == .backButton ? "BackButton" : $0.id] }
283
+ if rects.count == node.items.count { frames[node.id] = rects.reduce(CGRect.null) { $0.union($1) } }
284
+ }
285
+ } else if let view = self.controls[node.id], !view.isHidden, view.window != nil {
286
+ let surfaces: [UIView]
287
+ if let fab = view as? NativeUIFab { surfaces = fab.buttons.values.filter { !$0.isHidden } } else { surfaces = [view] }
288
+ if !surfaces.isEmpty {
289
+ frames[node.id] = surfaces.reduce(CGRect.null) { $0.union($1.convert($1.bounds, to: webView)) }
290
+ }
291
+ }
292
+ }
293
+ if frames.count != ids.count, let rail = self.verticalBars {
294
+ let railNodes = self.snapshotControls.filter { $0.placement == .verticalBars }
295
+ let components = NativeUIVerticalBarsGeometry.componentFrames(in: rail.view, relativeTo: webView, controls: railNodes)
296
+ for node in nodes where frames[node.id] == nil { frames[node.id] = components[node.id] }
297
+ }
298
+ return frames
299
+ }
300
+ let views: (Set<String>) -> [UIView]? = { ids in
301
+ let railIDs = Set(self.snapshotControls.filter { $0.placement == .verticalBars }.map(\.id))
302
+ // A shared rail can only retire when every one of its controls has a prepared image.
303
+ guard Set(self.snapshotControls.map(\.id)).isSuperset(of: ids),
304
+ ids.isDisjoint(with: railIDs) || ids.isSuperset(of: railIDs) else {
305
+ return nil
306
+ }
307
+ var views = Array(self.controls.filter { ids.contains($0.key) }.values)
308
+ if !railIDs.isEmpty, ids.isSuperset(of: railIDs), let rail = self.verticalBars { views.append(rail.view) }
309
+ return views
310
+ }
311
+ self.coveredSnapshots.handle(options, in: webView, frames: measure, views: views, complete: { resolve($0) })
312
+ }
313
+ }
314
+
315
+ public func getVerticalBarsGeometry(_ options: [String: Any], resolve: @escaping ([String: Any]) -> Void, reject: @escaping (String) -> Void) {
316
+ DispatchQueue.main.async { [weak self] in
317
+ guard let self, let webView = self.webView, let rail = self.verticalBars,
318
+ let width = (options["viewportWidth"] as? Double), width.isFinite, width > 0,
319
+ webView.bounds.width > 0 else { resolve(["items": [:]]); return }
320
+ let scale = width / webView.bounds.width
321
+ let ids = Set((options["ids"] as? [String]) ?? [])
322
+ let measured = NativeUIVerticalBarsGeometry.measure(in: rail.view, relativeTo: webView, ids: ids)
323
+ var items = measured.mapValues { frame in
324
+ ["x": frame.minX * scale, "y": frame.minY * scale,
325
+ "width": frame.width * scale, "height": frame.height * scale]
326
+ }
327
+ for (id, surface) in rail.measuredFrames(relativeTo: webView) where ids.contains(id) {
328
+ items[id] = ["x": surface.frame.minX * scale, "y": surface.frame.minY * scale,
329
+ "width": surface.frame.width * scale, "height": surface.frame.height * scale,
330
+ "cornerRadius": surface.cornerRadius * scale]
331
+ }
332
+ resolve(["revision": self.revision, "items": items])
333
+ }
334
+ }
335
+
336
+ public func completeExtensionAction(_ options: [String: Any], resolve: @escaping ([String: Any]) -> Void, reject: @escaping (String) -> Void) {
337
+ DispatchQueue.main.async { [weak self] in
338
+ guard let request = (options["requestId"] as? String), let accepted = (options["accepted"] as? Bool) else {
339
+ reject("Invalid extension acknowledgement"); return
340
+ }
341
+ self?.extensionActions.finish(request, error: accepted ? nil : NativeUIError.rejected)
342
+ resolve([:])
343
+ }
344
+ }
345
+
346
+ @MainActor private func extensionContent(_ node: NativeUIControl, viewportWidth: CGFloat, scale: CGFloat) throws -> AnyView {
347
+ guard let data = node.extensionContent else { throw NativeUIError.unavailable }
348
+ let session = extensionSessions[node.id] ?? UUID()
349
+ extensionSessions[node.id] = session
350
+ let actions = NativeUIActions(availableNames: Set(data.actions.keys)) { [weak self] name in
351
+ guard let self, self.extensionNodes[node.id] == node, self.extensionSessions[node.id] == session, let target = data.actions[name] else {
352
+ throw NativeUIError.unavailable
353
+ }
354
+ try await self.extensionActions.perform(owner: node.id) { request in
355
+ self.sequence += 1
356
+ self.notifyListeners("activate", ["id": target, "revision": self.revision,
357
+ "sequence": self.sequence, "requestId": request])
358
+ }
359
+ }
360
+ let context = NativeUIContext(node: node, viewportWidth: viewportWidth, pointsPerCSSPixel: scale)
361
+ return AnyView(try NativeUI.content(data, actions: actions, context: context)
362
+ .environment(\.layoutDirection, node.rtl ? .rightToLeft : .leftToRight)
363
+ .environment(\.colorScheme, node.dark ? .dark : .light)
364
+ .id(node.id))
365
+ }
366
+
367
+ public func clear(_ options: [String: Any], resolve: @escaping ([String: Any]) -> Void, reject: @escaping (String) -> Void) {
368
+ DispatchQueue.main.async { [weak self] in
369
+ guard let self else { resolve([:]); return }
370
+ let next = (options["revision"] as? Int) ?? 0
371
+ if next >= self.revision {
372
+ self.revision = next
373
+ self.restoreTopEdge?()
374
+ self.restoreTopEdge = nil
375
+ self.resetCoveredSnapshots()
376
+ self.removeControls()
377
+ }
378
+ resolve([:])
379
+ }
380
+ }
381
+
382
+ private func removeControl(_ id: String, duration: TimeInterval = 0) {
383
+ extensionActions.cancel(owner: id)
384
+ extensionNodes.removeValue(forKey: id)
385
+ extensionSessions.removeValue(forKey: id)
386
+ let extensionController = extensionControllers.removeValue(forKey: id)
387
+ searchControllers.removeValue(forKey: id)?.detach()
388
+ let control = controls.removeValue(forKey: id)
389
+ if let extensionController { extensionController.detach(duration: duration) } else if let control { NativeUICrossfade.retire(control, duration: duration) }
390
+ fingerprints.removeValue(forKey: id)
391
+ pendingTabSelections.removeValue(forKey: id)
392
+ pendingTabExpiryWorks.removeValue(forKey: id)?.cancel()
393
+ }
394
+
395
+ private func removeControls(duration: TimeInterval = 0) {
396
+ scrollBindings.reset()
397
+ snapshotControls = []
398
+ toolbarTransition?.restore()
399
+ toolbarTransition = nil
400
+ extensionActions.cancel()
401
+ extensionNodes.removeAll()
402
+ extensionSessions.removeAll()
403
+ Array(controls.keys).forEach { removeControl($0, duration: duration) }
404
+ host?.removeFromSuperview()
405
+ host = nil
406
+ verticalBars?.detach(duration: duration)
407
+ verticalBars = nil
408
+ modalAnimationID = nil
409
+ rendering.clear()
410
+ pendingTabSelections.removeAll()
411
+ pendingTabExpiryWorks.values.forEach { $0.cancel() }
412
+ pendingTabExpiryWorks.removeAll()
413
+ }
414
+
415
+ private func syncTabBar(_ tabBar: UITabBar, id: String, node: NativeUIControl) {
416
+ var pending = pendingTabSelections[id]
417
+ NativeUITabBar.update(tabBar, node: node, rendering: rendering, pendingSelection: &pending)
418
+ if let pending {
419
+ pendingTabSelections[id] = pending
420
+ } else {
421
+ pendingTabSelections.removeValue(forKey: id)
422
+ pendingTabExpiryWorks.removeValue(forKey: id)?.cancel()
423
+ }
424
+ }
425
+
426
+ private func schedulePendingTabExpiry(_ id: String, until: CFAbsoluteTime) {
427
+ pendingTabExpiryWorks[id]?.cancel()
428
+ let delay = max(0, until - CFAbsoluteTimeGetCurrent()) + 0.02
429
+ let work = DispatchWorkItem { [weak self] in
430
+ guard let self else { return }
431
+ self.pendingTabExpiryWorks.removeValue(forKey: id)
432
+ guard let pending = self.pendingTabSelections[id],
433
+ CFAbsoluteTimeGetCurrent() >= pending.until,
434
+ let tabBar = self.controls[id] as? UITabBar,
435
+ let node = self.fingerprints[id] else { return }
436
+ self.syncTabBar(tabBar, id: id, node: node)
437
+ }
438
+ pendingTabExpiryWorks[id] = work
439
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
440
+ }
441
+
442
+ public func setToolbarTransitionProgress(_ options: [String: Any], resolve: @escaping ([String: Any]) -> Void, reject: @escaping (String) -> Void) {
443
+ guard let id = (options["id"] as? Int), let progress = (options["progress"] as? Double), progress.isFinite, (0...1).contains(progress) else {
444
+ reject("Invalid toolbar progress"); return
445
+ }
446
+ DispatchQueue.main.async { [weak self] in
447
+ if let transition = self?.toolbarTransition, transition.id == id { transition.setProgress(progress) }
448
+ resolve([:])
449
+ }
450
+ }
451
+
452
+ public func update(_ options: [String: Any], resolve: @escaping ([String: Any]) -> Void, reject: @escaping (String) -> Void) {
453
+ DispatchQueue.main.async { [weak self] in
454
+ guard let self else { reject("Plugin released"); return }
455
+ guard #available(iOS 26.0, *) else { reject("Requires iOS 26"); return }
456
+ let next = (options["revision"] as? Int) ?? 0
457
+ guard next > self.revision else { resolve(["revision": self.revision]); return }
458
+ guard let webView = self.webView, let parent = webView.superview else {
459
+ reject("WebView unavailable"); return
460
+ }
461
+ // Decode and validate the entire batch before changing visible controls.
462
+ guard JSONSerialization.isValidJSONObject(options),
463
+ let snapshot = try? JSONDecoder().decode(NativeUISnapshot.self, from: JSONSerialization.data(withJSONObject: options)), snapshot.isValid else {
464
+ reject("Invalid control snapshot"); return
465
+ }
466
+ self.coveredSnapshots.restoreControls()
467
+ if snapshot.coveredSnapshotToken == nil && self.coveredSnapshots.covering {
468
+ _ = self.coveredSnapshots.restore(in: webView)
469
+ }
470
+ defer {
471
+ if snapshot.coveredSnapshotToken == nil {
472
+ self.coveredSnapshots.finishCovering()
473
+ }
474
+ }
475
+ if self.toolbarTransition?.id != snapshot.toolbarTransition?.id {
476
+ self.toolbarTransition?.restore()
477
+ self.toolbarTransition = nil
478
+ }
479
+ self.notifyWebViewMetricsChange()
480
+ let duration = NativeUICrossfade.duration(snapshot.transitionDuration)
481
+ var tabBarWidths: [String: CGFloat] = [:]
482
+ var existing = Set(self.controls.keys)
483
+ let pendingVerticalNavigation = snapshot.verticalBarNavigationPhase != nil && self.verticalBars != nil
484
+ let verticalBars = snapshot.controls.filter { $0.placement == .verticalBars }
485
+ let accessories = snapshot.controls.filter { $0.extensionContent?.tabBarId != nil }
486
+ var snapshots = snapshot.controls.filter { $0.placement != .verticalBars && $0.extensionContent?.tabBarId == nil }
487
+ if let transition = snapshot.toolbarTransition {
488
+ let sources = snapshots.filter { transition.sourceIds.contains($0.id) }
489
+ snapshots += transition.destination.filter { target in
490
+ !snapshots.contains { $0.id == target.id } &&
491
+ !sources.contains { $0.transitionId != nil && $0.transitionId == target.transitionId }
492
+ }
493
+ }
494
+ let extensionIDs = Set(snapshot.controls.filter { $0.kind == .extension }.map(\.id))
495
+ for id in Array(self.extensionNodes.keys) where !extensionIDs.contains(id) {
496
+ self.extensionActions.cancel(owner: id)
497
+ self.extensionNodes.removeValue(forKey: id)
498
+ self.extensionSessions.removeValue(forKey: id)
499
+ }
500
+ let verticalSearchActive = verticalBars.contains { $0.search.map { $0.available && $0.active } ?? false }
501
+ let width = snapshot.viewportWidth
502
+ self.revision = next
503
+ if !pendingVerticalNavigation && snapshots.isEmpty && verticalBars.isEmpty && accessories.isEmpty && (snapshot.toolbarTransition?.destination.isEmpty ?? true) {
504
+ self.removeControls(duration: duration)
505
+ resolve(["revision": next]); return
506
+ }
507
+ let scale = webView.bounds.width / width
508
+ existing.formUnion(NativeUIButton.reassociate(snapshots, controls: &self.controls, fingerprints: &self.fingerprints))
509
+ let retained = Set(snapshots.map(\.id))
510
+ for id in Array(self.controls.keys) where !retained.contains(id) {
511
+ self.removeControl(id, duration: duration)
512
+ }
513
+ var rejectedControls: [String] = []
514
+ // Rebind only on a layout snapshot, never on native scroll ticks.
515
+ rejectedControls += self.scrollBindings.replace(
516
+ snapshots.map { NativeUIScrollNode(id: $0.id, scroll: $0.scroll) }, in: webView, scale: scale
517
+ ) { [weak self, weak webView] id, frame, clip in
518
+ guard let self, let webView, !self.coveredSnapshots.hidesNativeControls, !self.keyboardVisible,
519
+ self.extensionNodes[id]?.scroll != nil, let controller = self.extensionControllers[id], let parent = controller.view.superview else { return }
520
+ controller.place(webView.convert(frame, to: parent), clip: clip.isNull ? .null : webView.convert(clip, to: parent))
521
+ }
522
+ var fabs: [(NativeUIFab, NativeUIControl)] = []
523
+ var searches: [(NativeUISearchControlling, NativeUIControl, CGRect, CGRect, UIView?, Bool)] = []
524
+ var rejectedSearches: [String] = []
525
+ let enteringVerticalBars = self.verticalBars == nil
526
+ if (verticalBars.isEmpty && !pendingVerticalNavigation) || (self.keyboardVisible && !verticalSearchActive) {
527
+ self.verticalBars?.detach(duration: duration)
528
+ self.verticalBars = nil
529
+ self.modalAnimationID = nil
530
+ if self.keyboardVisible { rejectedControls.append(contentsOf: verticalBars.map(\.id)) }
531
+ } else if let owner = self.viewController {
532
+ let rail = self.verticalBars ?? NativeUIVerticalBarsController(
533
+ activate: { [weak self] id in self?.activate(id) },
534
+ changed: { [weak self] id, phase, value, composing, valueVersion in
535
+ self?.searchChanged(id, phase: phase, value: value, composing: composing, valueVersion: valueVersion) ?? 0
536
+ })
537
+ self.verticalBars = rail
538
+ rail.attach(to: owner, in: owner.view)
539
+ if let frame = snapshot.verticalBarFrame {
540
+ rail.view.frame = webView.convert(frame.rect.applying(CGAffineTransform(scaleX: scale, y: scale)), to: owner.view)
541
+ } else {
542
+ rail.view.frame = owner.view.bounds
543
+ }
544
+ rail.view.isHidden = false
545
+ if enteringVerticalBars && snapshot.modalAnimation == nil { NativeUICrossfade.enter(rail.view, duration: duration) }
546
+ } else {
547
+ rejectedControls.append(contentsOf: verticalBars.map(\.id))
548
+ }
549
+ // A system tab host has one accessory slot. Ambiguous owners stay on the Web.
550
+ var accessoryContent: AnyView?
551
+ for node in accessories {
552
+ guard #available(iOS 26.1, *), accessories.count == 1, !self.keyboardVisible, self.verticalBars != nil,
553
+ // swiftlint:disable:next force_unwrapping
554
+ verticalBars.filter({ $0.kind == .tabBar }).map(\.id) == [node.extensionContent!.tabBarId!],
555
+ let content = try? self.extensionContent(node, viewportWidth: width, scale: scale) else {
556
+ rejectedControls.append(node.id)
557
+ self.extensionNodes.removeValue(forKey: node.id)
558
+ self.extensionSessions.removeValue(forKey: node.id)
559
+ self.extensionActions.cancel(owner: node.id)
560
+ continue
561
+ }
562
+ self.extensionNodes[node.id] = node
563
+ accessoryContent = AnyView(content.background(NativeUICoveredSnapshotAnchor(id: node.id)))
564
+ }
565
+ // Publish toolbar, tabs and accessory together so the hosting tree receives
566
+ // one animation transaction. The shell owns only the initial crossfade.
567
+ self.verticalBars?.apply(verticalBars, rendering: self.rendering, edge: snapshot.verticalBarEdge ?? "right",
568
+ animated: !enteringVerticalBars && snapshot.modalAnimation == nil && !self.coveredSnapshots.covering,
569
+ accessory: accessoryContent, navigation: snapshot.verticalBarNavigation ?? [],
570
+ navigationPhase: snapshot.verticalBarNavigationPhase)
571
+ self.verticalBars?.view.isUserInteractionEnabled = snapshot.modalAnimation == nil
572
+ if let layer = self.verticalBars?.view.layer {
573
+ if let motion = snapshot.modalAnimation {
574
+ if self.modalAnimationID != motion.id {
575
+ self.modalAnimationID = motion.id
576
+ let translate = CABasicAnimation(keyPath: "transform.translation.y")
577
+ translate.fromValue = motion.fromY * scale
578
+ translate.toValue = 0
579
+ let opacity = CABasicAnimation(keyPath: "opacity")
580
+ opacity.fromValue = motion.opacity
581
+ opacity.toValue = 1
582
+ let group = CAAnimationGroup()
583
+ group.animations = [translate, opacity]
584
+ group.duration = motion.duration / 1000
585
+ translate.duration = group.duration
586
+ opacity.duration = group.duration
587
+ group.timingFunction = CAMediaTimingFunction(controlPoints: Float(motion.easing[0]), Float(motion.easing[1]), Float(motion.easing[2]), Float(motion.easing[3]))
588
+ let elapsed = max(0, Date().timeIntervalSince1970 - motion.startedAt / 1000)
589
+ group.beginTime = layer.convertTime(CACurrentMediaTime(), from: nil) - elapsed
590
+ layer.add(group, forKey: "native-ui-modal-entry")
591
+ }
592
+ } else {
593
+ self.modalAnimationID = nil
594
+ layer.removeAnimation(forKey: "native-ui-modal-entry")
595
+ }
596
+ }
597
+
598
+ if snapshots.isEmpty && (snapshot.toolbarTransition?.destination.isEmpty ?? true) {
599
+ self.host?.removeFromSuperview()
600
+ self.host = nil
601
+ self.snapshotControls = snapshot.controls.filter { !rejectedControls.contains($0.id) }
602
+ resolve(["revision": next, "rejectedControls": rejectedControls])
603
+ return
604
+ }
605
+ let host = self.host ?? NativeUIHost()
606
+ self.host = host
607
+ host.frame = parent.bounds
608
+ host.autoresizingMask = [.flexibleWidth, .flexibleHeight]
609
+ host.backgroundColor = .clear
610
+ host.isAccessibilityElement = false
611
+ UIView.performWithoutAnimation {
612
+ if host.superview !== parent { parent.addSubview(host) }
613
+ for node in snapshots {
614
+ let id = node.id
615
+ if node.kind != .extension, self.extensionControllers[id] != nil { self.removeControl(id) }
616
+ let previous = self.controls[id]
617
+ let previousBounds = previous?.bounds
618
+ let previousCenter = previous?.center
619
+ let reject = {
620
+ // Keep the old cover until JS paints Web and omits this ID.
621
+ if let previous, self.controls[id] === previous {
622
+ // swiftlint:disable:next force_unwrapping
623
+ previous.bounds = previousBounds!
624
+ // swiftlint:disable:next force_unwrapping
625
+ previous.center = previousCenter!
626
+ previous.layoutIfNeeded()
627
+ } else { self.removeControl(id) }
628
+ if !rejectedControls.contains(id) { rejectedControls.append(id) }
629
+ }
630
+ // Only native search owns its keyboard; other controls return to Web.
631
+ if self.keyboardVisible && self.searchControllers[id]?.ownsKeyboard != true {
632
+ reject(); continue
633
+ }
634
+ let local = node.frame.rect
635
+ let bounds = webView.convert(CGRect(x: local.minX * scale, y: local.minY * scale,
636
+ width: local.width * scale, height: local.height * scale), to: parent)
637
+ if node.kind == .extension {
638
+ if rejectedControls.contains(id) { reject(); continue }
639
+ guard let owner = self.viewController, let content = try? self.extensionContent(node, viewportWidth: width, scale: scale) else {
640
+ self.extensionNodes.removeValue(forKey: id)
641
+ self.extensionSessions.removeValue(forKey: id)
642
+ self.extensionActions.cancel(owner: id)
643
+ reject(); continue
644
+ }
645
+ if let controller = self.extensionControllers[id] {
646
+ controller.update(content)
647
+ } else {
648
+ if let previous = self.controls.removeValue(forKey: id) { previous.removeFromSuperview() }
649
+ self.searchControllers.removeValue(forKey: id)?.detach()
650
+ let controller = NativeUIExtensionController(content: content, owner: owner)
651
+ self.extensionControllers[id] = controller
652
+ self.controls[id] = controller.view
653
+ }
654
+ self.extensionNodes[id] = node
655
+ self.extensionControllers[id]?.place(parent.convert(bounds, to: owner.view))
656
+ self.controls[id]?.overrideUserInterfaceStyle = node.dark ? .dark : .light
657
+ continue
658
+ }
659
+ if let search = node.search {
660
+ guard let owner = self.viewController else { rejectedSearches.append(id); continue }
661
+ let controller: NativeUISearchControlling
662
+ let previousCover = self.controls[id]
663
+ let replacing = self.searchControllers[id]
664
+ if let existing = replacing { controller = existing } else {
665
+ // Keep the ordinary UITabBar cover until the search controller applies.
666
+ controller = NativeUISearchController()
667
+ controller.activate = { [weak self] id in self?.activate(id) }
668
+ controller.changed = { [weak self] id, phase, value, composing, valueVersion in
669
+ self?.searchChanged(id, phase: phase, value: value, composing: composing, valueVersion: valueVersion) ?? 0
670
+ }
671
+ controller.attach(to: owner, in: owner.view)
672
+ self.searchControllers[id] = controller
673
+ self.controls[id] = controller.surface
674
+ }
675
+ let trigger = search.trigger.frame.rect
676
+ let searchBarFrame = webView.convert(CGRect(x: webView.bounds.minX + local.minX * scale,
677
+ y: webView.bounds.minY + local.minY * scale, width: local.width * scale, height: local.height * scale), to: owner.view)
678
+ let triggerFrame = webView.convert(CGRect(x: webView.bounds.minX + trigger.minX * scale,
679
+ y: webView.bounds.minY + trigger.minY * scale, width: trigger.width * scale, height: trigger.height * scale), to: owner.view)
680
+ searches.append((controller, node, searchBarFrame, triggerFrame, previousCover, replacing == nil))
681
+ continue
682
+ } else if self.searchControllers[id] != nil {
683
+ self.removeControl(id)
684
+ }
685
+ if self.fingerprints[id] != node {
686
+ if node.kind == NativeUIFab.kind {
687
+ let fab = (self.controls[id] as? NativeUIFab) ?? NativeUIFab()
688
+ if fab.superview == nil { host.addSubview(fab) }
689
+ self.controls[id] = fab
690
+ fabs.append((fab, node))
691
+ } else if NativeUIButton.kinds.contains(node.kind), let button = self.controls[id] as? UIButton {
692
+ _ = NativeUIButton.make(node, existing: button, rendering: self.rendering,
693
+ activate: { [weak self] id in self?.activate(id) })
694
+ } else if let segment = self.controls[id] as? NativeUISegment, node.kind == NativeUISegment.kind {
695
+ segment.update(node, scale: scale, rendering: self.rendering)
696
+ } else if let tabBar = self.controls[id] as? UITabBar, node.kind == NativeUITabBar.kind {
697
+ self.syncTabBar(tabBar, id: id, node: node)
698
+ } else {
699
+ guard let control = NativeUIComponents.make(node, scale: scale, rendering: self.rendering, tabDelegate: self,
700
+ activate: { [weak self] id in self?.activate(id) }) else { reject(); continue }
701
+ self.controls.removeValue(forKey: id)?.removeFromSuperview()
702
+ self.controls[id] = control
703
+ host.addSubview(control)
704
+ }
705
+ self.fingerprints[id] = node
706
+ } else if let tabBar = self.controls[id] as? UITabBar, self.pendingTabSelections[id] != nil {
707
+ // Resolve an in-flight native tap even when other fingerprint fields are unchanged.
708
+ self.syncTabBar(tabBar, id: id, node: node)
709
+ }
710
+ guard let control = self.controls[id] else { reject(); continue }
711
+ if let tabBar = control as? UITabBar {
712
+ guard NativeUITabBar.fit(tabBar, node: node, bounds: bounds) else { reject(); continue }
713
+ if let content = NativeUITabBar.contentFrame(tabBar) { tabBarWidths[id] = content.width / scale }
714
+ } else if control.frame != bounds && !(self.toolbarTransition?.controlIds.contains(id) ?? false) {
715
+ control.frame = bounds
716
+ }
717
+ control.overrideUserInterfaceStyle = node.dark ? .dark : .light
718
+ }
719
+ host.isHidden = false
720
+ host.layoutIfNeeded()
721
+ }
722
+ // FABs update in place. Only icon content animates; the glass surface
723
+ // and Ionic's staggered child visibility keep their own identities.
724
+ for (fab, node) in fabs {
725
+ fab.apply(node, scale: scale, rendering: self.rendering, activate: { [weak self] id in self?.activate(id) })
726
+ }
727
+ for (controller, node, frame, triggerFrame, previousCover, created) in searches {
728
+ let id = node.id
729
+ let webFrame = webView.convert(webView.bounds, to: controller.surface.superview)
730
+ if !controller.apply(node, webFrame: webFrame, barFrame: frame, triggerFrame: triggerFrame, rendering: self.rendering) {
731
+ if created {
732
+ self.searchControllers.removeValue(forKey: id)
733
+ controller.detach()
734
+ if let previousCover, previousCover.superview != nil {
735
+ self.controls[id] = previousCover
736
+ } else {
737
+ self.controls.removeValue(forKey: id)?.removeFromSuperview()
738
+ self.fingerprints.removeValue(forKey: id)
739
+ self.pendingTabSelections[id] = nil
740
+ self.pendingTabExpiryWorks.removeValue(forKey: id)?.cancel()
741
+ }
742
+ } else {
743
+ self.removeControl(id)
744
+ }
745
+ rejectedSearches.append(id)
746
+ } else {
747
+ if created, let previousCover, previousCover !== controller.surface {
748
+ previousCover.removeFromSuperview()
749
+ }
750
+ if let measured = controller.restingTabWidth { tabBarWidths[id] = measured / scale }
751
+ controller.surface.isHidden = self.keyboardVisible && !controller.ownsKeyboard
752
+ }
753
+ }
754
+ self.scrollBindings.update()
755
+ for (id, control) in self.controls where !existing.contains(id) && self.searchControllers[id] == nil {
756
+ NativeUICrossfade.enter(control, duration: duration)
757
+ }
758
+ if let prepared = snapshot.toolbarTransition {
759
+ if self.toolbarTransition == nil {
760
+ let origin = webView.convert(webView.bounds.origin, to: host)
761
+ self.toolbarTransition = NativeUIToolbarTransition(prepared, sources: snapshots, controls: self.controls,
762
+ scale: scale, origin: origin, rendering: self.rendering)
763
+ } else if let transition = self.toolbarTransition { transition.setProgress(transition.progress) }
764
+ }
765
+ self.snapshotControls = snapshot.controls.filter { !rejectedControls.contains($0.id) }
766
+ let complete = {
767
+ var result: [String: Any] = ["revision": next, "rejectedSearches": rejectedSearches, "rejectedControls": rejectedControls, "tabBarWidths": tabBarWidths]
768
+ if let transition = self.toolbarTransition { result["toolbarTransition"] = transition.id }
769
+ resolve(result)
770
+ }
771
+ if let coordinator = searches.first?.0.transitionCoordinator,
772
+ coordinator.animate(alongsideTransition: nil, completion: { _ in complete() }) { return }
773
+ complete()
774
+
775
+ }
776
+ }
777
+
778
+ public func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
779
+ guard item.isEnabled, let itemId = item.accessibilityIdentifier else { return }
780
+ if let controlId = controls.first(where: { $0.value === tabBar })?.key {
781
+ let pending = NativeUITabBar.PendingSelection.start(itemId)
782
+ pendingTabSelections[controlId] = pending
783
+ schedulePendingTabExpiry(controlId, until: pending.until)
784
+ }
785
+ activate(itemId)
786
+ }
787
+
788
+ private func searchChanged(_ id: String, phase: NativeUISearchPhase, value: String,
789
+ composing: Bool, valueVersion: Int) -> Int {
790
+ sequence += 1
791
+ notifyListeners("search", ["id": id, "phase": phase.rawValue, "value": value,
792
+ "composing": composing, "valueVersion": valueVersion, "sequence": sequence, "revision": revision])
793
+ return sequence
794
+ }
795
+
796
+ private func activate(_ id: String) {
797
+ sequence += 1
798
+ notifyListeners("activate", ["id": id, "revision": revision, "sequence": sequence])
799
+
800
+ }
801
+ }