@rdlabo/capacitor-native-ui 0.0.0-stage → 2.0.0-1

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 (261) hide show
  1. package/LICENSE +21 -0
  2. package/Package.swift +23 -0
  3. package/README.md +66 -2
  4. package/RdlaboCapacitorNativeUi.podspec +17 -0
  5. package/android/build.gradle +69 -0
  6. package/android/proguard-rules.pro +0 -0
  7. package/android/src/main/AndroidManifest.xml +1 -0
  8. package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUI.kt +51 -0
  9. package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUIPlugin.kt +192 -0
  10. package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUISnapshot.kt +57 -0
  11. package/dist/_virtual/_rolldown/runtime.js +13 -0
  12. package/dist/core.d.ts +4 -0
  13. package/dist/core.js +3 -0
  14. package/dist/native/activation.d.ts +14 -0
  15. package/dist/native/activation.d.ts.map +1 -0
  16. package/dist/native/activation.js +305 -0
  17. package/dist/native/activation.js.map +1 -0
  18. package/dist/native/components/index.d.ts +19 -0
  19. package/dist/native/components/index.d.ts.map +1 -0
  20. package/dist/native/components/index.js +38 -0
  21. package/dist/native/components/index.js.map +1 -0
  22. package/dist/native/components/ion-back-button.d.ts +10 -0
  23. package/dist/native/components/ion-back-button.d.ts.map +1 -0
  24. package/dist/native/components/ion-back-button.js +21 -0
  25. package/dist/native/components/ion-back-button.js.map +1 -0
  26. package/dist/native/components/ion-button.d.ts +10 -0
  27. package/dist/native/components/ion-button.d.ts.map +1 -0
  28. package/dist/native/components/ion-button.js +25 -0
  29. package/dist/native/components/ion-button.js.map +1 -0
  30. package/dist/native/components/ion-buttons.d.ts +11 -0
  31. package/dist/native/components/ion-buttons.d.ts.map +1 -0
  32. package/dist/native/components/ion-buttons.js +30 -0
  33. package/dist/native/components/ion-buttons.js.map +1 -0
  34. package/dist/native/components/ion-fab.d.ts +10 -0
  35. package/dist/native/components/ion-fab.d.ts.map +1 -0
  36. package/dist/native/components/ion-fab.js +132 -0
  37. package/dist/native/components/ion-fab.js.map +1 -0
  38. package/dist/native/components/ion-menu-button.d.ts +13 -0
  39. package/dist/native/components/ion-menu-button.d.ts.map +1 -0
  40. package/dist/native/components/ion-menu-button.js +44 -0
  41. package/dist/native/components/ion-menu-button.js.map +1 -0
  42. package/dist/native/components/ion-segment.d.ts +11 -0
  43. package/dist/native/components/ion-segment.d.ts.map +1 -0
  44. package/dist/native/components/ion-segment.js +30 -0
  45. package/dist/native/components/ion-segment.js.map +1 -0
  46. package/dist/native/components/ion-tab-bar.d.ts +11 -0
  47. package/dist/native/components/ion-tab-bar.d.ts.map +1 -0
  48. package/dist/native/components/ion-tab-bar.js +50 -0
  49. package/dist/native/components/ion-tab-bar.js.map +1 -0
  50. package/dist/native/components/searchable-tabs.d.ts +20 -0
  51. package/dist/native/components/searchable-tabs.d.ts.map +1 -0
  52. package/dist/native/components/searchable-tabs.js +413 -0
  53. package/dist/native/components/searchable-tabs.js.map +1 -0
  54. package/dist/native/definitions.d.ts +237 -0
  55. package/dist/native/definitions.d.ts.map +1 -0
  56. package/dist/native/definitions.js +0 -0
  57. package/dist/native/extensions.d.ts +31 -0
  58. package/dist/native/extensions.d.ts.map +1 -0
  59. package/dist/native/extensions.js +122 -0
  60. package/dist/native/extensions.js.map +1 -0
  61. package/dist/native/foldable/fold-state.d.ts +5 -0
  62. package/dist/native/foldable/fold-state.d.ts.map +1 -0
  63. package/dist/native/foldable/fold-state.js +15 -0
  64. package/dist/native/foldable/fold-state.js.map +1 -0
  65. package/dist/native/foldable/types.d.ts +12 -0
  66. package/dist/native/foldable/types.d.ts.map +1 -0
  67. package/dist/native/foldable/types.js +0 -0
  68. package/dist/native/html.d.ts +9 -0
  69. package/dist/native/html.d.ts.map +1 -0
  70. package/dist/native/html.js +197 -0
  71. package/dist/native/html.js.map +1 -0
  72. package/dist/native/index.d.ts +10 -0
  73. package/dist/native/index.d.ts.map +1 -0
  74. package/dist/native/index.js +19 -0
  75. package/dist/native/index.js.map +1 -0
  76. package/dist/native/legacy-layout.d.ts +5 -0
  77. package/dist/native/legacy-layout.d.ts.map +1 -0
  78. package/dist/native/legacy-layout.js +27 -0
  79. package/dist/native/legacy-layout.js.map +1 -0
  80. package/dist/native/overlays/content.d.ts +4 -0
  81. package/dist/native/overlays/content.d.ts.map +1 -0
  82. package/dist/native/overlays/content.js +51 -0
  83. package/dist/native/overlays/content.js.map +1 -0
  84. package/dist/native/overlays/controller.d.ts +8 -0
  85. package/dist/native/overlays/controller.d.ts.map +1 -0
  86. package/dist/native/overlays/controller.js +285 -0
  87. package/dist/native/overlays/controller.js.map +1 -0
  88. package/dist/native/overlays/dialog-animation.d.ts +9 -0
  89. package/dist/native/overlays/dialog-animation.d.ts.map +1 -0
  90. package/dist/native/overlays/dialog-animation.js +53 -0
  91. package/dist/native/overlays/dialog-animation.js.map +1 -0
  92. package/dist/native/overlays/dismiss-animation.d.ts +4 -0
  93. package/dist/native/overlays/dismiss-animation.d.ts.map +1 -0
  94. package/dist/native/overlays/dismiss-animation.js +18 -0
  95. package/dist/native/overlays/dismiss-animation.js.map +1 -0
  96. package/dist/native/overlays/focus.d.ts +5 -0
  97. package/dist/native/overlays/focus.d.ts.map +1 -0
  98. package/dist/native/overlays/focus.js +59 -0
  99. package/dist/native/overlays/focus.js.map +1 -0
  100. package/dist/native/overlays/popover.d.ts +12 -0
  101. package/dist/native/overlays/popover.d.ts.map +1 -0
  102. package/dist/native/overlays/popover.js +110 -0
  103. package/dist/native/overlays/popover.js.map +1 -0
  104. package/dist/native/overlays/styles.d.ts +11 -0
  105. package/dist/native/overlays/styles.d.ts.map +1 -0
  106. package/dist/native/overlays/styles.js +167 -0
  107. package/dist/native/overlays/styles.js.map +1 -0
  108. package/dist/native/prehide.d.ts +8 -0
  109. package/dist/native/prehide.d.ts.map +1 -0
  110. package/dist/native/prehide.js +238 -0
  111. package/dist/native/prehide.js.map +1 -0
  112. package/dist/native/runtime.d.ts +7 -0
  113. package/dist/native/runtime.d.ts.map +1 -0
  114. package/dist/native/runtime.js +776 -0
  115. package/dist/native/runtime.js.map +1 -0
  116. package/dist/native/shared/bridge.d.ts +6 -0
  117. package/dist/native/shared/bridge.d.ts.map +1 -0
  118. package/dist/native/shared/bridge.js +18 -0
  119. package/dist/native/shared/bridge.js.map +1 -0
  120. package/dist/native/shared/candidate.d.ts +25 -0
  121. package/dist/native/shared/candidate.d.ts.map +1 -0
  122. package/dist/native/shared/candidate.js +106 -0
  123. package/dist/native/shared/candidate.js.map +1 -0
  124. package/dist/native/shared/clipping.d.ts +5 -0
  125. package/dist/native/shared/clipping.d.ts.map +1 -0
  126. package/dist/native/shared/clipping.js +26 -0
  127. package/dist/native/shared/clipping.js.map +1 -0
  128. package/dist/native/shared/control-options.d.ts +6 -0
  129. package/dist/native/shared/control-options.d.ts.map +1 -0
  130. package/dist/native/shared/control-options.js +17 -0
  131. package/dist/native/shared/control-options.js.map +1 -0
  132. package/dist/native/shared/crossfade.d.ts +12 -0
  133. package/dist/native/shared/crossfade.d.ts.map +1 -0
  134. package/dist/native/shared/crossfade.js +67 -0
  135. package/dist/native/shared/crossfade.js.map +1 -0
  136. package/dist/native/shared/diagnostics.d.ts +4 -0
  137. package/dist/native/shared/diagnostics.d.ts.map +1 -0
  138. package/dist/native/shared/diagnostics.js +18 -0
  139. package/dist/native/shared/diagnostics.js.map +1 -0
  140. package/dist/native/shared/dom.d.ts +46 -0
  141. package/dist/native/shared/dom.d.ts.map +1 -0
  142. package/dist/native/shared/dom.js +180 -0
  143. package/dist/native/shared/dom.js.map +1 -0
  144. package/dist/native/shared/events.d.ts +15 -0
  145. package/dist/native/shared/events.d.ts.map +1 -0
  146. package/dist/native/shared/events.js +17 -0
  147. package/dist/native/shared/events.js.map +1 -0
  148. package/dist/native/shared/icons.d.ts +8 -0
  149. package/dist/native/shared/icons.d.ts.map +1 -0
  150. package/dist/native/shared/icons.js +61 -0
  151. package/dist/native/shared/icons.js.map +1 -0
  152. package/dist/native/shared/modal.d.ts +10 -0
  153. package/dist/native/shared/modal.d.ts.map +1 -0
  154. package/dist/native/shared/modal.js +160 -0
  155. package/dist/native/shared/modal.js.map +1 -0
  156. package/dist/native/shared/projection-id.d.ts +4 -0
  157. package/dist/native/shared/projection-id.d.ts.map +1 -0
  158. package/dist/native/shared/projection-id.js +7 -0
  159. package/dist/native/shared/projection-id.js.map +1 -0
  160. package/dist/native/shared/toolbar-options.d.ts +13 -0
  161. package/dist/native/shared/toolbar-options.d.ts.map +1 -0
  162. package/dist/native/shared/toolbar-options.js +28 -0
  163. package/dist/native/shared/toolbar-options.js.map +1 -0
  164. package/dist/native/shared/vertical-bars-geometry.d.ts +10 -0
  165. package/dist/native/shared/vertical-bars-geometry.d.ts.map +1 -0
  166. package/dist/native/shared/vertical-bars-geometry.js +85 -0
  167. package/dist/native/shared/vertical-bars-geometry.js.map +1 -0
  168. package/dist/native/startup.d.ts +8 -0
  169. package/dist/native/startup.d.ts.map +1 -0
  170. package/dist/native/startup.js +70 -0
  171. package/dist/native/startup.js.map +1 -0
  172. package/dist/native/vertical-bars-web-search.d.ts +10 -0
  173. package/dist/native/vertical-bars-web-search.d.ts.map +1 -0
  174. package/dist/native/vertical-bars-web-search.js +90 -0
  175. package/dist/native/vertical-bars-web-search.js.map +1 -0
  176. package/dist/native/vertical-bars-web.d.ts +5 -0
  177. package/dist/native/vertical-bars-web.d.ts.map +1 -0
  178. package/dist/native/vertical-bars-web.js +422 -0
  179. package/dist/native/vertical-bars-web.js.map +1 -0
  180. package/dist/native-integration/index.d.ts +25 -0
  181. package/dist/native-integration/index.d.ts.map +1 -0
  182. package/dist/native-integration/index.js +33 -0
  183. package/dist/native-integration/index.js.map +1 -0
  184. package/dist/vertical-bars.d.ts +6 -0
  185. package/dist/vertical-bars.js +5 -0
  186. package/docs/appearance.md +45 -0
  187. package/docs/configuration.md +219 -0
  188. package/docs/framework-independent.md +107 -0
  189. package/docs/ionic-vertical-bars.md +173 -0
  190. package/docs/migration.md +184 -0
  191. package/docs/native-ui-extensions.md +394 -0
  192. package/docs/native-ui.md +528 -0
  193. package/docs/setup.md +224 -0
  194. package/docs/vertical-bars.md +431 -0
  195. package/ios/Sources/NativeUIPlugin/Components/NativeUIButton.swift +71 -0
  196. package/ios/Sources/NativeUIPlugin/Components/NativeUIButtons.swift +24 -0
  197. package/ios/Sources/NativeUIPlugin/Components/NativeUIComponents.swift +19 -0
  198. package/ios/Sources/NativeUIPlugin/Components/NativeUIFab.swift +80 -0
  199. package/ios/Sources/NativeUIPlugin/Components/NativeUISearchController.swift +439 -0
  200. package/ios/Sources/NativeUIPlugin/Components/NativeUISegment.swift +91 -0
  201. package/ios/Sources/NativeUIPlugin/Components/NativeUITabBar.swift +218 -0
  202. package/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift +590 -0
  203. package/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalSearch.swift +118 -0
  204. package/ios/Sources/NativeUIPlugin/Extensions/NativeUI.swift +178 -0
  205. package/ios/Sources/NativeUIPlugin/Extensions/NativeUIRendering.swift +38 -0
  206. package/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift +615 -0
  207. package/ios/Sources/NativeUIPlugin/Overlays/NativeUIOverlayController.swift +119 -0
  208. package/ios/Sources/NativeUIPlugin/Overlays/NativeUIOverlayHost.swift +75 -0
  209. package/ios/Sources/NativeUIPlugin/Shared/NativeUIControlRendering.swift +21 -0
  210. package/ios/Sources/NativeUIPlugin/Shared/NativeUICrossfade.swift +35 -0
  211. package/ios/Sources/NativeUIPlugin/Shared/NativeUIHost.swift +8 -0
  212. package/ios/Sources/NativeUIPlugin/Shared/NativeUISnapshot.swift +161 -0
  213. package/ios/Sources/NativeUIPlugin/Shared/NativeUIVerticalBarsGeometry.swift +52 -0
  214. package/ios/Tests/NativeUIPluginTests/NativeUIExtensionTests.swift +165 -0
  215. package/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +805 -0
  216. package/package.json +89 -4
  217. package/src/core.ts +14 -0
  218. package/src/native/activation.ts +385 -0
  219. package/src/native/components/index.ts +45 -0
  220. package/src/native/components/ion-back-button.ts +22 -0
  221. package/src/native/components/ion-button.ts +25 -0
  222. package/src/native/components/ion-buttons.ts +37 -0
  223. package/src/native/components/ion-fab.ts +151 -0
  224. package/src/native/components/ion-menu-button.ts +37 -0
  225. package/src/native/components/ion-segment.ts +27 -0
  226. package/src/native/components/ion-tab-bar.ts +50 -0
  227. package/src/native/components/searchable-tabs.ts +499 -0
  228. package/src/native/definitions.ts +274 -0
  229. package/src/native/extensions.ts +176 -0
  230. package/src/native/foldable/fold-state.ts +10 -0
  231. package/src/native/foldable/types.ts +4 -0
  232. package/src/native/html.ts +199 -0
  233. package/src/native/index.ts +32 -0
  234. package/src/native/legacy-layout.ts +21 -0
  235. package/src/native/overlays/content.ts +51 -0
  236. package/src/native/overlays/controller.ts +349 -0
  237. package/src/native/overlays/dialog-animation.ts +53 -0
  238. package/src/native/overlays/dismiss-animation.ts +15 -0
  239. package/src/native/overlays/focus.ts +64 -0
  240. package/src/native/overlays/popover.ts +120 -0
  241. package/src/native/overlays/styles.ts +181 -0
  242. package/src/native/prehide.ts +285 -0
  243. package/src/native/runtime.ts +979 -0
  244. package/src/native/shared/bridge.ts +18 -0
  245. package/src/native/shared/candidate.ts +162 -0
  246. package/src/native/shared/clipping.ts +24 -0
  247. package/src/native/shared/control-options.ts +20 -0
  248. package/src/native/shared/crossfade.ts +67 -0
  249. package/src/native/shared/diagnostics.ts +13 -0
  250. package/src/native/shared/dom.ts +304 -0
  251. package/src/native/shared/events.ts +14 -0
  252. package/src/native/shared/icons.ts +57 -0
  253. package/src/native/shared/modal.ts +157 -0
  254. package/src/native/shared/projection-id.ts +2 -0
  255. package/src/native/shared/toolbar-options.ts +49 -0
  256. package/src/native/shared/vertical-bars-geometry.ts +80 -0
  257. package/src/native/startup.ts +68 -0
  258. package/src/native/vertical-bars-web-search.ts +86 -0
  259. package/src/native/vertical-bars-web.ts +500 -0
  260. package/src/native-integration/index.ts +49 -0
  261. package/src/vertical-bars.ts +12 -0
@@ -0,0 +1,615 @@
1
+ import Capacitor
2
+ import UIKit
3
+ import SwiftUI
4
+ import WebKit
5
+
6
+ @objc(NativeUIPlugin)
7
+ public class NativeUIPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDelegate {
8
+ public let identifier = "NativeUIPlugin"
9
+ public let jsName = "NativeUI"
10
+ public let pluginMethods: [CAPPluginMethod] = [
11
+ CAPPluginMethod(name: "completeExtensionAction", returnType: CAPPluginReturnPromise),
12
+ CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise),
13
+ CAPPluginMethod(name: "getWebViewMetrics", returnType: CAPPluginReturnPromise),
14
+ CAPPluginMethod(name: "getVerticalBarsGeometry", returnType: CAPPluginReturnPromise),
15
+ CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise),
16
+ CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise),
17
+ CAPPluginMethod(name: "prepareOverlay", returnType: CAPPluginReturnPromise),
18
+ CAPPluginMethod(name: "presentOverlay", returnType: CAPPluginReturnPromise),
19
+ CAPPluginMethod(name: "dismissOverlay", returnType: CAPPluginReturnPromise),
20
+ CAPPluginMethod(name: "closeOverlay", returnType: CAPPluginReturnPromise),
21
+ CAPPluginMethod(name: "stopOverlays", returnType: CAPPluginReturnPromise)
22
+ ]
23
+ private var overlays: NativeUIOverlayController?
24
+ private var host: NativeUIHost?
25
+ private var verticalBars: NativeUIVerticalBarsControlling?
26
+ private var controls: [String: UIView] = [:]
27
+ private var searchControllers: [String: NativeUISearchControlling] = [:]
28
+ private var extensionControllers: [String: NativeUIExtensionController] = [:]
29
+ private var extensionSessions: [String: UUID] = [:]
30
+ private var extensionNodes: [String: NativeUIControl] = [:]
31
+ private let extensionActions = NativeUIExtensionActions()
32
+ private var fingerprints: [String: NativeUIControl] = [:]
33
+ private let rendering = NativeUIControlRendering()
34
+ private var configurationGeneration = 0
35
+ private var revision = 0
36
+ private var sequence = 0
37
+ private var keyboardVisible = false
38
+ private var pendingTabSelections: [String: NativeUITabBar.PendingSelection] = [:]
39
+ private var pendingTabExpiryWorks: [String: DispatchWorkItem] = [:]
40
+ private var restoreTopEdge: (() -> Void)?
41
+ private var observers: [NSObjectProtocol] = []
42
+ private var lastWebViewRadius: Double?
43
+ private var lastVerticalBarEdge: String?
44
+
45
+ public override func load() {
46
+ #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
47
+ if #available(iOS 27.1, *), let webView = bridge?.webView {
48
+ webView.registerForTraitChanges(UITraitCollection.systemTraitsAffectingVerticalBarEdge) { [weak self] (_: WKWebView, _: UITraitCollection) in
49
+ self?.notifyWebViewMetricsChange()
50
+ }
51
+ }
52
+ #endif
53
+ for name in [UIApplication.didEnterBackgroundNotification, UIResponder.keyboardWillChangeFrameNotification, UIResponder.keyboardWillHideNotification,
54
+ UIDevice.orientationDidChangeNotification] {
55
+ observers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] notification in
56
+ guard let self else { return }
57
+ let keyboard = name == UIResponder.keyboardWillChangeFrameNotification || name == UIResponder.keyboardWillHideNotification
58
+ if keyboard {
59
+ if name == UIResponder.keyboardWillHideNotification { self.keyboardVisible = false }
60
+ else if let window = self.bridge?.webView?.window,
61
+ let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
62
+ let local = window.convert(frame, from: window.screen.coordinateSpace)
63
+ let overlap = window.bounds.intersection(local)
64
+ self.keyboardVisible = !overlap.isNull && overlap.width > 0 && overlap.height > 0
65
+ }
66
+ }
67
+ if !keyboard {
68
+ self.host?.isHidden = true
69
+ self.verticalBars?.view.isHidden = true
70
+ self.extensionControllers.values.forEach { $0.view.isHidden = true }
71
+ }
72
+ var searchOwnsKeyboard = self.verticalBars?.ownsKeyboardChrome == true
73
+ self.searchControllers.values.forEach { controller in
74
+ if controller.ownsKeyboardChrome { searchOwnsKeyboard = true }
75
+ if !keyboard { controller.surface.isHidden = true }
76
+ }
77
+ if keyboard {
78
+ if !searchOwnsKeyboard {
79
+ self.bridge?.triggerWindowJSEvent(eventName: "nativeUIRefresh")
80
+ }
81
+ } else if name == UIDevice.orientationDidChangeNotification {
82
+ self.notifyWebViewMetricsChange()
83
+ }
84
+ })
85
+ }
86
+ for name in [UIApplication.didBecomeActiveNotification, UIResponder.keyboardDidHideNotification,
87
+ UIDevice.orientationDidChangeNotification] {
88
+ observers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
89
+ if name == UIResponder.keyboardDidHideNotification { self?.keyboardVisible = false }
90
+ self?.bridge?.triggerWindowJSEvent(eventName: "nativeUIRefresh", data: name == UIApplication.didBecomeActiveNotification ? "{\"retireSearch\":true}" : "{}")
91
+ if name == UIApplication.didBecomeActiveNotification { self?.notifyWebViewMetricsChange() }
92
+ })
93
+ }
94
+ }
95
+
96
+ deinit {
97
+ observers.forEach(NotificationCenter.default.removeObserver)
98
+ }
99
+
100
+ @objc func prepareOverlay(_ call: CAPPluginCall) {
101
+ DispatchQueue.main.async { [weak self] in
102
+ guard let self, let id = call.getString("id"),
103
+ let source = self.bridge?.webView, let owner = self.bridge?.viewController else {
104
+ call.reject("Popover source unavailable"); return
105
+ }
106
+ if self.overlays == nil {
107
+ self.overlays = NativeUIOverlayController(source: source, owner: owner) { [weak self] id, action in
108
+ self?.notifyListeners("overlay", data: ["id": id, "action": action])
109
+ }
110
+ }
111
+ guard self.overlays?.prepare(id) == true else { call.reject("Popover already pending"); return }
112
+ call.resolve()
113
+ }
114
+ }
115
+
116
+ @objc func presentOverlay(_ call: CAPPluginCall) {
117
+ DispatchQueue.main.async { [weak self] in
118
+ guard let self, let overlays = self.overlays, let id = call.getString("id") else {
119
+ call.reject("Popover not prepared"); return
120
+ }
121
+ overlays.present(id, options: call.getObject("presentation"), anchored: { [weak self] anchor, host, completion in
122
+ guard let self, let rail = self.verticalBars,
123
+ rail.presentPopover(anchor: anchor, host: host, completion: completion) else { return false }
124
+ let cancel = host.cancelAnchored
125
+ host.cancelAnchored = { [weak self, weak rail] in
126
+ cancel?()
127
+ // Cancellation removed the rail; invalidate native ownership even
128
+ // if the next DOM snapshot is identical to the previous one.
129
+ if let self, let rail, self.verticalBars === rail { self.verticalBars = nil }
130
+ self?.bridge?.triggerWindowJSEvent(eventName: "nativeUIRefresh")
131
+ }
132
+ return true
133
+ }) { presented in
134
+ if presented { call.resolve() } else { call.reject("Native Vertical Bars anchor unavailable") }
135
+ }
136
+ }
137
+ }
138
+
139
+ @objc func dismissOverlay(_ call: CAPPluginCall) {
140
+ DispatchQueue.main.async { [weak self] in
141
+ guard let overlays = self?.overlays, let id = call.getString("id") else { call.resolve(); return }
142
+ overlays.dismiss(id, animated: call.getBool("animated") ?? false) { call.resolve() }
143
+ }
144
+ }
145
+
146
+ @objc func closeOverlay(_ call: CAPPluginCall) {
147
+ DispatchQueue.main.async { [weak self] in
148
+ guard let overlays = self?.overlays, let id = call.getString("id") else { call.resolve(); return }
149
+ overlays.close(id) { call.resolve() }
150
+ }
151
+ }
152
+
153
+ @objc func stopOverlays(_ call: CAPPluginCall) {
154
+ DispatchQueue.main.async { [weak self] in
155
+ guard let self, let overlays = self.overlays else { call.resolve(); return }
156
+ overlays.stop {
157
+ if self.overlays === overlays { self.overlays = nil }
158
+ call.resolve()
159
+ }
160
+ }
161
+ }
162
+
163
+ private func webViewMetrics() -> JSObject? {
164
+ guard #available(iOS 26.0, *), let webView = bridge?.webView else { return nil }
165
+ webView.layoutIfNeeded()
166
+ var metrics: JSObject = ["radius": Double(webView.effectiveRadius(corner: .topLeft)), "verticalBarEdge": NSNull()]
167
+ #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
168
+ if #available(iOS 27.1, *) {
169
+ switch webView.traitCollection.verticalBarEdge {
170
+ case .leading: metrics["verticalBarEdge"] = "leading"
171
+ case .trailing: metrics["verticalBarEdge"] = "trailing"
172
+ default: break
173
+ }
174
+ }
175
+ #endif
176
+ return metrics
177
+ }
178
+
179
+ private func notifyWebViewMetricsChange() {
180
+ let metrics = webViewMetrics() ?? ["radius": 0]
181
+ let radius = metrics["radius"] as? Double ?? 0
182
+ let edge = metrics["verticalBarEdge"] as? String
183
+ guard radius != lastWebViewRadius || edge != lastVerticalBarEdge else { return }
184
+ lastVerticalBarEdge = edge
185
+ lastWebViewRadius = radius
186
+ notifyListeners("webViewMetricsChange", data: metrics)
187
+ }
188
+
189
+ @objc func getWebViewMetrics(_ call: CAPPluginCall) {
190
+ DispatchQueue.main.async { [weak self] in
191
+ call.resolve(self?.webViewMetrics() ?? ["radius": 0])
192
+ }
193
+ }
194
+
195
+ @objc func configure(_ call: CAPPluginCall) {
196
+ DispatchQueue.main.async { [weak self] in
197
+ guard let self else { call.resolve(["supported": false]); return }
198
+ self.configurationGeneration += 1
199
+ let generation = self.configurationGeneration
200
+ let configure = { [self] in
201
+ guard generation == self.configurationGeneration else {
202
+ call.reject("Configuration superseded"); return
203
+ }
204
+ // A new JS context starts revision numbering again (live reload / navigation).
205
+ self.restoreTopEdge?()
206
+ self.restoreTopEdge = nil
207
+ self.removeControls()
208
+ self.revision = 0
209
+ if #available(iOS 26.0, *) {
210
+ self.bridge?.webView?.layoutIfNeeded()
211
+ // Ionic already paints the header edge; a second native effect can
212
+ // add a dark scrim when the OS and Web themes differ.
213
+ if call.getBool("suppressScrollEdgeEffect") == true, let effect = self.bridge?.webView?.scrollView.topEdgeEffect {
214
+ let hidden = effect.isHidden
215
+ effect.isHidden = true
216
+ self.restoreTopEdge = { [weak effect] in effect?.isHidden = hidden }
217
+ }
218
+ call.resolve(["supported": true, "extensions": NativeUI.names])
219
+ }
220
+ else { call.resolve(["supported": false]) }
221
+ }
222
+ // A replacement JS context cannot close its predecessor's child window.
223
+ // Finish dismissing it while its rail still owns the SwiftUI presentation.
224
+ if let overlays = self.overlays {
225
+ overlays.stop {
226
+ if self.overlays === overlays { self.overlays = nil }
227
+ configure()
228
+ }
229
+ } else { configure() }
230
+ }
231
+ }
232
+
233
+ @objc func getVerticalBarsGeometry(_ call: CAPPluginCall) {
234
+ DispatchQueue.main.async { [weak self] in
235
+ guard let self, let webView = self.bridge?.webView, let rail = self.verticalBars,
236
+ let width = call.getDouble("viewportWidth"), width.isFinite, width > 0,
237
+ webView.bounds.width > 0 else { call.resolve(["items": [:]]); return }
238
+ let scale = width / webView.bounds.width
239
+ let ids = Set(call.getArray("ids", String.self) ?? [])
240
+ let measured = NativeUIVerticalBarsGeometry.measure(in: rail.view, relativeTo: webView, ids: ids).merging(rail.measuredFrames(relativeTo: webView).filter { ids.contains($0.key) }) { _, swiftUI in swiftUI }
241
+ let items = measured.mapValues { frame in
242
+ ["x": frame.minX * scale, "y": frame.minY * scale,
243
+ "width": frame.width * scale, "height": frame.height * scale]
244
+ }
245
+ call.resolve(["revision": self.revision, "items": items])
246
+ }
247
+ }
248
+
249
+ @objc func completeExtensionAction(_ call: CAPPluginCall) {
250
+ DispatchQueue.main.async { [weak self] in
251
+ guard let request = call.getString("requestId"), let accepted = call.getBool("accepted") else {
252
+ call.reject("Invalid extension acknowledgement"); return
253
+ }
254
+ self?.extensionActions.finish(request, error: accepted ? nil : NativeUIError.rejected)
255
+ call.resolve()
256
+ }
257
+ }
258
+
259
+ @MainActor private func extensionContent(_ node: NativeUIControl, viewportWidth: CGFloat, scale: CGFloat) throws -> AnyView {
260
+ guard let data = node.extensionContent else { throw NativeUIError.unavailable }
261
+ let session = extensionSessions[node.id] ?? UUID()
262
+ extensionSessions[node.id] = session
263
+ let actions = NativeUIActions(availableNames: Set(data.actions.keys)) { [weak self] name in
264
+ guard let self, self.extensionNodes[node.id] == node, self.extensionSessions[node.id] == session, let target = data.actions[name] else {
265
+ throw NativeUIError.unavailable
266
+ }
267
+ try await self.extensionActions.perform(owner: node.id) { request in
268
+ self.sequence += 1
269
+ self.notifyListeners("activate", data: ["id": target, "revision": self.revision,
270
+ "sequence": self.sequence, "requestId": request])
271
+ }
272
+ }
273
+ let context = NativeUIContext(node: node, viewportWidth: viewportWidth, pointsPerCSSPixel: scale)
274
+ return AnyView(try NativeUI.content(data, actions: actions, context: context)
275
+ .environment(\.layoutDirection, node.rtl ? .rightToLeft : .leftToRight)
276
+ .environment(\.colorScheme, node.dark ? .dark : .light)
277
+ .id(node.id))
278
+ }
279
+
280
+ @objc func clear(_ call: CAPPluginCall) {
281
+ DispatchQueue.main.async { [weak self] in
282
+ guard let self else { call.resolve(); return }
283
+ let next = call.getInt("revision") ?? 0
284
+ if next >= self.revision {
285
+ self.revision = next
286
+ self.restoreTopEdge?()
287
+ self.restoreTopEdge = nil
288
+ self.removeControls()
289
+ }
290
+ call.resolve()
291
+ }
292
+ }
293
+
294
+ private func removeControl(_ id: String, duration: TimeInterval = 0) {
295
+ extensionActions.cancel(owner: id)
296
+ extensionNodes.removeValue(forKey: id)
297
+ extensionSessions.removeValue(forKey: id)
298
+ let extensionController = extensionControllers.removeValue(forKey: id)
299
+ searchControllers.removeValue(forKey: id)?.detach()
300
+ let control = controls.removeValue(forKey: id)
301
+ if let extensionController { extensionController.detach(duration: duration) }
302
+ else if let control { NativeUICrossfade.retire(control, duration: duration) }
303
+ fingerprints.removeValue(forKey: id)
304
+ pendingTabSelections.removeValue(forKey: id)
305
+ pendingTabExpiryWorks.removeValue(forKey: id)?.cancel()
306
+ }
307
+
308
+ private func removeControls(duration: TimeInterval = 0) {
309
+ extensionActions.cancel()
310
+ extensionNodes.removeAll()
311
+ extensionSessions.removeAll()
312
+ Array(controls.keys).forEach { removeControl($0, duration: duration) }
313
+ host?.removeFromSuperview()
314
+ host = nil
315
+ verticalBars?.detach(duration: duration)
316
+ verticalBars = nil
317
+ rendering.clear()
318
+ pendingTabSelections.removeAll()
319
+ pendingTabExpiryWorks.values.forEach { $0.cancel() }
320
+ pendingTabExpiryWorks.removeAll()
321
+ }
322
+
323
+ private func syncTabBar(_ tabBar: UITabBar, id: String, node: NativeUIControl) {
324
+ var pending = pendingTabSelections[id]
325
+ NativeUITabBar.update(tabBar, node: node, rendering: rendering, pendingSelection: &pending)
326
+ if let pending {
327
+ pendingTabSelections[id] = pending
328
+ } else {
329
+ pendingTabSelections.removeValue(forKey: id)
330
+ pendingTabExpiryWorks.removeValue(forKey: id)?.cancel()
331
+ }
332
+ }
333
+
334
+ private func schedulePendingTabExpiry(_ id: String, until: CFAbsoluteTime) {
335
+ pendingTabExpiryWorks[id]?.cancel()
336
+ let delay = max(0, until - CFAbsoluteTimeGetCurrent()) + 0.02
337
+ let work = DispatchWorkItem { [weak self] in
338
+ guard let self else { return }
339
+ self.pendingTabExpiryWorks.removeValue(forKey: id)
340
+ guard let pending = self.pendingTabSelections[id],
341
+ CFAbsoluteTimeGetCurrent() >= pending.until,
342
+ let tabBar = self.controls[id] as? UITabBar,
343
+ let node = self.fingerprints[id] else { return }
344
+ self.syncTabBar(tabBar, id: id, node: node)
345
+ }
346
+ pendingTabExpiryWorks[id] = work
347
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
348
+ }
349
+
350
+ @objc func update(_ call: CAPPluginCall) {
351
+ DispatchQueue.main.async { [weak self] in
352
+ guard let self else { call.reject("Plugin released"); return }
353
+ guard #available(iOS 26.0, *) else { call.reject("Requires iOS 26"); return }
354
+ let next = call.getInt("revision") ?? 0
355
+ guard next > self.revision else { call.resolve(["revision": self.revision]); return }
356
+ guard let webView = self.bridge?.webView, let parent = webView.superview else {
357
+ call.reject("WebView unavailable"); return
358
+ }
359
+ // Decode and validate the entire batch before changing visible controls.
360
+ guard let snapshot = try? call.decode(NativeUISnapshot.self), snapshot.isValid else {
361
+ call.reject("Invalid control snapshot"); return
362
+ }
363
+ self.notifyWebViewMetricsChange()
364
+ let duration = NativeUICrossfade.duration(snapshot.transitionDuration)
365
+ let existing = Set(self.controls.keys)
366
+ let verticalBars = snapshot.controls.filter { $0.placement == .verticalBars }
367
+ let accessories = snapshot.controls.filter { $0.extensionContent?.tabBarId != nil }
368
+ let snapshots = snapshot.controls.filter { $0.placement != .verticalBars && $0.extensionContent?.tabBarId == nil }
369
+ let extensionIDs = Set(snapshot.controls.filter { $0.kind == .extension }.map(\.id))
370
+ for id in Array(self.extensionNodes.keys) where !extensionIDs.contains(id) {
371
+ self.extensionActions.cancel(owner: id)
372
+ self.extensionNodes.removeValue(forKey: id)
373
+ self.extensionSessions.removeValue(forKey: id)
374
+ }
375
+ let verticalSearchActive = verticalBars.contains { $0.search.map { $0.available && $0.active } ?? false }
376
+ let width = snapshot.viewportWidth
377
+ self.revision = next
378
+ if snapshots.isEmpty && verticalBars.isEmpty && accessories.isEmpty {
379
+ self.removeControls(duration: duration)
380
+ call.resolve(["revision": next]); return
381
+ }
382
+ let scale = webView.bounds.width / width
383
+ let retained = Set(snapshots.map(\.id))
384
+ for id in Array(self.controls.keys) where !retained.contains(id) {
385
+ self.removeControl(id, duration: duration)
386
+ }
387
+ var rejectedControls: [String] = []
388
+ var fabs: [(NativeUIFab, NativeUIControl)] = []
389
+ var searches: [(NativeUISearchControlling, NativeUIControl, CGRect, CGRect, UIView?, Bool)] = []
390
+ var rejectedSearches: [String] = []
391
+ if verticalBars.isEmpty || (self.keyboardVisible && !verticalSearchActive) {
392
+ self.verticalBars?.detach(duration: duration)
393
+ self.verticalBars = nil
394
+ if self.keyboardVisible { rejectedControls.append(contentsOf: verticalBars.map(\.id)) }
395
+ } else if let owner = self.bridge?.viewController {
396
+ let entering = self.verticalBars == nil
397
+ let rail = self.verticalBars ?? NativeUIVerticalBarsController(
398
+ activate: { [weak self] id in self?.activate(id) },
399
+ changed: { [weak self] id, phase, value, composing, valueVersion in
400
+ self?.searchChanged(id, phase: phase, value: value, composing: composing, valueVersion: valueVersion) ?? 0
401
+ })
402
+ self.verticalBars = rail
403
+ rail.attach(to: owner, in: owner.view)
404
+ if let frame = snapshot.verticalBarFrame {
405
+ rail.view.frame = webView.convert(frame.rect.applying(CGAffineTransform(scaleX: scale, y: scale)), to: owner.view)
406
+ } else {
407
+ rail.view.frame = owner.view.bounds
408
+ }
409
+ // The shell owns the entry crossfade; later native updates keep SwiftUI motion.
410
+ rail.apply(verticalBars, rendering: self.rendering, edge: snapshot.verticalBarEdge ?? "right", animated: !entering)
411
+ rail.view.isHidden = false
412
+ if entering { NativeUICrossfade.enter(rail.view, duration: duration) }
413
+ } else {
414
+ rejectedControls.append(contentsOf: verticalBars.map(\.id))
415
+ }
416
+ // A system tab host has one accessory slot. Ambiguous owners stay on the Web.
417
+ var accessoryContent: AnyView?
418
+ for node in accessories {
419
+ guard #available(iOS 26.1, *), accessories.count == 1, !self.keyboardVisible, self.verticalBars != nil,
420
+ verticalBars.filter({ $0.kind == .tabBar }).map(\.id) == [node.extensionContent!.tabBarId!],
421
+ let content = try? self.extensionContent(node, viewportWidth: width, scale: scale) else {
422
+ rejectedControls.append(node.id)
423
+ self.extensionNodes.removeValue(forKey: node.id)
424
+ self.extensionSessions.removeValue(forKey: node.id)
425
+ self.extensionActions.cancel(owner: node.id)
426
+ continue
427
+ }
428
+ self.extensionNodes[node.id] = node
429
+ accessoryContent = content
430
+ }
431
+ self.verticalBars?.setAccessory(accessoryContent)
432
+ if snapshots.isEmpty {
433
+ self.host?.removeFromSuperview()
434
+ self.host = nil
435
+ call.resolve(["revision": next, "rejectedControls": rejectedControls])
436
+ return
437
+ }
438
+ let host = self.host ?? NativeUIHost()
439
+ self.host = host
440
+ host.frame = parent.bounds
441
+ host.autoresizingMask = [.flexibleWidth, .flexibleHeight]
442
+ host.backgroundColor = .clear
443
+ host.isAccessibilityElement = false
444
+ UIView.performWithoutAnimation {
445
+ if host.superview !== parent { parent.addSubview(host) }
446
+ for node in snapshots {
447
+ let id = node.id
448
+ if node.kind != .extension, self.extensionControllers[id] != nil { self.removeControl(id) }
449
+ let previous = self.controls[id]
450
+ let previousBounds = previous?.bounds
451
+ let previousCenter = previous?.center
452
+ let reject = {
453
+ // Keep the old cover until JS paints Web and omits this ID.
454
+ if let previous, self.controls[id] === previous {
455
+ previous.bounds = previousBounds!
456
+ previous.center = previousCenter!
457
+ previous.layoutIfNeeded()
458
+ } else { self.removeControl(id) }
459
+ rejectedControls.append(id)
460
+ }
461
+ // Only native search owns its keyboard; other controls return to Web.
462
+ if self.keyboardVisible && self.searchControllers[id]?.ownsKeyboard != true {
463
+ reject(); continue
464
+ }
465
+ let local = node.frame.rect
466
+ let bounds = webView.convert(CGRect(x: local.minX * scale, y: local.minY * scale,
467
+ width: local.width * scale, height: local.height * scale), to: parent)
468
+ if node.kind == .extension {
469
+ guard let owner = self.bridge?.viewController, let content = try? self.extensionContent(node, viewportWidth: width, scale: scale) else {
470
+ self.extensionNodes.removeValue(forKey: id)
471
+ self.extensionSessions.removeValue(forKey: id)
472
+ self.extensionActions.cancel(owner: id)
473
+ reject(); continue
474
+ }
475
+ if let controller = self.extensionControllers[id] {
476
+ controller.update(content)
477
+ } else {
478
+ if let previous = self.controls.removeValue(forKey: id) { previous.removeFromSuperview() }
479
+ self.searchControllers.removeValue(forKey: id)?.detach()
480
+ let controller = NativeUIExtensionController(content: content, owner: owner)
481
+ self.extensionControllers[id] = controller
482
+ self.controls[id] = controller.view
483
+ }
484
+ self.extensionNodes[id] = node
485
+ self.controls[id]?.frame = parent.convert(bounds, to: owner.view)
486
+ self.controls[id]?.isHidden = false
487
+ self.controls[id]?.overrideUserInterfaceStyle = node.dark ? .dark : .light
488
+ continue
489
+ }
490
+ if let search = node.search {
491
+ guard let owner = self.bridge?.viewController else { rejectedSearches.append(id); continue }
492
+ let controller: NativeUISearchControlling
493
+ let previousCover = self.controls[id]
494
+ let replacing = self.searchControllers[id]
495
+ if let existing = replacing { controller = existing }
496
+ else {
497
+ // Keep the ordinary UITabBar cover until the search controller applies.
498
+ controller = NativeUISearchController()
499
+ controller.activate = { [weak self] id in self?.activate(id) }
500
+ controller.changed = { [weak self] id, phase, value, composing, valueVersion in
501
+ self?.searchChanged(id, phase: phase, value: value, composing: composing, valueVersion: valueVersion) ?? 0
502
+ }
503
+ controller.attach(to: owner, in: owner.view)
504
+ self.searchControllers[id] = controller
505
+ self.controls[id] = controller.surface
506
+ }
507
+ let trigger = search.trigger.frame.rect
508
+ let searchBarFrame = webView.convert(CGRect(x: webView.bounds.minX + local.minX * scale,
509
+ y: webView.bounds.minY + local.minY * scale, width: local.width * scale, height: local.height * scale), to: owner.view)
510
+ let triggerFrame = webView.convert(CGRect(x: webView.bounds.minX + trigger.minX * scale,
511
+ y: webView.bounds.minY + trigger.minY * scale, width: trigger.width * scale, height: trigger.height * scale), to: owner.view)
512
+ searches.append((controller, node, searchBarFrame, triggerFrame, previousCover, replacing == nil))
513
+ continue
514
+ } else if self.searchControllers[id] != nil {
515
+ self.removeControl(id)
516
+ }
517
+ if self.fingerprints[id] != node {
518
+ if node.kind == NativeUIFab.kind {
519
+ let fab = (self.controls[id] as? NativeUIFab) ?? NativeUIFab()
520
+ if fab.superview == nil { host.addSubview(fab) }
521
+ self.controls[id] = fab
522
+ fabs.append((fab, node))
523
+ } else if let segment = self.controls[id] as? NativeUISegment, node.kind == NativeUISegment.kind {
524
+ segment.update(node, scale: scale, rendering: self.rendering)
525
+ } else if let tabBar = self.controls[id] as? UITabBar, node.kind == NativeUITabBar.kind {
526
+ self.syncTabBar(tabBar, id: id, node: node)
527
+ } else {
528
+ guard let control = NativeUIComponents.make(node, scale: scale, rendering: self.rendering, tabDelegate: self,
529
+ activate: { [weak self] id in self?.activate(id) }) else { reject(); continue }
530
+ self.controls.removeValue(forKey: id)?.removeFromSuperview()
531
+ self.controls[id] = control
532
+ host.addSubview(control)
533
+ }
534
+ self.fingerprints[id] = node
535
+ } else if let tabBar = self.controls[id] as? UITabBar, self.pendingTabSelections[id] != nil {
536
+ // Resolve an in-flight native tap even when other fingerprint fields are unchanged.
537
+ self.syncTabBar(tabBar, id: id, node: node)
538
+ }
539
+ guard let control = self.controls[id] else { reject(); continue }
540
+ if let tabBar = control as? UITabBar {
541
+ guard NativeUITabBar.fit(tabBar, node: node, bounds: bounds) else { reject(); continue }
542
+ } else if control.frame != bounds {
543
+ control.frame = bounds
544
+ }
545
+ control.overrideUserInterfaceStyle = node.dark ? .dark : .light
546
+ }
547
+ host.isHidden = false
548
+ host.layoutIfNeeded()
549
+ }
550
+ // FABs update in place. Only icon content animates; the glass surface
551
+ // and Ionic's staggered child visibility keep their own identities.
552
+ for (fab, node) in fabs {
553
+ fab.apply(node, scale: scale, rendering: self.rendering, activate: { [weak self] id in self?.activate(id) })
554
+ }
555
+ for (controller, node, frame, triggerFrame, previousCover, created) in searches {
556
+ let id = node.id
557
+ let webFrame = webView.convert(webView.bounds, to: controller.surface.superview)
558
+ if !controller.apply(node, webFrame: webFrame, barFrame: frame, triggerFrame: triggerFrame, rendering: self.rendering) {
559
+ if created {
560
+ self.searchControllers.removeValue(forKey: id)
561
+ controller.detach()
562
+ if let previousCover, previousCover.superview != nil {
563
+ self.controls[id] = previousCover
564
+ } else {
565
+ self.controls.removeValue(forKey: id)?.removeFromSuperview()
566
+ self.fingerprints.removeValue(forKey: id)
567
+ self.pendingTabSelections[id] = nil
568
+ self.pendingTabExpiryWorks.removeValue(forKey: id)?.cancel()
569
+ }
570
+ } else {
571
+ self.removeControl(id)
572
+ }
573
+ rejectedSearches.append(id)
574
+ } else {
575
+ if created, let previousCover, previousCover !== controller.surface {
576
+ previousCover.removeFromSuperview()
577
+ }
578
+ controller.surface.isHidden = self.keyboardVisible && !controller.ownsKeyboard
579
+ }
580
+ }
581
+ for (id, control) in self.controls where !existing.contains(id) && self.searchControllers[id] == nil {
582
+ NativeUICrossfade.enter(control, duration: duration)
583
+ }
584
+ let complete = { call.resolve(["revision": next, "rejectedSearches": rejectedSearches, "rejectedControls": rejectedControls]) }
585
+ if let coordinator = searches.first?.0.transitionCoordinator,
586
+ coordinator.animate(alongsideTransition: nil, completion: { _ in complete() }) { return }
587
+ complete()
588
+
589
+ }
590
+ }
591
+
592
+ public func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
593
+ guard item.isEnabled, let itemId = item.accessibilityIdentifier else { return }
594
+ if let controlId = controls.first(where: { $0.value === tabBar })?.key {
595
+ let pending = NativeUITabBar.PendingSelection.start(itemId)
596
+ pendingTabSelections[controlId] = pending
597
+ schedulePendingTabExpiry(controlId, until: pending.until)
598
+ }
599
+ activate(itemId)
600
+ }
601
+
602
+ private func searchChanged(_ id: String, phase: NativeUISearchPhase, value: String,
603
+ composing: Bool, valueVersion: Int) -> Int {
604
+ sequence += 1
605
+ notifyListeners("search", data: ["id": id, "phase": phase.rawValue, "value": value,
606
+ "composing": composing, "valueVersion": valueVersion, "sequence": sequence, "revision": revision])
607
+ return sequence
608
+ }
609
+
610
+ private func activate(_ id: String) {
611
+ sequence += 1
612
+ notifyListeners("activate", data: ["id": id, "revision": revision, "sequence": sequence])
613
+
614
+ }
615
+ }