@rdlabo/ionic-theme-ios27 1.2.0-beta.pr231.sha22a35d481cf3 → 1.2.0-beta.pr233.sha647eef34274b
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 +9 -0
- package/dist/native/components/searchable-tabs.d.ts.map +1 -1
- package/dist/native/components/searchable-tabs.js +6 -7
- package/dist/native/definitions.d.ts +4 -15
- package/dist/native/definitions.d.ts.map +1 -1
- package/dist/native/definitions.js +1 -6
- package/dist/native/index.d.ts +1 -2
- package/dist/native/index.d.ts.map +1 -1
- package/dist/native/index.js +57 -27
- package/dist/native/runtime.d.ts.map +1 -1
- package/dist/native/runtime.js +18 -10
- package/dist/native/vertical-bars-web.d.ts.map +1 -1
- package/dist/native/vertical-bars-web.js +2 -0
- package/dist/tab-bar-searchable/index.d.ts.map +1 -1
- package/dist/tab-bar-searchable/index.js +39 -11
- package/dist/vertical-bars.d.ts +1 -2
- package/dist/vertical-bars.d.ts.map +1 -1
- package/dist/vertical-bars.js +0 -1
- package/docs/iphone-duo-with-original-theme.md +16 -8
- package/docs/iphone-duo.md +57 -44
- package/docs/native-ui-shell.md +5 -3
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +1 -1
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +27 -2
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalSearch.swift +118 -0
- package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +27 -179
- package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +37 -0
- package/package.json +1 -1
- package/src/native/components/searchable-tabs.ts +8 -11
- package/src/native/definitions.ts +6 -19
- package/src/native/index.ts +60 -29
- package/src/native/runtime.ts +16 -10
- package/src/native/vertical-bars-web.ts +2 -0
- package/src/tab-bar-searchable/index.ts +40 -9
- package/src/vertical-bars.ts +1 -2
package/docs/iphone-duo.md
CHANGED
|
@@ -10,13 +10,12 @@ Adapt your Ionic app to iPhone Duo: place navigation and actions in its vertical
|
|
|
10
10
|
|
|
11
11
|
Available in `1.2.0-0` as an **experimental** feature alongside [Native UI Shell](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/native-ui-shell). APIs and supported behavior may change. The real system rail and hinge reporting require iOS 27.1 or later and an app built with Xcode 27.1 or newer.
|
|
12
12
|
|
|
13
|
-
This package provides
|
|
13
|
+
This package provides two independent pieces for that hardware. Each works **without the iOS 27 theme stylesheets** and **without the full Native UI Shell**:
|
|
14
14
|
|
|
15
15
|
- `dist/css/vertical-bars.css` — opt-in classes that reserve the rail's safe area, plus a registered custom property for a posture-driven split-pane width.
|
|
16
16
|
- `enableVerticalControlArea()` — moves eligible tabs and toolbar controls into the reserved area. On Capacitor iOS they are rendered by a native SwiftUI `TabView` and toolbar; everywhere else the same controls appear as Web clones.
|
|
17
|
-
- Device-layout reporting — the bundled Capacitor plugin reports rail placement, hinge status and the WebView corner radius through `getDeviceLayout()` and the `deviceLayoutChange` event.
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
Device state belongs to [`@erkamyaman/capacitor-foldable`](https://github.com/erkamyaman/capacitor-foldable). The **application** subscribes to its events and chooses its layout. This package's **stylesheet and runtime** apply that decision by reserving space, projecting controls and adapting the split pane. The theme does not monitor hinge state or bar placement.
|
|
20
19
|
|
|
21
20
|
## Choose what to adopt
|
|
22
21
|
|
|
@@ -24,10 +23,10 @@ To keep your existing theme and add only the standalone support, follow [iPhone
|
|
|
24
23
|
|
|
25
24
|
| Goal | Stylesheet | Runtime |
|
|
26
25
|
| ------------------------------------------------ | ------------------- | ------------------------------------------------------------------ |
|
|
27
|
-
| Hinge posture only (layout switches) | none | none — subscribe to
|
|
28
|
-
| Posture-driven split-pane width | `vertical-bars.css` | none — subscribe to
|
|
26
|
+
| Hinge posture only (layout switches) | none | none — subscribe to `Foldable` directly |
|
|
27
|
+
| Posture-driven split-pane width | `vertical-bars.css` | none — subscribe to `Foldable` directly |
|
|
29
28
|
| Vertical rail for tabs and toolbar actions | `vertical-bars.css` | `enableVerticalControlArea()` |
|
|
30
|
-
| Native shell plus the rail | `vertical-bars.css` | `enableNativeUIShell()` —
|
|
29
|
+
| Native shell plus the rail | `vertical-bars.css` | `enableNativeUIShell()` — includes rail projection |
|
|
31
30
|
|
|
32
31
|
```scss
|
|
33
32
|
@use '@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css';
|
|
@@ -39,39 +38,49 @@ The `/vertical-bars` entry point imports `@capacitor/core` at module load, so in
|
|
|
39
38
|
|
|
40
39
|
## Read the device layout
|
|
41
40
|
|
|
42
|
-
|
|
41
|
+
Install the device-state plugin in the application, then sync the native project:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install @erkamyaman/capacitor-foldable
|
|
45
|
+
npx cap sync
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Use Capacitor 8.5 or later and build with Xcode 27.1 or newer for iPhone Duo's iOS 27.1 APIs. The dependency is needed for device-driven layout, not for the theme's CSS, browser simulation, or native control projection alone. Do not import the plugin's `ionic-tabs.css` alongside this package's rail projection; both would reposition the same tabs.
|
|
49
|
+
|
|
50
|
+
For a posture-driven split pane, subscribe directly without starting a projection runtime:
|
|
43
51
|
|
|
44
52
|
```ts
|
|
45
|
-
import {
|
|
46
|
-
import { HingeStatus, IonicNativeUIShell } from '@rdlabo/ionic-theme-ios27/vertical-bars';
|
|
53
|
+
import { Foldable, type FoldState } from '@erkamyaman/capacitor-foldable';
|
|
47
54
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
55
|
+
const applyFold = (fold: FoldState) => {
|
|
56
|
+
const pane = document.querySelector('ion-split-pane');
|
|
57
|
+
pane?.classList.toggle('ios-theme-split-pane-half-open', fold.state === 'half-opened');
|
|
58
|
+
const expanded = fold.state === 'half-opened' || (fold.state === 'flat' && !!fold.hingeBounds);
|
|
59
|
+
pane?.setAttribute('when', expanded ? '(min-width: 900px)' : '(min-width: 992px)');
|
|
60
|
+
};
|
|
61
|
+
let receivedEvent = false;
|
|
62
|
+
let disposed = false;
|
|
63
|
+
const listener = await Foldable.addListener('foldStateChange', (fold) => {
|
|
64
|
+
receivedEvent = true;
|
|
65
|
+
if (!disposed) applyFold(fold);
|
|
66
|
+
});
|
|
67
|
+
const initialFold = await Foldable.getFoldState();
|
|
68
|
+
if (!disposed && !receivedEvent) applyFold(initialFold);
|
|
69
|
+
|
|
70
|
+
// When the consumer goes away:
|
|
71
|
+
// disposed = true;
|
|
72
|
+
// await listener.remove();
|
|
60
73
|
```
|
|
61
74
|
|
|
62
|
-
`
|
|
75
|
+
`getFoldState()` and `foldStateChange` report `state` (`'flat'`, `'half-opened'`, or `'closed'`), `posture`, and optional hinge geometry. Without fold information, the plugin returns a flat state without `hingeBounds`; restore the ordinary split-pane breakpoint in that case. The Web implementation also returns a flat state. A half-opened state uses the 900px breakpoint even without hinge geometry; a flat state with hinge geometry also uses 900px. A closed state restores the ordinary 992px breakpoint. Events received during initialization take precedence over the initial read.
|
|
63
76
|
|
|
64
|
-
|
|
|
65
|
-
| ----------------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
66
|
-
| `placement` | `{ edge: 'leading' \| 'trailing' \| null, inset }` — the rail's logical edge in the reading direction and its UIKit safe-area inset in points; `edge` is `null` on devices without a rail |
|
|
67
|
-
| `hingeStatus` | `HingeStatus.Closed`, `PartiallyOpen`, or `FullyOpen`; `null` when the device reports no hinge |
|
|
68
|
-
| `webViewMetrics.radius` | the WebView's effective top-left corner radius in points |
|
|
77
|
+
`getBarPlacement()` and `barPlacementChange` report `{ verticalBarEdge: 'leading' | 'trailing' | null, inset: number }`. The edge is **logical**: leading is the physical left in LTR and the physical right in RTL. Pass `{ edge: verticalBarEdge, nativeEdge: verticalBarEdge, inset }` to `setPlacement()`. No start/stop monitoring calls are needed; remove each listener when its owner is disposed.
|
|
69
78
|
|
|
70
|
-
|
|
79
|
+
The theme never reads UIKit bar-placement traits. The application supplies `nativeEdge` on the initial read and each event, even when choosing a fixed `edge`. Omit `nativeEdge` to keep the last supplied value; pass `null` when the plugin reports no edge. An explicit `nativeEdge: null` or an initial unregistered edge prevents native vertical projection: `enableVerticalControlArea()` keeps the requested Web rail, while the full Native UI Shell temporarily restores its ordinary horizontal layout. The requested rail is retained so a later reported edge can restore vertical layout. Native vertical projection starts only after a matching non-null edge is supplied. Omitting `nativeEdge` after supplying it preserves that value, including `null`. Passing `{ edge: null, nativeEdge }` updates the reported edge while keeping the rail disabled.
|
|
71
80
|
|
|
72
|
-
|
|
81
|
+
Foldable reports the measured width reserved by the native bar as `inset` and notifies changes through `barPlacementChange`. Pass this value to `setPlacement()` when following the reported edge, rather than assuming a fixed width. When no bar is reported, `inset` is `0`; the theme clears the explicit width and uses its CSS safe-area rules if the application still requests a Web rail. Applications choosing a different edge can supply their own width or use the CSS fallback. WebView corner radius remains a rendering concern: `configureNativeTransition()` uses the shell's `getWebViewMetrics()` API, independently of `Foldable`.
|
|
73
82
|
|
|
74
|
-
**
|
|
83
|
+
**Migration:** the theme's former `DeviceLayout`, `HingeStatus`, `getDeviceLayout()`, `deviceLayoutChange`, and start/stop device-layout monitoring APIs have been removed. Replace device subscriptions with the `Foldable` APIs above; use `getWebViewMetrics()` for one-shot radius reads. Foldable can infer Duo bar placement from safe-area insets when the app is built without the iOS 27.1 SDK. Hinge data still requires the newer SDK. Apps can also request a fixed rail placement independently of the reported edge.
|
|
75
84
|
|
|
76
85
|
## Reserve the vertical rail
|
|
77
86
|
|
|
@@ -81,9 +90,9 @@ Add `.ios-theme-vertical-bars` to `ion-app` to reserve the rail region on the ph
|
|
|
81
90
|
<ion-app class="ios-theme-vertical-bars">...</ion-app>
|
|
82
91
|
```
|
|
83
92
|
|
|
84
|
-
The classes are physical — `-left` always means the physical left edge — because CSS and the native renderer work in physical coordinates. `setPlacement` (below) is the usual way to apply them: it accepts the logical `
|
|
93
|
+
The classes are physical — `-left` always means the physical left edge — because CSS and the native renderer work in physical coordinates. `setPlacement` (below) is the usual way to apply them: it accepts the logical `verticalBarEdge` reported by `Foldable` and resolves it through the document's direction, so an RTL app does not need its own conversion.
|
|
85
94
|
|
|
86
|
-
For Chrome development, no native plugin is needed — the class alone reserves `80px` to simulate iPhone Duo. When `setPlacement` receives
|
|
95
|
+
For Chrome development, no native plugin is needed — the class alone reserves `80px` to simulate iPhone Duo. When `setPlacement` receives an explicit `{ edge, inset }`, that inset replaces the fallback width, even when it is less than `80px`. Override `--ios-theme-vertical-bars-safe-area-left` or `--ios-theme-vertical-bars-safe-area-right` when simulating a different layout.
|
|
87
96
|
|
|
88
97
|
This keeps routers and component backgrounds full-viewport. `ion-content` moves its scroll foreground, `ion-toolbar` moves its container foreground, and `ion-fab` adjusts only when placed beside the system UI. The corresponding Ionic safe-area variable is reset inside those foreground components so descendants do not add the inset again.
|
|
89
98
|
|
|
@@ -97,16 +106,19 @@ Start the standalone runtime once at application startup:
|
|
|
97
106
|
|
|
98
107
|
```ts
|
|
99
108
|
import { Capacitor, type PluginListenerHandle } from '@capacitor/core';
|
|
100
|
-
import { enableVerticalControlArea
|
|
109
|
+
import { enableVerticalControlArea } from '@rdlabo/ionic-theme-ios27/vertical-bars';
|
|
110
|
+
import { Foldable } from '@erkamyaman/capacitor-foldable';
|
|
101
111
|
|
|
102
112
|
// Start on Chrome too; the Web projection stays idle until the class is present.
|
|
103
113
|
const rail = await enableVerticalControlArea();
|
|
104
114
|
let layoutListener: PluginListenerHandle | undefined;
|
|
105
115
|
|
|
106
116
|
if (Capacitor.getPlatform() === 'ios') {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
117
|
+
layoutListener = await Foldable.addListener('barPlacementChange', ({ verticalBarEdge, inset }) =>
|
|
118
|
+
rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge, inset }),
|
|
119
|
+
);
|
|
120
|
+
const { verticalBarEdge, inset } = await Foldable.getBarPlacement();
|
|
121
|
+
rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge, inset });
|
|
110
122
|
}
|
|
111
123
|
|
|
112
124
|
// Call when the application owner is disposed.
|
|
@@ -118,11 +130,11 @@ const stopVerticalArea = async () => {
|
|
|
118
130
|
|
|
119
131
|
`setPlacement` on the handle and the exported `setVerticalControlAreaPlacement` are the same function; either applies the application's chosen placement to the CSS layout and both projections. It requires a mounted `ion-app` — call it after the app root exists.
|
|
120
132
|
|
|
121
|
-
- Pass
|
|
133
|
+
- Pass `{ edge, nativeEdge }`: `edge` is the application's chosen logical edge; `nativeEdge` is `verticalBarEdge` from `Foldable.getBarPlacement()`/`barPlacementChange`. They resolve through the nearest `dir` attribute, or the explicit `rtl` argument.
|
|
122
134
|
- Pass `null` to restore the ordinary layout.
|
|
123
|
-
- The device-layout listener reports what iOS chose; the application decides whether to apply it.
|
|
135
|
+
- The device-layout listener reports what iOS chose; the application decides whether to apply it. The theme compares the application's chosen edge with its supplied `nativeEdge`; a mismatch uses the Web rail until the edges match again. For a fixed right-in-LTR rail, pass `{ edge: 'trailing', nativeEdge: verticalBarEdge }` on each `Foldable` update.
|
|
124
136
|
|
|
125
|
-
Start either `enableVerticalControlArea()` or the full `enableNativeUIShell()` — not both. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime. If the app already uses `enableNativeUIShell()`, keep that single runtime and call `setVerticalControlAreaPlacement(
|
|
137
|
+
Start either `enableVerticalControlArea()` or the full `enableNativeUIShell()` — not both. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime. If the app already uses `enableNativeUIShell()`, keep that single runtime and call `setVerticalControlAreaPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge, inset })` from its listener.
|
|
126
138
|
|
|
127
139
|
On supported iOS versions the runtime hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI `TabView` and toolbar; on Web, Android, or when native projection is unavailable, Web clones remain the fallback. Back navigation can come from outside a fixed toolbar; menu buttons and other toolbar actions still require one.
|
|
128
140
|
|
|
@@ -184,7 +196,7 @@ ion-split-pane {
|
|
|
184
196
|
}
|
|
185
197
|
```
|
|
186
198
|
|
|
187
|
-
The registered `--ios-theme-split-pane-width` defaults to `320px`; `.ios-theme-split-pane-half-open` sets it to `50vw`. Set `halfOpened` when `
|
|
199
|
+
The registered `--ios-theme-split-pane-width` defaults to `320px`; `.ios-theme-split-pane-half-open` sets it to `50vw`. Set `halfOpened` when `foldStateChange` reports `state === 'half-opened'` (and read the initial value with `getFoldState()`). Ionic's `when` decides whether the menu is a persistent side pane; use the 900px breakpoint for a half-opened state or a flat state with hinge geometry, and the ordinary 992px breakpoint when closed or flat without geometry. Missing `hingeBounds` alone does not mean the device is flat. The application chooses where to apply this width rule; an ordinary split pane elsewhere is unchanged. This layout does not enable Vertical Bars or move an overlay menu.
|
|
188
200
|
|
|
189
201
|
## Vertical Control Area API
|
|
190
202
|
|
|
@@ -262,10 +274,11 @@ Stops synchronization, restores Web controls and releases native resources.
|
|
|
262
274
|
|
|
263
275
|
#### VerticalBarPlacement
|
|
264
276
|
|
|
265
|
-
| Prop
|
|
266
|
-
|
|
|
267
|
-
| **`edge`**
|
|
268
|
-
| **`inset`**
|
|
277
|
+
| Prop | Type | Description |
|
|
278
|
+
| ---------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
279
|
+
| **`edge`** | <code><a href="#verticalbaredge">VerticalBarEdge</a></code> | |
|
|
280
|
+
| **`inset`** | <code>number</code> | Explicit rail width in CSS pixels; omitted to use the stylesheet's safe-area rules. |
|
|
281
|
+
| **`nativeEdge`** | <code><a href="#verticalbaredge">VerticalBarEdge</a></code> | Native logical edge reported by the application's device plugin. Null or an unregistered edge uses a Web rail in verticalBarsOnly mode, or the ordinary Native UI Shell layout otherwise. Omission keeps the last supplied value. |
|
|
269
282
|
|
|
270
283
|
|
|
271
284
|
#### NativeUIShellStatus
|
package/docs/native-ui-shell.md
CHANGED
|
@@ -90,13 +90,15 @@ The native host accepts input only within native controls. Tab interaction and a
|
|
|
90
90
|
|
|
91
91
|
## Searchable tabs
|
|
92
92
|
|
|
93
|
-
Existing `attachTabBarSearchable(tabBar, fabButton, footer)` registrations automatically use native search when their bottom tab bar and glass search controls are supported. No new component option, route, native setup or page listener is required. Ordinary tab bars continue using `UITabBar`; searchable groups use a persistent `UITabBarController`, `UITab` / `UISearchTab`, and `UISearchController`. The original Capacitor WebView continues to render results and handle navigation.
|
|
93
|
+
Existing `attachTabBarSearchable(tabBar, fabButton, footer)` registrations automatically use native search when their bottom tab bar and glass search controls are supported. No new component option, route, native setup or page listener is required. Ordinary tab bars continue using `UITabBar`; horizontal searchable groups use a persistent `UITabBarController`, `UITab` / `UISearchTab`, and `UISearchController`. The original Capacitor WebView continues to render results and handle navigation.
|
|
94
|
+
|
|
95
|
+
With vertical bars, the native rail keeps every tab and its selection. SwiftUI’s `searchable` and `searchToolbarBehavior(.minimize)` provide the system search button, field, dismissal control and transitions within the same native navigation surface. Apple determines their placement for the current device layout and focuses the native field when system search opens. Input, focus, clear and submit events use the same bridge as horizontal search. When native search is unavailable, the Web search presentation also preserves vertical tabs.
|
|
94
96
|
|
|
95
97
|
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.
|
|
96
98
|
|
|
97
|
-
|
|
99
|
+
For horizontal tabs, 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.
|
|
98
100
|
|
|
99
|
-
|
|
101
|
+
With horizontal tabs, 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`.
|
|
100
102
|
|
|
101
103
|
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`.
|
|
102
104
|
|
|
@@ -35,7 +35,7 @@ final class ShellSearchHost: UIView {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
// Preserve UISearchBar's delegate while returning its clear action to Ionic.
|
|
38
|
-
|
|
38
|
+
final class ShellSearchInputDelegate: NSObject, UITextFieldDelegate {
|
|
39
39
|
weak var original: UITextFieldDelegate?
|
|
40
40
|
var clear: (() -> Void)?
|
|
41
41
|
override func responds(to selector: Selector!) -> Bool {
|
|
@@ -6,6 +6,7 @@ protocol ShellVerticalBarsControlling: AnyObject {
|
|
|
6
6
|
var view: UIView { get }
|
|
7
7
|
func attach(to owner: UIViewController, in parent: UIView)
|
|
8
8
|
@MainActor func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String)
|
|
9
|
+
var ownsKeyboardChrome: Bool { get }
|
|
9
10
|
func detach()
|
|
10
11
|
}
|
|
11
12
|
|
|
@@ -30,6 +31,7 @@ final class ShellVerticalBarsModel: ObservableObject {
|
|
|
30
31
|
let slot: ShellControl.ToolbarSlot?
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
@Published var search: ShellVerticalSearchModel?
|
|
33
35
|
@Published var back: Item?
|
|
34
36
|
@Published var groups: [Group] = []
|
|
35
37
|
@Published var tabs: [Item] = []
|
|
@@ -163,6 +165,7 @@ private struct ShellVerticalBarsView: View {
|
|
|
163
165
|
}
|
|
164
166
|
}
|
|
165
167
|
}
|
|
168
|
+
.modifier(ShellVerticalBarsSearch(model: model))
|
|
166
169
|
.modifier(ShellVerticalBarsCompression())
|
|
167
170
|
.background(Color.clear)
|
|
168
171
|
}
|
|
@@ -179,6 +182,16 @@ private struct ShellVerticalBarsPage: View {
|
|
|
179
182
|
}
|
|
180
183
|
}
|
|
181
184
|
|
|
185
|
+
@available(iOS 26.0, *)
|
|
186
|
+
private struct ShellVerticalBarsSearch: ViewModifier {
|
|
187
|
+
@ObservedObject var model: ShellVerticalBarsModel
|
|
188
|
+
@ViewBuilder func body(content: Content) -> some View {
|
|
189
|
+
if let search = model.search {
|
|
190
|
+
content.modifier(ShellVerticalSearchModifier(model: search))
|
|
191
|
+
} else { content }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
182
195
|
@available(iOS 26.0, *)
|
|
183
196
|
private struct ShellVerticalBarsCompression: ViewModifier {
|
|
184
197
|
@ViewBuilder func body(content: Content) -> some View {
|
|
@@ -271,9 +284,11 @@ private struct ShellVerticalBarsLegacyToolbar: ViewModifier {
|
|
|
271
284
|
final class ShellVerticalBarsController: ShellVerticalBarsControlling {
|
|
272
285
|
private final class TransparentHostingController<Content: View>: UIHostingController<Content> {
|
|
273
286
|
var railEdge = "right"
|
|
287
|
+
var layoutSearch: (() -> Void)?
|
|
274
288
|
override func viewDidLayoutSubviews() {
|
|
275
289
|
super.viewDidLayoutSubviews()
|
|
276
290
|
makeFullSizeSurfacesTransparent(in: view)
|
|
291
|
+
layoutSearch?()
|
|
277
292
|
}
|
|
278
293
|
|
|
279
294
|
private func makeFullSizeSurfacesTransparent(in surface: UIView) {
|
|
@@ -323,7 +338,7 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling {
|
|
|
323
338
|
private func containsBarSurface(in view: UIView, at point: CGPoint) -> Bool {
|
|
324
339
|
guard !view.isHidden, view.alpha > 0.05 else { return false }
|
|
325
340
|
let name = NSStringFromClass(type(of: view))
|
|
326
|
-
if (name.contains("TabBar") || name.contains("Platter") || name.contains("Pocket") || name.contains("Sidebar")),
|
|
341
|
+
if (name.contains("TabBar") || name.contains("Platter") || name.contains("Pocket") || name.contains("Sidebar") || view is UISearchBar || view is UINavigationBar),
|
|
327
342
|
convert(view.bounds, from: view).contains(point) { return true }
|
|
328
343
|
return view.subviews.contains { containsBarSurface(in: $0, at: point) }
|
|
329
344
|
}
|
|
@@ -333,14 +348,20 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling {
|
|
|
333
348
|
private lazy var controller = TransparentHostingController(rootView: ShellVerticalBarsView(model: model))
|
|
334
349
|
private let container = RailContainer()
|
|
335
350
|
private weak var owner: UIViewController?
|
|
351
|
+
private let changed: (String, ShellSearchPhase, String, Bool, Int) -> Int
|
|
336
352
|
|
|
337
353
|
var view: UIView { container }
|
|
338
354
|
|
|
339
|
-
|
|
355
|
+
var ownsKeyboardChrome: Bool { model.search?.ownsKeyboardChrome == true }
|
|
356
|
+
|
|
357
|
+
init(activate: @escaping (String) -> Void,
|
|
358
|
+
changed: @escaping (String, ShellSearchPhase, String, Bool, Int) -> Int) {
|
|
359
|
+
self.changed = changed
|
|
340
360
|
model.activate = activate
|
|
341
361
|
container.backgroundColor = .clear
|
|
342
362
|
controller.view.backgroundColor = .clear
|
|
343
363
|
controller.view.isOpaque = false
|
|
364
|
+
controller.layoutSearch = { [weak self] in self?.model.search?.configureField() }
|
|
344
365
|
}
|
|
345
366
|
|
|
346
367
|
func attach(to owner: UIViewController, in parent: UIView) {
|
|
@@ -361,6 +382,10 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling {
|
|
|
361
382
|
container.railEdge = edge
|
|
362
383
|
controller.railEdge = edge
|
|
363
384
|
let tabsAppear = model.tabs.isEmpty && controls.contains { $0.kind == .tabBar && !$0.items.isEmpty }
|
|
385
|
+
if let search = controls.first(where: { $0.search != nil })?.search {
|
|
386
|
+
if model.search?.configuration.id == search.id { model.search?.apply(search) }
|
|
387
|
+
else { model.search = ShellVerticalSearchModel(search, in: container, activate: model.activate, changed: changed) }
|
|
388
|
+
} else { model.search = nil }
|
|
364
389
|
model.apply(controls, rendering: rendering)
|
|
365
390
|
if tabsAppear { ShellCrossfade.enter(container, duration: ShellCrossfade.duration(180)) }
|
|
366
391
|
controller.overrideUserInterfaceStyle = controls.contains(where: \.dark) ? .dark : .light
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import Combine
|
|
2
|
+
import SwiftUI
|
|
3
|
+
import UIKit
|
|
4
|
+
|
|
5
|
+
@available(iOS 26.0, *)
|
|
6
|
+
final class ShellVerticalSearchModel: ObservableObject {
|
|
7
|
+
@Published private(set) var configuration: ShellSearch
|
|
8
|
+
@Published var text: String
|
|
9
|
+
@Published var presented: Bool
|
|
10
|
+
@Published var focused: Bool
|
|
11
|
+
private weak var railView: UIView?
|
|
12
|
+
private let activate: (String) -> Void
|
|
13
|
+
private let changed: (String, ShellSearchPhase, String, Bool, Int) -> Int
|
|
14
|
+
private let inputDelegate = ShellSearchInputDelegate()
|
|
15
|
+
private var editingSequence = 0
|
|
16
|
+
private var valueVersion: Int
|
|
17
|
+
|
|
18
|
+
init(_ configuration: ShellSearch, in railView: UIView,
|
|
19
|
+
activate: @escaping (String) -> Void,
|
|
20
|
+
changed: @escaping (String, ShellSearchPhase, String, Bool, Int) -> Int) {
|
|
21
|
+
self.configuration = configuration
|
|
22
|
+
self.railView = railView
|
|
23
|
+
self.activate = activate
|
|
24
|
+
self.changed = changed
|
|
25
|
+
text = configuration.value
|
|
26
|
+
presented = configuration.available && configuration.active
|
|
27
|
+
focused = configuration.focused
|
|
28
|
+
valueVersion = configuration.valueVersion
|
|
29
|
+
inputDelegate.clear = { [weak self] in self?.emit(.clear) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
var ownsKeyboardChrome: Bool { presented && (focused || textField?.isFirstResponder == true) }
|
|
33
|
+
|
|
34
|
+
private var textField: UISearchTextField? {
|
|
35
|
+
func find(_ view: UIView) -> UISearchTextField? {
|
|
36
|
+
guard !view.isHidden, view.alpha > 0.01 else { return nil }
|
|
37
|
+
if let field = view as? UISearchTextField { return field }
|
|
38
|
+
for child in view.subviews {
|
|
39
|
+
if let field = find(child) { return field }
|
|
40
|
+
}
|
|
41
|
+
return nil
|
|
42
|
+
}
|
|
43
|
+
return railView.flatMap(find)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func apply(_ next: ShellSearch) {
|
|
47
|
+
let previous = configuration
|
|
48
|
+
configuration = next
|
|
49
|
+
if next.valueVersion != valueVersion || next.editSequence >= editingSequence {
|
|
50
|
+
valueVersion = next.valueVersion
|
|
51
|
+
text = next.value
|
|
52
|
+
}
|
|
53
|
+
presented = next.available && next.active
|
|
54
|
+
// The first Web acknowledgement still carries the old focus state.
|
|
55
|
+
if !next.active { focused = false }
|
|
56
|
+
else if previous.focused != next.focused { focused = next.focused }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func input(_ value: String) {
|
|
60
|
+
text = value
|
|
61
|
+
emit(.input)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func present(_ value: Bool) {
|
|
65
|
+
presented = value
|
|
66
|
+
if value != configuration.active {
|
|
67
|
+
activate(value ? configuration.trigger.id : configuration.closeId)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func focus(_ value: Bool) {
|
|
72
|
+
focused = value
|
|
73
|
+
emit(value ? .focus : .blur)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func commit() { emit(.commit) }
|
|
77
|
+
|
|
78
|
+
func configureField() {
|
|
79
|
+
guard let field = textField else { return }
|
|
80
|
+
field.accessibilityIdentifier = configuration.id
|
|
81
|
+
field.accessibilityLabel = configuration.field.accessibilityLabel
|
|
82
|
+
field.isEnabled = !configuration.disabled
|
|
83
|
+
field.autocapitalizationType = .none
|
|
84
|
+
field.autocorrectionType = .no
|
|
85
|
+
field.spellCheckingType = .no
|
|
86
|
+
field.clearButtonMode = .always
|
|
87
|
+
if field.delegate !== inputDelegate {
|
|
88
|
+
inputDelegate.original = field.delegate
|
|
89
|
+
field.delegate = inputDelegate
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private func emit(_ phase: ShellSearchPhase) {
|
|
94
|
+
editingSequence = changed(configuration.id, phase, text,
|
|
95
|
+
textField?.markedTextRange != nil, valueVersion)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@available(iOS 26.0, *)
|
|
100
|
+
struct ShellVerticalSearchModifier: ViewModifier {
|
|
101
|
+
@ObservedObject var model: ShellVerticalSearchModel
|
|
102
|
+
@FocusState private var focused: Bool
|
|
103
|
+
|
|
104
|
+
@ViewBuilder func body(content: Content) -> some View {
|
|
105
|
+
if model.configuration.available {
|
|
106
|
+
content
|
|
107
|
+
.searchable(text: Binding(get: { model.text }, set: model.input),
|
|
108
|
+
isPresented: Binding(get: { model.presented }, set: model.present),
|
|
109
|
+
placement: .toolbar, prompt: Text(model.configuration.placeholder))
|
|
110
|
+
.searchToolbarBehavior(.minimize)
|
|
111
|
+
.searchPresentationToolbarBehavior(.avoidHidingContent)
|
|
112
|
+
.searchFocused($focused)
|
|
113
|
+
.onSubmit(of: .search, model.commit)
|
|
114
|
+
.onChange(of: focused) { _, value in model.focus(value) }
|
|
115
|
+
.onChange(of: model.focused) { _, value in focused = value }
|
|
116
|
+
} else { content }
|
|
117
|
+
}
|
|
118
|
+
}
|