@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/README.md +84 -126
- 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/runtime.d.ts.map +1 -1
- package/dist/native/runtime.js +14 -7
- package/docs/migration.md +105 -3
- package/docs/native-ui-shell.md +4 -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 +79 -12
- package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +258 -2
- package/package.json +1 -1
- package/src/native/components/searchable-tabs.ts +142 -22
- package/src/native/runtime.ts +11 -6
- package/docs/ios-adaptive.md +0 -90
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
|
-
|
|
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
|
-
-
|
|
26
|
-
-
|
|
127
|
+
- --ion-color-primary-brightness-rgb: 130, 255, 255;
|
|
128
|
+
- --ion-color-primary-brightness: #96feff;
|
|
27
129
|
}
|
|
28
130
|
```
|
package/docs/native-ui-shell.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|