@webview-native-ui/engine 1.0.1 → 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 (230) hide show
  1. package/README.md +14 -0
  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/session.js +14 -8
  116. package/dist/projection/pause.d.ts +10 -0
  117. package/dist/projection/pause.js +58 -0
  118. package/dist/projection/styles.d.ts +1 -1
  119. package/dist/projection/styles.js +0 -1
  120. package/dist/projection/surfaces.js +2 -1
  121. package/dist/projection/web.js +39 -5
  122. package/dist/types.d.ts +5 -1
  123. package/docs/README.md +1 -0
  124. package/docs/configuration/README.md +3 -1
  125. package/docs/configuration/ionic-overlays.md +36 -2
  126. package/docs/configuration/pause-resume.md +51 -0
  127. package/docs/controls.md +39 -0
  128. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIButton.swift +98 -0
  129. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIButtons.swift +24 -0
  130. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIComponents.swift +19 -0
  131. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIFab.swift +80 -0
  132. package/ios/Sources/WebViewNativeUIControls/Components/NativeUISearchController.swift +449 -0
  133. package/ios/Sources/WebViewNativeUIControls/Components/NativeUISegment.swift +91 -0
  134. package/ios/Sources/WebViewNativeUIControls/Components/NativeUITabBar.swift +213 -0
  135. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIToolbarTransition.swift +186 -0
  136. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIVerticalBars.swift +696 -0
  137. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIVerticalSearch.swift +117 -0
  138. package/ios/Sources/WebViewNativeUIControls/Extensions/NativeUI.swift +180 -0
  139. package/ios/Sources/WebViewNativeUIControls/Extensions/NativeUIRendering.swift +38 -0
  140. package/ios/Sources/WebViewNativeUIControls/NativeUIController.swift +801 -0
  141. package/ios/Sources/WebViewNativeUIControls/Overlays/NativeUIOverlayController.swift +131 -0
  142. package/ios/Sources/WebViewNativeUIControls/Overlays/NativeUIOverlayHost.swift +85 -0
  143. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIControlRendering.swift +21 -0
  144. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUICoveredSnapshots.swift +14 -0
  145. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUICrossfade.swift +35 -0
  146. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIHost.swift +8 -0
  147. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIScrollTracker.swift +2 -0
  148. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUISnapshot.swift +206 -0
  149. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIVerticalBarsGeometry.swift +93 -0
  150. package/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +2 -2
  151. package/ios/Sources/WebViewNativeUIEngine/NativeUIProjection.swift +1 -1
  152. package/ios/Sources/WebViewNativeUIEngine/Resources/native-ui-engine.js +3 -4
  153. package/package.json +30 -5
  154. package/src/controls/activation.ts +427 -0
  155. package/src/controls/components/index.ts +45 -0
  156. package/src/controls/components/ion-back-button.ts +22 -0
  157. package/src/controls/components/ion-button.ts +28 -0
  158. package/src/controls/components/ion-buttons.ts +37 -0
  159. package/src/controls/components/ion-fab.ts +151 -0
  160. package/src/controls/components/ion-menu-button.ts +37 -0
  161. package/src/controls/components/ion-segment.ts +27 -0
  162. package/src/controls/components/ion-tab-bar.ts +55 -0
  163. package/src/controls/components/searchable-tabs.ts +534 -0
  164. package/src/controls/definitions.ts +338 -0
  165. package/src/controls/extensions.ts +198 -0
  166. package/src/controls/foldable/fold-state.ts +10 -0
  167. package/src/controls/foldable/types.ts +4 -0
  168. package/src/controls/html.ts +239 -0
  169. package/src/controls/index.ts +7 -0
  170. package/src/controls/ionic.ts +1 -0
  171. package/src/controls/legacy-layout.ts +21 -0
  172. package/src/controls/overlays/content.ts +51 -0
  173. package/src/controls/overlays/controller.ts +394 -0
  174. package/src/controls/overlays/covered-overlay.ts +11 -0
  175. package/src/controls/overlays/dialog-animation.ts +53 -0
  176. package/src/controls/overlays/dismiss-animation.ts +15 -0
  177. package/src/controls/overlays/focus.ts +64 -0
  178. package/src/controls/overlays/popover.ts +116 -0
  179. package/src/controls/overlays/styles.ts +195 -0
  180. package/src/controls/prehide.ts +285 -0
  181. package/src/controls/runtime.ts +1731 -0
  182. package/src/controls/shared/bridge.ts +18 -0
  183. package/src/controls/shared/candidate.ts +163 -0
  184. package/src/controls/shared/clipping.ts +1 -0
  185. package/src/controls/shared/connection.ts +105 -0
  186. package/src/controls/shared/control-options.ts +20 -0
  187. package/src/controls/shared/covered-snapshot.ts +52 -0
  188. package/src/controls/shared/covered-source.ts +78 -0
  189. package/src/controls/shared/crossfade.ts +70 -0
  190. package/src/controls/shared/diagnostics.ts +13 -0
  191. package/src/controls/shared/dom.ts +310 -0
  192. package/src/controls/shared/events.ts +14 -0
  193. package/src/controls/shared/horizontal-navigation.ts +106 -0
  194. package/src/controls/shared/icons.ts +64 -0
  195. package/src/controls/shared/modal-animation.ts +109 -0
  196. package/src/controls/shared/modal.ts +286 -0
  197. package/src/controls/shared/projection-id.ts +2 -0
  198. package/src/controls/shared/scroll-placement.ts +1 -0
  199. package/src/controls/shared/tab-bar-width.ts +66 -0
  200. package/src/controls/shared/toolbar-options.ts +51 -0
  201. package/src/controls/shared/transition-identity.ts +67 -0
  202. package/src/controls/shared/vertical-bars-geometry.ts +92 -0
  203. package/src/controls/shared/vertical-navigation.ts +34 -0
  204. package/src/controls/startup.ts +74 -0
  205. package/src/controls/transport.ts +11 -0
  206. package/src/controls/vertical-bars-web-search.ts +86 -0
  207. package/src/controls/vertical-bars-web.ts +674 -0
  208. package/src/dom/geometry.ts +24 -0
  209. package/src/dom/overlays.ts +196 -0
  210. package/src/dom/scroll-bindings.ts +66 -0
  211. package/src/dom/scroll-placement.ts +92 -0
  212. package/src/dom/visibility.ts +29 -0
  213. package/src/index.ts +19 -0
  214. package/src/integration/index.ts +64 -0
  215. package/src/ionic.ts +34 -0
  216. package/src/native/bootstrap.ts +132 -0
  217. package/src/native/bridge.ts +18 -0
  218. package/src/native/resource-url.ts +11 -0
  219. package/src/native/session.ts +708 -0
  220. package/src/native/updates.ts +103 -0
  221. package/src/native/values.ts +29 -0
  222. package/src/projection/events.ts +58 -0
  223. package/src/projection/pause.ts +58 -0
  224. package/src/projection/placement.ts +34 -0
  225. package/src/projection/scope.ts +13 -0
  226. package/src/projection/styles.ts +26 -0
  227. package/src/projection/surface.ts +88 -0
  228. package/src/projection/surfaces.ts +119 -0
  229. package/src/projection/web.ts +1013 -0
  230. package/src/types.ts +58 -0
@@ -0,0 +1,213 @@
1
+ import UIKit
2
+
3
+ enum NativeUITabBar {
4
+ struct Anchor: Decodable, Equatable {
5
+ let x: Double
6
+ let y: Double
7
+ var isValid: Bool { [0, 0.5, 1].contains(x) && [0, 1].contains(y) }
8
+ }
9
+
10
+ /// Optimistic tab selection until the Web selected state catches up (or times out).
11
+ struct PendingSelection: Equatable {
12
+ var id: String
13
+ var until: CFAbsoluteTime
14
+ static func start(_ id: String, ttl: CFTimeInterval = 1) -> PendingSelection {
15
+ PendingSelection(id: id, until: CFAbsoluteTimeGetCurrent() + ttl)
16
+ }
17
+
18
+ // UIKit and SwiftUI keep the same optimistic selection until Web confirms it.
19
+ static func reconcile(_ pending: inout PendingSelection?, domSelection: String?,
20
+ isAvailable: (String) -> Bool, now: CFAbsoluteTime = CFAbsoluteTimeGetCurrent()) -> String? {
21
+ guard let current = pending else { return domSelection }
22
+ if current.id == domSelection || now >= current.until || !isAvailable(current.id) {
23
+ pending = nil
24
+ return domSelection
25
+ }
26
+ return current.id
27
+ }
28
+ }
29
+
30
+ static let kind = NativeUIComponent.tabBar
31
+ /// Tab bars waiting for an iOS 26 title-layout warmup after the next `fit`.
32
+ private static var pendingTitleWarmup = Set<ObjectIdentifier>()
33
+
34
+ static func make(_ node: NativeUIControl, rendering: NativeUIControlRendering, delegate: UITabBarDelegate) -> UITabBar {
35
+ let control = UITabBar()
36
+ configureLayout(control)
37
+ control.delegate = delegate
38
+ update(control, node: node, rendering: rendering)
39
+ return control
40
+ }
41
+
42
+ static func configureLayout(_ tabBar: UITabBar) {
43
+ // The reader accepts icon-top tabs. Keep that layout local to the native
44
+ // bar instead of letting the iPad idiom turn it into inline items.
45
+ if #available(iOS 17.0, *) {
46
+ tabBar.traitOverrides.horizontalSizeClass = .compact
47
+ tabBar.traitOverrides.verticalSizeClass = .regular
48
+ }
49
+ tabBar.itemPositioning = .fill
50
+ }
51
+
52
+ static func update(_ tabBar: UITabBar, node: NativeUIControl, rendering: NativeUIControlRendering) {
53
+ var pending: PendingSelection?
54
+ update(tabBar, node: node, rendering: rendering, pendingSelection: &pending)
55
+ }
56
+
57
+ static func update(_ tabBar: UITabBar, node: NativeUIControl, rendering: NativeUIControlRendering,
58
+ pendingSelection: inout PendingSelection?) {
59
+ tabBar.tintColor = node.tabTint.map { rendering.color($0) }
60
+ let items = node.items
61
+ let ids = items.map(\.id)
62
+ var needsTitleWarmup = false
63
+ // Keep UIKit's item identities and interaction state across DOM selection updates.
64
+ if tabBar.items?.map({ $0.accessibilityIdentifier ?? "" }) != ids {
65
+ tabBar.items = items.map { item in
66
+ let tab = UITabBarItem(title: item.content.label, image: rendering.image(item.content), tag: 0)
67
+ tab.accessibilityIdentifier = item.id
68
+ return tab
69
+ }
70
+ needsTitleWarmup = true
71
+ }
72
+ let nativeItems = tabBar.items ?? []
73
+ for (tab, item) in zip(nativeItems, items) {
74
+ let title = item.content.label
75
+ if tab.title != title {
76
+ tab.title = title
77
+ needsTitleWarmup = true
78
+ }
79
+ let icon = rendering.image(item.content)
80
+ if tab.image !== icon {
81
+ tab.image = icon
82
+ needsTitleWarmup = true
83
+ }
84
+ if tab.selectedImage !== icon { tab.selectedImage = icon }
85
+ tab.isEnabled = !item.content.disabled
86
+ tab.accessibilityLabel = item.content.accessibilityLabel
87
+ if applyTypography(item.content, to: tab) { needsTitleWarmup = true }
88
+ applyBadge(item.content.badge, to: tab, rendering: rendering)
89
+ }
90
+ let selectedID = PendingSelection.reconcile(&pendingSelection,
91
+ domSelection: items.first(where: { $0.content.selected })?.id,
92
+ isAvailable: { id in items.contains { $0.id == id && !$0.content.disabled } })
93
+ let selectedItem = nativeItems.first { $0.accessibilityIdentifier == selectedID }
94
+ if tabBar.selectedItem !== selectedItem { tabBar.selectedItem = selectedItem }
95
+ tabBar.semanticContentAttribute = node.rtl ? .forceRightToLeft : .forceLeftToRight
96
+ tabBar.accessibilityIdentifier = node.id
97
+ // Warm after `fit` settles geometry — not on every motion-driven frame sync.
98
+ if needsTitleWarmup { pendingTitleWarmup.insert(ObjectIdentifier(tabBar)) }
99
+ }
100
+
101
+ @discardableResult
102
+ static func applyTypography(_ content: NativeUIItemContent, to item: UITabBarItem) -> Bool {
103
+ let weight: UIFont.Weight
104
+ switch content.fontWeight {
105
+ case ..<150: weight = .ultraLight
106
+ case ..<250: weight = .thin
107
+ case ..<350: weight = .light
108
+ case ..<450: weight = .regular
109
+ case ..<550: weight = .medium
110
+ case ..<650: weight = .semibold
111
+ case ..<750: weight = .bold
112
+ case ..<850: weight = .heavy
113
+ default: weight = .black
114
+ }
115
+ let font = UIFont.systemFont(ofSize: content.fontSize, weight: weight)
116
+ var changed = false
117
+ for state in [UIControl.State.normal, .selected] where item.titleTextAttributes(for: state)?[.font] as? UIFont != font {
118
+ item.setTitleTextAttributes([.font: font], for: state)
119
+ changed = true
120
+ }
121
+ return changed
122
+ }
123
+
124
+ static func applyBadge(_ badge: NativeUIBadge?, to item: UITabBarItem, rendering: NativeUIControlRendering) {
125
+ // nil removes a badge; an empty string keeps the native notification dot.
126
+ item.badgeValue = badge?.value
127
+ item.accessibilityValue = badge?.value
128
+ item.badgeColor = badge.map { rendering.color($0.color) }
129
+ let attributes = badge.map { [NSAttributedString.Key.foregroundColor: rendering.color($0.textColor)] }
130
+ item.setBadgeTextAttributes(attributes, for: .normal)
131
+ item.setBadgeTextAttributes(attributes, for: .selected)
132
+ }
133
+
134
+ static func fit(_ tabBar: UITabBar, node: NativeUIControl, bounds: CGRect) -> Bool {
135
+ // Place a new bar before asking UIKit for its safe-area-dependent size.
136
+ // Measuring at the initial zero frame makes a restored iPad bar 5pt shorter.
137
+ if tabBar.bounds.isEmpty {
138
+ tabBar.frame = bounds
139
+ tabBar.layoutIfNeeded()
140
+ }
141
+ let fitted = tabBar.sizeThatFits(bounds.size)
142
+ var size = CGSize(width: bounds.width,
143
+ height: max(bounds.height, fitted.height))
144
+ if tabBar.bounds.size != size { tabBar.bounds.size = size }
145
+ tabBar.layoutIfNeeded()
146
+ // UIKit's tab content occupies only part of UITabBar's outer frame.
147
+ // Align that content to ion-tab-bar, whose CSS already owns placement.
148
+ guard var content = contentFrame(tabBar) else {
149
+ return false
150
+ }
151
+ // Reserve ion-tab-bar's width for the tab content, excluding UIKit's outer gutters.
152
+ // UIKit can cap the content width for a small number of items.
153
+ // Do not keep expanding the outer frame beyond its natural size.
154
+ size.width = min(bounds.width + tabBar.bounds.width - content.width,
155
+ max(bounds.width, fitted.width))
156
+ if abs(tabBar.bounds.width - size.width) > 0.1 {
157
+ tabBar.bounds.size = size
158
+ tabBar.layoutIfNeeded()
159
+ guard let measured = contentFrame(tabBar) else {
160
+ return false
161
+ }
162
+ content = measured
163
+ }
164
+ // UIKit owns the adaptive platter size, including changes after badge/title updates.
165
+ // Preserve the DOM placement anchor instead of requiring identical item widths.
166
+ let anchor = node.tabBarAnchor
167
+ let x = anchor?.x ?? 0
168
+ let y = anchor?.y ?? 0
169
+ let center = CGPoint(x: bounds.minX + (bounds.width - content.width) * x - content.minX + size.width / 2,
170
+ y: bounds.minY + (bounds.height - content.height) * y - content.minY + size.height / 2)
171
+ if tabBar.center != center { tabBar.center = center }
172
+ if pendingTitleWarmup.remove(ObjectIdentifier(tabBar)) != nil {
173
+ warmUnselectedTitleLayout(tabBar)
174
+ }
175
+ return true
176
+ }
177
+
178
+ /// iOS 26 only: programmatically visit each unselected item so UIKit measures
179
+ /// its title the same way a user tap would. Selection does not call the
180
+ /// tab-bar delegate. No-op on iOS 27+, where first paint already matches Web.
181
+ static func warmUnselectedTitleLayout(_ tabBar: UITabBar) {
182
+ let version = ProcessInfo.processInfo.operatingSystemVersion
183
+ guard version.majorVersion == 26, let items = tabBar.items, items.count > 1 else { return }
184
+ let selected = tabBar.selectedItem
185
+ UIView.performWithoutAnimation {
186
+ for item in items where item !== selected {
187
+ tabBar.selectedItem = item
188
+ tabBar.layoutIfNeeded()
189
+ }
190
+ if tabBar.selectedItem !== selected {
191
+ tabBar.selectedItem = selected
192
+ tabBar.layoutIfNeeded()
193
+ }
194
+ }
195
+ }
196
+
197
+ static func contentFrame(_ tabBar: UITabBar) -> CGRect? {
198
+ contentFrames(tabBar).reduce(nil) { (result: CGRect?, frame) in result.map { $0.union(frame) } ?? frame }
199
+ }
200
+
201
+ static func contentFrames(_ tabBar: UITabBar) -> [CGRect] {
202
+ contentViews(tabBar).map { $0.frame }
203
+ }
204
+
205
+ static func contentViews(_ tabBar: UITabBar) -> [UIView] {
206
+ func containsControl(_ view: UIView) -> Bool {
207
+ view is UIControl || view.subviews.contains(where: containsControl)
208
+ }
209
+ return tabBar.subviews
210
+ .filter { !$0.isHidden && $0.alpha > 0 && containsControl($0) }
211
+ .filter { !$0.frame.isEmpty }
212
+ }
213
+ }
@@ -0,0 +1,186 @@
1
+ import UIKit
2
+
3
+ /// A toolbar shares the page animation's clock, including interactive reversal.
4
+ /// Existing glass surfaces survive; only their content and geometry interpolate.
5
+ @MainActor
6
+ final class NativeUIToolbarTransition {
7
+ let id: Int
8
+ let controlIds: Set<String>
9
+ private var updates: [(CGFloat) -> Void] = []
10
+ private var restorations: [() -> Void] = []
11
+ private(set) var progress: CGFloat = 0
12
+
13
+ @available(iOS 26.0, *)
14
+ init(_ transition: NativeUIToolbarTransitionSnapshot, sources: [NativeUIControl], controls: [String: UIView],
15
+ scale: CGFloat, origin: CGPoint, rendering: NativeUIControlRendering) {
16
+ id = transition.id
17
+ controlIds = Set(transition.sourceIds + transition.destination.map(\.id))
18
+ let outgoing = sources.filter { transition.sourceIds.contains($0.id) }
19
+ var remaining = transition.destination
20
+ func bounds(_ node: NativeUIControl) -> CGRect {
21
+ CGRect(x: origin.x + node.frame.x * scale, y: origin.y + node.frame.y * scale,
22
+ width: node.frame.width * scale, height: node.frame.height * scale)
23
+ }
24
+ for source in outgoing {
25
+ guard let view = controls[source.id] else { continue }
26
+ let match = remaining.firstIndex { $0.transitionId != nil && $0.transitionId == source.transitionId }
27
+ guard let match else {
28
+ // A removed surface exits in place instead of switching to the
29
+ // moving Web page. Keep the same clock so cancellation reverses it.
30
+ let alpha = view.alpha
31
+ let interactive = view.isUserInteractionEnabled
32
+ view.isUserInteractionEnabled = false
33
+ updates.append { value in view.alpha = alpha * (1 - value) }
34
+ restorations.append {
35
+ view.alpha = alpha
36
+ view.isUserInteractionEnabled = interactive
37
+ }
38
+ continue
39
+ }
40
+ let destination = remaining.remove(at: match)
41
+ let originalFrame = view.frame
42
+ let sourceGlass = source.kind == .buttons || source.items.first?.content.buttonFill != .clear
43
+ let targetGlass = destination.kind == .buttons || destination.items.first?.content.buttonFill != .clear
44
+ let container: UIView
45
+ if let button = view as? UIButton {
46
+ let configuration = button.configuration
47
+ var empty = configuration
48
+ empty?.title = nil
49
+ empty?.image = nil
50
+ button.configuration = empty
51
+ let sourceConfiguration = empty
52
+ let targetConfiguration: UIButton.Configuration = targetGlass ? .glass() : .plain()
53
+ var showingDestination = false
54
+ updates.append { value in
55
+ guard sourceGlass != targetGlass, showingDestination != (value > 0) else { return }
56
+ showingDestination = value > 0
57
+ button.configuration = showingDestination ? targetConfiguration : sourceConfiguration
58
+ }
59
+ let labels = button.subviews.filter { $0 is UILabel }.map { ($0, $0.isHidden) }
60
+ labels.forEach { $0.0.isHidden = true }
61
+ restorations.append { labels.forEach { $0.0.isHidden = $0.1 } }
62
+ container = button
63
+ restorations.append { button.configuration = configuration }
64
+ } else if let glass = view as? UIVisualEffectView {
65
+ container = glass.contentView
66
+ let originalEffect = glass.effect
67
+ var showingDestination = false
68
+ updates.append { value in
69
+ guard sourceGlass != targetGlass, showingDestination != (value > 0) else { return }
70
+ showingDestination = value > 0
71
+ glass.effect = showingDestination && !targetGlass ? nil : originalEffect
72
+ }
73
+ restorations.append { glass.effect = originalEffect }
74
+ let children = container.subviews.map { ($0, $0.isHidden) }
75
+ children.forEach { $0.0.isHidden = true }
76
+ restorations.append { children.forEach { $0.0.isHidden = $0.1 } }
77
+ } else { continue }
78
+ let from = bounds(source), to = bounds(destination)
79
+ updates.append { value in
80
+ view.frame = Self.interpolate(from, to, value)
81
+ if view is UIVisualEffectView { view.layer.cornerRadius = view.bounds.height / 2 }
82
+ }
83
+ restorations.append { view.frame = originalFrame }
84
+ var targetItems = destination.items
85
+ for item in source.items {
86
+ let index = targetItems.firstIndex { $0.content.transitionId != nil && $0.content.transitionId == item.content.transitionId }
87
+ let target = index.map { targetItems.remove(at: $0) }
88
+ addContent(item, target, to: container, fromSurface: from, toSurface: to, scale: scale, rtl: destination.rtl, rendering: rendering)
89
+ }
90
+ for item in targetItems { addContent(nil, item, to: container, fromSurface: from, toSurface: to, scale: scale, rtl: destination.rtl, rendering: rendering) }
91
+ }
92
+ // New destinations use their final native view from the first frame through
93
+ // commit. The normal control update reuses it when the page takes ownership.
94
+ for destination in remaining {
95
+ guard let view = controls[destination.id] else { continue }
96
+ let originalAlpha = view.alpha
97
+ let originalTransform = view.transform
98
+ let interactive = view.isUserInteractionEnabled
99
+ view.isUserInteractionEnabled = false
100
+ updates.append { value in
101
+ view.alpha = originalAlpha * value
102
+ view.transform = originalTransform.scaledBy(x: max(0.001, value), y: max(0.001, value))
103
+ }
104
+ restorations.append {
105
+ view.alpha = originalAlpha
106
+ view.transform = originalTransform
107
+ view.isUserInteractionEnabled = interactive
108
+ }
109
+ }
110
+ setProgress(0)
111
+ }
112
+
113
+ @available(iOS 26.0, *)
114
+ private func addContent(_ source: NativeUIItem?, _ destination: NativeUIItem?, to host: UIView,
115
+ fromSurface: CGRect, toSurface: CGRect, scale: CGFloat, rtl: Bool, rendering: NativeUIControlRendering) {
116
+ // swiftlint:disable:next force_unwrapping
117
+ var from = (source ?? destination)!.frame.rect.applying(CGAffineTransform(scaleX: scale, y: scale))
118
+ // swiftlint:disable:next force_unwrapping
119
+ var to = (destination ?? source)!.frame.rect.applying(CGAffineTransform(scaleX: scale, y: scale))
120
+ // Preserve window position where possible. When a numbered surface moves
121
+ // past another action, grow/shrink its extra items from inside that surface.
122
+ func contained(_ rect: CGRect, in size: CGSize) -> CGRect {
123
+ let halfWidth = min(rect.width, size.width) / 2
124
+ let halfHeight = min(rect.height, size.height) / 2
125
+ let center = CGPoint(x: min(max(rect.midX, halfWidth), size.width - halfWidth),
126
+ y: min(max(rect.midY, halfHeight), size.height - halfHeight))
127
+ return CGRect(x: center.x - rect.width / 2, y: center.y - rect.height / 2, width: rect.width, height: rect.height)
128
+ }
129
+ if source == nil {
130
+ from = contained(to.offsetBy(dx: toSurface.minX - fromSurface.minX, dy: toSurface.minY - fromSurface.minY), in: fromSurface.size)
131
+ }
132
+ if destination == nil {
133
+ to = contained(from.offsetBy(dx: fromSurface.minX - toSurface.minX, dy: fromSurface.minY - toSurface.minY), in: toSurface.size)
134
+ }
135
+ let unchanged = source.map { from in destination.map { to in
136
+ let a = from.content, b = to.content
137
+ return a.label == b.label && a.icon == b.icon && a.iconWidth == b.iconWidth && a.iconHeight == b.iconHeight &&
138
+ a.fontSize == b.fontSize && a.fontWeight == b.fontWeight && a.color == b.color && a.badge == b.badge &&
139
+ a.disabled == b.disabled && a.selected == b.selected && a.iconPosition == b.iconPosition &&
140
+ a.imagePadding == b.imagePadding && a.contentInsetLeading == b.contentInsetLeading &&
141
+ a.contentInsetTrailing == b.contentInsetTrailing && a.iconTemplate == b.iconTemplate
142
+ } ?? false } ?? false
143
+ for (item, incoming) in [(source, false), (unchanged ? nil : destination, true)] {
144
+ guard let item else { continue }
145
+ let content = NativeUIButton.render(item.content, glass: false, rendering: rendering, activate: { _ in })
146
+ content.semanticContentAttribute = rtl ? .forceRightToLeft : .forceLeftToRight
147
+ content.isUserInteractionEnabled = false
148
+ content.accessibilityElementsHidden = true
149
+ host.addSubview(content)
150
+ updates.append { value in
151
+ let position = Self.interpolate(from, to, value)
152
+ // Native matched items keep their own text width while their centres move.
153
+ // Resizing both labels would truncate the destination until the transition ends.
154
+ content.bounds = CGRect(origin: .zero, size: (incoming ? to : from).size)
155
+ content.center = CGPoint(x: position.midX, y: position.midY)
156
+ // Matched native toolbar content adopts the destination as playback starts.
157
+ // Crossfading both glyphs superimposes Menu/Back and old/new titles.
158
+ if source == nil || destination == nil {
159
+ // New/removed native items grow/shrink within the changing shared surface.
160
+ let amount = incoming ? value : 1 - value
161
+ content.transform = CGAffineTransform(scaleX: max(0.001, amount), y: max(0.001, amount))
162
+ content.alpha = amount > 0 ? 1 : 0
163
+ } else {
164
+ content.alpha = unchanged ? 1 : incoming ? (value > 0 ? 1 : 0) : (value == 0 ? 1 : 0)
165
+ }
166
+ }
167
+ restorations.append { content.removeFromSuperview() }
168
+ }
169
+ }
170
+
171
+ static func interpolate(_ from: CGRect, _ to: CGRect, _ value: CGFloat) -> CGRect {
172
+ CGRect(x: from.minX + (to.minX - from.minX) * value, y: from.minY + (to.minY - from.minY) * value,
173
+ width: from.width + (to.width - from.width) * value, height: from.height + (to.height - from.height) * value)
174
+ }
175
+
176
+ func setProgress(_ value: CGFloat) {
177
+ progress = min(1, max(0, value))
178
+ UIView.performWithoutAnimation { updates.forEach { $0(progress) } }
179
+ }
180
+
181
+ func restore() {
182
+ UIView.performWithoutAnimation { restorations.reversed().forEach { $0() } }
183
+ restorations.removeAll()
184
+ updates.removeAll()
185
+ }
186
+ }