@rdlabo/ionic-theme-ios27 1.2.0-1 → 1.2.0-3

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.
Files changed (75) hide show
  1. package/README.md +9 -0
  2. package/dist/css/components/ion-tabs.css +1 -1
  3. package/dist/css/ionic-theme-ios27.css +1 -1
  4. package/dist/css/vertical-bars.css +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +31 -12
  7. package/dist/native/components/index.d.ts.map +1 -1
  8. package/dist/native/components/index.js +4 -1
  9. package/dist/native/components/ion-button.d.ts.map +1 -1
  10. package/dist/native/components/ion-button.js +3 -3
  11. package/dist/native/components/ion-buttons.d.ts.map +1 -1
  12. package/dist/native/components/ion-buttons.js +3 -2
  13. package/dist/native/components/searchable-tabs.d.ts.map +1 -1
  14. package/dist/native/components/searchable-tabs.js +6 -7
  15. package/dist/native/definitions.d.ts +8 -15
  16. package/dist/native/definitions.d.ts.map +1 -1
  17. package/dist/native/definitions.js +1 -6
  18. package/dist/native/index.d.ts +1 -2
  19. package/dist/native/index.d.ts.map +1 -1
  20. package/dist/native/index.js +64 -28
  21. package/dist/native/prehide.d.ts.map +1 -1
  22. package/dist/native/prehide.js +6 -4
  23. package/dist/native/runtime.d.ts.map +1 -1
  24. package/dist/native/runtime.js +40 -19
  25. package/dist/native/shared/candidate.d.ts.map +1 -1
  26. package/dist/native/shared/candidate.js +8 -0
  27. package/dist/native/shared/dom.d.ts.map +1 -1
  28. package/dist/native/shared/dom.js +11 -10
  29. package/dist/native/shared/modal.d.ts +8 -0
  30. package/dist/native/shared/modal.d.ts.map +1 -0
  31. package/dist/native/shared/modal.js +143 -0
  32. package/dist/native/vertical-bars-web.d.ts.map +1 -1
  33. package/dist/native/vertical-bars-web.js +11 -3
  34. package/dist/sheets-of-glass/animations.d.ts +13 -4
  35. package/dist/sheets-of-glass/animations.d.ts.map +1 -1
  36. package/dist/sheets-of-glass/animations.js +56 -40
  37. package/dist/sheets-of-glass/index.d.ts +1 -1
  38. package/dist/sheets-of-glass/index.d.ts.map +1 -1
  39. package/dist/sheets-of-glass/index.js +64 -20
  40. package/dist/tab-bar-searchable/index.d.ts.map +1 -1
  41. package/dist/tab-bar-searchable/index.js +39 -11
  42. package/dist/vertical-bars.d.ts +1 -2
  43. package/dist/vertical-bars.d.ts.map +1 -1
  44. package/dist/vertical-bars.js +0 -1
  45. package/docs/iphone-duo-with-original-theme.md +37 -15
  46. package/docs/iphone-duo.md +90 -47
  47. package/docs/migration.md +3 -3
  48. package/docs/native-ui-shell.md +12 -8
  49. package/docs/special-markup.md +0 -13
  50. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +1 -1
  51. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +50 -6
  52. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalSearch.swift +118 -0
  53. package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +32 -179
  54. package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +5 -0
  55. package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +67 -0
  56. package/package.json +1 -1
  57. package/src/index.ts +40 -9
  58. package/src/native/components/index.ts +6 -1
  59. package/src/native/components/ion-button.ts +4 -3
  60. package/src/native/components/ion-buttons.ts +4 -2
  61. package/src/native/components/searchable-tabs.ts +8 -11
  62. package/src/native/definitions.ts +11 -19
  63. package/src/native/index.ts +67 -30
  64. package/src/native/prehide.ts +7 -4
  65. package/src/native/runtime.ts +40 -18
  66. package/src/native/shared/candidate.ts +10 -0
  67. package/src/native/shared/dom.ts +11 -10
  68. package/src/native/shared/modal.ts +157 -0
  69. package/src/native/vertical-bars-web.ts +12 -3
  70. package/src/sheets-of-glass/animations.ts +66 -41
  71. package/src/sheets-of-glass/index.ts +82 -16
  72. package/src/styles/components/ion-tabs.scss +7 -4
  73. package/src/styles/vertical-bars.scss +110 -35
  74. package/src/tab-bar-searchable/index.ts +40 -9
  75. package/src/vertical-bars.ts +1 -2
@@ -5,7 +5,8 @@ import UIKit
5
5
  protocol ShellVerticalBarsControlling: AnyObject {
6
6
  var view: UIView { get }
7
7
  func attach(to owner: UIViewController, in parent: UIView)
8
- func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String)
8
+ @MainActor func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String)
9
+ var ownsKeyboardChrome: Bool { get }
9
10
  func detach()
10
11
  }
11
12
 
@@ -16,6 +17,9 @@ final class ShellVerticalBarsModel: ObservableObject {
16
17
  let label: String
17
18
  let accessibilityLabel: String
18
19
  let image: UIImage?
20
+ let background: Color?
21
+ let borderColor: Color?
22
+ let borderWidth: CGFloat
19
23
  let badge: ShellBadge?
20
24
  let disabled: Bool
21
25
  var selected: Bool
@@ -27,6 +31,7 @@ final class ShellVerticalBarsModel: ObservableObject {
27
31
  let slot: ShellControl.ToolbarSlot?
28
32
  }
29
33
 
34
+ @Published var search: ShellVerticalSearchModel?
30
35
  @Published var back: Item?
31
36
  @Published var groups: [Group] = []
32
37
  @Published var tabs: [Item] = []
@@ -41,7 +46,11 @@ final class ShellVerticalBarsModel: ObservableObject {
41
46
  func item(_ source: ShellItem) -> Item {
42
47
  Item(id: source.id, label: source.content.label,
43
48
  accessibilityLabel: source.content.accessibilityLabel,
44
- image: rendering.image(source.content), badge: source.content.badge, disabled: source.content.disabled,
49
+ image: rendering.image(source.content),
50
+ background: source.content.backgroundColor.map { Color(uiColor: rendering.color($0)) },
51
+ borderColor: source.content.borderColor.map { Color(uiColor: rendering.color($0)) },
52
+ borderWidth: source.content.borderWidth ?? 0,
53
+ badge: source.content.badge, disabled: source.content.disabled,
45
54
  selected: source.content.selected)
46
55
  }
47
56
  withAnimation(.smooth(duration: 0.3)) {
@@ -99,11 +108,21 @@ private struct ShellVerticalBarsLabel: View {
99
108
  }
100
109
 
101
110
  @available(iOS 26.0, *)
111
+ @ViewBuilder
102
112
  private func verticalBarsButton(_ item: ShellVerticalBarsModel.Item, model: ShellVerticalBarsModel) -> some View {
103
- Button { model.activate(item.id) } label: { ShellVerticalBarsLabel(item: item) }
113
+ let button = Button { model.activate(item.id) } label: { ShellVerticalBarsLabel(item: item) }
104
114
  .disabled(item.disabled)
105
115
  .accessibilityLabel(item.accessibilityLabel)
106
116
  .accessibilityIdentifier(item.id)
117
+ if let background = item.background {
118
+ button.buttonStyle(.glassProminent).tint(background)
119
+ } else {
120
+ button.overlay {
121
+ if let borderColor = item.borderColor {
122
+ Capsule().strokeBorder(borderColor, lineWidth: item.borderWidth).allowsHitTesting(false)
123
+ }
124
+ }
125
+ }
107
126
  }
108
127
 
109
128
  @available(iOS 26.0, *)
@@ -146,6 +165,7 @@ private struct ShellVerticalBarsView: View {
146
165
  }
147
166
  }
148
167
  }
168
+ .modifier(ShellVerticalBarsSearch(model: model))
149
169
  .modifier(ShellVerticalBarsCompression())
150
170
  .background(Color.clear)
151
171
  }
@@ -162,6 +182,16 @@ private struct ShellVerticalBarsPage: View {
162
182
  }
163
183
  }
164
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
+
165
195
  @available(iOS 26.0, *)
166
196
  private struct ShellVerticalBarsCompression: ViewModifier {
167
197
  @ViewBuilder func body(content: Content) -> some View {
@@ -254,9 +284,11 @@ private struct ShellVerticalBarsLegacyToolbar: ViewModifier {
254
284
  final class ShellVerticalBarsController: ShellVerticalBarsControlling {
255
285
  private final class TransparentHostingController<Content: View>: UIHostingController<Content> {
256
286
  var railEdge = "right"
287
+ var layoutSearch: (() -> Void)?
257
288
  override func viewDidLayoutSubviews() {
258
289
  super.viewDidLayoutSubviews()
259
290
  makeFullSizeSurfacesTransparent(in: view)
291
+ layoutSearch?()
260
292
  }
261
293
 
262
294
  private func makeFullSizeSurfacesTransparent(in surface: UIView) {
@@ -306,7 +338,7 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling {
306
338
  private func containsBarSurface(in view: UIView, at point: CGPoint) -> Bool {
307
339
  guard !view.isHidden, view.alpha > 0.05 else { return false }
308
340
  let name = NSStringFromClass(type(of: view))
309
- 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),
310
342
  convert(view.bounds, from: view).contains(point) { return true }
311
343
  return view.subviews.contains { containsBarSurface(in: $0, at: point) }
312
344
  }
@@ -316,14 +348,20 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling {
316
348
  private lazy var controller = TransparentHostingController(rootView: ShellVerticalBarsView(model: model))
317
349
  private let container = RailContainer()
318
350
  private weak var owner: UIViewController?
351
+ private let changed: (String, ShellSearchPhase, String, Bool, Int) -> Int
319
352
 
320
353
  var view: UIView { container }
321
354
 
322
- init(activate: @escaping (String) -> Void) {
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
323
360
  model.activate = activate
324
361
  container.backgroundColor = .clear
325
362
  controller.view.backgroundColor = .clear
326
363
  controller.view.isOpaque = false
364
+ controller.layoutSearch = { [weak self] in self?.model.search?.configureField() }
327
365
  }
328
366
 
329
367
  func attach(to owner: UIViewController, in parent: UIView) {
@@ -340,10 +378,16 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling {
340
378
  controller.didMove(toParent: owner)
341
379
  }
342
380
 
343
- func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String) {
381
+ @MainActor func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String) {
344
382
  container.railEdge = edge
345
383
  controller.railEdge = edge
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 }
346
389
  model.apply(controls, rendering: rendering)
390
+ if tabsAppear { ShellCrossfade.enter(container, duration: ShellCrossfade.duration(180)) }
347
391
  controller.overrideUserInterfaceStyle = controls.contains(where: \.dark) ? .dark : .light
348
392
  }
349
393
 
@@ -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
+ }
@@ -8,9 +8,7 @@ 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: "getDeviceLayout", returnType: CAPPluginReturnPromise),
12
- CAPPluginMethod(name: "startDeviceLayoutMonitoring", returnType: CAPPluginReturnPromise),
13
- CAPPluginMethod(name: "stopDeviceLayoutMonitoring", returnType: CAPPluginReturnPromise),
11
+ CAPPluginMethod(name: "getWebViewMetrics", returnType: CAPPluginReturnPromise),
14
12
  CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise),
15
13
  CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise)
16
14
  ]
@@ -27,16 +25,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
27
25
  private var pendingTabExpiryWorks: [String: DispatchWorkItem] = [:]
28
26
  private var restoreTopEdge: (() -> Void)?
29
27
  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?
28
+ private var lastWebViewRadius: Double?
40
29
 
41
30
  public override func load() {
42
31
  for name in [UIApplication.didEnterBackgroundNotification, UIResponder.keyboardWillChangeFrameNotification, UIResponder.keyboardWillHideNotification,
@@ -57,7 +46,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
57
46
  self.host?.isHidden = true
58
47
  self.verticalBars?.view.isHidden = true
59
48
  }
60
- var searchOwnsKeyboard = false
49
+ var searchOwnsKeyboard = self.verticalBars?.ownsKeyboardChrome == true
61
50
  self.searchControllers.values.forEach { controller in
62
51
  if controller.ownsKeyboardChrome { searchOwnsKeyboard = true }
63
52
  if !keyboard { controller.surface.isHidden = true }
@@ -68,7 +57,6 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
68
57
  }
69
58
  } else if name == UIDevice.orientationDidChangeNotification {
70
59
  self.notifyWebViewMetricsChange()
71
- self.notifyVerticalBarPlacementChange()
72
60
  }
73
61
  })
74
62
  }
@@ -78,8 +66,6 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
78
66
  if name == UIResponder.keyboardDidHideNotification { self?.keyboardVisible = false }
79
67
  self?.bridge?.triggerWindowJSEvent(eventName: "nativeUIShellRefresh", data: name == UIApplication.didBecomeActiveNotification ? "{\"retireSearch\":true}" : "{}")
80
68
  if name == UIApplication.didBecomeActiveNotification { self?.notifyWebViewMetricsChange() }
81
- if name == UIApplication.didBecomeActiveNotification { self?.notifyVerticalBarPlacementChange() }
82
- if name == UIApplication.didBecomeActiveNotification { self?.refreshHingeStatus() }
83
69
  })
84
70
  }
85
71
  }
@@ -88,17 +74,6 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
88
74
  observers.forEach(NotificationCenter.default.removeObserver)
89
75
  }
90
76
 
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
-
102
77
  private func webViewMetrics() -> JSObject? {
103
78
  guard #available(iOS 26.0, *), let webView = bridge?.webView else { return nil }
104
79
  webView.layoutIfNeeded()
@@ -106,158 +81,21 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
106
81
  }
107
82
 
108
83
  private func notifyWebViewMetricsChange() {
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
+ let metrics = webViewMetrics() ?? ["radius": 0]
85
+ let radius = metrics["radius"] as? Double ?? 0
86
+ guard radius != lastWebViewRadius else { return }
87
+ lastWebViewRadius = radius
88
+ notifyListeners("webViewMetricsChange", data: metrics)
126
89
  }
127
90
 
128
- @objc func getDeviceLayout(_ call: CAPPluginCall) {
91
+ @objc func getWebViewMetrics(_ call: CAPPluginCall) {
129
92
  DispatchQueue.main.async { [weak self] in
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() }
136
- }
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
172
- }
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) }
93
+ call.resolve(self?.webViewMetrics() ?? ["radius": 0])
219
94
  }
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
256
95
  }
257
96
 
258
97
  @objc func configure(_ call: CAPPluginCall) {
259
98
  DispatchQueue.main.async { [weak self] in
260
- self?.observeVerticalBarPlacement()
261
99
  // A new JS context starts revision numbering again (live reload / navigation).
262
100
  self?.restoreTopEdge?()
263
101
  self?.restoreTopEdge = nil
@@ -352,10 +190,12 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
352
190
  guard let snapshot = try? call.decode(ShellSnapshot.self), snapshot.isValid else {
353
191
  call.reject("Invalid control snapshot"); return
354
192
  }
193
+ self.notifyWebViewMetricsChange()
355
194
  let duration = ShellCrossfade.duration(snapshot.transitionDuration)
356
195
  let existing = Set(self.controls.keys)
357
196
  let verticalBars = snapshot.controls.filter { $0.placement == .verticalBars }
358
197
  let snapshots = snapshot.controls.filter { $0.placement != .verticalBars }
198
+ let verticalSearchActive = verticalBars.contains { $0.search.map { $0.available && $0.active } ?? false }
359
199
  let width = snapshot.viewportWidth
360
200
  self.revision = next
361
201
  if snapshots.isEmpty && verticalBars.isEmpty {
@@ -371,14 +211,23 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
371
211
  var fabs: [(ShellFab, ShellControl)] = []
372
212
  var searches: [(ShellSearchControlling, ShellControl, CGRect, CGRect, UIView?, Bool)] = []
373
213
  var rejectedSearches: [String] = []
374
- if verticalBars.isEmpty || self.keyboardVisible {
214
+ if verticalBars.isEmpty || (self.keyboardVisible && !verticalSearchActive) {
375
215
  self.verticalBars?.detach()
376
216
  self.verticalBars = nil
377
217
  if self.keyboardVisible { rejectedControls.append(contentsOf: verticalBars.map(\.id)) }
378
218
  } else if let owner = self.bridge?.viewController {
379
- let rail = self.verticalBars ?? ShellVerticalBarsController(activate: { [weak self] id in self?.activate(id) })
219
+ let rail = self.verticalBars ?? ShellVerticalBarsController(
220
+ activate: { [weak self] id in self?.activate(id) },
221
+ changed: { [weak self] id, phase, value, composing, valueVersion in
222
+ self?.searchChanged(id, phase: phase, value: value, composing: composing, valueVersion: valueVersion) ?? 0
223
+ })
380
224
  self.verticalBars = rail
381
225
  rail.attach(to: owner, in: owner.view)
226
+ if let frame = snapshot.verticalBarFrame {
227
+ rail.view.frame = webView.convert(frame.rect.applying(CGAffineTransform(scaleX: scale, y: scale)), to: owner.view)
228
+ } else {
229
+ rail.view.frame = owner.view.bounds
230
+ }
382
231
  rail.apply(verticalBars, rendering: self.rendering, edge: snapshot.verticalBarEdge ?? "right")
383
232
  rail.view.isHidden = false
384
233
  } else {
@@ -430,11 +279,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
430
279
  controller = ShellSearchController()
431
280
  controller.activate = { [weak self] id in self?.activate(id) }
432
281
  controller.changed = { [weak self] id, phase, value, composing, valueVersion in
433
- guard let self else { return 0 }
434
- self.sequence += 1
435
- self.notifyListeners("search", data: ["id": id, "phase": phase.rawValue, "value": value,
436
- "composing": composing, "valueVersion": valueVersion, "sequence": self.sequence, "revision": self.revision])
437
- return self.sequence
282
+ self?.searchChanged(id, phase: phase, value: value, composing: composing, valueVersion: valueVersion) ?? 0
438
283
  }
439
284
  controller.attach(to: owner, in: owner.view)
440
285
  self.searchControllers[id] = controller
@@ -535,6 +380,14 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
535
380
  activate(itemId)
536
381
  }
537
382
 
383
+ private func searchChanged(_ id: String, phase: ShellSearchPhase, value: String,
384
+ composing: Bool, valueVersion: Int) -> Int {
385
+ sequence += 1
386
+ notifyListeners("search", data: ["id": id, "phase": phase.rawValue, "value": value,
387
+ "composing": composing, "valueVersion": valueVersion, "sequence": sequence, "revision": revision])
388
+ return sequence
389
+ }
390
+
538
391
  private func activate(_ id: String) {
539
392
  sequence += 1
540
393
  notifyListeners("activate", data: ["id": id, "revision": revision, "sequence": sequence])
@@ -11,11 +11,13 @@ struct ShellSnapshot: Decodable {
11
11
  let revision: Int
12
12
  let transitionDuration: Double?
13
13
  let viewportWidth: Double
14
+ let verticalBarFrame: ShellFrame?
14
15
  let verticalBarEdge: String?
15
16
  let controls: [ShellControl]
16
17
 
17
18
  var isValid: Bool {
18
19
  revision >= 0 && (transitionDuration.map { $0.isFinite && $0 >= 0 && $0 <= 500 } ?? true) && viewportWidth.isFinite && viewportWidth > 0 && controls.count <= 100 &&
20
+ (verticalBarFrame.map { $0.isValid } ?? true) &&
19
21
  (verticalBarEdge == nil || verticalBarEdge == "left" || verticalBarEdge == "right") &&
20
22
  Set(controls.map(\.id)).count == controls.count && controls.allSatisfy(\.isValid)
21
23
  }
@@ -105,6 +107,9 @@ struct ShellItemContent: Decodable, Equatable {
105
107
  let fontSize: Double
106
108
  let fontWeight: Double
107
109
  let color: String
110
+ let backgroundColor: String?
111
+ let borderColor: String?
112
+ let borderWidth: CGFloat?
108
113
  let badge: ShellBadge?
109
114
  var icon: String?
110
115
  var iconWidth: Double?