@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.
- package/README.md +14 -0
- package/WebViewNativeUIControls.podspec +17 -0
- package/dist/controls/activation.d.ts +15 -0
- package/dist/controls/activation.js +370 -0
- package/dist/controls/components/index.d.ts +16 -0
- package/dist/controls/components/index.js +38 -0
- package/dist/controls/components/ion-back-button.d.ts +4 -0
- package/dist/controls/components/ion-back-button.js +16 -0
- package/dist/controls/components/ion-button.d.ts +4 -0
- package/dist/controls/components/ion-button.js +26 -0
- package/dist/controls/components/ion-buttons.d.ts +5 -0
- package/dist/controls/components/ion-buttons.js +34 -0
- package/dist/controls/components/ion-fab.d.ts +4 -0
- package/dist/controls/components/ion-fab.js +164 -0
- package/dist/controls/components/ion-menu-button.d.ts +7 -0
- package/dist/controls/components/ion-menu-button.js +41 -0
- package/dist/controls/components/ion-segment.d.ts +5 -0
- package/dist/controls/components/ion-segment.js +24 -0
- package/dist/controls/components/ion-tab-bar.d.ts +5 -0
- package/dist/controls/components/ion-tab-bar.js +49 -0
- package/dist/controls/components/searchable-tabs.d.ts +20 -0
- package/dist/controls/components/searchable-tabs.js +525 -0
- package/dist/controls/definitions.d.ts +360 -0
- package/dist/controls/definitions.js +1 -0
- package/dist/controls/extensions.d.ts +44 -0
- package/dist/controls/extensions.js +164 -0
- package/dist/controls/foldable/fold-state.d.ts +4 -0
- package/dist/controls/foldable/fold-state.js +8 -0
- package/dist/controls/foldable/types.d.ts +9 -0
- package/dist/controls/foldable/types.js +1 -0
- package/dist/controls/html.d.ts +8 -0
- package/dist/controls/html.js +260 -0
- package/dist/controls/index.d.ts +7 -0
- package/dist/controls/index.js +4 -0
- package/dist/controls/ionic.d.ts +1 -0
- package/dist/controls/ionic.js +1 -0
- package/dist/controls/legacy-layout.d.ts +7 -0
- package/dist/controls/legacy-layout.js +23 -0
- package/dist/controls/overlays/content.d.ts +2 -0
- package/dist/controls/overlays/content.js +55 -0
- package/dist/controls/overlays/controller.d.ts +7 -0
- package/dist/controls/overlays/controller.js +369 -0
- package/dist/controls/overlays/covered-overlay.d.ts +4 -0
- package/dist/controls/overlays/covered-overlay.js +9 -0
- package/dist/controls/overlays/dialog-animation.d.ts +7 -0
- package/dist/controls/overlays/dialog-animation.js +49 -0
- package/dist/controls/overlays/dismiss-animation.d.ts +2 -0
- package/dist/controls/overlays/dismiss-animation.js +14 -0
- package/dist/controls/overlays/focus.d.ts +2 -0
- package/dist/controls/overlays/focus.js +67 -0
- package/dist/controls/overlays/popover.d.ts +10 -0
- package/dist/controls/overlays/popover.js +117 -0
- package/dist/controls/overlays/styles.d.ts +9 -0
- package/dist/controls/overlays/styles.js +194 -0
- package/dist/controls/prehide.d.ts +6 -0
- package/dist/controls/prehide.js +280 -0
- package/dist/controls/runtime.d.ts +5 -0
- package/dist/controls/runtime.js +1685 -0
- package/dist/controls/shared/bridge.d.ts +4 -0
- package/dist/controls/shared/bridge.js +15 -0
- package/dist/controls/shared/candidate.d.ts +23 -0
- package/dist/controls/shared/candidate.js +128 -0
- package/dist/controls/shared/clipping.d.ts +1 -0
- package/dist/controls/shared/clipping.js +1 -0
- package/dist/controls/shared/connection.d.ts +21 -0
- package/dist/controls/shared/connection.js +112 -0
- package/dist/controls/shared/control-options.d.ts +4 -0
- package/dist/controls/shared/control-options.js +14 -0
- package/dist/controls/shared/covered-snapshot.d.ts +35 -0
- package/dist/controls/shared/covered-snapshot.js +41 -0
- package/dist/controls/shared/covered-source.d.ts +22 -0
- package/dist/controls/shared/covered-source.js +72 -0
- package/dist/controls/shared/crossfade.d.ts +11 -0
- package/dist/controls/shared/crossfade.js +71 -0
- package/dist/controls/shared/diagnostics.d.ts +2 -0
- package/dist/controls/shared/diagnostics.js +16 -0
- package/dist/controls/shared/dom.d.ts +45 -0
- package/dist/controls/shared/dom.js +258 -0
- package/dist/controls/shared/events.d.ts +12 -0
- package/dist/controls/shared/events.js +13 -0
- package/dist/controls/shared/horizontal-navigation.d.ts +36 -0
- package/dist/controls/shared/horizontal-navigation.js +90 -0
- package/dist/controls/shared/icons.d.ts +5 -0
- package/dist/controls/shared/icons.js +66 -0
- package/dist/controls/shared/modal-animation.d.ts +21 -0
- package/dist/controls/shared/modal-animation.js +97 -0
- package/dist/controls/shared/modal.d.ts +26 -0
- package/dist/controls/shared/modal.js +261 -0
- package/dist/controls/shared/projection-id.d.ts +2 -0
- package/dist/controls/shared/projection-id.js +2 -0
- package/dist/controls/shared/scroll-placement.d.ts +1 -0
- package/dist/controls/shared/scroll-placement.js +1 -0
- package/dist/controls/shared/tab-bar-width.d.ts +7 -0
- package/dist/controls/shared/tab-bar-width.js +72 -0
- package/dist/controls/shared/toolbar-options.d.ts +10 -0
- package/dist/controls/shared/toolbar-options.js +38 -0
- package/dist/controls/shared/transition-identity.d.ts +3 -0
- package/dist/controls/shared/transition-identity.js +64 -0
- package/dist/controls/shared/vertical-bars-geometry.d.ts +8 -0
- package/dist/controls/shared/vertical-bars-geometry.js +82 -0
- package/dist/controls/shared/vertical-navigation.d.ts +5 -0
- package/dist/controls/shared/vertical-navigation.js +39 -0
- package/dist/controls/startup.d.ts +7 -0
- package/dist/controls/startup.js +72 -0
- package/dist/controls/transport.d.ts +10 -0
- package/dist/controls/transport.js +1 -0
- package/dist/controls/vertical-bars-web-search.d.ts +8 -0
- package/dist/controls/vertical-bars-web-search.js +83 -0
- package/dist/controls/vertical-bars-web.d.ts +2 -0
- package/dist/controls/vertical-bars-web.js +609 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/integration/index.d.ts +36 -0
- package/dist/integration/index.js +25 -0
- package/dist/native/session.js +14 -8
- package/dist/projection/pause.d.ts +10 -0
- package/dist/projection/pause.js +58 -0
- package/dist/projection/styles.d.ts +1 -1
- package/dist/projection/styles.js +0 -1
- package/dist/projection/surfaces.js +2 -1
- package/dist/projection/web.js +39 -5
- package/dist/types.d.ts +5 -1
- package/docs/README.md +1 -0
- package/docs/configuration/README.md +3 -1
- package/docs/configuration/ionic-overlays.md +36 -2
- package/docs/configuration/pause-resume.md +51 -0
- package/docs/controls.md +39 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUIButton.swift +98 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUIButtons.swift +24 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUIComponents.swift +19 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUIFab.swift +80 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUISearchController.swift +449 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUISegment.swift +91 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUITabBar.swift +213 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUIToolbarTransition.swift +186 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUIVerticalBars.swift +696 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUIVerticalSearch.swift +117 -0
- package/ios/Sources/WebViewNativeUIControls/Extensions/NativeUI.swift +180 -0
- package/ios/Sources/WebViewNativeUIControls/Extensions/NativeUIRendering.swift +38 -0
- package/ios/Sources/WebViewNativeUIControls/NativeUIController.swift +801 -0
- package/ios/Sources/WebViewNativeUIControls/Overlays/NativeUIOverlayController.swift +131 -0
- package/ios/Sources/WebViewNativeUIControls/Overlays/NativeUIOverlayHost.swift +85 -0
- package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIControlRendering.swift +21 -0
- package/ios/Sources/WebViewNativeUIControls/Shared/NativeUICoveredSnapshots.swift +14 -0
- package/ios/Sources/WebViewNativeUIControls/Shared/NativeUICrossfade.swift +35 -0
- package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIHost.swift +8 -0
- package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIScrollTracker.swift +2 -0
- package/ios/Sources/WebViewNativeUIControls/Shared/NativeUISnapshot.swift +206 -0
- package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIVerticalBarsGeometry.swift +93 -0
- package/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +2 -2
- package/ios/Sources/WebViewNativeUIEngine/NativeUIProjection.swift +1 -1
- package/ios/Sources/WebViewNativeUIEngine/Resources/native-ui-engine.js +3 -4
- package/package.json +30 -5
- package/src/controls/activation.ts +427 -0
- package/src/controls/components/index.ts +45 -0
- package/src/controls/components/ion-back-button.ts +22 -0
- package/src/controls/components/ion-button.ts +28 -0
- package/src/controls/components/ion-buttons.ts +37 -0
- package/src/controls/components/ion-fab.ts +151 -0
- package/src/controls/components/ion-menu-button.ts +37 -0
- package/src/controls/components/ion-segment.ts +27 -0
- package/src/controls/components/ion-tab-bar.ts +55 -0
- package/src/controls/components/searchable-tabs.ts +534 -0
- package/src/controls/definitions.ts +338 -0
- package/src/controls/extensions.ts +198 -0
- package/src/controls/foldable/fold-state.ts +10 -0
- package/src/controls/foldable/types.ts +4 -0
- package/src/controls/html.ts +239 -0
- package/src/controls/index.ts +7 -0
- package/src/controls/ionic.ts +1 -0
- package/src/controls/legacy-layout.ts +21 -0
- package/src/controls/overlays/content.ts +51 -0
- package/src/controls/overlays/controller.ts +394 -0
- package/src/controls/overlays/covered-overlay.ts +11 -0
- package/src/controls/overlays/dialog-animation.ts +53 -0
- package/src/controls/overlays/dismiss-animation.ts +15 -0
- package/src/controls/overlays/focus.ts +64 -0
- package/src/controls/overlays/popover.ts +116 -0
- package/src/controls/overlays/styles.ts +195 -0
- package/src/controls/prehide.ts +285 -0
- package/src/controls/runtime.ts +1731 -0
- package/src/controls/shared/bridge.ts +18 -0
- package/src/controls/shared/candidate.ts +163 -0
- package/src/controls/shared/clipping.ts +1 -0
- package/src/controls/shared/connection.ts +105 -0
- package/src/controls/shared/control-options.ts +20 -0
- package/src/controls/shared/covered-snapshot.ts +52 -0
- package/src/controls/shared/covered-source.ts +78 -0
- package/src/controls/shared/crossfade.ts +70 -0
- package/src/controls/shared/diagnostics.ts +13 -0
- package/src/controls/shared/dom.ts +310 -0
- package/src/controls/shared/events.ts +14 -0
- package/src/controls/shared/horizontal-navigation.ts +106 -0
- package/src/controls/shared/icons.ts +64 -0
- package/src/controls/shared/modal-animation.ts +109 -0
- package/src/controls/shared/modal.ts +286 -0
- package/src/controls/shared/projection-id.ts +2 -0
- package/src/controls/shared/scroll-placement.ts +1 -0
- package/src/controls/shared/tab-bar-width.ts +66 -0
- package/src/controls/shared/toolbar-options.ts +51 -0
- package/src/controls/shared/transition-identity.ts +67 -0
- package/src/controls/shared/vertical-bars-geometry.ts +92 -0
- package/src/controls/shared/vertical-navigation.ts +34 -0
- package/src/controls/startup.ts +74 -0
- package/src/controls/transport.ts +11 -0
- package/src/controls/vertical-bars-web-search.ts +86 -0
- package/src/controls/vertical-bars-web.ts +674 -0
- package/src/dom/geometry.ts +24 -0
- package/src/dom/overlays.ts +196 -0
- package/src/dom/scroll-bindings.ts +66 -0
- package/src/dom/scroll-placement.ts +92 -0
- package/src/dom/visibility.ts +29 -0
- package/src/index.ts +19 -0
- package/src/integration/index.ts +64 -0
- package/src/ionic.ts +34 -0
- package/src/native/bootstrap.ts +132 -0
- package/src/native/bridge.ts +18 -0
- package/src/native/resource-url.ts +11 -0
- package/src/native/session.ts +708 -0
- package/src/native/updates.ts +103 -0
- package/src/native/values.ts +29 -0
- package/src/projection/events.ts +58 -0
- package/src/projection/pause.ts +58 -0
- package/src/projection/placement.ts +34 -0
- package/src/projection/scope.ts +13 -0
- package/src/projection/styles.ts +26 -0
- package/src/projection/surface.ts +88 -0
- package/src/projection/surfaces.ts +119 -0
- package/src/projection/web.ts +1013 -0
- 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
|
+
}
|