@rdlabo/ionic-theme-ios27 1.1.0 → 1.2.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 +8 -1
- package/dist/css/components/ion-list.css +1 -1
- package/dist/css/components/ion-menu.css +1 -1
- package/dist/css/ionic-theme-ios27.css +1 -1
- package/dist/css/vertical-bars.css +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -8
- package/dist/native/components/index.d.ts +1 -0
- package/dist/native/components/index.d.ts.map +1 -1
- package/dist/native/components/index.js +8 -3
- package/dist/native/components/ion-back-button.d.ts.map +1 -1
- package/dist/native/components/ion-back-button.js +5 -2
- package/dist/native/components/ion-button.d.ts.map +1 -1
- package/dist/native/components/ion-button.js +10 -2
- package/dist/native/components/ion-buttons.d.ts.map +1 -1
- package/dist/native/components/ion-buttons.js +12 -4
- package/dist/native/components/ion-fab.js +4 -4
- package/dist/native/components/ion-menu-button.d.ts.map +1 -1
- package/dist/native/components/ion-menu-button.js +5 -4
- package/dist/native/components/searchable-tabs.d.ts.map +1 -1
- package/dist/native/components/searchable-tabs.js +4 -1
- package/dist/native/definitions.d.ts +29 -3
- package/dist/native/definitions.d.ts.map +1 -1
- package/dist/native/definitions.js +6 -1
- package/dist/native/index.d.ts +6 -2
- package/dist/native/index.d.ts.map +1 -1
- package/dist/native/index.js +154 -17
- package/dist/native/prehide.d.ts +5 -0
- package/dist/native/prehide.d.ts.map +1 -0
- package/dist/native/prehide.js +246 -0
- package/dist/native/runtime.d.ts +1 -1
- package/dist/native/runtime.d.ts.map +1 -1
- package/dist/native/runtime.js +162 -28
- package/dist/native/shared/candidate.d.ts.map +1 -1
- package/dist/native/shared/candidate.js +7 -3
- package/dist/native/shared/dom.d.ts +33 -2
- package/dist/native/shared/dom.d.ts.map +1 -1
- package/dist/native/shared/dom.js +171 -10
- package/dist/native/vertical-bars-web.d.ts +3 -0
- package/dist/native/vertical-bars-web.d.ts.map +1 -0
- package/dist/native/vertical-bars-web.js +341 -0
- package/dist/native-integration/index.d.ts +3 -2
- package/dist/native-integration/index.d.ts.map +1 -1
- package/dist/native-integration/index.js +11 -5
- package/dist/transition/index.d.ts +2 -2
- package/dist/transition/index.d.ts.map +1 -1
- package/dist/transition/index.js +1 -1
- package/dist/transition/ios.transition.d.ts.map +1 -1
- package/dist/transition/ios.transition.js +1 -0
- package/dist/utils.d.ts +3 -3
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +1 -1
- package/dist/vertical-bars.d.ts +4 -0
- package/dist/vertical-bars.d.ts.map +1 -0
- package/dist/vertical-bars.js +2 -0
- package/docs/iphone-duo.md +255 -0
- package/docs/migration.md +2 -2
- package/docs/native-ui-shell.md +15 -3
- package/docs/special-markup.md +6 -0
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +15 -1
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +358 -0
- package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +215 -35
- package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +10 -1
- package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +29 -0
- package/package.json +6 -2
- package/src/index.ts +15 -8
- package/src/native/components/index.ts +12 -3
- package/src/native/components/ion-back-button.ts +8 -2
- package/src/native/components/ion-button.ts +12 -2
- package/src/native/components/ion-buttons.ts +9 -4
- package/src/native/components/ion-fab.ts +4 -4
- package/src/native/components/ion-menu-button.ts +5 -4
- package/src/native/components/searchable-tabs.ts +9 -1
- package/src/native/definitions.ts +40 -3
- package/src/native/index.ts +188 -20
- package/src/native/prehide.ts +255 -0
- package/src/native/runtime.ts +173 -27
- package/src/native/shared/candidate.ts +11 -3
- package/src/native/shared/dom.ts +212 -11
- package/src/native/vertical-bars-web.ts +374 -0
- package/src/native-integration/index.ts +13 -8
- package/src/styles/components/ion-list.scss +2 -2
- package/src/styles/components/ion-menu.scss +2 -2
- package/src/styles/ionic-theme-ios27.scss +1 -0
- package/src/styles/vertical-bars.scss +342 -0
- package/src/transition/index.ts +11 -6
- package/src/transition/ios.transition.ts +1 -0
- package/src/utils.ts +8 -8
- package/src/vertical-bars.ts +3 -0
- package/dist/native/lifecycle.d.ts +0 -4
- package/dist/native/lifecycle.d.ts.map +0 -1
- package/dist/native/lifecycle.js +0 -34
- package/src/native/lifecycle.ts +0 -38
|
@@ -8,13 +8,16 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
8
8
|
public let jsName = "IonicNativeUIShell"
|
|
9
9
|
public let pluginMethods: [CAPPluginMethod] = [
|
|
10
10
|
CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise),
|
|
11
|
-
CAPPluginMethod(name: "
|
|
11
|
+
CAPPluginMethod(name: "getDeviceLayout", returnType: CAPPluginReturnPromise),
|
|
12
|
+
CAPPluginMethod(name: "startDeviceLayoutMonitoring", returnType: CAPPluginReturnPromise),
|
|
13
|
+
CAPPluginMethod(name: "stopDeviceLayoutMonitoring", returnType: CAPPluginReturnPromise),
|
|
12
14
|
CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise),
|
|
13
15
|
CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise)
|
|
14
16
|
]
|
|
15
17
|
private var host: ShellHost?
|
|
18
|
+
private var verticalBars: ShellVerticalBarsControlling?
|
|
16
19
|
private var controls: [String: UIView] = [:]
|
|
17
|
-
private var searchControllers: [String:
|
|
20
|
+
private var searchControllers: [String: ShellSearchControlling] = [:]
|
|
18
21
|
private var fingerprints: [String: ShellControl] = [:]
|
|
19
22
|
private let rendering = ShellRendering()
|
|
20
23
|
private var revision = 0
|
|
@@ -24,6 +27,16 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
24
27
|
private var pendingTabExpiryWorks: [String: DispatchWorkItem] = [:]
|
|
25
28
|
private var restoreTopEdge: (() -> Void)?
|
|
26
29
|
private var observers: [NSObjectProtocol] = []
|
|
30
|
+
private var lastVerticalBarEdge: String?
|
|
31
|
+
private var lastVerticalBarInset: CGFloat = 0
|
|
32
|
+
private var verticalBarPlacementObserved = false
|
|
33
|
+
private weak var observedVerticalBarView: UIView?
|
|
34
|
+
private var unregisterVerticalBarObservation: (() -> Void)?
|
|
35
|
+
private weak var observedHingeView: UIView?
|
|
36
|
+
private var hingeInteraction: UIInteraction?
|
|
37
|
+
private var hingeStatus: String?
|
|
38
|
+
private var deviceLayoutMonitoring = 0
|
|
39
|
+
private var lastDeviceLayout: String?
|
|
27
40
|
|
|
28
41
|
public override func load() {
|
|
29
42
|
for name in [UIApplication.didEnterBackgroundNotification, UIResponder.keyboardWillChangeFrameNotification, UIResponder.keyboardWillHideNotification,
|
|
@@ -40,14 +53,14 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
40
53
|
self.keyboardVisible = !overlap.isNull && overlap.width > 0 && overlap.height > 0
|
|
41
54
|
}
|
|
42
55
|
}
|
|
43
|
-
if !keyboard {
|
|
56
|
+
if !keyboard {
|
|
57
|
+
self.host?.isHidden = true
|
|
58
|
+
self.verticalBars?.view.isHidden = true
|
|
59
|
+
}
|
|
44
60
|
var searchOwnsKeyboard = false
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if controller.ownsKeyboardChrome { searchOwnsKeyboard = true }
|
|
49
|
-
if !keyboard { controller.surface.isHidden = true }
|
|
50
|
-
}
|
|
61
|
+
self.searchControllers.values.forEach { controller in
|
|
62
|
+
if controller.ownsKeyboardChrome { searchOwnsKeyboard = true }
|
|
63
|
+
if !keyboard { controller.surface.isHidden = true }
|
|
51
64
|
}
|
|
52
65
|
if keyboard {
|
|
53
66
|
if !searchOwnsKeyboard {
|
|
@@ -55,6 +68,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
55
68
|
}
|
|
56
69
|
} else if name == UIDevice.orientationDidChangeNotification {
|
|
57
70
|
self.notifyWebViewMetricsChange()
|
|
71
|
+
self.notifyVerticalBarPlacementChange()
|
|
58
72
|
}
|
|
59
73
|
})
|
|
60
74
|
}
|
|
@@ -64,6 +78,8 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
64
78
|
if name == UIResponder.keyboardDidHideNotification { self?.keyboardVisible = false }
|
|
65
79
|
self?.bridge?.triggerWindowJSEvent(eventName: "nativeUIShellRefresh", data: name == UIApplication.didBecomeActiveNotification ? "{\"retireSearch\":true}" : "{}")
|
|
66
80
|
if name == UIApplication.didBecomeActiveNotification { self?.notifyWebViewMetricsChange() }
|
|
81
|
+
if name == UIApplication.didBecomeActiveNotification { self?.notifyVerticalBarPlacementChange() }
|
|
82
|
+
if name == UIApplication.didBecomeActiveNotification { self?.refreshHingeStatus() }
|
|
67
83
|
})
|
|
68
84
|
}
|
|
69
85
|
}
|
|
@@ -72,6 +88,17 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
72
88
|
observers.forEach(NotificationCenter.default.removeObserver)
|
|
73
89
|
}
|
|
74
90
|
|
|
91
|
+
private func stopDeviceLayoutObservation() {
|
|
92
|
+
unregisterVerticalBarObservation?()
|
|
93
|
+
unregisterVerticalBarObservation = nil
|
|
94
|
+
observedVerticalBarView = nil
|
|
95
|
+
if let interaction = hingeInteraction {
|
|
96
|
+
observedHingeView?.removeInteraction(interaction)
|
|
97
|
+
}
|
|
98
|
+
hingeInteraction = nil
|
|
99
|
+
observedHingeView = nil
|
|
100
|
+
}
|
|
101
|
+
|
|
75
102
|
private func webViewMetrics() -> JSObject? {
|
|
76
103
|
guard #available(iOS 26.0, *), let webView = bridge?.webView else { return nil }
|
|
77
104
|
webView.layoutIfNeeded()
|
|
@@ -79,35 +106,168 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
79
106
|
}
|
|
80
107
|
|
|
81
108
|
private func notifyWebViewMetricsChange() {
|
|
82
|
-
|
|
83
|
-
|
|
109
|
+
notifyDeviceLayoutChange()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private func deviceLayout() -> JSObject {
|
|
113
|
+
var layout: JSObject = ["placement": verticalBarPlacement(),
|
|
114
|
+
"webViewMetrics": webViewMetrics() ?? ["radius": 0]]
|
|
115
|
+
layout["hingeStatus"] = hingeStatus ?? NSNull()
|
|
116
|
+
return layout
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private func notifyDeviceLayoutChange() {
|
|
120
|
+
guard deviceLayoutMonitoring > 0 else { return }
|
|
121
|
+
let layout = deviceLayout()
|
|
122
|
+
let fingerprint = "\(verticalBarEdge() ?? "none"):\(verticalBarInset(for: verticalBarEdge())):\(hingeStatus ?? "none"):\(webViewMetrics()?["radius"] ?? 0)"
|
|
123
|
+
guard fingerprint != lastDeviceLayout else { return }
|
|
124
|
+
lastDeviceLayout = fingerprint
|
|
125
|
+
notifyListeners("deviceLayoutChange", data: layout)
|
|
84
126
|
}
|
|
85
127
|
|
|
86
|
-
@objc func
|
|
128
|
+
@objc func getDeviceLayout(_ call: CAPPluginCall) {
|
|
87
129
|
DispatchQueue.main.async { [weak self] in
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
130
|
+
self?.observeVerticalBarPlacement()
|
|
131
|
+
self?.observeHingeStatus()
|
|
132
|
+
self?.refreshHingeStatus()
|
|
133
|
+
DispatchQueue.main.async {
|
|
134
|
+
call.resolve(self?.deviceLayout() ?? ["placement": ["edge": NSNull(), "inset": 0], "hingeStatus": NSNull(), "webViewMetrics": ["radius": 0]])
|
|
135
|
+
if self?.deviceLayoutMonitoring == 0 { self?.stopDeviceLayoutObservation() }
|
|
91
136
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@objc func startDeviceLayoutMonitoring(_ call: CAPPluginCall) {
|
|
141
|
+
DispatchQueue.main.async { [weak self] in
|
|
142
|
+
self?.deviceLayoutMonitoring += 1
|
|
143
|
+
self?.lastDeviceLayout = nil
|
|
144
|
+
self?.observeVerticalBarPlacement()
|
|
145
|
+
self?.observeHingeStatus()
|
|
146
|
+
self?.refreshHingeStatus()
|
|
147
|
+
call.resolve()
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@objc func stopDeviceLayoutMonitoring(_ call: CAPPluginCall) {
|
|
152
|
+
DispatchQueue.main.async { [weak self] in
|
|
153
|
+
if let self, self.deviceLayoutMonitoring > 0 {
|
|
154
|
+
self.deviceLayoutMonitoring -= 1
|
|
155
|
+
if self.deviceLayoutMonitoring == 0 {
|
|
156
|
+
self.lastDeviceLayout = nil
|
|
157
|
+
self.stopDeviceLayoutObservation()
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
call.resolve()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Logical edge in the reading direction, matching UIVerticalBarEdge.
|
|
165
|
+
private func verticalBarEdge() -> String? {
|
|
166
|
+
#if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
|
|
167
|
+
if #available(iOS 27.1, *), let webView = bridge?.webView {
|
|
168
|
+
switch webView.traitCollection.verticalBarEdge {
|
|
169
|
+
case .leading: return "leading"
|
|
170
|
+
case .trailing: return "trailing"
|
|
171
|
+
default: break
|
|
95
172
|
}
|
|
96
|
-
call.resolve(metrics)
|
|
97
173
|
}
|
|
174
|
+
#endif
|
|
175
|
+
// Toolchains older than the verticalBarEdge trait still expose the rail
|
|
176
|
+
// as a deep safe-area inset on the physical edge (iPhone Duo reserves
|
|
177
|
+
// ~80pt; ordinary iPhones stay below 70pt even in landscape).
|
|
178
|
+
guard let webView = bridge?.webView else { return nil }
|
|
179
|
+
webView.layoutIfNeeded()
|
|
180
|
+
let rtl = webView.effectiveUserInterfaceLayoutDirection == .rightToLeft
|
|
181
|
+
if webView.safeAreaInsets.right >= 70 { return rtl ? "leading" : "trailing" }
|
|
182
|
+
if webView.safeAreaInsets.left >= 70 { return rtl ? "trailing" : "leading" }
|
|
183
|
+
return nil
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private func verticalBarInset(for edge: String?) -> CGFloat {
|
|
187
|
+
guard let edge, let webView = bridge?.webView else { return 0 }
|
|
188
|
+
webView.layoutIfNeeded()
|
|
189
|
+
let rtl = webView.effectiveUserInterfaceLayoutDirection == .rightToLeft
|
|
190
|
+
let physicalRight = (edge == "trailing") != rtl
|
|
191
|
+
return physicalRight ? webView.safeAreaInsets.right : webView.safeAreaInsets.left
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private func verticalBarPlacement() -> JSObject {
|
|
195
|
+
if let edge = verticalBarEdge() { return ["edge": edge, "inset": Double(verticalBarInset(for: edge))] }
|
|
196
|
+
return ["edge": NSNull(), "inset": 0]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private func notifyVerticalBarPlacementChange() {
|
|
200
|
+
let edge = verticalBarEdge()
|
|
201
|
+
let inset = verticalBarInset(for: edge)
|
|
202
|
+
guard !verticalBarPlacementObserved || edge != lastVerticalBarEdge || abs(inset - lastVerticalBarInset) > 0.5 else { return }
|
|
203
|
+
verticalBarPlacementObserved = true
|
|
204
|
+
lastVerticalBarEdge = edge
|
|
205
|
+
lastVerticalBarInset = inset
|
|
206
|
+
notifyDeviceLayoutChange()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private func observeVerticalBarPlacement() {
|
|
210
|
+
#if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
|
|
211
|
+
if #available(iOS 27.1, *), let webView = bridge?.webView, observedVerticalBarView !== webView {
|
|
212
|
+
unregisterVerticalBarObservation?()
|
|
213
|
+
observedVerticalBarView = webView
|
|
214
|
+
let traits: [UITrait] = [UITraitLayoutDirection.self] + UITraitCollection.systemTraitsAffectingVerticalBarEdge
|
|
215
|
+
let registration = webView.registerForTraitChanges(traits) { [weak self] (_: UIView, _: UITraitCollection) in
|
|
216
|
+
self?.notifyVerticalBarPlacementChange()
|
|
217
|
+
}
|
|
218
|
+
unregisterVerticalBarObservation = { [weak webView] in webView?.unregisterForTraitChanges(registration) }
|
|
219
|
+
}
|
|
220
|
+
#endif
|
|
221
|
+
notifyVerticalBarPlacementChange()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private func observeHingeStatus() {
|
|
225
|
+
#if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
|
|
226
|
+
if #available(iOS 27.1, *), let webView = bridge?.webView, observedHingeView !== webView {
|
|
227
|
+
if let interaction = hingeInteraction {
|
|
228
|
+
observedHingeView?.removeInteraction(interaction)
|
|
229
|
+
}
|
|
230
|
+
observedHingeView = webView
|
|
231
|
+
let interaction = UIHingeInteraction { [weak self] _, update in
|
|
232
|
+
let status: String?
|
|
233
|
+
switch update.hinge?.status {
|
|
234
|
+
case .closed: status = "closed"
|
|
235
|
+
case .partiallyOpen: status = "partiallyOpen"
|
|
236
|
+
case .fullyOpen: status = "fullyOpen"
|
|
237
|
+
default: status = nil
|
|
238
|
+
}
|
|
239
|
+
guard status != self?.hingeStatus else { return }
|
|
240
|
+
self?.hingeStatus = status
|
|
241
|
+
self?.notifyDeviceLayoutChange()
|
|
242
|
+
}
|
|
243
|
+
hingeInteraction = interaction
|
|
244
|
+
webView.addInteraction(interaction)
|
|
245
|
+
}
|
|
246
|
+
#endif
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private func refreshHingeStatus() {
|
|
250
|
+
#if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
|
|
251
|
+
if #available(iOS 27.1, *), let interaction = hingeInteraction as? UIHingeInteraction {
|
|
252
|
+
interaction.isEnabled = false
|
|
253
|
+
interaction.isEnabled = true
|
|
254
|
+
}
|
|
255
|
+
#endif
|
|
98
256
|
}
|
|
99
257
|
|
|
100
258
|
@objc func configure(_ call: CAPPluginCall) {
|
|
101
259
|
DispatchQueue.main.async { [weak self] in
|
|
260
|
+
self?.observeVerticalBarPlacement()
|
|
102
261
|
// A new JS context starts revision numbering again (live reload / navigation).
|
|
103
262
|
self?.restoreTopEdge?()
|
|
104
263
|
self?.restoreTopEdge = nil
|
|
105
264
|
self?.removeControls()
|
|
106
265
|
self?.revision = 0
|
|
107
266
|
if #available(iOS 26.0, *) {
|
|
267
|
+
self?.bridge?.webView?.layoutIfNeeded()
|
|
108
268
|
// Ionic already paints the header edge; a second native effect can
|
|
109
269
|
// add a dark scrim when the OS and Web themes differ.
|
|
110
|
-
if let effect = self?.bridge?.webView?.scrollView.topEdgeEffect {
|
|
270
|
+
if call.getBool("verticalBarsOnly") != true, let effect = self?.bridge?.webView?.scrollView.topEdgeEffect {
|
|
111
271
|
let hidden = effect.isHidden
|
|
112
272
|
effect.isHidden = true
|
|
113
273
|
self?.restoreTopEdge = { [weak effect] in effect?.isHidden = hidden }
|
|
@@ -133,9 +293,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
133
293
|
}
|
|
134
294
|
|
|
135
295
|
private func removeControl(_ id: String, duration: TimeInterval = 0) {
|
|
136
|
-
|
|
137
|
-
(searchControllers.removeValue(forKey: id) as? ShellSearchController)?.detach()
|
|
138
|
-
}
|
|
296
|
+
searchControllers.removeValue(forKey: id)?.detach()
|
|
139
297
|
if let control = controls.removeValue(forKey: id) { ShellCrossfade.retire(control, duration: duration) }
|
|
140
298
|
fingerprints.removeValue(forKey: id)
|
|
141
299
|
pendingTabSelections.removeValue(forKey: id)
|
|
@@ -146,6 +304,8 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
146
304
|
Array(controls.keys).forEach { removeControl($0, duration: duration) }
|
|
147
305
|
host?.removeFromSuperview()
|
|
148
306
|
host = nil
|
|
307
|
+
verticalBars?.detach()
|
|
308
|
+
verticalBars = nil
|
|
149
309
|
rendering.clear()
|
|
150
310
|
pendingTabSelections.removeAll()
|
|
151
311
|
pendingTabExpiryWorks.values.forEach { $0.cancel() }
|
|
@@ -194,19 +354,14 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
194
354
|
}
|
|
195
355
|
let duration = ShellCrossfade.duration(snapshot.transitionDuration)
|
|
196
356
|
let existing = Set(self.controls.keys)
|
|
197
|
-
let
|
|
357
|
+
let verticalBars = snapshot.controls.filter { $0.placement == .verticalBars }
|
|
358
|
+
let snapshots = snapshot.controls.filter { $0.placement != .verticalBars }
|
|
198
359
|
let width = snapshot.viewportWidth
|
|
199
360
|
self.revision = next
|
|
200
|
-
if snapshots.isEmpty {
|
|
361
|
+
if snapshots.isEmpty && verticalBars.isEmpty {
|
|
201
362
|
self.removeControls(duration: duration)
|
|
202
363
|
call.resolve(["revision": next]); return
|
|
203
364
|
}
|
|
204
|
-
let host = self.host ?? ShellHost()
|
|
205
|
-
self.host = host
|
|
206
|
-
host.frame = parent.bounds
|
|
207
|
-
host.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
208
|
-
host.backgroundColor = .clear
|
|
209
|
-
host.isAccessibilityElement = false
|
|
210
365
|
let scale = webView.bounds.width / width
|
|
211
366
|
let retained = Set(snapshots.map(\.id))
|
|
212
367
|
for id in Array(self.controls.keys) where !retained.contains(id) {
|
|
@@ -214,8 +369,33 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
214
369
|
}
|
|
215
370
|
var rejectedControls: [String] = []
|
|
216
371
|
var fabs: [(ShellFab, ShellControl)] = []
|
|
217
|
-
var searches: [(
|
|
372
|
+
var searches: [(ShellSearchControlling, ShellControl, CGRect, CGRect, UIView?, Bool)] = []
|
|
218
373
|
var rejectedSearches: [String] = []
|
|
374
|
+
if verticalBars.isEmpty || self.keyboardVisible {
|
|
375
|
+
self.verticalBars?.detach()
|
|
376
|
+
self.verticalBars = nil
|
|
377
|
+
if self.keyboardVisible { rejectedControls.append(contentsOf: verticalBars.map(\.id)) }
|
|
378
|
+
} else if let owner = self.bridge?.viewController {
|
|
379
|
+
let rail = self.verticalBars ?? ShellVerticalBarsController(activate: { [weak self] id in self?.activate(id) })
|
|
380
|
+
self.verticalBars = rail
|
|
381
|
+
rail.attach(to: owner, in: owner.view)
|
|
382
|
+
rail.apply(verticalBars, rendering: self.rendering, edge: snapshot.verticalBarEdge ?? "right")
|
|
383
|
+
rail.view.isHidden = false
|
|
384
|
+
} else {
|
|
385
|
+
rejectedControls.append(contentsOf: verticalBars.map(\.id))
|
|
386
|
+
}
|
|
387
|
+
if snapshots.isEmpty {
|
|
388
|
+
self.host?.removeFromSuperview()
|
|
389
|
+
self.host = nil
|
|
390
|
+
call.resolve(["revision": next, "rejectedControls": rejectedControls])
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
let host = self.host ?? ShellHost()
|
|
394
|
+
self.host = host
|
|
395
|
+
host.frame = parent.bounds
|
|
396
|
+
host.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
397
|
+
host.backgroundColor = .clear
|
|
398
|
+
host.isAccessibilityElement = false
|
|
219
399
|
UIView.performWithoutAnimation {
|
|
220
400
|
if host.superview !== parent { parent.addSubview(host) }
|
|
221
401
|
for node in snapshots {
|
|
@@ -233,7 +413,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
233
413
|
rejectedControls.append(id)
|
|
234
414
|
}
|
|
235
415
|
// Only native search owns its keyboard; other controls return to Web.
|
|
236
|
-
if self.keyboardVisible &&
|
|
416
|
+
if self.keyboardVisible && self.searchControllers[id]?.ownsKeyboard != true {
|
|
237
417
|
reject(); continue
|
|
238
418
|
}
|
|
239
419
|
let local = node.frame.rect
|
|
@@ -241,9 +421,9 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
241
421
|
width: local.width * scale, height: local.height * scale), to: parent)
|
|
242
422
|
if let search = node.search {
|
|
243
423
|
guard let owner = self.bridge?.viewController else { rejectedSearches.append(id); continue }
|
|
244
|
-
let controller:
|
|
424
|
+
let controller: ShellSearchControlling
|
|
245
425
|
let previousCover = self.controls[id]
|
|
246
|
-
let replacing = self.searchControllers[id]
|
|
426
|
+
let replacing = self.searchControllers[id]
|
|
247
427
|
if let existing = replacing { controller = existing }
|
|
248
428
|
else {
|
|
249
429
|
// Keep the ordinary UITabBar cover until the search controller applies.
|
|
@@ -11,10 +11,12 @@ struct ShellSnapshot: Decodable {
|
|
|
11
11
|
let revision: Int
|
|
12
12
|
let transitionDuration: Double?
|
|
13
13
|
let viewportWidth: Double
|
|
14
|
+
let verticalBarEdge: String?
|
|
14
15
|
let controls: [ShellControl]
|
|
15
16
|
|
|
16
17
|
var isValid: Bool {
|
|
17
18
|
revision >= 0 && (transitionDuration.map { $0.isFinite && $0 >= 0 && $0 <= 500 } ?? true) && viewportWidth.isFinite && viewportWidth > 0 && controls.count <= 100 &&
|
|
19
|
+
(verticalBarEdge == nil || verticalBarEdge == "left" || verticalBarEdge == "right") &&
|
|
18
20
|
Set(controls.map(\.id)).count == controls.count && controls.allSatisfy(\.isValid)
|
|
19
21
|
}
|
|
20
22
|
}
|
|
@@ -31,8 +33,12 @@ struct ShellFrame: Decodable, Equatable {
|
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
struct ShellControl: Decodable, Equatable {
|
|
36
|
+
enum Placement: String, Decodable { case verticalBars = "vertical-bars" }
|
|
37
|
+
enum ToolbarSlot: String, Decodable { case start, end }
|
|
34
38
|
let id: String
|
|
35
39
|
let kind: ShellComponent
|
|
40
|
+
let placement: Placement?
|
|
41
|
+
let toolbarSlot: ToolbarSlot?
|
|
36
42
|
let frame: ShellFrame
|
|
37
43
|
let items: [ShellItem]
|
|
38
44
|
let dark: Bool
|
|
@@ -40,12 +46,14 @@ struct ShellControl: Decodable, Equatable {
|
|
|
40
46
|
let tabBarAnchor: ShellTabBar.Anchor?
|
|
41
47
|
let search: ShellSearch?
|
|
42
48
|
|
|
43
|
-
private enum CodingKeys: String, CodingKey { case id, kind, items, dark, rtl, tabBarAnchor, search }
|
|
49
|
+
private enum CodingKeys: String, CodingKey { case id, kind, placement, toolbarSlot, items, dark, rtl, tabBarAnchor, search }
|
|
44
50
|
|
|
45
51
|
init(from decoder: Decoder) throws {
|
|
46
52
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
|
47
53
|
id = try values.decode(String.self, forKey: .id)
|
|
48
54
|
kind = try values.decode(ShellComponent.self, forKey: .kind)
|
|
55
|
+
placement = try values.decodeIfPresent(Placement.self, forKey: .placement)
|
|
56
|
+
toolbarSlot = try values.decodeIfPresent(ToolbarSlot.self, forKey: .toolbarSlot)
|
|
49
57
|
frame = try ShellFrame(from: decoder)
|
|
50
58
|
items = try values.decode([ShellItem].self, forKey: .items)
|
|
51
59
|
dark = try values.decode(Bool.self, forKey: .dark)
|
|
@@ -57,6 +65,7 @@ struct ShellControl: Decodable, Equatable {
|
|
|
57
65
|
var isValid: Bool {
|
|
58
66
|
ShellComponents.supported.contains(kind) && !id.isEmpty && frame.isValid && !items.isEmpty && items.count <= 30 &&
|
|
59
67
|
(!ShellButton.kinds.contains(kind) || items.count == 1) &&
|
|
68
|
+
(toolbarSlot == nil || (placement == .verticalBars && [.button, .buttons, .menuButton].contains(kind))) &&
|
|
60
69
|
Set(items.map(\.id)).count == items.count && items.allSatisfy(\.isValid) &&
|
|
61
70
|
(tabBarAnchor.map { kind == .tabBar && $0.isValid } ?? true) &&
|
|
62
71
|
(search.map { kind == .tabBar && $0.isValid } ?? true)
|
|
@@ -37,6 +37,35 @@ final class ShellSnapshotTests: XCTestCase {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
@MainActor func testVerticalBarsTabOptimismWaitsForWebAndRollsBackWhenStale() throws {
|
|
41
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires SwiftUI adaptive tabs") }
|
|
42
|
+
func tabs(_ selected: String, includeRight: Bool = true) throws -> [ShellControl] {
|
|
43
|
+
var items = [item(["id": "left", "selected": selected == "left"])]
|
|
44
|
+
if includeRight {
|
|
45
|
+
items.append(item(["id": "right", "selected": selected == "right",
|
|
46
|
+
"badge": ["value": "3", "color": "rgb(255, 0, 0)", "textColor": "rgb(255, 255, 255)"]]))
|
|
47
|
+
}
|
|
48
|
+
return try decode([control(["kind": "ion-tab-bar", "items": items])]).controls
|
|
49
|
+
}
|
|
50
|
+
let model = ShellVerticalBarsModel()
|
|
51
|
+
let rendering = ShellRendering()
|
|
52
|
+
model.apply(try tabs("left"), rendering: rendering, now: 100)
|
|
53
|
+
XCTAssertEqual(model.tabs.last?.badge?.value, "3")
|
|
54
|
+
model.select("right", now: 100, ttl: 10)
|
|
55
|
+
model.apply(try tabs("left"), rendering: rendering, now: 101)
|
|
56
|
+
XCTAssertEqual(model.selection, "right", "a stale Web echo must not undo the optimistic selection")
|
|
57
|
+
model.apply(try tabs("right"), rendering: rendering, now: 102)
|
|
58
|
+
XCTAssertEqual(model.selection, "right", "the matching Web echo confirms the selection")
|
|
59
|
+
|
|
60
|
+
model.apply(try tabs("left"), rendering: rendering, now: 200)
|
|
61
|
+
model.select("right", now: 200, ttl: 10)
|
|
62
|
+
model.apply(try tabs("left"), rendering: rendering, now: 211)
|
|
63
|
+
XCTAssertEqual(model.selection, "left", "an expired selection rolls back to Web state")
|
|
64
|
+
model.select("right", now: 220, ttl: 10)
|
|
65
|
+
model.apply(try tabs("left", includeRight: false), rendering: rendering, now: 221)
|
|
66
|
+
XCTAssertEqual(model.selection, "left", "a removed target rolls back immediately")
|
|
67
|
+
}
|
|
68
|
+
|
|
40
69
|
@MainActor func testSegmentSelectionEchoPreservesNativeViewsAndActionsUseUpdatedItems() throws {
|
|
41
70
|
guard #available(iOS 26.0, *) else { return }
|
|
42
71
|
func node(_ items: [JSObject]) throws -> ShellControl {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rdlabo/ionic-theme-ios27",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.2.0-0",
|
|
5
5
|
"description": "iOS27 Theme for Ionic Framework",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"module": "./dist/index.js",
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
"./native": {
|
|
17
17
|
"types": "./dist/native/index.d.ts",
|
|
18
18
|
"import": "./dist/native/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./vertical-bars": {
|
|
21
|
+
"types": "./dist/vertical-bars.d.ts",
|
|
22
|
+
"import": "./dist/vertical-bars.js"
|
|
19
23
|
}
|
|
20
24
|
},
|
|
21
25
|
"files": [
|
|
@@ -31,7 +35,7 @@
|
|
|
31
35
|
"prebuild:css": "rdlabo-copy-structured-list src/styles/utils/structured-list.scss",
|
|
32
36
|
"build:css": "rm -rf dist/css && sass src/styles:dist/css --style=compressed --no-source-map",
|
|
33
37
|
"build:ts": "tsc",
|
|
34
|
-
"docgen": "docgen --project tsconfig.docgen.json --api NativeUIShellHandle --output-readme docs/native-ui-shell.md",
|
|
38
|
+
"docgen": "docgen --project tsconfig.docgen.json --api NativeUIShellHandle --output-readme docs/native-ui-shell.md && docgen --project tsconfig.docgen.json --api VerticalControlAreaHandle --output-readme docs/iphone-duo.md",
|
|
35
39
|
"build:demo": "npm run build && cd demo && npm install && npm run build -- --configuration=production",
|
|
36
40
|
"lint": "prettier --check \"./**/*.{scss,ts}\" && prettier --parser angular --check \"./**/*.html\"",
|
|
37
41
|
"fmt": "prettier --write \"./**/*.{scss,ts}\" && prettier --parser angular --write \"./**/*.html\"",
|
package/src/index.ts
CHANGED
|
@@ -14,23 +14,30 @@ export const registerTabBarEffect = (targetElement: HTMLElement): registeredEffe
|
|
|
14
14
|
const win = targetElement.ownerDocument.defaultView;
|
|
15
15
|
if (!targetElement.classList.contains('ios') || !win) return undefined;
|
|
16
16
|
const reducedMotion = win.matchMedia('(prefers-reduced-motion: reduce)');
|
|
17
|
+
const verticalBarsRoot = targetElement.closest('ion-app');
|
|
17
18
|
let effect: registeredEffect | undefined;
|
|
18
19
|
const update = () => {
|
|
19
20
|
effect?.destroy();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
const isVertical =
|
|
22
|
+
!!verticalBarsRoot?.classList.contains('ios-theme-vertical-bars') && !targetElement.closest('ion-menu, ion-modal, ion-popover');
|
|
23
|
+
effect =
|
|
24
|
+
reducedMotion.matches || isVertical
|
|
25
|
+
? undefined
|
|
26
|
+
: registerEffect(targetElement, 'ion-tab-button', 'tab-selected', {
|
|
27
|
+
small: 'scale(1.1, 1)',
|
|
28
|
+
medium: 'scale(1.2)',
|
|
29
|
+
large: 'scale(1.15, 1.4)',
|
|
30
|
+
xlarge: 'scale(1.3)',
|
|
31
|
+
});
|
|
28
32
|
};
|
|
29
33
|
update();
|
|
30
34
|
reducedMotion.addEventListener('change', update);
|
|
35
|
+
const placementObserver = new win.MutationObserver(update);
|
|
36
|
+
if (verticalBarsRoot) placementObserver.observe(verticalBarsRoot, { attributes: true, attributeFilter: ['class'] });
|
|
31
37
|
return {
|
|
32
38
|
destroy: () => {
|
|
33
39
|
reducedMotion.removeEventListener('change', update);
|
|
40
|
+
placementObserver.disconnect();
|
|
34
41
|
effect?.destroy();
|
|
35
42
|
effect = undefined;
|
|
36
43
|
},
|
|
@@ -5,7 +5,7 @@ import * as menuButton from './ion-menu-button';
|
|
|
5
5
|
import * as tabBar from './ion-tab-bar';
|
|
6
6
|
import * as segment from './ion-segment';
|
|
7
7
|
import * as fab from './ion-fab';
|
|
8
|
-
import { visible } from '../shared/dom';
|
|
8
|
+
import { isDisabledButtonGroupChild, isVerticalBarsSource, visible } from '../shared/dom';
|
|
9
9
|
import type { Candidate, Identify } from '../shared/candidate';
|
|
10
10
|
|
|
11
11
|
// Static composition only. Each component declares its own tag, discovery and reader.
|
|
@@ -27,10 +27,19 @@ export const motionSelector = [
|
|
|
27
27
|
...components.filter((component) => 'tracksMotion' in component && component.tracksMotion).map((component) => component.tag),
|
|
28
28
|
].join(', ');
|
|
29
29
|
|
|
30
|
+
export const isVerticalBarsCandidate = isVerticalBarsSource;
|
|
31
|
+
|
|
30
32
|
export const readCandidate = (element: HTMLElement, id: Identify): Candidate | undefined => {
|
|
31
|
-
|
|
33
|
+
const verticalBars = isVerticalBarsCandidate(element);
|
|
34
|
+
if ((!element.classList.contains('ios') && !verticalBars) || !visible(element, verticalBars) || element.closest('ion-modal, ion-popover'))
|
|
35
|
+
return;
|
|
32
36
|
const style = getComputedStyle(element);
|
|
33
|
-
if (
|
|
37
|
+
if (
|
|
38
|
+
!isDisabledButtonGroupChild(element) &&
|
|
39
|
+
!style.getPropertyValue('--ios-theme-glass-background-rgb').trim() &&
|
|
40
|
+
!style.getPropertyValue('--ios26-glass-background-rgb').trim() &&
|
|
41
|
+
!verticalBars
|
|
42
|
+
)
|
|
34
43
|
return;
|
|
35
44
|
if (element.contains(element.ownerDocument.activeElement)) return;
|
|
36
45
|
return components.find((component) => component.tag === element.localName)?.read(element, id);
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { createCandidate, appendItem } from '../shared/candidate';
|
|
2
2
|
import type { Candidate, Identify } from '../shared/candidate';
|
|
3
|
-
import { inFixedToolbar } from '../shared/dom';
|
|
3
|
+
import { inFixedToolbar, isVerticalBarsSource } from '../shared/dom';
|
|
4
4
|
|
|
5
5
|
export const tag = 'ion-back-button';
|
|
6
6
|
|
|
7
7
|
export const read = (element: HTMLElement, id: Identify): Candidate | undefined => {
|
|
8
8
|
const button = element as HTMLIonBackButtonElement;
|
|
9
|
-
if (
|
|
9
|
+
if (
|
|
10
|
+
!button.shadowRoot ||
|
|
11
|
+
button.icon !== undefined ||
|
|
12
|
+
button.color !== undefined ||
|
|
13
|
+
(!isVerticalBarsSource(element) && !inFixedToolbar(element))
|
|
14
|
+
)
|
|
15
|
+
return;
|
|
10
16
|
const candidate = createCandidate(element, tag, id);
|
|
11
17
|
const label = button.shadowRoot.querySelector('[part="text"]')?.textContent?.trim() ?? '';
|
|
12
18
|
return appendItem(candidate, element, id, button.shadowRoot, label) ? candidate : undefined;
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import { createCandidate, appendItem } from '../shared/candidate';
|
|
2
2
|
import type { Candidate, Identify } from '../shared/candidate';
|
|
3
|
-
import { inFixedToolbar } from '../shared/dom';
|
|
3
|
+
import { inFixedToolbar, isVerticalBarsToolbarAction, isVerticalBarsToolbarGroup } from '../shared/dom';
|
|
4
4
|
|
|
5
5
|
export const tag = 'ion-button';
|
|
6
6
|
|
|
7
7
|
export const read = (element: HTMLElement, id: Identify): Candidate | undefined => {
|
|
8
8
|
const button = element as HTMLIonButtonElement;
|
|
9
|
-
|
|
9
|
+
const verticalBars = !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest('ion-app.ios-theme-vertical-bars');
|
|
10
|
+
if (verticalBars && element.parentElement && isVerticalBarsToolbarGroup(element.parentElement)) return;
|
|
11
|
+
const fill = button.fill ?? 'default';
|
|
12
|
+
if (
|
|
13
|
+
!inFixedToolbar(element) ||
|
|
14
|
+
(verticalBars && !isVerticalBarsToolbarAction(element)) ||
|
|
15
|
+
(!verticalBars && fill !== 'default') ||
|
|
16
|
+
(verticalBars && !['default', 'clear'].includes(fill)) ||
|
|
17
|
+
button.classList.contains('ion-color')
|
|
18
|
+
)
|
|
19
|
+
return;
|
|
10
20
|
const candidate = createCandidate(element, tag, id);
|
|
11
21
|
return appendItem(candidate, element, id) ? candidate : undefined;
|
|
12
22
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createCandidate, appendItem } from '../shared/candidate';
|
|
2
2
|
import type { Candidate, Identify } from '../shared/candidate';
|
|
3
|
-
import { inFixedToolbar } from '../shared/dom';
|
|
3
|
+
import { childElements, verticalBarsToolbarActions, inFixedToolbar, isVerticalBarsToolbarGroup } from '../shared/dom';
|
|
4
4
|
import * as menuButton from './ion-menu-button';
|
|
5
5
|
|
|
6
6
|
export const tag = 'ion-buttons';
|
|
@@ -8,18 +8,23 @@ export const tracksMotion = true;
|
|
|
8
8
|
|
|
9
9
|
export const read = (element: HTMLElement, id: Identify): Candidate | undefined => {
|
|
10
10
|
if (!inFixedToolbar(element)) return;
|
|
11
|
-
|
|
11
|
+
let children = childElements(element);
|
|
12
12
|
if (children.length === 1) return menuButton.read(element, id);
|
|
13
|
+
const verticalBars = !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest('ion-app.ios-theme-vertical-bars');
|
|
14
|
+
if (verticalBars && !isVerticalBarsToolbarGroup(element)) return;
|
|
15
|
+
if (verticalBars) children = verticalBarsToolbarActions(element);
|
|
13
16
|
if (
|
|
14
17
|
!children.length ||
|
|
15
18
|
children.some(
|
|
16
19
|
(child) =>
|
|
17
|
-
!child.matches(`${menuButton.tag}.ios`) &&
|
|
18
|
-
(!child.matches(
|
|
20
|
+
!child.matches(`${menuButton.tag}${verticalBars ? '' : '.ios'}`) &&
|
|
21
|
+
(!child.matches(`ion-button${verticalBars ? '' : '.ios.button-clear'}`) ||
|
|
22
|
+
!(verticalBars ? ['default', 'clear'] : ['clear']).includes((child as HTMLIonButtonElement).fill ?? 'default')),
|
|
19
23
|
)
|
|
20
24
|
)
|
|
21
25
|
return;
|
|
22
26
|
const candidate = createCandidate(element, tag, id);
|
|
27
|
+
if (verticalBars && children.length !== childElements(element).length) candidate.sources = children;
|
|
23
28
|
for (const child of children) {
|
|
24
29
|
const supported = child.matches(menuButton.tag)
|
|
25
30
|
? menuButton.append(candidate, child as HTMLIonMenuButtonElement, id)
|