@rdlabo/ionic-theme-ios27 0.1.0 → 0.2.1-beta.pr185.sha0793981f6cf4
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 +34 -8
- package/dist/css/components/ion-button.css +1 -1
- package/dist/css/components/ion-fab.css +1 -1
- package/dist/css/components/ion-list.css +1 -1
- package/dist/css/components/ion-menu.css +1 -1
- package/dist/css/components/ion-searchbar.css +1 -1
- package/dist/css/components/ion-tabs.css +1 -1
- package/dist/css/components/ion-toolbar.css +1 -1
- package/dist/css/ionic-theme-ios27.css +1 -1
- package/dist/css/md-ion-list-inset.css +1 -1
- package/dist/css/utils/translucent.css +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -2
- package/dist/native/components/searchable-tabs.d.ts +2 -0
- package/dist/native/components/searchable-tabs.d.ts.map +1 -1
- package/dist/native/components/searchable-tabs.js +131 -21
- package/dist/native/definitions.d.ts +19 -0
- package/dist/native/definitions.d.ts.map +1 -1
- package/dist/native/index.d.ts +4 -3
- package/dist/native/index.d.ts.map +1 -1
- package/dist/native/index.js +22 -9
- package/dist/native/lifecycle.d.ts +4 -0
- package/dist/native/lifecycle.d.ts.map +1 -0
- package/dist/native/lifecycle.js +34 -0
- package/dist/native/runtime.d.ts +2 -2
- package/dist/native/runtime.d.ts.map +1 -1
- package/dist/native/runtime.js +57 -11
- package/dist/tab-bar-searchable/animations/enter.d.ts +1 -1
- package/dist/tab-bar-searchable/animations/enter.d.ts.map +1 -1
- package/dist/tab-bar-searchable/animations/enter.js +1 -1
- package/dist/tab-bar-searchable/animations/leave.d.ts +1 -1
- package/dist/tab-bar-searchable/animations/leave.d.ts.map +1 -1
- package/dist/tab-bar-searchable/animations/leave.js +1 -1
- package/dist/tab-bar-searchable/index.d.ts +2 -2
- package/dist/tab-bar-searchable/index.d.ts.map +1 -1
- package/dist/tab-bar-searchable/index.js +2 -3
- package/dist/transition/ios.transition.d.ts +6 -1
- package/dist/transition/ios.transition.d.ts.map +1 -1
- package/dist/transition/ios.transition.js +10 -385
- package/docs/features.md +3 -1
- package/docs/ios-adaptive.md +20 -21
- package/docs/native-ui-shell.md +117 -3
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +243 -64
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellTabBar.swift +77 -6
- package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +106 -11
- package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +258 -2
- package/node_modules/@rdlabo/ionic-theme-utils/LICENSE +21 -0
- package/node_modules/@rdlabo/ionic-theme-utils/README.md +28 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/dom.d.ts +3 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/dom.d.ts.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/dom.js +13 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/dom.js.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/focus-controller.d.ts +6 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/focus-controller.d.ts.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/focus-controller.js +37 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/focus-controller.js.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/index.d.ts +13 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/index.d.ts.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/index.js +11 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/index.js.map +1 -0
- package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/animations/ios.enter.d.ts +0 -1
- package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/animations/ios.enter.d.ts.map +1 -0
- package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/animations/ios.enter.js +8 -5
- package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/animations/ios.enter.js.map +1 -0
- package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/animations/ios.leave.d.ts.map +1 -1
- package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/animations/ios.leave.js +11 -5
- package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/animations/ios.leave.js.map +1 -0
- package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/callout-surface.js +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/callout-surface.js.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/popover-interface.js +2 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/popover-interface.js.map +1 -0
- package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/utils.d.ts +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/utils.d.ts.map +1 -0
- package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/utils.js +3 -2
- package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/utils.js.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/sheets-of-glass.d.ts +16 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/sheets-of-glass.d.ts.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/sheets-of-glass.js +2 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/sheets-of-glass.js.map +1 -0
- package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/tab-bar-searchable/interfaces.js +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/tab-bar-searchable/interfaces.js.map +1 -0
- package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/tab-bar-searchable/utils.js +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/tab-bar-searchable/utils.js.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/theme.d.ts +15 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/theme.d.ts.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/theme.js +49 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/theme.js.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/transition/ios.transition.d.ts +18 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/transition/ios.transition.d.ts.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/transition/ios.transition.js +412 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/transition/ios.transition.js.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/package.json +64 -0
- package/node_modules/@rdlabo/ionic-theme-utils/src/dom.spec.ts +19 -0
- package/node_modules/@rdlabo/ionic-theme-utils/src/dom.ts +16 -0
- package/node_modules/@rdlabo/ionic-theme-utils/src/focus-controller.ts +38 -0
- package/node_modules/@rdlabo/ionic-theme-utils/src/index.ts +59 -0
- package/{src → node_modules/@rdlabo/ionic-theme-utils/src}/popover/animations/ios.enter.ts +7 -5
- package/{src → node_modules/@rdlabo/ionic-theme-utils/src}/popover/animations/ios.leave.ts +14 -5
- package/node_modules/@rdlabo/ionic-theme-utils/src/popover/utils.spec.ts +58 -0
- package/{src → node_modules/@rdlabo/ionic-theme-utils/src}/popover/utils.ts +4 -2
- package/node_modules/@rdlabo/ionic-theme-utils/src/sheets-of-glass.ts +17 -0
- package/node_modules/@rdlabo/ionic-theme-utils/src/styles/index.scss +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/src/styles/md-ion-list-inset.scss +20 -0
- package/node_modules/@rdlabo/ionic-theme-utils/src/styles/structured-list.scss +310 -0
- package/node_modules/@rdlabo/ionic-theme-utils/src/tab-bar-searchable/utils.spec.ts +56 -0
- package/node_modules/@rdlabo/ionic-theme-utils/src/theme.ts +51 -0
- package/node_modules/@rdlabo/ionic-theme-utils/src/transition/ios.transition.spec.ts +52 -0
- package/node_modules/@rdlabo/ionic-theme-utils/src/transition/ios.transition.ts +542 -0
- package/package.json +13 -5
- package/src/index.ts +4 -2
- package/src/native/components/searchable-tabs.ts +142 -22
- package/src/native/definitions.ts +34 -0
- package/src/native/index.ts +38 -11
- package/src/native/lifecycle.ts +38 -0
- package/src/native/runtime.ts +60 -10
- package/src/styles/components/ion-button.scss +21 -10
- package/src/styles/components/ion-fab.scss +10 -4
- package/src/styles/components/ion-list.scss +12 -3
- package/src/styles/components/ion-menu.scss +17 -10
- package/src/styles/components/ion-searchbar.scss +1 -1
- package/src/styles/components/ion-tabs.scss +14 -10
- package/src/styles/components/ion-toolbar.scss +9 -10
- package/src/styles/md-ion-list-inset.scss +2 -2
- package/src/styles/utils/dark/ion-list.scss +1 -1
- package/src/styles/utils/translucent.scss +44 -0
- package/src/tab-bar-searchable/animations/enter.ts +1 -2
- package/src/tab-bar-searchable/animations/leave.ts +1 -2
- package/src/tab-bar-searchable/index.ts +12 -4
- package/src/transition/ios.transition.ts +14 -473
- package/dist/css/utils/structured-list.css +0 -1
- package/dist/popover/animations/ios.enter.d.ts.map +0 -1
- package/dist/popover/popover-interface.js +0 -1
- package/dist/popover/utils.d.ts.map +0 -1
- package/src/styles/utils/structured-list.scss +0 -223
- /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/animations/ios.leave.d.ts +0 -0
- /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/callout-surface.d.ts +0 -0
- /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/callout-surface.d.ts.map +0 -0
- /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/popover-interface.d.ts +0 -0
- /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/popover-interface.d.ts.map +0 -0
- /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/tab-bar-searchable/interfaces.d.ts +0 -0
- /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/tab-bar-searchable/interfaces.d.ts.map +0 -0
- /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/tab-bar-searchable/utils.d.ts +0 -0
- /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/tab-bar-searchable/utils.d.ts.map +0 -0
- /package/{src → node_modules/@rdlabo/ionic-theme-utils/src}/popover/callout-surface.ts +0 -0
- /package/{src → node_modules/@rdlabo/ionic-theme-utils/src}/popover/popover-interface.ts +0 -0
- /package/{src → node_modules/@rdlabo/ionic-theme-utils/src}/tab-bar-searchable/interfaces.ts +0 -0
- /package/{src → node_modules/@rdlabo/ionic-theme-utils/src}/tab-bar-searchable/utils.ts +0 -0
|
@@ -47,7 +47,24 @@ private final class ShellSearchInputDelegate: NSObject, UITextFieldDelegate {
|
|
|
47
47
|
|
|
48
48
|
@available(iOS 26.0, *)
|
|
49
49
|
final class ShellSearchController: UITabBarController, UITabBarControllerDelegate, UISearchBarDelegate {
|
|
50
|
+
// Wire stays active+focused; local session drives chrome (idle / presented / focused).
|
|
51
|
+
private enum Session: Equatable { case idle, presented, focused }
|
|
52
|
+
|
|
53
|
+
private final class IdleBarDelegate: NSObject, UITabBarDelegate {
|
|
54
|
+
var select: ((String) -> Void)?
|
|
55
|
+
func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
|
|
56
|
+
guard item.isEnabled, let id = item.accessibilityIdentifier else { return }
|
|
57
|
+
select?(id)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
let surface = ShellSearchHost()
|
|
62
|
+
/// Ordinary `UITabBar` chrome while searchable is registered but the session is idle.
|
|
63
|
+
/// Matches `ShellTabBar.fit` so Index/Album resting tabs do not jump.
|
|
64
|
+
private let idleBar = UITabBar()
|
|
65
|
+
private let idleBarDelegate = IdleBarDelegate()
|
|
66
|
+
/// Resting search trigger pinned to the Web FAB while `available && !active`.
|
|
67
|
+
private var searchTrigger: UIButton?
|
|
51
68
|
private let search = UISearchController(searchResultsController: nil)
|
|
52
69
|
private let inputDelegate = ShellSearchInputDelegate()
|
|
53
70
|
private var searchTab: UISearchTab!
|
|
@@ -59,7 +76,13 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat
|
|
|
59
76
|
private var valueVersion = -1
|
|
60
77
|
private var lastLayout = ""
|
|
61
78
|
private var layoutItems: [ShellItemContent] = []
|
|
79
|
+
private var lockedWebFrame: CGRect? // frozen while search is active (width changes re-lock)
|
|
80
|
+
private var session: Session = .idle
|
|
81
|
+
private var focusWork: DispatchWorkItem?
|
|
82
|
+
private var pendingSelection: ShellTabBar.PendingSelection? // optimistic ordinary tab
|
|
83
|
+
private var pendingExpiryWork: DispatchWorkItem?
|
|
62
84
|
var ownsKeyboard: Bool { search.searchBar.searchTextField.isFirstResponder }
|
|
85
|
+
var ownsKeyboardChrome: Bool { session == .focused || ownsKeyboard }
|
|
63
86
|
var activate: ((String) -> Void)?
|
|
64
87
|
var changed: ((String, ShellSearchPhase, String, Bool, Int) -> Int)?
|
|
65
88
|
|
|
@@ -72,6 +95,14 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat
|
|
|
72
95
|
delegate = self
|
|
73
96
|
mode = .tabBar
|
|
74
97
|
ShellTabBar.configureLayout(tabBar)
|
|
98
|
+
ShellTabBar.configureLayout(idleBar)
|
|
99
|
+
// Must not use self as idleBar.delegate — UITabBarController asserts item↔VC pairing.
|
|
100
|
+
idleBar.delegate = idleBarDelegate
|
|
101
|
+
idleBarDelegate.select = { [weak self] id in
|
|
102
|
+
self?.armPendingSelection(id)
|
|
103
|
+
self?.activate?(id)
|
|
104
|
+
}
|
|
105
|
+
idleBar.isHidden = true
|
|
75
106
|
search.obscuresBackgroundDuringPresentation = false
|
|
76
107
|
search.hidesNavigationBarDuringPresentation = false
|
|
77
108
|
search.searchBar.delegate = self
|
|
@@ -84,6 +115,7 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat
|
|
|
84
115
|
child.view.backgroundColor = .clear
|
|
85
116
|
child.definesPresentationContext = true
|
|
86
117
|
child.navigationItem.searchController = self?.search
|
|
118
|
+
child.navigationItem.hidesSearchBarWhenScrolling = false
|
|
87
119
|
child.navigationItem.preferredSearchBarPlacement = .integrated
|
|
88
120
|
let navigation = UINavigationController(rootViewController: child)
|
|
89
121
|
navigation.view.backgroundColor = .clear
|
|
@@ -93,17 +125,124 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat
|
|
|
93
125
|
inputDelegate.clear = { [weak self] in self?.emit(.clear) }
|
|
94
126
|
}
|
|
95
127
|
|
|
128
|
+
deinit {
|
|
129
|
+
focusWork?.cancel()
|
|
130
|
+
pendingExpiryWork?.cancel()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private func wantedSession(active: Bool, focused: Bool) -> Session {
|
|
134
|
+
if !active { return .idle }
|
|
135
|
+
return focused ? .focused : .presented
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private func endEditing() {
|
|
139
|
+
focusWork?.cancel()
|
|
140
|
+
focusWork = nil
|
|
141
|
+
if search.searchBar.searchTextField.isFirstResponder {
|
|
142
|
+
search.searchBar.searchTextField.resignFirstResponder()
|
|
143
|
+
}
|
|
144
|
+
if search.isActive { search.isActive = false }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private func applySession(_ wanted: Session, selectingSearchTab: Bool) {
|
|
148
|
+
if wanted == session && !selectingSearchTab { return }
|
|
149
|
+
if wanted == .idle {
|
|
150
|
+
endEditing()
|
|
151
|
+
session = .idle
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
if selectingSearchTab || selectedTab !== searchTab { selectedTab = searchTab }
|
|
155
|
+
if wanted == .presented {
|
|
156
|
+
if session == .focused { endEditing() }
|
|
157
|
+
session = .presented
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
session = .focused
|
|
161
|
+
if !search.isActive { search.isActive = true }
|
|
162
|
+
guard !search.searchBar.searchTextField.isFirstResponder, focusWork == nil else { return }
|
|
163
|
+
let work = DispatchWorkItem { [weak self] in
|
|
164
|
+
guard let self else { return }
|
|
165
|
+
self.focusWork = nil
|
|
166
|
+
guard self.session == .focused, !self.search.searchBar.searchTextField.isFirstResponder else { return }
|
|
167
|
+
if !self.search.isActive { self.search.isActive = true }
|
|
168
|
+
_ = self.search.searchBar.searchTextField.becomeFirstResponder()
|
|
169
|
+
}
|
|
170
|
+
focusWork = work
|
|
171
|
+
DispatchQueue.main.async(execute: work)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private func armPendingSelection(_ id: String) {
|
|
175
|
+
pendingSelection = .start(id)
|
|
176
|
+
schedulePendingExpiry()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private func clearPendingSelection() {
|
|
180
|
+
pendingExpiryWork?.cancel()
|
|
181
|
+
pendingExpiryWork = nil
|
|
182
|
+
pendingSelection = nil
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private func schedulePendingExpiry() {
|
|
186
|
+
pendingExpiryWork?.cancel()
|
|
187
|
+
pendingExpiryWork = nil
|
|
188
|
+
guard let pending = pendingSelection else { return }
|
|
189
|
+
let delay = max(0, pending.until - CFAbsoluteTimeGetCurrent()) + 0.02
|
|
190
|
+
let work = DispatchWorkItem { [weak self] in
|
|
191
|
+
guard let self else { return }
|
|
192
|
+
self.pendingExpiryWork = nil
|
|
193
|
+
guard let pending = self.pendingSelection, CFAbsoluteTimeGetCurrent() >= pending.until else { return }
|
|
194
|
+
self.pendingSelection = nil
|
|
195
|
+
// No later apply: revert idle chrome + controller selection to the last DOM selected id.
|
|
196
|
+
if let item = self.idleBar.items?.first(where: { $0.accessibilityIdentifier == self.selectedID }) {
|
|
197
|
+
self.idleBar.selectedItem = item
|
|
198
|
+
}
|
|
199
|
+
if let selected = self.ordinary[self.selectedID], self.selectedTab !== selected {
|
|
200
|
+
self.selectedTab = selected
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
pendingExpiryWork = work
|
|
204
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private func resolveOrdinarySelection(_ items: [ShellItem], fallback: UITab?) {
|
|
208
|
+
let domSelected = ordinary[selectedID] ?? fallback
|
|
209
|
+
guard let pending = pendingSelection else {
|
|
210
|
+
pendingExpiryWork?.cancel()
|
|
211
|
+
pendingExpiryWork = nil
|
|
212
|
+
if selectedTab !== domSelected { selectedTab = domSelected }
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
let pendingTab = ordinary[pending.id]
|
|
216
|
+
let expired = CFAbsoluteTimeGetCurrent() >= pending.until
|
|
217
|
+
let unavailable = pendingTab == nil || (items.first { $0.id == pending.id }?.content.disabled ?? true)
|
|
218
|
+
if unavailable || expired {
|
|
219
|
+
clearPendingSelection()
|
|
220
|
+
selectedTab = domSelected
|
|
221
|
+
} else if selectedID == pending.id {
|
|
222
|
+
clearPendingSelection()
|
|
223
|
+
if selectedTab !== domSelected { selectedTab = domSelected }
|
|
224
|
+
} else if selectedTab !== pendingTab {
|
|
225
|
+
selectedTab = pendingTab
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
96
229
|
func attach(to parent: UIViewController, in container: UIView) {
|
|
97
230
|
parent.addChild(self)
|
|
98
231
|
container.addSubview(surface)
|
|
99
232
|
surface.addSubview(view)
|
|
233
|
+
if idleBar.superview !== surface { surface.addSubview(idleBar) }
|
|
100
234
|
didMove(toParent: parent)
|
|
101
235
|
}
|
|
102
236
|
|
|
103
237
|
func detach() {
|
|
104
238
|
closing = true
|
|
105
|
-
|
|
239
|
+
lockedWebFrame = nil
|
|
240
|
+
clearPendingSelection()
|
|
241
|
+
applySession(.idle, selectingSearchTab: false)
|
|
106
242
|
willMove(toParent: nil)
|
|
243
|
+
idleBar.removeFromSuperview()
|
|
244
|
+
searchTrigger?.removeFromSuperview()
|
|
245
|
+
searchTrigger = nil
|
|
107
246
|
surface.removeFromSuperview()
|
|
108
247
|
view.removeFromSuperview()
|
|
109
248
|
removeFromParent()
|
|
@@ -117,18 +256,16 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat
|
|
|
117
256
|
editingSequence = 0
|
|
118
257
|
valueVersion = -1
|
|
119
258
|
}
|
|
259
|
+
let previousAvailable = self.configuration?.available
|
|
260
|
+
let wasActive = self.configuration.map { $0.available && $0.active } ?? false
|
|
120
261
|
self.configuration = configuration
|
|
121
262
|
let available = configuration.available
|
|
122
263
|
let active = available && configuration.active
|
|
123
|
-
|
|
124
|
-
// advance native editing beyond the last input accepted by Ionic.
|
|
125
|
-
if !active { closing = true }
|
|
264
|
+
let wanted = wantedSession(active: active, focused: configuration.focused)
|
|
126
265
|
let items = snapshot.items
|
|
127
266
|
let content = items.map(\.content)
|
|
128
267
|
if layoutItems != content {
|
|
129
268
|
layoutItems = content
|
|
130
|
-
// Defer fitting while search is active, but keep the resting layout
|
|
131
|
-
// invalid until new labels, typography, icons and badges are measured.
|
|
132
269
|
lastLayout = ""
|
|
133
270
|
}
|
|
134
271
|
let ids = items.map(\.id)
|
|
@@ -153,7 +290,11 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat
|
|
|
153
290
|
searchTab.image = rendering.image(trigger.content)
|
|
154
291
|
searchTab.isEnabled = !configuration.disabled
|
|
155
292
|
let requested = ids.compactMap { ordinary[$0] } + (available ? [searchTab!] : [])
|
|
156
|
-
if tabs.map(\.identifier) != requested.map(\.identifier) {
|
|
293
|
+
if !active, wasActive || tabs.isEmpty || tabs.map(\.identifier) != requested.map(\.identifier) {
|
|
294
|
+
// Leave / availability flips rebuild tabs (UISearchTab morph cleanup when leaving).
|
|
295
|
+
tabs = requested
|
|
296
|
+
lastLayout = ""
|
|
297
|
+
}
|
|
157
298
|
for item in items {
|
|
158
299
|
if let nativeItem = ordinary[item.id]?.viewController?.tabBarItem {
|
|
159
300
|
nativeItem.accessibilityLabel = item.content.accessibilityLabel
|
|
@@ -161,7 +302,6 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat
|
|
|
161
302
|
ShellTabBar.applyBadge(item.content.badge, to: nativeItem, rendering: rendering)
|
|
162
303
|
}
|
|
163
304
|
}
|
|
164
|
-
// This is a public iOS 27 property; dynamic dispatch also supports apps built with SDK 26.
|
|
165
305
|
let prominent = NSSelectorFromString("setProminentTabIdentifier:")
|
|
166
306
|
if responds(to: prominent) { setValue(available ? searchTab.identifier : nil, forKey: "prominentTabIdentifier") }
|
|
167
307
|
let field = configuration.field
|
|
@@ -180,74 +320,107 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat
|
|
|
180
320
|
inputDelegate.original = search.searchBar.searchTextField.delegate
|
|
181
321
|
search.searchBar.searchTextField.delegate = inputDelegate
|
|
182
322
|
}
|
|
183
|
-
|
|
323
|
+
let layout = "\(webFrame):\(barFrame):\(triggerFrame):\(available):\(snapshot.rtl)"
|
|
324
|
+
// Resting keeps closing=true so hidden UISearchBar noise cannot emit; clear it while active.
|
|
325
|
+
closing = !active
|
|
184
326
|
surface.isHidden = false
|
|
185
327
|
surface.overrideUserInterfaceStyle = snapshot.dark ? .dark : .light
|
|
328
|
+
if active {
|
|
329
|
+
// UISearchTab session owns the bar + keyboard; freeze Web layout for the session.
|
|
330
|
+
if tabs.isEmpty || tabs.map(\.identifier) != requested.map(\.identifier) {
|
|
331
|
+
tabs = requested
|
|
332
|
+
}
|
|
333
|
+
let reveal = {
|
|
334
|
+
self.idleBar.isHidden = true
|
|
335
|
+
self.searchTrigger?.isHidden = true
|
|
336
|
+
self.view.isHidden = false
|
|
337
|
+
self.view.frame = self.surface.bounds
|
|
338
|
+
}
|
|
339
|
+
if !wasActive {
|
|
340
|
+
UIView.transition(with: surface, duration: 0.35, options: [.transitionCrossDissolve, .beginFromCurrentState], animations: reveal)
|
|
341
|
+
} else {
|
|
342
|
+
reveal()
|
|
343
|
+
}
|
|
344
|
+
let nextLock = surface.bounds.isEmpty ? webFrame : surface.frame
|
|
345
|
+
if let locked = lockedWebFrame, abs(locked.width - webFrame.width) > 0.5 {
|
|
346
|
+
// Rotation / size-class change: adopt the new width while still ignoring keyboard shrink.
|
|
347
|
+
lockedWebFrame = webFrame
|
|
348
|
+
} else if lockedWebFrame == nil {
|
|
349
|
+
lockedWebFrame = nextLock
|
|
350
|
+
}
|
|
351
|
+
if let lockedWebFrame, surface.frame != lockedWebFrame { surface.frame = lockedWebFrame }
|
|
352
|
+
clearPendingSelection()
|
|
353
|
+
applySession(wanted, selectingSearchTab: !wasActive)
|
|
354
|
+
return true
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
surface.frame = webFrame
|
|
186
358
|
view.semanticContentAttribute = snapshot.rtl ? .forceRightToLeft : .forceLeftToRight
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
search.isActive = false
|
|
192
|
-
selectedTab = ordinary[selectedID] ?? requested.first
|
|
193
|
-
view.frame = surface.bounds
|
|
194
|
-
view.layoutIfNeeded()
|
|
195
|
-
guard fit(barFrame: surface.convert(barFrame, from: surface.superview), triggerFrame: surface.convert(triggerFrame, from: surface.superview),
|
|
196
|
-
available: available, anchor: snapshot.tabBarAnchor) else { return false }
|
|
197
|
-
lastLayout = layout
|
|
359
|
+
idleBar.semanticContentAttribute = snapshot.rtl ? .forceRightToLeft : .forceLeftToRight
|
|
360
|
+
if lockedWebFrame != nil {
|
|
361
|
+
lockedWebFrame = nil
|
|
362
|
+
lastLayout = ""
|
|
198
363
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
364
|
+
applySession(.idle, selectingSearchTab: false)
|
|
365
|
+
|
|
366
|
+
// Resting chrome: ordinary UITabBar (+ FAB trigger when available). UISearchTab stays off-screen.
|
|
367
|
+
if idleBar.superview !== surface { surface.addSubview(idleBar) }
|
|
368
|
+
let localBar = surface.convert(barFrame, from: surface.superview)
|
|
369
|
+
let localTrigger = surface.convert(triggerFrame, from: surface.superview)
|
|
370
|
+
// Apply optimistic selection to the visible idle bar before reconciling the hidden controller.
|
|
371
|
+
ShellTabBar.update(idleBar, node: snapshot, rendering: rendering, pendingSelection: &pendingSelection)
|
|
372
|
+
resolveOrdinarySelection(items, fallback: requested.first)
|
|
373
|
+
if !available {
|
|
374
|
+
searchTrigger?.removeFromSuperview()
|
|
375
|
+
searchTrigger = nil
|
|
376
|
+
} else {
|
|
377
|
+
let triggerButton = ShellButton.render(configuration.trigger.content, glass: true, existing: searchTrigger, rendering: rendering) { [weak self] id in
|
|
378
|
+
self?.activate?(id)
|
|
379
|
+
}
|
|
380
|
+
if searchTrigger !== triggerButton {
|
|
381
|
+
searchTrigger?.removeFromSuperview()
|
|
382
|
+
searchTrigger = triggerButton
|
|
383
|
+
surface.addSubview(triggerButton)
|
|
384
|
+
}
|
|
385
|
+
triggerButton.frame = localTrigger
|
|
206
386
|
}
|
|
207
|
-
return true
|
|
208
|
-
}
|
|
209
387
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
let
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
frame.origin.x += dx
|
|
221
|
-
frame.size.height += barFrame.maxY - rect.maxY
|
|
222
|
-
var targetSearch: CGFloat?
|
|
223
|
-
if available {
|
|
224
|
-
guard let control = searchControl else { return false }
|
|
225
|
-
let target = triggerFrame.midX
|
|
226
|
-
targetSearch = target
|
|
227
|
-
frame.size.width += target - control.convert(control.bounds, to: surface).midX - dx
|
|
388
|
+
let revealResting = {
|
|
389
|
+
self.view.isHidden = true
|
|
390
|
+
self.idleBar.isHidden = false
|
|
391
|
+
self.idleBar.overrideUserInterfaceStyle = snapshot.dark ? .dark : .light
|
|
392
|
+
self.searchTrigger?.isHidden = !available
|
|
393
|
+
self.searchTrigger?.overrideUserInterfaceStyle = snapshot.dark ? .dark : .light
|
|
394
|
+
}
|
|
395
|
+
let animateChrome = wasActive || (previousAvailable != nil && previousAvailable != available)
|
|
396
|
+
if animateChrome {
|
|
397
|
+
UIView.transition(with: surface, duration: 0.35, options: [.transitionCrossDissolve, .beginFromCurrentState], animations: revealResting)
|
|
228
398
|
} else {
|
|
229
|
-
|
|
399
|
+
revealResting()
|
|
230
400
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
guard abs(final.minX + final.width * x - (barFrame.minX + barFrame.width * x)) <= 1,
|
|
235
|
-
abs(final.maxY - barFrame.maxY) <= 1 else { return false }
|
|
236
|
-
if let targetSearch,
|
|
237
|
-
let control = searchControl {
|
|
238
|
-
let rect = control.convert(control.bounds, to: surface)
|
|
239
|
-
return abs(rect.midX - targetSearch) <= 1 && abs(rect.midY - triggerFrame.midY) <= 1
|
|
401
|
+
if lastLayout != layout {
|
|
402
|
+
guard ShellTabBar.fit(idleBar, node: snapshot, bounds: localBar) else { return false }
|
|
403
|
+
lastLayout = layout
|
|
240
404
|
}
|
|
241
405
|
return true
|
|
242
406
|
}
|
|
243
407
|
|
|
244
408
|
func tabBarController(_ tabBarController: UITabBarController, shouldSelectTab tab: UITab) -> Bool {
|
|
245
409
|
guard let configuration else { return false }
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
410
|
+
if tab === searchTab {
|
|
411
|
+
clearPendingSelection()
|
|
412
|
+
activate?(configuration.trigger.id)
|
|
413
|
+
return true
|
|
414
|
+
}
|
|
415
|
+
if configuration.active {
|
|
416
|
+
if tab.identifier == selectedID { activate?(configuration.closeId) }
|
|
417
|
+
else { activate?(tab.identifier) }
|
|
418
|
+
return false
|
|
419
|
+
}
|
|
420
|
+
armPendingSelection(tab.identifier)
|
|
421
|
+
if selectedTab !== tab { selectedTab = tab }
|
|
422
|
+
activate?(tab.identifier)
|
|
423
|
+
return true
|
|
251
424
|
}
|
|
252
425
|
|
|
253
426
|
private func emit(_ phase: ShellSearchPhase) {
|
|
@@ -255,8 +428,14 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat
|
|
|
255
428
|
editingSequence = changed?(configuration.id, phase, search.searchBar.text ?? "", search.searchBar.searchTextField.markedTextRange != nil, valueVersion) ?? editingSequence
|
|
256
429
|
}
|
|
257
430
|
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { emit(.input) }
|
|
258
|
-
func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
|
|
259
|
-
|
|
431
|
+
func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
|
|
432
|
+
if session != .idle { session = .focused }
|
|
433
|
+
emit(.focus)
|
|
434
|
+
}
|
|
435
|
+
func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
|
|
436
|
+
if session == .focused { session = .presented }
|
|
437
|
+
emit(.blur)
|
|
438
|
+
}
|
|
260
439
|
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { emit(.commit) }
|
|
261
440
|
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
|
|
262
441
|
closing = true
|
|
@@ -7,7 +7,18 @@ enum ShellTabBar {
|
|
|
7
7
|
var isValid: Bool { [0, 0.5, 1].contains(x) && [0, 1].contains(y) }
|
|
8
8
|
}
|
|
9
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
|
+
|
|
10
19
|
static let kind = ShellComponent.tabBar
|
|
20
|
+
/// Tab bars waiting for an iOS 26 title-layout warmup after the next `fit`.
|
|
21
|
+
private static var pendingTitleWarmup = Set<ObjectIdentifier>()
|
|
11
22
|
|
|
12
23
|
static func make(_ node: ShellControl, rendering: ShellRendering, delegate: UITabBarDelegate) -> UITabBar {
|
|
13
24
|
let control = UITabBar()
|
|
@@ -31,8 +42,15 @@ enum ShellTabBar {
|
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
static func update(_ tabBar: UITabBar, node: ShellControl, rendering: ShellRendering) {
|
|
45
|
+
var pending: PendingSelection?
|
|
46
|
+
update(tabBar, node: node, rendering: rendering, pendingSelection: &pending)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static func update(_ tabBar: UITabBar, node: ShellControl, rendering: ShellRendering,
|
|
50
|
+
pendingSelection: inout PendingSelection?) {
|
|
34
51
|
let items = node.items
|
|
35
52
|
let ids = items.map(\.id)
|
|
53
|
+
var needsTitleWarmup = false
|
|
36
54
|
// Keep UIKit's item identities and interaction state across DOM selection updates.
|
|
37
55
|
if tabBar.items?.map({ $0.accessibilityIdentifier ?? "" }) != ids {
|
|
38
56
|
tabBar.items = items.map { item in
|
|
@@ -40,26 +58,54 @@ enum ShellTabBar {
|
|
|
40
58
|
tab.accessibilityIdentifier = item.id
|
|
41
59
|
return tab
|
|
42
60
|
}
|
|
61
|
+
needsTitleWarmup = true
|
|
43
62
|
}
|
|
44
63
|
let nativeItems = tabBar.items ?? []
|
|
45
64
|
for (tab, item) in zip(nativeItems, items) {
|
|
46
65
|
let title = item.content.label
|
|
47
|
-
if tab.title != title {
|
|
66
|
+
if tab.title != title {
|
|
67
|
+
tab.title = title
|
|
68
|
+
needsTitleWarmup = true
|
|
69
|
+
}
|
|
48
70
|
let icon = rendering.image(item.content)
|
|
49
|
-
if tab.image !== icon {
|
|
71
|
+
if tab.image !== icon {
|
|
72
|
+
tab.image = icon
|
|
73
|
+
needsTitleWarmup = true
|
|
74
|
+
}
|
|
50
75
|
if tab.selectedImage !== icon { tab.selectedImage = icon }
|
|
51
76
|
tab.isEnabled = !item.content.disabled
|
|
52
77
|
tab.accessibilityLabel = item.content.accessibilityLabel
|
|
53
|
-
applyTypography(item.content, to: tab)
|
|
78
|
+
if applyTypography(item.content, to: tab) { needsTitleWarmup = true }
|
|
54
79
|
applyBadge(item.content.badge, to: tab, rendering: rendering)
|
|
55
80
|
}
|
|
56
|
-
let
|
|
57
|
-
|
|
81
|
+
let domSelected = zip(nativeItems, items).first { $0.1.content.selected }
|
|
82
|
+
let selectedItem = domSelected?.0
|
|
83
|
+
if let pending = pendingSelection {
|
|
84
|
+
let pendingItem = zip(nativeItems, items).first { $0.1.id == pending.id }
|
|
85
|
+
let expired = CFAbsoluteTimeGetCurrent() >= pending.until
|
|
86
|
+
let unavailable = pendingItem == nil || pendingItem!.1.content.disabled
|
|
87
|
+
if unavailable || expired {
|
|
88
|
+
// Ionic rejected the tap, the item vanished, or the echo timed out.
|
|
89
|
+
pendingSelection = nil
|
|
90
|
+
if tabBar.selectedItem !== selectedItem { tabBar.selectedItem = selectedItem }
|
|
91
|
+
} else if domSelected?.1.id == pending.id {
|
|
92
|
+
pendingSelection = nil
|
|
93
|
+
if tabBar.selectedItem !== selectedItem { tabBar.selectedItem = selectedItem }
|
|
94
|
+
} else if tabBar.selectedItem !== pendingItem!.0 {
|
|
95
|
+
// Keep the optimistic native selection until the Web selected state catches up.
|
|
96
|
+
tabBar.selectedItem = pendingItem!.0
|
|
97
|
+
}
|
|
98
|
+
} else if tabBar.selectedItem !== selectedItem {
|
|
99
|
+
tabBar.selectedItem = selectedItem
|
|
100
|
+
}
|
|
58
101
|
tabBar.semanticContentAttribute = node.rtl ? .forceRightToLeft : .forceLeftToRight
|
|
59
102
|
tabBar.accessibilityIdentifier = node.id
|
|
103
|
+
// Warm after `fit` settles geometry — not on every motion-driven frame sync.
|
|
104
|
+
if needsTitleWarmup { pendingTitleWarmup.insert(ObjectIdentifier(tabBar)) }
|
|
60
105
|
}
|
|
61
106
|
|
|
62
|
-
|
|
107
|
+
@discardableResult
|
|
108
|
+
static func applyTypography(_ content: ShellItemContent, to item: UITabBarItem) -> Bool {
|
|
63
109
|
let weight: UIFont.Weight
|
|
64
110
|
switch content.fontWeight {
|
|
65
111
|
case ..<150: weight = .ultraLight
|
|
@@ -73,11 +119,14 @@ enum ShellTabBar {
|
|
|
73
119
|
default: weight = .black
|
|
74
120
|
}
|
|
75
121
|
let font = UIFont.systemFont(ofSize: content.fontSize, weight: weight)
|
|
122
|
+
var changed = false
|
|
76
123
|
for state in [UIControl.State.normal, .selected] {
|
|
77
124
|
if item.titleTextAttributes(for: state)?[.font] as? UIFont != font {
|
|
78
125
|
item.setTitleTextAttributes([.font: font], for: state)
|
|
126
|
+
changed = true
|
|
79
127
|
}
|
|
80
128
|
}
|
|
129
|
+
return changed
|
|
81
130
|
}
|
|
82
131
|
|
|
83
132
|
static func applyBadge(_ badge: ShellBadge?, to item: UITabBarItem, rendering: ShellRendering) {
|
|
@@ -128,9 +177,31 @@ enum ShellTabBar {
|
|
|
128
177
|
let center = CGPoint(x: bounds.minX + (bounds.width - content.width) * x - content.minX + size.width / 2,
|
|
129
178
|
y: bounds.minY + (bounds.height - content.height) * y - content.minY + size.height / 2)
|
|
130
179
|
if tabBar.center != center { tabBar.center = center }
|
|
180
|
+
if pendingTitleWarmup.remove(ObjectIdentifier(tabBar)) != nil {
|
|
181
|
+
warmUnselectedTitleLayout(tabBar)
|
|
182
|
+
}
|
|
131
183
|
return true
|
|
132
184
|
}
|
|
133
185
|
|
|
186
|
+
/// iOS 26 only: programmatically visit each unselected item so UIKit measures
|
|
187
|
+
/// its title the same way a user tap would. Selection does not call the
|
|
188
|
+
/// tab-bar delegate. No-op on iOS 27+, where first paint already matches Web.
|
|
189
|
+
static func warmUnselectedTitleLayout(_ tabBar: UITabBar) {
|
|
190
|
+
let version = ProcessInfo.processInfo.operatingSystemVersion
|
|
191
|
+
guard version.majorVersion == 26, let items = tabBar.items, items.count > 1 else { return }
|
|
192
|
+
let selected = tabBar.selectedItem
|
|
193
|
+
UIView.performWithoutAnimation {
|
|
194
|
+
for item in items where item !== selected {
|
|
195
|
+
tabBar.selectedItem = item
|
|
196
|
+
tabBar.layoutIfNeeded()
|
|
197
|
+
}
|
|
198
|
+
if tabBar.selectedItem !== selected {
|
|
199
|
+
tabBar.selectedItem = selected
|
|
200
|
+
tabBar.layoutIfNeeded()
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
134
205
|
private static func contentFrame(_ tabBar: UITabBar) -> CGRect? {
|
|
135
206
|
func containsControl(_ view: UIView) -> Bool {
|
|
136
207
|
view is UIControl || view.subviews.contains(where: containsControl)
|