@rdlabo/ionic-theme-ios27 1.2.1 → 2.0.0-beta.pr303.shaf4f5ca16816f

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 (195) hide show
  1. package/README.md +24 -280
  2. package/dist/css/components/ion-toolbar.css +1 -1
  3. package/dist/css/ionic-theme-ios27.css +1 -1
  4. package/dist/css/vertical-bars.css +1 -1
  5. package/dist/index.d.ts +3 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +3 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/layout/index.d.ts +18 -0
  10. package/dist/layout/index.d.ts.map +1 -0
  11. package/dist/layout/index.js +26 -0
  12. package/dist/layout/index.js.map +1 -0
  13. package/dist/segment/index.js +3 -3
  14. package/dist/segment/index.js.map +1 -1
  15. package/dist/sheets-of-glass/index.js +7 -7
  16. package/dist/sheets-of-glass/index.js.map +1 -1
  17. package/dist/tab-bar-searchable/dismiss-button.js +53 -3
  18. package/dist/tab-bar-searchable/dismiss-button.js.map +1 -1
  19. package/dist/tab-bar-searchable/index.js +18 -3
  20. package/dist/tab-bar-searchable/index.js.map +1 -1
  21. package/dist/transition/ios.transition.d.ts.map +1 -1
  22. package/dist/transition/ios.transition.js +7 -4
  23. package/dist/transition/ios.transition.js.map +1 -1
  24. package/dist/transition/native-ui.d.ts +6 -0
  25. package/dist/transition/native-ui.d.ts.map +1 -0
  26. package/dist/{native-integration/index.js → transition/native-ui.js} +16 -30
  27. package/dist/transition/native-ui.js.map +1 -0
  28. package/docs/adaptive-themes.md +151 -0
  29. package/docs/add-native-ui.md +53 -0
  30. package/docs/animation.md +1 -1
  31. package/docs/eslint.md +1 -1
  32. package/docs/features.md +3 -3
  33. package/docs/iphone-duo-with-original-theme.md +40 -28
  34. package/docs/iphone-duo.md +29 -32
  35. package/docs/migration.md +12 -24
  36. package/docs/setup.md +82 -0
  37. package/docs/special-markup.md +5 -5
  38. package/docs/using-ion-item-group.md +2 -2
  39. package/docs/vertical-bars-layout.md +47 -0
  40. package/package.json +12 -42
  41. package/src/index.ts +3 -1
  42. package/src/layout/index.ts +29 -0
  43. package/src/segment/index.ts +3 -3
  44. package/src/sheets-of-glass/index.ts +7 -7
  45. package/src/styles/components/ion-toolbar.scss +1 -1
  46. package/src/styles/vertical-bars.scss +15 -18
  47. package/src/tab-bar-searchable/dismiss-button.ts +73 -4
  48. package/src/tab-bar-searchable/index.ts +20 -3
  49. package/src/transition/ios.transition.ts +7 -4
  50. package/src/{native-integration/index.ts → transition/native-ui.ts} +17 -46
  51. package/Package.swift +0 -21
  52. package/dist/_virtual/_rolldown/runtime.js +0 -13
  53. package/dist/native/components/index.d.ts +0 -19
  54. package/dist/native/components/index.d.ts.map +0 -1
  55. package/dist/native/components/index.js +0 -40
  56. package/dist/native/components/index.js.map +0 -1
  57. package/dist/native/components/ion-back-button.d.ts +0 -9
  58. package/dist/native/components/ion-back-button.d.ts.map +0 -1
  59. package/dist/native/components/ion-back-button.js +0 -20
  60. package/dist/native/components/ion-back-button.js.map +0 -1
  61. package/dist/native/components/ion-button.d.ts +0 -10
  62. package/dist/native/components/ion-button.d.ts.map +0 -1
  63. package/dist/native/components/ion-button.js +0 -23
  64. package/dist/native/components/ion-button.js.map +0 -1
  65. package/dist/native/components/ion-buttons.d.ts +0 -11
  66. package/dist/native/components/ion-buttons.d.ts.map +0 -1
  67. package/dist/native/components/ion-buttons.js +0 -30
  68. package/dist/native/components/ion-buttons.js.map +0 -1
  69. package/dist/native/components/ion-fab.d.ts +0 -10
  70. package/dist/native/components/ion-fab.d.ts.map +0 -1
  71. package/dist/native/components/ion-fab.js +0 -132
  72. package/dist/native/components/ion-fab.js.map +0 -1
  73. package/dist/native/components/ion-menu-button.d.ts +0 -13
  74. package/dist/native/components/ion-menu-button.d.ts.map +0 -1
  75. package/dist/native/components/ion-menu-button.js +0 -31
  76. package/dist/native/components/ion-menu-button.js.map +0 -1
  77. package/dist/native/components/ion-segment.d.ts +0 -11
  78. package/dist/native/components/ion-segment.d.ts.map +0 -1
  79. package/dist/native/components/ion-segment.js +0 -30
  80. package/dist/native/components/ion-segment.js.map +0 -1
  81. package/dist/native/components/ion-tab-bar.d.ts +0 -11
  82. package/dist/native/components/ion-tab-bar.d.ts.map +0 -1
  83. package/dist/native/components/ion-tab-bar.js +0 -50
  84. package/dist/native/components/ion-tab-bar.js.map +0 -1
  85. package/dist/native/components/searchable-tabs.d.ts +0 -20
  86. package/dist/native/components/searchable-tabs.d.ts.map +0 -1
  87. package/dist/native/components/searchable-tabs.js +0 -383
  88. package/dist/native/components/searchable-tabs.js.map +0 -1
  89. package/dist/native/definitions.d.ts +0 -152
  90. package/dist/native/definitions.d.ts.map +0 -1
  91. package/dist/native/definitions.js +0 -0
  92. package/dist/native/foldable/fold-state.d.ts +0 -5
  93. package/dist/native/foldable/fold-state.d.ts.map +0 -1
  94. package/dist/native/foldable/fold-state.js +0 -14
  95. package/dist/native/foldable/fold-state.js.map +0 -1
  96. package/dist/native/foldable/types.d.ts +0 -12
  97. package/dist/native/foldable/types.d.ts.map +0 -1
  98. package/dist/native/foldable/types.js +0 -0
  99. package/dist/native/index.d.ts +0 -11
  100. package/dist/native/index.d.ts.map +0 -1
  101. package/dist/native/index.js +0 -242
  102. package/dist/native/index.js.map +0 -1
  103. package/dist/native/prehide.d.ts +0 -7
  104. package/dist/native/prehide.d.ts.map +0 -1
  105. package/dist/native/prehide.js +0 -230
  106. package/dist/native/prehide.js.map +0 -1
  107. package/dist/native/runtime.d.ts +0 -5
  108. package/dist/native/runtime.d.ts.map +0 -1
  109. package/dist/native/runtime.js +0 -640
  110. package/dist/native/runtime.js.map +0 -1
  111. package/dist/native/shared/candidate.d.ts +0 -21
  112. package/dist/native/shared/candidate.d.ts.map +0 -1
  113. package/dist/native/shared/candidate.js +0 -98
  114. package/dist/native/shared/candidate.js.map +0 -1
  115. package/dist/native/shared/crossfade.d.ts +0 -10
  116. package/dist/native/shared/crossfade.d.ts.map +0 -1
  117. package/dist/native/shared/crossfade.js +0 -50
  118. package/dist/native/shared/crossfade.js.map +0 -1
  119. package/dist/native/shared/dom.d.ts +0 -46
  120. package/dist/native/shared/dom.d.ts.map +0 -1
  121. package/dist/native/shared/dom.js +0 -172
  122. package/dist/native/shared/dom.js.map +0 -1
  123. package/dist/native/shared/events.d.ts +0 -11
  124. package/dist/native/shared/events.d.ts.map +0 -1
  125. package/dist/native/shared/events.js +0 -13
  126. package/dist/native/shared/events.js.map +0 -1
  127. package/dist/native/shared/icons.d.ts +0 -8
  128. package/dist/native/shared/icons.d.ts.map +0 -1
  129. package/dist/native/shared/icons.js +0 -61
  130. package/dist/native/shared/icons.js.map +0 -1
  131. package/dist/native/shared/modal.d.ts +0 -10
  132. package/dist/native/shared/modal.d.ts.map +0 -1
  133. package/dist/native/shared/modal.js +0 -160
  134. package/dist/native/shared/modal.js.map +0 -1
  135. package/dist/native/vertical-bars-web-search.d.ts +0 -9
  136. package/dist/native/vertical-bars-web-search.d.ts.map +0 -1
  137. package/dist/native/vertical-bars-web-search.js +0 -84
  138. package/dist/native/vertical-bars-web-search.js.map +0 -1
  139. package/dist/native/vertical-bars-web.d.ts +0 -5
  140. package/dist/native/vertical-bars-web.d.ts.map +0 -1
  141. package/dist/native/vertical-bars-web.js +0 -341
  142. package/dist/native/vertical-bars-web.js.map +0 -1
  143. package/dist/native-integration/index.d.ts +0 -23
  144. package/dist/native-integration/index.d.ts.map +0 -1
  145. package/dist/native-integration/index.js.map +0 -1
  146. package/dist/native-integration/transition.d.ts +0 -5
  147. package/dist/native-integration/transition.d.ts.map +0 -1
  148. package/dist/native-integration/transition.js +0 -16
  149. package/dist/native-integration/transition.js.map +0 -1
  150. package/dist/vertical-bars.d.ts +0 -5
  151. package/dist/vertical-bars.js +0 -4
  152. package/docs/e2e-testing.md +0 -51
  153. package/docs/feedback.md +0 -65
  154. package/docs/native-ui-shell.md +0 -285
  155. package/docs/vertical-bars.md +0 -285
  156. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellButton.swift +0 -71
  157. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellButtons.swift +0 -24
  158. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellComponents.swift +0 -18
  159. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFab.swift +0 -83
  160. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +0 -432
  161. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSegment.swift +0 -91
  162. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellTabBar.swift +0 -221
  163. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +0 -437
  164. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalSearch.swift +0 -118
  165. package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +0 -396
  166. package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellCrossfade.swift +0 -35
  167. package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellHost.swift +0 -8
  168. package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellRendering.swift +0 -27
  169. package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +0 -135
  170. package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +0 -650
  171. package/src/native/components/index.ts +0 -53
  172. package/src/native/components/ion-back-button.ts +0 -19
  173. package/src/native/components/ion-button.ts +0 -24
  174. package/src/native/components/ion-buttons.ts +0 -38
  175. package/src/native/components/ion-fab.ts +0 -151
  176. package/src/native/components/ion-menu-button.ts +0 -24
  177. package/src/native/components/ion-segment.ts +0 -27
  178. package/src/native/components/ion-tab-bar.ts +0 -50
  179. package/src/native/components/searchable-tabs.ts +0 -459
  180. package/src/native/definitions.ts +0 -191
  181. package/src/native/foldable/fold-state.ts +0 -9
  182. package/src/native/foldable/types.ts +0 -4
  183. package/src/native/index.ts +0 -281
  184. package/src/native/prehide.ts +0 -265
  185. package/src/native/runtime.ts +0 -781
  186. package/src/native/shared/candidate.ts +0 -150
  187. package/src/native/shared/crossfade.ts +0 -50
  188. package/src/native/shared/dom.ts +0 -286
  189. package/src/native/shared/events.ts +0 -8
  190. package/src/native/shared/icons.ts +0 -57
  191. package/src/native/shared/modal.ts +0 -157
  192. package/src/native/vertical-bars-web-search.ts +0 -80
  193. package/src/native/vertical-bars-web.ts +0 -411
  194. package/src/native-integration/transition.ts +0 -15
  195. package/src/vertical-bars.ts +0 -11
@@ -1,396 +0,0 @@
1
- import Capacitor
2
- import UIKit
3
- import WebKit
4
-
5
- @objc(IonicNativeUIShellPlugin)
6
- public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDelegate {
7
- public let identifier = "IonicNativeUIShellPlugin"
8
- public let jsName = "IonicNativeUIShell"
9
- public let pluginMethods: [CAPPluginMethod] = [
10
- CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise),
11
- CAPPluginMethod(name: "getWebViewMetrics", returnType: CAPPluginReturnPromise),
12
- CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise),
13
- CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise)
14
- ]
15
- private var host: ShellHost?
16
- private var verticalBars: ShellVerticalBarsControlling?
17
- private var controls: [String: UIView] = [:]
18
- private var searchControllers: [String: ShellSearchControlling] = [:]
19
- private var fingerprints: [String: ShellControl] = [:]
20
- private let rendering = ShellRendering()
21
- private var revision = 0
22
- private var sequence = 0
23
- private var keyboardVisible = false
24
- private var pendingTabSelections: [String: ShellTabBar.PendingSelection] = [:]
25
- private var pendingTabExpiryWorks: [String: DispatchWorkItem] = [:]
26
- private var restoreTopEdge: (() -> Void)?
27
- private var observers: [NSObjectProtocol] = []
28
- private var lastWebViewRadius: Double?
29
-
30
- public override func load() {
31
- for name in [UIApplication.didEnterBackgroundNotification, UIResponder.keyboardWillChangeFrameNotification, UIResponder.keyboardWillHideNotification,
32
- UIDevice.orientationDidChangeNotification] {
33
- observers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] notification in
34
- guard let self else { return }
35
- let keyboard = name == UIResponder.keyboardWillChangeFrameNotification || name == UIResponder.keyboardWillHideNotification
36
- if keyboard {
37
- if name == UIResponder.keyboardWillHideNotification { self.keyboardVisible = false }
38
- else if let window = self.bridge?.webView?.window,
39
- let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
40
- let local = window.convert(frame, from: window.screen.coordinateSpace)
41
- let overlap = window.bounds.intersection(local)
42
- self.keyboardVisible = !overlap.isNull && overlap.width > 0 && overlap.height > 0
43
- }
44
- }
45
- if !keyboard {
46
- self.host?.isHidden = true
47
- self.verticalBars?.view.isHidden = true
48
- }
49
- var searchOwnsKeyboard = self.verticalBars?.ownsKeyboardChrome == true
50
- self.searchControllers.values.forEach { controller in
51
- if controller.ownsKeyboardChrome { searchOwnsKeyboard = true }
52
- if !keyboard { controller.surface.isHidden = true }
53
- }
54
- if keyboard {
55
- if !searchOwnsKeyboard {
56
- self.bridge?.triggerWindowJSEvent(eventName: "nativeUIShellRefresh")
57
- }
58
- } else if name == UIDevice.orientationDidChangeNotification {
59
- self.notifyWebViewMetricsChange()
60
- }
61
- })
62
- }
63
- for name in [UIApplication.didBecomeActiveNotification, UIResponder.keyboardDidHideNotification,
64
- UIDevice.orientationDidChangeNotification] {
65
- observers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
66
- if name == UIResponder.keyboardDidHideNotification { self?.keyboardVisible = false }
67
- self?.bridge?.triggerWindowJSEvent(eventName: "nativeUIShellRefresh", data: name == UIApplication.didBecomeActiveNotification ? "{\"retireSearch\":true}" : "{}")
68
- if name == UIApplication.didBecomeActiveNotification { self?.notifyWebViewMetricsChange() }
69
- })
70
- }
71
- }
72
-
73
- deinit {
74
- observers.forEach(NotificationCenter.default.removeObserver)
75
- }
76
-
77
- private func webViewMetrics() -> JSObject? {
78
- guard #available(iOS 26.0, *), let webView = bridge?.webView else { return nil }
79
- webView.layoutIfNeeded()
80
- return ["radius": Double(webView.effectiveRadius(corner: .topLeft))]
81
- }
82
-
83
- private func notifyWebViewMetricsChange() {
84
- let metrics = webViewMetrics() ?? ["radius": 0]
85
- let radius = metrics["radius"] as? Double ?? 0
86
- guard radius != lastWebViewRadius else { return }
87
- lastWebViewRadius = radius
88
- notifyListeners("webViewMetricsChange", data: metrics)
89
- }
90
-
91
- @objc func getWebViewMetrics(_ call: CAPPluginCall) {
92
- DispatchQueue.main.async { [weak self] in
93
- call.resolve(self?.webViewMetrics() ?? ["radius": 0])
94
- }
95
- }
96
-
97
- @objc func configure(_ call: CAPPluginCall) {
98
- DispatchQueue.main.async { [weak self] in
99
- // A new JS context starts revision numbering again (live reload / navigation).
100
- self?.restoreTopEdge?()
101
- self?.restoreTopEdge = nil
102
- self?.removeControls()
103
- self?.revision = 0
104
- if #available(iOS 26.0, *) {
105
- self?.bridge?.webView?.layoutIfNeeded()
106
- // Ionic already paints the header edge; a second native effect can
107
- // add a dark scrim when the OS and Web themes differ.
108
- if call.getBool("verticalBarsOnly") != true, let effect = self?.bridge?.webView?.scrollView.topEdgeEffect {
109
- let hidden = effect.isHidden
110
- effect.isHidden = true
111
- self?.restoreTopEdge = { [weak effect] in effect?.isHidden = hidden }
112
- }
113
- call.resolve(["supported": true])
114
- }
115
- else { call.resolve(["supported": false]) }
116
- }
117
- }
118
-
119
- @objc func clear(_ call: CAPPluginCall) {
120
- DispatchQueue.main.async { [weak self] in
121
- guard let self else { call.resolve(); return }
122
- let next = call.getInt("revision") ?? 0
123
- if next >= self.revision {
124
- self.revision = next
125
- self.restoreTopEdge?()
126
- self.restoreTopEdge = nil
127
- self.removeControls()
128
- }
129
- call.resolve()
130
- }
131
- }
132
-
133
- private func removeControl(_ id: String, duration: TimeInterval = 0) {
134
- searchControllers.removeValue(forKey: id)?.detach()
135
- if let control = controls.removeValue(forKey: id) { ShellCrossfade.retire(control, duration: duration) }
136
- fingerprints.removeValue(forKey: id)
137
- pendingTabSelections.removeValue(forKey: id)
138
- pendingTabExpiryWorks.removeValue(forKey: id)?.cancel()
139
- }
140
-
141
- private func removeControls(duration: TimeInterval = 0) {
142
- Array(controls.keys).forEach { removeControl($0, duration: duration) }
143
- host?.removeFromSuperview()
144
- host = nil
145
- verticalBars?.detach()
146
- verticalBars = nil
147
- rendering.clear()
148
- pendingTabSelections.removeAll()
149
- pendingTabExpiryWorks.values.forEach { $0.cancel() }
150
- pendingTabExpiryWorks.removeAll()
151
- }
152
-
153
- private func syncTabBar(_ tabBar: UITabBar, id: String, node: ShellControl) {
154
- var pending = pendingTabSelections[id]
155
- ShellTabBar.update(tabBar, node: node, rendering: rendering, pendingSelection: &pending)
156
- if let pending {
157
- pendingTabSelections[id] = pending
158
- } else {
159
- pendingTabSelections.removeValue(forKey: id)
160
- pendingTabExpiryWorks.removeValue(forKey: id)?.cancel()
161
- }
162
- }
163
-
164
- private func schedulePendingTabExpiry(_ id: String, until: CFAbsoluteTime) {
165
- pendingTabExpiryWorks[id]?.cancel()
166
- let delay = max(0, until - CFAbsoluteTimeGetCurrent()) + 0.02
167
- let work = DispatchWorkItem { [weak self] in
168
- guard let self else { return }
169
- self.pendingTabExpiryWorks.removeValue(forKey: id)
170
- guard let pending = self.pendingTabSelections[id],
171
- CFAbsoluteTimeGetCurrent() >= pending.until,
172
- let tabBar = self.controls[id] as? UITabBar,
173
- let node = self.fingerprints[id] else { return }
174
- self.syncTabBar(tabBar, id: id, node: node)
175
- }
176
- pendingTabExpiryWorks[id] = work
177
- DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
178
- }
179
-
180
- @objc func update(_ call: CAPPluginCall) {
181
- DispatchQueue.main.async { [weak self] in
182
- guard let self else { call.reject("Plugin released"); return }
183
- guard #available(iOS 26.0, *) else { call.reject("Requires iOS 26"); return }
184
- let next = call.getInt("revision") ?? 0
185
- guard next > self.revision else { call.resolve(["revision": self.revision]); return }
186
- guard let webView = self.bridge?.webView, let parent = webView.superview else {
187
- call.reject("WebView unavailable"); return
188
- }
189
- // Decode and validate the entire batch before changing visible controls.
190
- guard let snapshot = try? call.decode(ShellSnapshot.self), snapshot.isValid else {
191
- call.reject("Invalid control snapshot"); return
192
- }
193
- self.notifyWebViewMetricsChange()
194
- let duration = ShellCrossfade.duration(snapshot.transitionDuration)
195
- let existing = Set(self.controls.keys)
196
- let verticalBars = snapshot.controls.filter { $0.placement == .verticalBars }
197
- let snapshots = snapshot.controls.filter { $0.placement != .verticalBars }
198
- let verticalSearchActive = verticalBars.contains { $0.search.map { $0.available && $0.active } ?? false }
199
- let width = snapshot.viewportWidth
200
- self.revision = next
201
- if snapshots.isEmpty && verticalBars.isEmpty {
202
- self.removeControls(duration: duration)
203
- call.resolve(["revision": next]); return
204
- }
205
- let scale = webView.bounds.width / width
206
- let retained = Set(snapshots.map(\.id))
207
- for id in Array(self.controls.keys) where !retained.contains(id) {
208
- self.removeControl(id, duration: duration)
209
- }
210
- var rejectedControls: [String] = []
211
- var fabs: [(ShellFab, ShellControl)] = []
212
- var searches: [(ShellSearchControlling, ShellControl, CGRect, CGRect, UIView?, Bool)] = []
213
- var rejectedSearches: [String] = []
214
- if verticalBars.isEmpty || (self.keyboardVisible && !verticalSearchActive) {
215
- self.verticalBars?.detach()
216
- self.verticalBars = nil
217
- if self.keyboardVisible { rejectedControls.append(contentsOf: verticalBars.map(\.id)) }
218
- } else if let owner = self.bridge?.viewController {
219
- let rail = self.verticalBars ?? ShellVerticalBarsController(
220
- activate: { [weak self] id in self?.activate(id) },
221
- changed: { [weak self] id, phase, value, composing, valueVersion in
222
- self?.searchChanged(id, phase: phase, value: value, composing: composing, valueVersion: valueVersion) ?? 0
223
- })
224
- self.verticalBars = rail
225
- rail.attach(to: owner, in: owner.view)
226
- if let frame = snapshot.verticalBarFrame {
227
- rail.view.frame = webView.convert(frame.rect.applying(CGAffineTransform(scaleX: scale, y: scale)), to: owner.view)
228
- } else {
229
- rail.view.frame = owner.view.bounds
230
- }
231
- rail.apply(verticalBars, rendering: self.rendering, edge: snapshot.verticalBarEdge ?? "right")
232
- rail.view.isHidden = false
233
- } else {
234
- rejectedControls.append(contentsOf: verticalBars.map(\.id))
235
- }
236
- if snapshots.isEmpty {
237
- self.host?.removeFromSuperview()
238
- self.host = nil
239
- call.resolve(["revision": next, "rejectedControls": rejectedControls])
240
- return
241
- }
242
- let host = self.host ?? ShellHost()
243
- self.host = host
244
- host.frame = parent.bounds
245
- host.autoresizingMask = [.flexibleWidth, .flexibleHeight]
246
- host.backgroundColor = .clear
247
- host.isAccessibilityElement = false
248
- UIView.performWithoutAnimation {
249
- if host.superview !== parent { parent.addSubview(host) }
250
- for node in snapshots {
251
- let id = node.id
252
- let previous = self.controls[id]
253
- let previousBounds = previous?.bounds
254
- let previousCenter = previous?.center
255
- let reject = {
256
- // Keep the old cover until JS paints Web and omits this ID.
257
- if let previous, self.controls[id] === previous {
258
- previous.bounds = previousBounds!
259
- previous.center = previousCenter!
260
- previous.layoutIfNeeded()
261
- } else { self.removeControl(id) }
262
- rejectedControls.append(id)
263
- }
264
- // Only native search owns its keyboard; other controls return to Web.
265
- if self.keyboardVisible && self.searchControllers[id]?.ownsKeyboard != true {
266
- reject(); continue
267
- }
268
- let local = node.frame.rect
269
- let bounds = webView.convert(CGRect(x: local.minX * scale, y: local.minY * scale,
270
- width: local.width * scale, height: local.height * scale), to: parent)
271
- if let search = node.search {
272
- guard let owner = self.bridge?.viewController else { rejectedSearches.append(id); continue }
273
- let controller: ShellSearchControlling
274
- let previousCover = self.controls[id]
275
- let replacing = self.searchControllers[id]
276
- if let existing = replacing { controller = existing }
277
- else {
278
- // Keep the ordinary UITabBar cover until the search controller applies.
279
- controller = ShellSearchController()
280
- controller.activate = { [weak self] id in self?.activate(id) }
281
- controller.changed = { [weak self] id, phase, value, composing, valueVersion in
282
- self?.searchChanged(id, phase: phase, value: value, composing: composing, valueVersion: valueVersion) ?? 0
283
- }
284
- controller.attach(to: owner, in: owner.view)
285
- self.searchControllers[id] = controller
286
- self.controls[id] = controller.surface
287
- }
288
- let trigger = search.trigger.frame.rect
289
- let searchBarFrame = webView.convert(CGRect(x: webView.bounds.minX + local.minX * scale,
290
- y: webView.bounds.minY + local.minY * scale, width: local.width * scale, height: local.height * scale), to: owner.view)
291
- let triggerFrame = webView.convert(CGRect(x: webView.bounds.minX + trigger.minX * scale,
292
- y: webView.bounds.minY + trigger.minY * scale, width: trigger.width * scale, height: trigger.height * scale), to: owner.view)
293
- searches.append((controller, node, searchBarFrame, triggerFrame, previousCover, replacing == nil))
294
- continue
295
- } else if self.searchControllers[id] != nil {
296
- self.removeControl(id)
297
- }
298
- if self.fingerprints[id] != node {
299
- if node.kind == ShellFab.kind {
300
- let fab = (self.controls[id] as? ShellFab) ?? ShellFab()
301
- if fab.superview == nil { host.addSubview(fab) }
302
- self.controls[id] = fab
303
- fabs.append((fab, node))
304
- } else if let segment = self.controls[id] as? ShellSegment, node.kind == ShellSegment.kind {
305
- segment.update(node, scale: scale, rendering: self.rendering)
306
- } else if let tabBar = self.controls[id] as? UITabBar, node.kind == ShellTabBar.kind {
307
- self.syncTabBar(tabBar, id: id, node: node)
308
- } else {
309
- guard let control = ShellComponents.make(node, scale: scale, rendering: self.rendering, tabDelegate: self,
310
- activate: { [weak self] id in self?.activate(id) }) else { reject(); continue }
311
- self.controls.removeValue(forKey: id)?.removeFromSuperview()
312
- self.controls[id] = control
313
- host.addSubview(control)
314
- }
315
- self.fingerprints[id] = node
316
- } else if let tabBar = self.controls[id] as? UITabBar, self.pendingTabSelections[id] != nil {
317
- // Resolve an in-flight native tap even when other fingerprint fields are unchanged.
318
- self.syncTabBar(tabBar, id: id, node: node)
319
- }
320
- guard let control = self.controls[id] else { reject(); continue }
321
- if let tabBar = control as? UITabBar {
322
- guard ShellTabBar.fit(tabBar, node: node, bounds: bounds) else { reject(); continue }
323
- } else if control.frame != bounds {
324
- control.frame = bounds
325
- }
326
- control.overrideUserInterfaceStyle = node.dark ? .dark : .light
327
- }
328
- host.isHidden = false
329
- host.layoutIfNeeded()
330
- }
331
- // FABs update in place. Only icon content animates; the glass surface
332
- // and Ionic's staggered child visibility keep their own identities.
333
- for (fab, node) in fabs {
334
- fab.apply(node, scale: scale, rendering: self.rendering, activate: { [weak self] id in self?.activate(id) })
335
- }
336
- for (controller, node, frame, triggerFrame, previousCover, created) in searches {
337
- let id = node.id
338
- let webFrame = webView.convert(webView.bounds, to: controller.surface.superview)
339
- if !controller.apply(node, webFrame: webFrame, barFrame: frame, triggerFrame: triggerFrame, rendering: self.rendering) {
340
- if created {
341
- self.searchControllers.removeValue(forKey: id)
342
- controller.detach()
343
- if let previousCover, previousCover.superview != nil {
344
- self.controls[id] = previousCover
345
- } else {
346
- self.controls.removeValue(forKey: id)?.removeFromSuperview()
347
- self.fingerprints.removeValue(forKey: id)
348
- self.pendingTabSelections[id] = nil
349
- self.pendingTabExpiryWorks.removeValue(forKey: id)?.cancel()
350
- }
351
- } else {
352
- self.removeControl(id)
353
- }
354
- rejectedSearches.append(id)
355
- } else {
356
- if created, let previousCover, previousCover !== controller.surface {
357
- previousCover.removeFromSuperview()
358
- }
359
- controller.surface.isHidden = self.keyboardVisible && !controller.ownsKeyboard
360
- }
361
- }
362
- for (id, control) in self.controls where !existing.contains(id) && self.searchControllers[id] == nil {
363
- ShellCrossfade.enter(control, duration: duration)
364
- }
365
- let complete = { call.resolve(["revision": next, "rejectedSearches": rejectedSearches, "rejectedControls": rejectedControls]) }
366
- if let coordinator = searches.first?.0.transitionCoordinator,
367
- coordinator.animate(alongsideTransition: nil, completion: { _ in complete() }) { return }
368
- complete()
369
-
370
- }
371
- }
372
-
373
- public func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
374
- guard item.isEnabled, let itemId = item.accessibilityIdentifier else { return }
375
- if let controlId = controls.first(where: { $0.value === tabBar })?.key {
376
- let pending = ShellTabBar.PendingSelection.start(itemId)
377
- pendingTabSelections[controlId] = pending
378
- schedulePendingTabExpiry(controlId, until: pending.until)
379
- }
380
- activate(itemId)
381
- }
382
-
383
- private func searchChanged(_ id: String, phase: ShellSearchPhase, value: String,
384
- composing: Bool, valueVersion: Int) -> Int {
385
- sequence += 1
386
- notifyListeners("search", data: ["id": id, "phase": phase.rawValue, "value": value,
387
- "composing": composing, "valueVersion": valueVersion, "sequence": sequence, "revision": revision])
388
- return sequence
389
- }
390
-
391
- private func activate(_ id: String) {
392
- sequence += 1
393
- notifyListeners("activate", data: ["id": id, "revision": revision, "sequence": sequence])
394
-
395
- }
396
- }
@@ -1,35 +0,0 @@
1
- import UIKit
2
-
3
- @MainActor enum ShellCrossfade {
4
- static func duration(_ milliseconds: Double?) -> TimeInterval {
5
- UIAccessibility.isReduceMotionEnabled ? 0 : (milliseconds ?? 0) / 1000
6
- }
7
-
8
- static func enter(_ view: UIView, duration: TimeInterval) {
9
- guard duration > 0 else { return }
10
- view.alpha = 0
11
- UIView.animate(withDuration: duration, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) {
12
- view.alpha = 1
13
- }
14
- }
15
-
16
- static func retire(_ view: UIView, duration: TimeInterval) {
17
- // The outgoing copy is visual only: it cannot keep accepting actions or
18
- // VoiceOver focus once the Web source has been restored.
19
- if duration > 0, let host = view.superview, let parent = host.superview,
20
- !view.isHidden, !host.isHidden, let copy = view.snapshotView(afterScreenUpdates: false) {
21
- copy.frame = host.convert(view.frame, to: parent)
22
- copy.alpha = CGFloat(view.layer.presentation()?.opacity ?? Float(view.alpha))
23
- copy.isUserInteractionEnabled = false
24
- copy.isAccessibilityElement = false
25
- copy.accessibilityElementsHidden = true
26
- parent.insertSubview(copy, aboveSubview: host)
27
- UIView.animate(withDuration: duration, delay: 0, options: [.curveEaseInOut]) {
28
- copy.alpha = 0
29
- } completion: { _ in
30
- copy.removeFromSuperview()
31
- }
32
- }
33
- view.removeFromSuperview()
34
- }
35
- }
@@ -1,8 +0,0 @@
1
- import UIKit
2
-
3
- final class ShellHost: UIView {
4
- override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5
- let hit = super.hitTest(point, with: event)
6
- return hit === self ? nil : hit
7
- }
8
- }
@@ -1,27 +0,0 @@
1
- import UIKit
2
-
3
- final class ShellRendering {
4
- private var images: [String: UIImage] = [:]
5
- func clear() { images.removeAll() }
6
-
7
- func image(_ item: ShellItemContent) -> UIImage? {
8
- guard let data = item.icon, let width = item.iconWidth, width > 0 else { return nil }
9
- let template = item.iconTemplate == true
10
- let key = (template ? "template:" : "original:") + data
11
- if let cached = images[key] { return cached }
12
- guard let bytes = Data(base64Encoded: data), let raw = UIImage(data: bytes), let cg = raw.cgImage else { return nil }
13
- let result = UIImage(cgImage: cg, scale: CGFloat(cg.width) / width, orientation: .up)
14
- .withRenderingMode(template ? .alwaysTemplate : .alwaysOriginal)
15
- if images.count >= 128 { images.removeAll() }
16
- images[key] = result
17
- return result
18
- }
19
-
20
- func color(_ css: String?) -> UIColor {
21
- guard let css else { return .label }
22
- let numbers = css.components(separatedBy: CharacterSet(charactersIn: "0123456789.").inverted).compactMap(Double.init)
23
- guard numbers.count >= 3 else { return .label }
24
- return UIColor(red: numbers[0] / 255, green: numbers[1] / 255, blue: numbers[2] / 255,
25
- alpha: numbers.count > 3 ? numbers[3] : 1)
26
- }
27
- }
@@ -1,135 +0,0 @@
1
- import Foundation
2
- import CoreGraphics
3
-
4
- enum ShellComponent: String, Decodable {
5
- case button = "ion-button", buttons = "ion-buttons"
6
- case backButton = "ion-back-button", menuButton = "ion-menu-button"
7
- case tabBar = "ion-tab-bar", segment = "ion-segment", fab = "ion-fab"
8
- }
9
-
10
- struct ShellSnapshot: Decodable {
11
- let revision: Int
12
- let transitionDuration: Double?
13
- let viewportWidth: Double
14
- let verticalBarFrame: ShellFrame?
15
- let verticalBarEdge: String?
16
- let controls: [ShellControl]
17
-
18
- var isValid: Bool {
19
- revision >= 0 && (transitionDuration.map { $0.isFinite && $0 >= 0 && $0 <= 500 } ?? true) && viewportWidth.isFinite && viewportWidth > 0 && controls.count <= 100 &&
20
- (verticalBarFrame.map { $0.isValid } ?? true) &&
21
- (verticalBarEdge == nil || verticalBarEdge == "left" || verticalBarEdge == "right") &&
22
- Set(controls.map(\.id)).count == controls.count && controls.allSatisfy(\.isValid)
23
- }
24
- }
25
-
26
- // Frames and content decode from the same flat wire object. Keeping them separate
27
- // lets FABs compare artwork without treating movement or visibility as new content.
28
- struct ShellFrame: Decodable, Equatable {
29
- let x: Double
30
- let y: Double
31
- let width: Double
32
- let height: Double
33
- var rect: CGRect { CGRect(x: x, y: y, width: width, height: height) }
34
- var isValid: Bool { [x, y, width, height].allSatisfy(\.isFinite) && width > 0 && height > 0 }
35
- }
36
-
37
- struct ShellControl: Decodable, Equatable {
38
- enum Placement: String, Decodable { case verticalBars = "vertical-bars" }
39
- enum ToolbarSlot: String, Decodable { case start, end }
40
- let id: String
41
- let kind: ShellComponent
42
- let placement: Placement?
43
- let toolbarSlot: ToolbarSlot?
44
- let frame: ShellFrame
45
- let items: [ShellItem]
46
- let dark: Bool
47
- let rtl: Bool
48
- let tabBarAnchor: ShellTabBar.Anchor?
49
- let search: ShellSearch?
50
-
51
- private enum CodingKeys: String, CodingKey { case id, kind, placement, toolbarSlot, items, dark, rtl, tabBarAnchor, search }
52
-
53
- init(from decoder: Decoder) throws {
54
- let values = try decoder.container(keyedBy: CodingKeys.self)
55
- id = try values.decode(String.self, forKey: .id)
56
- kind = try values.decode(ShellComponent.self, forKey: .kind)
57
- placement = try values.decodeIfPresent(Placement.self, forKey: .placement)
58
- toolbarSlot = try values.decodeIfPresent(ToolbarSlot.self, forKey: .toolbarSlot)
59
- frame = try ShellFrame(from: decoder)
60
- items = try values.decode([ShellItem].self, forKey: .items)
61
- dark = try values.decode(Bool.self, forKey: .dark)
62
- rtl = try values.decode(Bool.self, forKey: .rtl)
63
- tabBarAnchor = try values.decodeIfPresent(ShellTabBar.Anchor.self, forKey: .tabBarAnchor)
64
- search = try values.decodeIfPresent(ShellSearch.self, forKey: .search)
65
- }
66
-
67
- var isValid: Bool {
68
- ShellComponents.supported.contains(kind) && !id.isEmpty && frame.isValid && !items.isEmpty && items.count <= 30 &&
69
- (!ShellButton.kinds.contains(kind) || items.count == 1) &&
70
- (toolbarSlot == nil || (placement == .verticalBars && [.button, .buttons, .menuButton].contains(kind))) &&
71
- Set(items.map(\.id)).count == items.count && items.allSatisfy(\.isValid) &&
72
- (tabBarAnchor.map { kind == .tabBar && $0.isValid } ?? true) &&
73
- (search.map { kind == .tabBar && $0.isValid } ?? true)
74
- }
75
- }
76
-
77
- struct ShellItem: Decodable, Equatable {
78
- let frame: ShellFrame
79
- let content: ShellItemContent
80
- let visible: Bool?
81
- var id: String { content.id }
82
-
83
- private enum CodingKeys: String, CodingKey { case visible }
84
-
85
- init(from decoder: Decoder) throws {
86
- frame = try ShellFrame(from: decoder)
87
- content = try ShellItemContent(from: decoder)
88
- visible = try decoder.container(keyedBy: CodingKeys.self).decodeIfPresent(Bool.self, forKey: .visible)
89
- }
90
-
91
- var isValid: Bool { frame.isValid && content.isValid }
92
- }
93
-
94
- struct ShellBadge: Decodable, Equatable {
95
- let value: String
96
- let color: String
97
- let textColor: String
98
- }
99
-
100
- struct ShellItemContent: Decodable, Equatable {
101
- enum IconPosition: String, Decodable { case leading, trailing, top }
102
- enum ButtonFill: String, Decodable { case clear, solid, outline }
103
- let id: String
104
- var label: String
105
- let accessibilityLabel: String
106
- let disabled: Bool
107
- let selected: Bool
108
- let fontSize: Double
109
- let fontWeight: Double
110
- let color: String
111
- let buttonFill: ButtonFill?
112
- let backgroundColor: String?
113
- let borderColor: String?
114
- let borderWidth: CGFloat?
115
- let badge: ShellBadge?
116
- var icon: String?
117
- var iconWidth: Double?
118
- var iconHeight: Double?
119
- let iconPosition: IconPosition?
120
- let imagePadding: Double?
121
- let contentInsetLeading: Double?
122
- let contentInsetTrailing: Double?
123
- let iconTemplate: Bool?
124
- let closeIcon: String?
125
- let closeIconWidth: Double?
126
- let closeIconHeight: Double?
127
- let iconTransition: Double?
128
-
129
- var isValid: Bool {
130
- !id.isEmpty && fontSize.isFinite && fontSize >= 0 && fontWeight.isFinite &&
131
- [iconWidth, iconHeight, closeIconWidth, closeIconHeight].compactMap { $0 }.allSatisfy { $0.isFinite && $0 > 0 } &&
132
- [imagePadding, contentInsetLeading, contentInsetTrailing].compactMap { $0 }.allSatisfy { $0.isFinite } &&
133
- (iconTransition.map { $0.isFinite && $0 >= 0 } ?? true)
134
- }
135
- }