@rdlabo/ionic-theme-ios27 0.2.1 → 1.0.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/docs/migration.md CHANGED
@@ -4,9 +4,111 @@ title: Migration
4
4
 
5
5
  # Migration
6
6
 
7
+ ## From the iOS 26 theme
8
+
9
+ For an app using `@rdlabo/ionic-theme-ios26`, the recommended migration keeps that package and adds `@rdlabo/ionic-theme-ios27`. The [README setup](https://docs.rdlabo.dev/projects/ionic-theme-ios27#get-started) selects iOS 27 or iOS 26 styles by browser capability and leaves Ionic's default iOS appearance on older browsers.
10
+
11
+ ### 1. Add the new package
12
+
13
+ Keep the iOS 26 package and add iOS 27. The new theme requires `@ionic/core` 8.8.1 or later (Ionic 8 or 9).
14
+
15
+ ```bash
16
+ npm install @rdlabo/ionic-theme-ios27
17
+ ```
18
+
19
+ ### 2. Make the styles adaptive
20
+
21
+ Replace unconditional iOS 26 imports in your global Sass stylesheet with two mutually exclusive branches. This example uses class-based dark mode:
22
+
23
+ ```diff
24
+ +@use 'sass:meta';
25
+ +
26
+ -@use '@rdlabo/ionic-theme-ios26/src/styles/default-variables.scss';
27
+ -@use '@rdlabo/ionic-theme-ios26/src/styles/ionic-theme-ios26.scss';
28
+ -@use '@rdlabo/ionic-theme-ios26/src/styles/ionic-theme-ios26-dark-class.scss';
29
+ -@use '@rdlabo/ionic-theme-ios26/src/styles/md-remove-ios-class-effect.scss';
30
+ +@supports (overflow-anchor: auto) {
31
+ + @include meta.load-css('@rdlabo/ionic-theme-ios27/src/styles/default-variables');
32
+ + @include meta.load-css('@rdlabo/ionic-theme-ios27/src/styles/ionic-theme-ios27');
33
+ + @include meta.load-css('@rdlabo/ionic-theme-ios27/src/styles/ionic-theme-ios27-dark-class');
34
+ + @include meta.load-css('@rdlabo/ionic-theme-ios27/src/styles/md-remove-ios-class-effect');
35
+ +}
36
+ +
37
+ +@supports (text-wrap: pretty) and (not (overflow-anchor: auto)) {
38
+ + @include meta.load-css('@rdlabo/ionic-theme-ios26/src/styles/default-variables');
39
+ + @include meta.load-css('@rdlabo/ionic-theme-ios26/src/styles/ionic-theme-ios26');
40
+ + @include meta.load-css('@rdlabo/ionic-theme-ios26/src/styles/ionic-theme-ios26-dark-class');
41
+ + @include meta.load-css('@rdlabo/ionic-theme-ios26/src/styles/md-remove-ios-class-effect');
42
+ +}
43
+ ```
44
+
45
+ Keep Ionic's matching dark palette. For system or always-dark mode, replace both `-dark-class` imports with the matching variant. If you use `md-ion-list-inset`, load the corresponding package's stylesheet inside each branch. Browsers without either feature retain Ionic's default styling.
46
+
47
+ ### 3. Switch the animations
48
+
49
+ Replace the iOS 26 animation import and gate the iOS 27 animations on the same browser features. Resolve the options before Ionic initializes:
50
+
51
+ ```diff
52
+ -import { iosTransitionAnimation, popoverEnterAnimation, popoverLeaveAnimation } from '@rdlabo/ionic-theme-ios26';
53
+ +import { iosTransitionAnimation, popoverEnterAnimation, popoverLeaveAnimation } from '@rdlabo/ionic-theme-ios27';
54
+ +
55
+ +function loadIOSAnimations() {
56
+ + if (typeof CSS === 'undefined') return {};
57
+ + if (!CSS.supports('overflow-anchor: auto') && !CSS.supports('text-wrap: pretty')) return {};
58
+ +
59
+ + return {
60
+ + navAnimation: iosTransitionAnimation,
61
+ + popoverEnter: popoverEnterAnimation,
62
+ + popoverLeave: popoverLeaveAnimation,
63
+ + };
64
+ +}
65
+
66
+ provideIonicAngular({
67
+ - navAnimation: isPlatform('ios') ? iosTransitionAnimation : undefined,
68
+ - popoverEnter: isPlatform('ios') ? popoverEnterAnimation : undefined,
69
+ - popoverLeave: isPlatform('ios') ? popoverLeaveAnimation : undefined,
70
+ + ...(isPlatform('ios') ? loadIOSAnimations() : {}),
71
+ });
72
+ ```
73
+
74
+ The iOS 27 page transition and popover animations serve both styled generations. Older browsers keep Ionic's defaults. The example uses Angular; pass the same options to React's `setupIonicReact` or Vue's `IonicVue`.
75
+
76
+ ### 4. Update customizations
77
+
78
+ Rename theme variables and opt-out classes used by your app. For example:
79
+
80
+ ```diff
81
+ ion-content {
82
+ - --ios26-content-box-shadow-rgb: 0, 0, 0;
83
+ + --ios-theme-content-box-shadow-rgb: 0, 0, 0;
84
+ }
85
+
86
+ -<ion-button class="ios26-disabled">Standard Ionic button</ion-button>
87
+ +<ion-button class="ios-theme-disabled">Standard Ionic button</ion-button>
88
+ ```
89
+
90
+ The old names remain as deprecated fallbacks. Check the resulting screens in light and dark modes on the browsers you support.
91
+
92
+ ### iOS 27 only
93
+
94
+ To switch entirely to iOS 27, remove the iOS 26 package and replace its stylesheet and animation imports. The stylesheet changes are:
95
+
96
+ ```diff
97
+ -@use '@rdlabo/ionic-theme-ios26/src/styles/default-variables.scss';
98
+ -@use '@rdlabo/ionic-theme-ios26/src/styles/ionic-theme-ios26.scss';
99
+ -@use '@rdlabo/ionic-theme-ios26/src/styles/ionic-theme-ios26-dark-class.scss';
100
+ -@use '@rdlabo/ionic-theme-ios26/src/styles/md-remove-ios-class-effect.scss';
101
+ +@use '@rdlabo/ionic-theme-ios27/src/styles/default-variables.scss';
102
+ +@use '@rdlabo/ionic-theme-ios27/src/styles/ionic-theme-ios27.scss';
103
+ +@use '@rdlabo/ionic-theme-ios27/src/styles/ionic-theme-ios27-dark-class.scss';
104
+ +@use '@rdlabo/ionic-theme-ios27/src/styles/md-remove-ios-class-effect.scss';
105
+ ```
106
+
107
+ Change the animation import from `@rdlabo/ionic-theme-ios26` to `@rdlabo/ionic-theme-ios27`; the existing `isPlatform('ios')` configuration can stay. See the README's [iOS 27-only setup](https://docs.rdlabo.dev/projects/ionic-theme-ios27#use-only-the-ios-27-theme). Unconditional imports apply the new styles on every browser using Ionic iOS mode.
108
+
7
109
  ## iOS 27 naming
8
110
 
9
- Use `@rdlabo/ionic-theme-ios27` for the current theme. Update stylesheet imports to `ionic-theme-ios27.scss` or `ionic-theme-ios27.css`, including the `-dark-always`, `-dark-system`, and `-dark-class` variants.
111
+ For the iOS 27 branch or an iOS 27-only app, use `@rdlabo/ionic-theme-ios27`. Its stylesheets are `ionic-theme-ios27.scss` or `ionic-theme-ios27.css`, including the `-dark-always`, `-dark-system`, and `-dark-class` variants. Keep the iOS 26 stylesheet names in the iOS 26 branch of an adaptive setup.
10
112
 
11
113
  Use the version-independent `--ios-theme-*` CSS variables. The corresponding `--ios26-*` variables remain supported as deprecated fallbacks. When both are set, the new name takes precedence. For example, use `--ios-theme-content-box-shadow-rgb` instead of `--ios26-content-box-shadow-rgb`.
12
114
 
@@ -22,7 +124,7 @@ Submit buttons now use each Ionic color's standard contrast value and an iOS 27
22
124
 
23
125
  ```diff
24
126
  :root {
25
- - --ion-color-primary-brightness-rgb: 130, 255, 255;
26
- - --ion-color-primary-brightness: #96feff;
127
+ - --ion-color-primary-brightness-rgb: 130, 255, 255;
128
+ - --ion-color-primary-brightness: #96feff;
27
129
  }
28
130
  ```
@@ -46,7 +46,7 @@ Native appearance follows the applied class, system or always-dark theme CSS. Sy
46
46
  | `ion-buttons` | Fixed toolbar, two or more direct clear `ion-button` / `ion-menu-button` children sharing the theme glass | One `UIGlassEffect` surface with independent native buttons |
47
47
  | `ion-back-button` | Standard icon and color in a fixed header/footer toolbar | Glass `UIButton`, using the resolved Ionic label/icon |
48
48
  | `ion-menu-button` | Fixed toolbar, inside the theme glass `ion-buttons` | Glass `UIButton`; original Ionic menu toggle |
49
- | `ion-tab-bar` | Fixed tabs, icon-only/label-only items, one icon per item, dot/text badges, selection and disabled state | `UITabBar` and `UITabBarItem` |
49
+ | `ion-tab-bar` | Fixed tabs, icon-only/label-only items, one icon per item, dot/text badges, selection and disabled state | `UITabBar` and `UITabBarItem` |
50
50
  | `ion-segment` | Fixed toolbar, non-scrollable, text **or** one icon per item | `UISegmentedControl` |
51
51
  | `ion-fab` / `ion-fab-button` / `ion-fab-list` | Glass FAB in an `ion-content` fixed slot; one main button and optional directional lists | Persistent glass `UIButton` per button; one FAB synchronization group |
52
52
 
@@ -92,7 +92,9 @@ Existing `attachTabBarSearchable(tabBar, fabButton, footer)` registrations autom
92
92
 
93
93
  Search registration does not bypass placement restrictions. Its searchbar and close button must be in fixed footer toolbars. Its trigger must belong to an `ion-fab[slot="fixed"]` directly inside `ion-content`, or directly on the existing non-scrolling `.ion-page` layout. A wrapper inside scrolling content is not a fixed slot.
94
94
 
95
- Opening search preserves the selected Ionic tab and does not automatically show the keyboard. Tap the search field or call the existing `ion-searchbar.setFocus()`. UIKit owns the search expansion, keyboard placement and return to the ordinary tabs. The trigger's resolved SVG and the search icon are projected from Ionic, including `ion-icon name`. The resting layout is measured against `ion-tab-bar` and both coordinates of the original FAB's center. A layout that cannot preserve those origins uses Web search.
95
+ While a registration is alive, Native UI Shell keeps the searchable controller even when the page is transitioning or temporarily unavailable (`available: false`), instead of demoting back to a separate ordinary control identity. Resting chrome (including Album before search opens) uses the same `UITabBar` + `ShellTabBar.fit` path as ordinary tabs, with the search trigger pinned to the FAB when available; `UISearchTab` is shown only while the search session is active. Flipping availability or entering/leaving search crossfades between those layers. Register before the destination page finishes entering (for example in `ionViewWillEnter`) so the first visit does not paint ordinary tabs and then swap.
96
+
97
+ Opening search preserves the selected Ionic tab and does not automatically show the keyboard (`automaticallyActivatesSearch` stays off). Tap the field or call `ion-searchbar.setFocus()` for the keyboard. While search is active, Native UI Shell freezes Web layout projection and holds Capacitor Keyboard resize at `none`; UIKit owns tab/search chrome (no Ionic `fit` mid-session). Leave re-fits ordinary tabs to `ion-tab-bar` and search to the FAB. Ordinary native tabs keep an optimistic selection until the Web `selected` state catches up. Input events and application `value` updates continue across the bridge until search closes. The trigger's resolved SVG and the search icon are projected from Ionic, including `ion-icon name`.
96
98
 
97
99
  Native edits pass through Ionic's input handlers, preserving `ionInput` debounce, `ionChange`, `ionFocus`, `ionBlur`, and `ionClear`. Programmatic `value` changes do not emit `ionInput`; synchronous application corrections and stale native input are distinguished. Native editing owns marked text and the caret. Returning through the footer's close action retains the value and does not emit `ionCancel` or `ionClear`.
98
100
 
@@ -247,5 +249,4 @@ On iOS, [`Components`](../ios/Sources/IonicNativeUIShellPlugin/Components) owns
247
249
 
248
250
  See the [demo and verification guide](../demo/native-ui-shell.md) for browser tests, Simulator tests and an independent SPM consumer built from the npm package.
249
251
 
250
-
251
252
  Search controllers retain their UIKit-managed transition and are excluded from the ordinary control acquisition crossfade.
@@ -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
- search.isActive = false
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
- // UIKit may clear the field while changing tabs. Retiring must not
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) { tabs = requested; lastLayout = "" }
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
- surface.frame = webFrame
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
- // Only measure the resting tabs. UIKit owns all frames during search and keyboard movement.
188
- let layout = "\(webFrame):\(barFrame):\(triggerFrame):\(available):\(snapshot.rtl)"
189
- if !active && lastLayout != layout {
190
- closing = true
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
- let wanted = active ? searchTab : ordinary[selectedID]
200
- if selectedTab !== wanted { selectedTab = wanted }
201
- closing = !active
202
- if !active { search.isActive = false }
203
- else if configuration.focused && !search.searchBar.searchTextField.isFirstResponder {
204
- search.isActive = true
205
- search.searchBar.searchTextField.becomeFirstResponder()
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
- private func fit(barFrame: CGRect, triggerFrame: CGRect, available: Bool, anchor: ShellTabBar.Anchor?) -> Bool {
211
- func descendants(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(descendants) }
212
- let groups = tabBar.subviews.filter { descendants($0).contains { $0 is UIControl } }
213
- guard ordinary.count > 1,
214
- let group = groups.first(where: { descendants($0).filter { $0 is UIControl }.count >= ordinary.count }) else { return false }
215
- let searchControl = groups.filter { $0 !== group }.flatMap(descendants).first { $0 is UIControl }
216
- let rect = group.convert(group.bounds, to: surface)
217
- let x = anchor?.x ?? 0
218
- let dx = barFrame.minX + (barFrame.width - rect.width) * x - rect.minX
219
- var frame = view.frame
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
- frame.size.width += barFrame.width - rect.width
399
+ revealResting()
230
400
  }
231
- view.frame = frame
232
- view.layoutIfNeeded()
233
- let final = group.convert(group.bounds, to: surface)
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
- let active = configuration.active
247
- if tab === searchTab { activate?(configuration.trigger.id) }
248
- else if active && tab.identifier == selectedID { activate?(configuration.closeId) }
249
- else { activate?(tab.identifier) }
250
- return false
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) { emit(.focus) }
259
- func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { emit(.blur) }
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