@webview-native-ui/engine 1.0.1 → 1.0.2-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 +14 -0
- package/WebViewNativeUIControls.podspec +17 -0
- package/dist/controls/activation.d.ts +15 -0
- package/dist/controls/activation.js +370 -0
- package/dist/controls/components/index.d.ts +16 -0
- package/dist/controls/components/index.js +38 -0
- package/dist/controls/components/ion-back-button.d.ts +4 -0
- package/dist/controls/components/ion-back-button.js +16 -0
- package/dist/controls/components/ion-button.d.ts +4 -0
- package/dist/controls/components/ion-button.js +26 -0
- package/dist/controls/components/ion-buttons.d.ts +5 -0
- package/dist/controls/components/ion-buttons.js +34 -0
- package/dist/controls/components/ion-fab.d.ts +4 -0
- package/dist/controls/components/ion-fab.js +164 -0
- package/dist/controls/components/ion-menu-button.d.ts +7 -0
- package/dist/controls/components/ion-menu-button.js +41 -0
- package/dist/controls/components/ion-segment.d.ts +5 -0
- package/dist/controls/components/ion-segment.js +24 -0
- package/dist/controls/components/ion-tab-bar.d.ts +5 -0
- package/dist/controls/components/ion-tab-bar.js +49 -0
- package/dist/controls/components/searchable-tabs.d.ts +20 -0
- package/dist/controls/components/searchable-tabs.js +525 -0
- package/dist/controls/definitions.d.ts +360 -0
- package/dist/controls/definitions.js +1 -0
- package/dist/controls/extensions.d.ts +44 -0
- package/dist/controls/extensions.js +164 -0
- package/dist/controls/foldable/fold-state.d.ts +4 -0
- package/dist/controls/foldable/fold-state.js +8 -0
- package/dist/controls/foldable/types.d.ts +9 -0
- package/dist/controls/foldable/types.js +1 -0
- package/dist/controls/html.d.ts +8 -0
- package/dist/controls/html.js +260 -0
- package/dist/controls/index.d.ts +7 -0
- package/dist/controls/index.js +4 -0
- package/dist/controls/ionic.d.ts +1 -0
- package/dist/controls/ionic.js +1 -0
- package/dist/controls/legacy-layout.d.ts +7 -0
- package/dist/controls/legacy-layout.js +23 -0
- package/dist/controls/overlays/content.d.ts +2 -0
- package/dist/controls/overlays/content.js +55 -0
- package/dist/controls/overlays/controller.d.ts +7 -0
- package/dist/controls/overlays/controller.js +369 -0
- package/dist/controls/overlays/covered-overlay.d.ts +4 -0
- package/dist/controls/overlays/covered-overlay.js +9 -0
- package/dist/controls/overlays/dialog-animation.d.ts +7 -0
- package/dist/controls/overlays/dialog-animation.js +49 -0
- package/dist/controls/overlays/dismiss-animation.d.ts +2 -0
- package/dist/controls/overlays/dismiss-animation.js +14 -0
- package/dist/controls/overlays/focus.d.ts +2 -0
- package/dist/controls/overlays/focus.js +67 -0
- package/dist/controls/overlays/popover.d.ts +10 -0
- package/dist/controls/overlays/popover.js +117 -0
- package/dist/controls/overlays/styles.d.ts +9 -0
- package/dist/controls/overlays/styles.js +194 -0
- package/dist/controls/prehide.d.ts +6 -0
- package/dist/controls/prehide.js +280 -0
- package/dist/controls/runtime.d.ts +5 -0
- package/dist/controls/runtime.js +1685 -0
- package/dist/controls/shared/bridge.d.ts +4 -0
- package/dist/controls/shared/bridge.js +15 -0
- package/dist/controls/shared/candidate.d.ts +23 -0
- package/dist/controls/shared/candidate.js +128 -0
- package/dist/controls/shared/clipping.d.ts +1 -0
- package/dist/controls/shared/clipping.js +1 -0
- package/dist/controls/shared/connection.d.ts +21 -0
- package/dist/controls/shared/connection.js +112 -0
- package/dist/controls/shared/control-options.d.ts +4 -0
- package/dist/controls/shared/control-options.js +14 -0
- package/dist/controls/shared/covered-snapshot.d.ts +35 -0
- package/dist/controls/shared/covered-snapshot.js +41 -0
- package/dist/controls/shared/covered-source.d.ts +22 -0
- package/dist/controls/shared/covered-source.js +72 -0
- package/dist/controls/shared/crossfade.d.ts +11 -0
- package/dist/controls/shared/crossfade.js +71 -0
- package/dist/controls/shared/diagnostics.d.ts +2 -0
- package/dist/controls/shared/diagnostics.js +16 -0
- package/dist/controls/shared/dom.d.ts +45 -0
- package/dist/controls/shared/dom.js +258 -0
- package/dist/controls/shared/events.d.ts +12 -0
- package/dist/controls/shared/events.js +13 -0
- package/dist/controls/shared/horizontal-navigation.d.ts +36 -0
- package/dist/controls/shared/horizontal-navigation.js +90 -0
- package/dist/controls/shared/icons.d.ts +5 -0
- package/dist/controls/shared/icons.js +66 -0
- package/dist/controls/shared/modal-animation.d.ts +21 -0
- package/dist/controls/shared/modal-animation.js +97 -0
- package/dist/controls/shared/modal.d.ts +26 -0
- package/dist/controls/shared/modal.js +261 -0
- package/dist/controls/shared/projection-id.d.ts +2 -0
- package/dist/controls/shared/projection-id.js +2 -0
- package/dist/controls/shared/scroll-placement.d.ts +1 -0
- package/dist/controls/shared/scroll-placement.js +1 -0
- package/dist/controls/shared/tab-bar-width.d.ts +7 -0
- package/dist/controls/shared/tab-bar-width.js +72 -0
- package/dist/controls/shared/toolbar-options.d.ts +10 -0
- package/dist/controls/shared/toolbar-options.js +38 -0
- package/dist/controls/shared/transition-identity.d.ts +3 -0
- package/dist/controls/shared/transition-identity.js +64 -0
- package/dist/controls/shared/vertical-bars-geometry.d.ts +8 -0
- package/dist/controls/shared/vertical-bars-geometry.js +82 -0
- package/dist/controls/shared/vertical-navigation.d.ts +5 -0
- package/dist/controls/shared/vertical-navigation.js +39 -0
- package/dist/controls/startup.d.ts +7 -0
- package/dist/controls/startup.js +72 -0
- package/dist/controls/transport.d.ts +10 -0
- package/dist/controls/transport.js +1 -0
- package/dist/controls/vertical-bars-web-search.d.ts +8 -0
- package/dist/controls/vertical-bars-web-search.js +83 -0
- package/dist/controls/vertical-bars-web.d.ts +2 -0
- package/dist/controls/vertical-bars-web.js +609 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/integration/index.d.ts +36 -0
- package/dist/integration/index.js +25 -0
- package/dist/native/session.js +14 -8
- package/dist/projection/pause.d.ts +10 -0
- package/dist/projection/pause.js +58 -0
- package/dist/projection/styles.d.ts +1 -1
- package/dist/projection/styles.js +0 -1
- package/dist/projection/surfaces.js +2 -1
- package/dist/projection/web.js +39 -5
- package/dist/types.d.ts +5 -1
- package/docs/README.md +1 -0
- package/docs/configuration/README.md +3 -1
- package/docs/configuration/ionic-overlays.md +36 -2
- package/docs/configuration/pause-resume.md +51 -0
- package/docs/controls.md +39 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUIButton.swift +98 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUIButtons.swift +24 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUIComponents.swift +19 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUIFab.swift +80 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUISearchController.swift +449 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUISegment.swift +91 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUITabBar.swift +213 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUIToolbarTransition.swift +186 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUIVerticalBars.swift +696 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUIVerticalSearch.swift +117 -0
- package/ios/Sources/WebViewNativeUIControls/Extensions/NativeUI.swift +180 -0
- package/ios/Sources/WebViewNativeUIControls/Extensions/NativeUIRendering.swift +38 -0
- package/ios/Sources/WebViewNativeUIControls/NativeUIController.swift +801 -0
- package/ios/Sources/WebViewNativeUIControls/Overlays/NativeUIOverlayController.swift +131 -0
- package/ios/Sources/WebViewNativeUIControls/Overlays/NativeUIOverlayHost.swift +85 -0
- package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIControlRendering.swift +21 -0
- package/ios/Sources/WebViewNativeUIControls/Shared/NativeUICoveredSnapshots.swift +14 -0
- package/ios/Sources/WebViewNativeUIControls/Shared/NativeUICrossfade.swift +35 -0
- package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIHost.swift +8 -0
- package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIScrollTracker.swift +2 -0
- package/ios/Sources/WebViewNativeUIControls/Shared/NativeUISnapshot.swift +206 -0
- package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIVerticalBarsGeometry.swift +93 -0
- package/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +2 -2
- package/ios/Sources/WebViewNativeUIEngine/NativeUIProjection.swift +1 -1
- package/ios/Sources/WebViewNativeUIEngine/Resources/native-ui-engine.js +3 -4
- package/package.json +30 -5
- package/src/controls/activation.ts +427 -0
- package/src/controls/components/index.ts +45 -0
- package/src/controls/components/ion-back-button.ts +22 -0
- package/src/controls/components/ion-button.ts +28 -0
- package/src/controls/components/ion-buttons.ts +37 -0
- package/src/controls/components/ion-fab.ts +151 -0
- package/src/controls/components/ion-menu-button.ts +37 -0
- package/src/controls/components/ion-segment.ts +27 -0
- package/src/controls/components/ion-tab-bar.ts +55 -0
- package/src/controls/components/searchable-tabs.ts +534 -0
- package/src/controls/definitions.ts +338 -0
- package/src/controls/extensions.ts +198 -0
- package/src/controls/foldable/fold-state.ts +10 -0
- package/src/controls/foldable/types.ts +4 -0
- package/src/controls/html.ts +239 -0
- package/src/controls/index.ts +7 -0
- package/src/controls/ionic.ts +1 -0
- package/src/controls/legacy-layout.ts +21 -0
- package/src/controls/overlays/content.ts +51 -0
- package/src/controls/overlays/controller.ts +394 -0
- package/src/controls/overlays/covered-overlay.ts +11 -0
- package/src/controls/overlays/dialog-animation.ts +53 -0
- package/src/controls/overlays/dismiss-animation.ts +15 -0
- package/src/controls/overlays/focus.ts +64 -0
- package/src/controls/overlays/popover.ts +116 -0
- package/src/controls/overlays/styles.ts +195 -0
- package/src/controls/prehide.ts +285 -0
- package/src/controls/runtime.ts +1731 -0
- package/src/controls/shared/bridge.ts +18 -0
- package/src/controls/shared/candidate.ts +163 -0
- package/src/controls/shared/clipping.ts +1 -0
- package/src/controls/shared/connection.ts +105 -0
- package/src/controls/shared/control-options.ts +20 -0
- package/src/controls/shared/covered-snapshot.ts +52 -0
- package/src/controls/shared/covered-source.ts +78 -0
- package/src/controls/shared/crossfade.ts +70 -0
- package/src/controls/shared/diagnostics.ts +13 -0
- package/src/controls/shared/dom.ts +310 -0
- package/src/controls/shared/events.ts +14 -0
- package/src/controls/shared/horizontal-navigation.ts +106 -0
- package/src/controls/shared/icons.ts +64 -0
- package/src/controls/shared/modal-animation.ts +109 -0
- package/src/controls/shared/modal.ts +286 -0
- package/src/controls/shared/projection-id.ts +2 -0
- package/src/controls/shared/scroll-placement.ts +1 -0
- package/src/controls/shared/tab-bar-width.ts +66 -0
- package/src/controls/shared/toolbar-options.ts +51 -0
- package/src/controls/shared/transition-identity.ts +67 -0
- package/src/controls/shared/vertical-bars-geometry.ts +92 -0
- package/src/controls/shared/vertical-navigation.ts +34 -0
- package/src/controls/startup.ts +74 -0
- package/src/controls/transport.ts +11 -0
- package/src/controls/vertical-bars-web-search.ts +86 -0
- package/src/controls/vertical-bars-web.ts +674 -0
- package/src/dom/geometry.ts +24 -0
- package/src/dom/overlays.ts +196 -0
- package/src/dom/scroll-bindings.ts +66 -0
- package/src/dom/scroll-placement.ts +92 -0
- package/src/dom/visibility.ts +29 -0
- package/src/index.ts +19 -0
- package/src/integration/index.ts +64 -0
- package/src/ionic.ts +34 -0
- package/src/native/bootstrap.ts +132 -0
- package/src/native/bridge.ts +18 -0
- package/src/native/resource-url.ts +11 -0
- package/src/native/session.ts +708 -0
- package/src/native/updates.ts +103 -0
- package/src/native/values.ts +29 -0
- package/src/projection/events.ts +58 -0
- package/src/projection/pause.ts +58 -0
- package/src/projection/placement.ts +34 -0
- package/src/projection/scope.ts +13 -0
- package/src/projection/styles.ts +26 -0
- package/src/projection/surface.ts +88 -0
- package/src/projection/surfaces.ts +119 -0
- package/src/projection/web.ts +1013 -0
- package/src/types.ts +58 -0
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
import Combine
|
|
2
|
+
import SwiftUI
|
|
3
|
+
import UIKit
|
|
4
|
+
|
|
5
|
+
protocol NativeUIVerticalBarsControlling: AnyObject {
|
|
6
|
+
var view: UIView { get }
|
|
7
|
+
func measuredFrames(relativeTo webView: UIView) -> [String: (frame: CGRect, cornerRadius: CGFloat)]
|
|
8
|
+
@MainActor func attach(to owner: UIViewController, in parent: UIView)
|
|
9
|
+
@MainActor func apply(_ controls: [NativeUIControl], rendering: NativeUIControlRendering, edge: String, animated: Bool, accessory: AnyView?, navigation: [String], navigationPhase: String?)
|
|
10
|
+
var ownsKeyboardChrome: Bool { get }
|
|
11
|
+
func presentPopover(anchor: String, host: NativeUIOverlayHost, completion: @escaping (Bool) -> Void) -> Bool
|
|
12
|
+
@MainActor func detach(duration: TimeInterval)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@available(iOS 26.0, *)
|
|
16
|
+
final class NativeUIVerticalBarsModel: ObservableObject {
|
|
17
|
+
struct Item: Identifiable, Equatable {
|
|
18
|
+
let id: String
|
|
19
|
+
let transitionID: String
|
|
20
|
+
let label: String
|
|
21
|
+
let accessibilityLabel: String
|
|
22
|
+
let image: UIImage?
|
|
23
|
+
let clear: Bool
|
|
24
|
+
let background: Color?
|
|
25
|
+
let borderColor: Color?
|
|
26
|
+
let borderWidth: CGFloat
|
|
27
|
+
let badge: NativeUIBadge?
|
|
28
|
+
let disabled: Bool
|
|
29
|
+
var isBack = false
|
|
30
|
+
var selected: Bool
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
struct Group: Identifiable, Equatable {
|
|
34
|
+
let id: String
|
|
35
|
+
let transitionID: String
|
|
36
|
+
let items: [Item]
|
|
37
|
+
let slot: NativeUIControl.ToolbarSlot?
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@Published var accessory: AnyView?
|
|
41
|
+
@Published var popoverAnchor: String?
|
|
42
|
+
var popoverHost: NativeUIOverlayHost?
|
|
43
|
+
var popoverPresented: ((Bool) -> Void)?
|
|
44
|
+
@Published var search: NativeUIVerticalSearchModel?
|
|
45
|
+
@Published var navigation: [String] = []
|
|
46
|
+
private var pageGroups: [String: [Group]] = [:]
|
|
47
|
+
@Published var groups: [Group] = []
|
|
48
|
+
@Published var tabTint: String?
|
|
49
|
+
@Published var tabs: [Item] = []
|
|
50
|
+
@Published var selection = ""
|
|
51
|
+
var activate: (String) -> Void = { _ in }
|
|
52
|
+
private var pendingSelection: NativeUITabBar.PendingSelection?
|
|
53
|
+
private var pendingExpiryWork: DispatchWorkItem?
|
|
54
|
+
|
|
55
|
+
func apply(_ controls: [NativeUIControl], rendering: NativeUIControlRendering, animated: Bool = true,
|
|
56
|
+
accessory: AnyView? = nil, navigation: [String] = [], now: CFAbsoluteTime = CFAbsoluteTimeGetCurrent()) {
|
|
57
|
+
func item(_ source: NativeUIItem, isBack: Bool = false) -> Item {
|
|
58
|
+
let image = rendering.image(source.content)
|
|
59
|
+
return Item(id: source.id, transitionID: source.content.transitionId ?? source.id, label: source.content.label,
|
|
60
|
+
accessibilityLabel: source.content.accessibilityLabel,
|
|
61
|
+
// UIKit toolbar extraction uses the UIImage's rendering mode.
|
|
62
|
+
image: source.content.disabled ? image?.withRenderingMode(.alwaysTemplate) : image,
|
|
63
|
+
clear: source.content.buttonFill == .clear,
|
|
64
|
+
background: source.content.backgroundColor.map { Color(uiColor: rendering.color($0)) },
|
|
65
|
+
borderColor: source.content.borderColor.map { Color(uiColor: rendering.color($0)) },
|
|
66
|
+
borderWidth: source.content.borderWidth ?? 0,
|
|
67
|
+
badge: source.content.badge, disabled: source.content.disabled, isBack: isBack,
|
|
68
|
+
selected: source.content.selected)
|
|
69
|
+
}
|
|
70
|
+
var transaction = Transaction(animation: animated && !UIAccessibility.isReduceMotionEnabled ? .default : nil)
|
|
71
|
+
// A newly created system tab host must not animate its selection shape.
|
|
72
|
+
transaction.disablesAnimations = !animated
|
|
73
|
+
withTransaction(transaction) {
|
|
74
|
+
if self.navigation != navigation {
|
|
75
|
+
if let page = self.navigation.last { pageGroups[page] = groups }
|
|
76
|
+
let retained = Set(self.navigation + navigation)
|
|
77
|
+
pageGroups = pageGroups.filter { retained.contains($0.key) }
|
|
78
|
+
self.navigation = navigation
|
|
79
|
+
}
|
|
80
|
+
if self.accessory != nil || accessory != nil { self.accessory = accessory }
|
|
81
|
+
let groups: [Group] = controls.compactMap { control in
|
|
82
|
+
guard [.button, .buttons, .menuButton, .backButton].contains(control.kind) else { return nil }
|
|
83
|
+
let items = control.items.map { item($0, isBack: control.kind == .backButton) }
|
|
84
|
+
return Group(id: control.id, transitionID: control.transitionId ?? control.id, items: items,
|
|
85
|
+
slot: control.toolbarSlot ?? (control.kind == .backButton ? .start : .end))
|
|
86
|
+
}
|
|
87
|
+
if self.groups != groups { self.groups = groups }
|
|
88
|
+
// Keep all snapshot publications in this transaction. A later unanimated
|
|
89
|
+
// accessory/tab publication can cancel the toolbar animation.
|
|
90
|
+
let tabBar = controls.first(where: { $0.kind == .tabBar })
|
|
91
|
+
if tabTint != tabBar?.tabTint { tabTint = tabBar?.tabTint }
|
|
92
|
+
let tabs = tabBar?.items.map { item($0) } ?? []
|
|
93
|
+
if self.tabs != tabs { self.tabs = tabs }
|
|
94
|
+
reconcileSelection(now: now)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func groups(on page: String?) -> [Group] {
|
|
99
|
+
page == navigation.last ? groups : page.flatMap { pageGroups[$0] } ?? []
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func select(_ id: String, now: CFAbsoluteTime = CFAbsoluteTimeGetCurrent(), ttl: CFTimeInterval = 1) {
|
|
103
|
+
guard let item = tabs.first(where: { $0.id == id }), !item.disabled else { return }
|
|
104
|
+
pendingSelection = .init(id: id, until: now + ttl)
|
|
105
|
+
selection = id
|
|
106
|
+
pendingExpiryWork?.cancel()
|
|
107
|
+
let work = DispatchWorkItem { [weak self] in self?.reconcileSelection() }
|
|
108
|
+
pendingExpiryWork = work
|
|
109
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + ttl + 0.02, execute: work)
|
|
110
|
+
activate(id)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private func reconcileSelection(now: CFAbsoluteTime = CFAbsoluteTimeGetCurrent()) {
|
|
114
|
+
let selection = NativeUITabBar.PendingSelection.reconcile(&pendingSelection,
|
|
115
|
+
domSelection: tabs.first(where: \.selected)?.id,
|
|
116
|
+
isAvailable: { id in tabs.contains { $0.id == id && !$0.disabled } }, now: now) ?? ""
|
|
117
|
+
if self.selection != selection { self.selection = selection }
|
|
118
|
+
if pendingSelection == nil {
|
|
119
|
+
pendingExpiryWork?.cancel()
|
|
120
|
+
pendingExpiryWork = nil
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@available(iOS 26.0, *)
|
|
126
|
+
private struct NativeUIVerticalBarsLabel: View {
|
|
127
|
+
let item: NativeUIVerticalBarsModel.Item
|
|
128
|
+
|
|
129
|
+
var body: some View {
|
|
130
|
+
if let image = item.isBack ? Image(systemName: "chevron.backward") : item.image.map({ Image(uiImage: $0) }) {
|
|
131
|
+
Label {
|
|
132
|
+
// Back remains icon-only on horizontal fallback bars as well.
|
|
133
|
+
Text(item.isBack ? "" : item.label.isEmpty ? item.accessibilityLabel : item.label)
|
|
134
|
+
} icon: {
|
|
135
|
+
image
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
Text(item.label)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@available(iOS 26.0, *)
|
|
144
|
+
@ViewBuilder
|
|
145
|
+
private func verticalBarsButton(_ item: NativeUIVerticalBarsModel.Item, model: NativeUIVerticalBarsModel) -> some View {
|
|
146
|
+
let button = Button { model.activate(item.id) } label: { NativeUIVerticalBarsLabel(item: item) }
|
|
147
|
+
.disabled(item.disabled)
|
|
148
|
+
.accessibilityLabel(item.accessibilityLabel)
|
|
149
|
+
.accessibilityIdentifier(item.isBack ? "BackButton" : item.id)
|
|
150
|
+
.background {
|
|
151
|
+
if item.isBack { NativeUIVerticalBarGeometryProbe() }
|
|
152
|
+
}
|
|
153
|
+
if let background = item.background {
|
|
154
|
+
button.buttonStyle(.glassProminent).tint(background)
|
|
155
|
+
} else {
|
|
156
|
+
button.overlay {
|
|
157
|
+
if let borderColor = item.borderColor {
|
|
158
|
+
Capsule().strokeBorder(borderColor, lineWidth: item.borderWidth).allowsHitTesting(false)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
@available(iOS 26.0, *)
|
|
165
|
+
private struct NativeUIVerticalPopoverContent: UIViewControllerRepresentable {
|
|
166
|
+
let host: NativeUIOverlayHost
|
|
167
|
+
func makeUIViewController(context: Context) -> NativeUIOverlayHost { host }
|
|
168
|
+
func updateUIViewController(_ controller: NativeUIOverlayHost, context: Context) {}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@available(iOS 26.0, *)
|
|
172
|
+
private struct NativeUIVerticalBarsButton: ToolbarContent {
|
|
173
|
+
@ObservedObject var model: NativeUIVerticalBarsModel
|
|
174
|
+
let page: String?
|
|
175
|
+
let groupID: String
|
|
176
|
+
let id: String
|
|
177
|
+
let placement: ToolbarItemPlacement
|
|
178
|
+
|
|
179
|
+
// Observe here so same-ID updates also refresh the shared toolbar background.
|
|
180
|
+
var body: some ToolbarContent {
|
|
181
|
+
if let group = model.groups(on: page).first(where: { $0.transitionID == groupID }),
|
|
182
|
+
let item = group.items.first(where: { $0.transitionID == id }) {
|
|
183
|
+
ToolbarItem(id: item.transitionID, placement: placement) {
|
|
184
|
+
verticalBarsButton(item, model: model)
|
|
185
|
+
.popover(isPresented: Binding(
|
|
186
|
+
get: { model.popoverAnchor == item.id },
|
|
187
|
+
set: { shown in
|
|
188
|
+
if !shown && model.popoverAnchor == item.id {
|
|
189
|
+
model.popoverAnchor = nil
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
)) {
|
|
193
|
+
if let host = model.popoverHost {
|
|
194
|
+
NativeUIVerticalPopoverContent(host: host)
|
|
195
|
+
.frame(width: host.preferredContentSize.width, height: host.preferredContentSize.height)
|
|
196
|
+
.preferredColorScheme(host.options?["dark"] as? Bool == true ? .dark : .light)
|
|
197
|
+
.presentationCompactAdaptation(.popover)
|
|
198
|
+
.interactiveDismissDisabled(host.options?["backdropDismiss"] as? Bool == false)
|
|
199
|
+
.onAppear {
|
|
200
|
+
guard model.popoverHost === host else { return }
|
|
201
|
+
host.anchoredVisible = true
|
|
202
|
+
let completion = model.popoverPresented
|
|
203
|
+
model.popoverPresented = nil
|
|
204
|
+
completion?(true)
|
|
205
|
+
}
|
|
206
|
+
.onDisappear {
|
|
207
|
+
if model.popoverHost === host {
|
|
208
|
+
model.popoverAnchor = nil
|
|
209
|
+
model.popoverHost = nil
|
|
210
|
+
}
|
|
211
|
+
host.presentationDidDisappear()
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
.sharedBackgroundVisibility(item.clear ? .hidden : .automatic)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private struct NativeUIVerticalBarGeometryProbe: UIViewRepresentable {
|
|
222
|
+
func makeUIView(context: Context) -> UIView {
|
|
223
|
+
let view = UIView()
|
|
224
|
+
view.isUserInteractionEnabled = false
|
|
225
|
+
view.accessibilityIdentifier = "BackButton"
|
|
226
|
+
return view
|
|
227
|
+
}
|
|
228
|
+
func updateUIView(_ uiView: UIView, context: Context) {}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
@available(iOS 26.0, *)
|
|
232
|
+
private struct NativeUIVerticalBarsBadge: ViewModifier {
|
|
233
|
+
let badge: NativeUIBadge?
|
|
234
|
+
|
|
235
|
+
@ViewBuilder func body(content: Content) -> some View {
|
|
236
|
+
if let badge { content.badge(badge.value) } else { content }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
@available(iOS 26.0, *)
|
|
241
|
+
private struct NativeUIVerticalBarsView: View {
|
|
242
|
+
@ObservedObject var model: NativeUIVerticalBarsModel
|
|
243
|
+
|
|
244
|
+
var body: some View {
|
|
245
|
+
Group {
|
|
246
|
+
if model.tabs.isEmpty {
|
|
247
|
+
NativeUIVerticalBarsPage(model: model)
|
|
248
|
+
} else {
|
|
249
|
+
TabView(selection: Binding(get: { model.selection }, set: { model.select($0) })) {
|
|
250
|
+
ForEach(model.tabs) { item in
|
|
251
|
+
NativeUIVerticalBarsPage(model: model)
|
|
252
|
+
// Keep toolbar and search accents independent of the tab selection.
|
|
253
|
+
.tint(nil)
|
|
254
|
+
.tag(item.id)
|
|
255
|
+
.tabItem { NativeUIVerticalBarsLabel(item: item) }
|
|
256
|
+
.modifier(NativeUIVerticalBarsBadge(badge: item.badge))
|
|
257
|
+
.disabled(item.disabled)
|
|
258
|
+
.accessibilityLabel(item.accessibilityLabel)
|
|
259
|
+
.accessibilityIdentifier(item.id)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
.tint(model.tabTint.map { Color(uiColor: NativeUIRendering.color($0)) })
|
|
263
|
+
.modifier(NativeUIExtensionAccessory(content: model.accessory))
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
.modifier(NativeUIVerticalBarsSearch(model: model))
|
|
267
|
+
.modifier(NativeUIVerticalBarsCompression())
|
|
268
|
+
.background(Color.clear)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
@available(iOS 26.0, *)
|
|
273
|
+
private struct NativeUIVerticalBarsPage: View {
|
|
274
|
+
@ObservedObject var model: NativeUIVerticalBarsModel
|
|
275
|
+
|
|
276
|
+
var body: some View {
|
|
277
|
+
NavigationStack(path: .constant(Array(model.navigation.dropFirst()))) {
|
|
278
|
+
page(model.navigation.first)
|
|
279
|
+
.navigationDestination(for: String.self) { page($0) }
|
|
280
|
+
}
|
|
281
|
+
.id(model.navigation.first)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private func page(_ id: String?) -> some View {
|
|
285
|
+
NativeUIVerticalBarsContent().modifier(NativeUIVerticalBarsToolbarAdapter(model: model, page: id))
|
|
286
|
+
.navigationBarBackButtonHidden(true)
|
|
287
|
+
.toolbar {
|
|
288
|
+
if model.search?.configuration.available == true {
|
|
289
|
+
DefaultToolbarItem(kind: .search, placement: .bottomBar)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Only this library-owned empty page suppresses UIKit's content slide.
|
|
296
|
+
// App-local extension views may contain their own, fully visible navigation stacks.
|
|
297
|
+
@available(iOS 26.0, *)
|
|
298
|
+
private struct NativeUIVerticalBarsContent: UIViewControllerRepresentable {
|
|
299
|
+
final class Controller: UIViewController {
|
|
300
|
+
override func viewDidLayoutSubviews() {
|
|
301
|
+
super.viewDidLayoutSubviews()
|
|
302
|
+
suppressContentTransition()
|
|
303
|
+
}
|
|
304
|
+
override func viewWillAppear(_ animated: Bool) {
|
|
305
|
+
super.viewWillAppear(animated)
|
|
306
|
+
suppressContentTransition()
|
|
307
|
+
}
|
|
308
|
+
override func viewWillDisappear(_ animated: Bool) {
|
|
309
|
+
super.viewWillDisappear(animated)
|
|
310
|
+
suppressContentTransition()
|
|
311
|
+
}
|
|
312
|
+
private func suppressContentTransition() {
|
|
313
|
+
guard let transition = transitionCoordinator,
|
|
314
|
+
transition.presentationStyle == .none,
|
|
315
|
+
!transition.containerView.isHidden else { return }
|
|
316
|
+
let content = transition.containerView
|
|
317
|
+
content.isHidden = true
|
|
318
|
+
if !transition.animate(alongsideTransition: nil, completion: { _ in content.isHidden = false }) {
|
|
319
|
+
content.isHidden = false
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
func makeUIViewController(context: Context) -> Controller {
|
|
324
|
+
let controller = Controller()
|
|
325
|
+
controller.view.backgroundColor = .clear
|
|
326
|
+
return controller
|
|
327
|
+
}
|
|
328
|
+
func updateUIViewController(_ controller: Controller, context: Context) {}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
@available(iOS 26.0, *)
|
|
332
|
+
private struct NativeUIVerticalBarsSearch: ViewModifier {
|
|
333
|
+
@ObservedObject var model: NativeUIVerticalBarsModel
|
|
334
|
+
@ViewBuilder func body(content: Content) -> some View {
|
|
335
|
+
if let search = model.search {
|
|
336
|
+
content.modifier(NativeUIVerticalSearchModifier(model: search))
|
|
337
|
+
} else { content }
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
@available(iOS 26.0, *)
|
|
342
|
+
private struct NativeUIVerticalBarsCompression: ViewModifier {
|
|
343
|
+
@ViewBuilder func body(content: Content) -> some View {
|
|
344
|
+
#if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
|
|
345
|
+
if #available(iOS 27.1, *) {
|
|
346
|
+
content.toolbarVerticalCompressionBehavior(.prefersToolbarItems)
|
|
347
|
+
} else {
|
|
348
|
+
content
|
|
349
|
+
}
|
|
350
|
+
#else
|
|
351
|
+
content
|
|
352
|
+
#endif
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
@available(iOS 26.0, *)
|
|
357
|
+
private struct NativeUIVerticalBarsToolbarAdapter: ViewModifier {
|
|
358
|
+
@ObservedObject var model: NativeUIVerticalBarsModel
|
|
359
|
+
let page: String?
|
|
360
|
+
|
|
361
|
+
@ViewBuilder func body(content: Content) -> some View {
|
|
362
|
+
#if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
|
|
363
|
+
if #available(iOS 27.1, *) {
|
|
364
|
+
content.modifier(NativeUIVerticalBarsToolbar(model: model, page: page))
|
|
365
|
+
} else {
|
|
366
|
+
content.modifier(NativeUIVerticalBarsLegacyToolbar(model: model, page: page))
|
|
367
|
+
}
|
|
368
|
+
#else
|
|
369
|
+
content.modifier(NativeUIVerticalBarsLegacyToolbar(model: model, page: page))
|
|
370
|
+
#endif
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
#if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
|
|
375
|
+
@available(iOS 27.1, *)
|
|
376
|
+
private struct NativeUIVerticalBarsToolbar: ViewModifier {
|
|
377
|
+
@ObservedObject var model: NativeUIVerticalBarsModel
|
|
378
|
+
let page: String?
|
|
379
|
+
|
|
380
|
+
func body(content: Content) -> some View {
|
|
381
|
+
content.toolbar {
|
|
382
|
+
ForEach(model.groups(on: page).filter { $0.slot == .start }, id: \.transitionID) { group in
|
|
383
|
+
if group.id != model.groups(on: page).first(where: { $0.slot == .start })?.id {
|
|
384
|
+
ToolbarSpacer(.fixed, placement: .topBarLeading)
|
|
385
|
+
}
|
|
386
|
+
ForEach(group.items, id: \.transitionID) { item in
|
|
387
|
+
NativeUIVerticalBarsButton(model: model, page: page, groupID: group.transitionID, id: item.transitionID, placement: .topBarLeading)
|
|
388
|
+
.axisBehavior(.verticalPreferred)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
ForEach(model.groups(on: page).filter { $0.slot != .start }, id: \.transitionID) { group in
|
|
392
|
+
if group.id != model.groups(on: page).first(where: { $0.slot != .start })?.id {
|
|
393
|
+
ToolbarSpacer(.fixed, placement: .topBarTrailing)
|
|
394
|
+
}
|
|
395
|
+
ForEach(group.items, id: \.transitionID) { item in
|
|
396
|
+
NativeUIVerticalBarsButton(model: model, page: page, groupID: group.transitionID, id: item.transitionID, placement: .topBarTrailing)
|
|
397
|
+
.axisBehavior(.verticalPreferred)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
#endif
|
|
404
|
+
|
|
405
|
+
@available(iOS 26.0, *)
|
|
406
|
+
private struct NativeUIVerticalBarsLegacyToolbar: ViewModifier {
|
|
407
|
+
@ObservedObject var model: NativeUIVerticalBarsModel
|
|
408
|
+
let page: String?
|
|
409
|
+
|
|
410
|
+
func body(content: Content) -> some View {
|
|
411
|
+
#if canImport(SwiftUI, _version: 8.0)
|
|
412
|
+
content.toolbar {
|
|
413
|
+
ForEach(model.groups(on: page), id: \.transitionID) { group in
|
|
414
|
+
if group.id != model.groups(on: page).first(where: { $0.slot == group.slot })?.id {
|
|
415
|
+
ToolbarSpacer(.fixed, placement: group.slot == .start ? .topBarLeading : .topBarTrailing)
|
|
416
|
+
}
|
|
417
|
+
ForEach(group.items, id: \.transitionID) { item in
|
|
418
|
+
NativeUIVerticalBarsButton(model: model, page: page, groupID: group.transitionID, id: item.transitionID, placement: group.slot == .start ? .topBarLeading : .topBarTrailing)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
#else
|
|
423
|
+
// Older SDKs cannot use ForEach as ToolbarContent; attach each item separately.
|
|
424
|
+
let base = AnyView(content)
|
|
425
|
+
// Outer toolbar modifiers precede inner ones, so attach in reverse order.
|
|
426
|
+
return model.groups(on: page).reversed().reduce(base) { view, group in
|
|
427
|
+
let items = group.items.reversed().reduce(view) { view, item in
|
|
428
|
+
AnyView(view.toolbar {
|
|
429
|
+
NativeUIVerticalBarsButton(model: model, page: page, groupID: group.transitionID, id: item.transitionID, placement: group.slot == .start ? .topBarLeading : .topBarTrailing)
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
return group.id == model.groups(on: page).first(where: { $0.slot == group.slot })?.id ? items : AnyView(items.toolbar {
|
|
433
|
+
ToolbarSpacer(.fixed, placement: group.slot == .start ? .topBarLeading : .topBarTrailing)
|
|
434
|
+
})
|
|
435
|
+
}
|
|
436
|
+
#endif
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
@available(iOS 26.0, *)
|
|
441
|
+
final class NativeUIVerticalBarsController: NativeUIVerticalBarsControlling {
|
|
442
|
+
private final class TransparentHostingController<Content: View>: UIHostingController<Content> {
|
|
443
|
+
var railEdge = "right"
|
|
444
|
+
var layoutSearch: (() -> Void)?
|
|
445
|
+
var layoutTabs: (() -> Void)?
|
|
446
|
+
override func viewDidLayoutSubviews() {
|
|
447
|
+
super.viewDidLayoutSubviews()
|
|
448
|
+
makeFullSizeSurfacesTransparent(in: view)
|
|
449
|
+
layoutSearch?()
|
|
450
|
+
layoutTabs?()
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private func makeFullSizeSurfacesTransparent(in surface: UIView) {
|
|
454
|
+
let frame = surface.convert(surface.bounds, to: view)
|
|
455
|
+
guard !(surface is UIVisualEffectView) else { return }
|
|
456
|
+
let coversHost = frame.insetBy(dx: -1, dy: -1).contains(view.bounds)
|
|
457
|
+
// SwiftUI may add an opaque backing behind the rail controls, sized to
|
|
458
|
+
// the rail or to an expanded tab bar. Keep that backing clear without
|
|
459
|
+
// touching the glass controls or materials.
|
|
460
|
+
let coversRail = frame.minY <= 1 && frame.maxY >= view.bounds.maxY - 1 &&
|
|
461
|
+
(railEdge == "left" ? frame.minX <= 1 : frame.maxX >= view.bounds.maxX - 1)
|
|
462
|
+
if coversHost || coversRail {
|
|
463
|
+
surface.backgroundColor = .clear
|
|
464
|
+
surface.isOpaque = false
|
|
465
|
+
}
|
|
466
|
+
surface.subviews.forEach(makeFullSizeSurfacesTransparent)
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private final class RailContainer: UIView {
|
|
471
|
+
var railEdge = "right"
|
|
472
|
+
|
|
473
|
+
private var railWidth: CGFloat {
|
|
474
|
+
let inset = railEdge == "left" ? safeAreaInsets.left : safeAreaInsets.right
|
|
475
|
+
return inset > 0 ? inset : 80
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// The rail is not masked: expanded rail content (a widened tab bar) is
|
|
479
|
+
// allowed to draw over the WebView. Inside the base rail touches behave
|
|
480
|
+
// as before; beyond it touches are captured only where they land inside
|
|
481
|
+
// an actual bar surface so empty overlap still belongs to the WebView.
|
|
482
|
+
// The SwiftUI hosting scaffold reports a full-size hosting view for
|
|
483
|
+
// every point, so the hit result cannot tell bar content from empty
|
|
484
|
+
// space — the bar frames decide instead.
|
|
485
|
+
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
486
|
+
let hit = super.hitTest(point, with: event)
|
|
487
|
+
let inRail = railEdge == "left" ? point.x <= railWidth : point.x >= bounds.maxX - railWidth
|
|
488
|
+
if inRail { return hit }
|
|
489
|
+
// Expanded system search leaves the rail; its field and close button
|
|
490
|
+
// must receive touches instead of passing them through to the WebView.
|
|
491
|
+
var ancestor = hit
|
|
492
|
+
while let view = ancestor, view !== self {
|
|
493
|
+
if view is UIControl { return hit }
|
|
494
|
+
ancestor = view.superview
|
|
495
|
+
}
|
|
496
|
+
guard hit != nil, containsBarSurface(at: point) else { return nil }
|
|
497
|
+
return hit
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private func containsBarSurface(at point: CGPoint) -> Bool {
|
|
501
|
+
containsBarSurface(in: self, at: point)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private func containsBarSurface(in view: UIView, at point: CGPoint) -> Bool {
|
|
505
|
+
guard !view.isHidden, view.alpha > 0.05 else { return false }
|
|
506
|
+
let name = NSStringFromClass(type(of: view))
|
|
507
|
+
if name.contains("TabBar") || name.contains("Platter") || name.contains("Pocket") || name.contains("Sidebar") || view is UISearchBar || view is UINavigationBar,
|
|
508
|
+
convert(view.bounds, from: view).contains(point) { return true }
|
|
509
|
+
return view.subviews.contains { containsBarSurface(in: $0, at: point) }
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private let model = NativeUIVerticalBarsModel()
|
|
514
|
+
private lazy var controller = TransparentHostingController(rootView: NativeUIVerticalBarsView(model: model))
|
|
515
|
+
private let container = RailContainer()
|
|
516
|
+
private weak var owner: UIViewController?
|
|
517
|
+
private let changed: (String, NativeUISearchPhase, String, Bool, Int) -> Int
|
|
518
|
+
|
|
519
|
+
var view: UIView { container }
|
|
520
|
+
func measuredFrames(relativeTo webView: UIView) -> [String: (frame: CGRect, cornerRadius: CGFloat)] {
|
|
521
|
+
var frames: [String: (frame: CGRect, cornerRadius: CGFloat)] = [:]
|
|
522
|
+
let labels = Dictionary(grouping: model.tabs, by: \.accessibilityLabel)
|
|
523
|
+
var tabViews: [UIView] = []
|
|
524
|
+
func visit(_ view: UIView) {
|
|
525
|
+
guard !view.isHidden else { return }
|
|
526
|
+
if let control = view as? UIControl,
|
|
527
|
+
let label = control.accessibilityLabel, let items = labels[label], items.count == 1,
|
|
528
|
+
!control.isHidden, control.bounds.width > 0, control.bounds.height > 0 {
|
|
529
|
+
if frames[items[0].id] != nil { return }
|
|
530
|
+
frames[items[0].id] = (control.convert(control.bounds, to: webView), control.effectiveRadius(corner: .allCorners))
|
|
531
|
+
tabViews.append(control)
|
|
532
|
+
}
|
|
533
|
+
view.subviews.forEach(visit)
|
|
534
|
+
}
|
|
535
|
+
visit(container)
|
|
536
|
+
if let first = tabViews.first, tabViews.count >= model.tabs.count {
|
|
537
|
+
var ancestor: UIView? = first.superview
|
|
538
|
+
var platter: UIView?
|
|
539
|
+
var cornerRadius: CGFloat = 0
|
|
540
|
+
while let current = ancestor, current !== container {
|
|
541
|
+
if current.bounds.width >= webView.bounds.width * 0.5 { break }
|
|
542
|
+
if current.bounds.width > 0, current.bounds.height > 0,
|
|
543
|
+
tabViews.allSatisfy({ $0.isDescendant(of: current) }) {
|
|
544
|
+
platter = current
|
|
545
|
+
// Layout wrappers can enclose the shaped surface without owning corners.
|
|
546
|
+
let radius = current.effectiveRadius(corner: .allCorners)
|
|
547
|
+
if radius > 0 { cornerRadius = radius }
|
|
548
|
+
}
|
|
549
|
+
ancestor = current.superview
|
|
550
|
+
}
|
|
551
|
+
if let platter { frames["__tabBar"] = (platter.convert(platter.bounds, to: webView), cornerRadius) }
|
|
552
|
+
}
|
|
553
|
+
return frames
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
var ownsKeyboardChrome: Bool { model.search?.ownsKeyboardChrome == true }
|
|
557
|
+
|
|
558
|
+
init(activate: @escaping (String) -> Void,
|
|
559
|
+
changed: @escaping (String, NativeUISearchPhase, String, Bool, Int) -> Int) {
|
|
560
|
+
self.changed = changed
|
|
561
|
+
model.activate = activate
|
|
562
|
+
container.backgroundColor = .clear
|
|
563
|
+
controller.view.backgroundColor = .clear
|
|
564
|
+
controller.view.isOpaque = false
|
|
565
|
+
controller.layoutSearch = { [weak self] in self?.model.search?.configureField() }
|
|
566
|
+
controller.layoutTabs = { [weak self] in self?.applyTabTint() }
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private weak var tintedTabBar: UITabBar?
|
|
570
|
+
private var appliedTabTint: String?
|
|
571
|
+
|
|
572
|
+
private func applyTabTint() {
|
|
573
|
+
let tint = model.tabTint.map { NativeUIRendering.color($0) }
|
|
574
|
+
func visit(_ owner: UIViewController) {
|
|
575
|
+
if let tabs = owner as? UITabBarController {
|
|
576
|
+
// Scope the accent to tabs, without recoloring toolbars or search.
|
|
577
|
+
if tintedTabBar !== tabs.tabBar || appliedTabTint != model.tabTint || (tint != nil && tabs.tabBar.tintColor != tint) {
|
|
578
|
+
tabs.tabBar.tintColor = tint
|
|
579
|
+
tintedTabBar = tabs.tabBar
|
|
580
|
+
appliedTabTint = model.tabTint
|
|
581
|
+
}
|
|
582
|
+
return
|
|
583
|
+
}
|
|
584
|
+
owner.children.forEach(visit)
|
|
585
|
+
}
|
|
586
|
+
visit(controller)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
@MainActor func attach(to owner: UIViewController, in parent: UIView) {
|
|
590
|
+
guard self.owner !== owner || container.superview !== parent else { return }
|
|
591
|
+
detach()
|
|
592
|
+
self.owner = owner
|
|
593
|
+
owner.addChild(controller)
|
|
594
|
+
container.frame = parent.bounds
|
|
595
|
+
container.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
596
|
+
controller.view.frame = container.bounds
|
|
597
|
+
controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
598
|
+
container.addSubview(controller.view)
|
|
599
|
+
parent.addSubview(container)
|
|
600
|
+
controller.didMove(toParent: owner)
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
@MainActor func apply(
|
|
604
|
+
_ controls: [NativeUIControl], rendering: NativeUIControlRendering,
|
|
605
|
+
edge: String, animated: Bool = true, accessory: AnyView? = nil, navigation: [String] = [],
|
|
606
|
+
navigationPhase: String? = nil
|
|
607
|
+
) {
|
|
608
|
+
// Native vertical toolbars retain their source throughout a back swipe.
|
|
609
|
+
// Begin when the finger commits; keep input locked until Web settles.
|
|
610
|
+
container.isUserInteractionEnabled = navigationPhase == nil
|
|
611
|
+
guard navigationPhase != "tracking" else { return }
|
|
612
|
+
container.railEdge = edge
|
|
613
|
+
controller.railEdge = edge
|
|
614
|
+
// A presentation identity can survive source replacement, but an open
|
|
615
|
+
// popover still belongs to its original action. Retire it before the
|
|
616
|
+
// toolbar binding starts observing the replacement item's action ID.
|
|
617
|
+
if let anchor = model.popoverAnchor,
|
|
618
|
+
!controls.contains(where: { [.button, .buttons, .menuButton].contains($0.kind) && $0.items.contains(where: { $0.id == anchor }) }) {
|
|
619
|
+
model.popoverHost?.dismissAnchored?()
|
|
620
|
+
model.popoverAnchor = nil
|
|
621
|
+
}
|
|
622
|
+
if let search = controls.first(where: { $0.search != nil })?.search {
|
|
623
|
+
if model.search?.configuration.id == search.id { model.search?.apply(search) } else { model.search = NativeUIVerticalSearchModel(search,
|
|
624
|
+
in: container, activate: model.activate, changed: changed) }
|
|
625
|
+
} else if model.search != nil { model.search = nil }
|
|
626
|
+
if animated {
|
|
627
|
+
model.apply(controls, rendering: rendering, animated: true, accessory: accessory, navigation: navigation)
|
|
628
|
+
} else {
|
|
629
|
+
// Resolve UIKit layout in the same unanimated handoff as SwiftUI.
|
|
630
|
+
UIView.performWithoutAnimation {
|
|
631
|
+
model.apply(controls, rendering: rendering, animated: false, accessory: accessory, navigation: navigation)
|
|
632
|
+
controller.view.setNeedsLayout()
|
|
633
|
+
controller.view.layoutIfNeeded()
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
controller.overrideUserInterfaceStyle = controls.contains(where: \.dark) ? .dark : .light
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
func presentPopover(anchor: String, host: NativeUIOverlayHost, completion: @escaping (Bool) -> Void) -> Bool {
|
|
640
|
+
guard container.window != nil, model.popoverAnchor == nil,
|
|
641
|
+
model.groups.flatMap(\.items).contains(where: { $0.id == anchor }) else { return false }
|
|
642
|
+
model.popoverHost = host
|
|
643
|
+
model.popoverPresented = completion
|
|
644
|
+
host.anchoredVisible = false
|
|
645
|
+
host.disappeared = false
|
|
646
|
+
host.cancelAnchored = { [weak self] in
|
|
647
|
+
// Native presentation callbacks and their deadline run on the main queue.
|
|
648
|
+
MainActor.assumeIsolated { self?.detach() }
|
|
649
|
+
}
|
|
650
|
+
host.dismissAnchored = { [weak self, weak host] in
|
|
651
|
+
guard let host else { return }
|
|
652
|
+
guard let self, self.model.popoverHost === host else {
|
|
653
|
+
host.completeDismissal()
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
// A backdrop tap clears the binding before SwiftUI finishes closing.
|
|
657
|
+
// Keep the live content and completion until onDisappear, including
|
|
658
|
+
// when dismissal has already started through the system binding.
|
|
659
|
+
if self.model.popoverAnchor != nil {
|
|
660
|
+
var transaction = Transaction()
|
|
661
|
+
transaction.disablesAnimations = host.options?["animated"] as? Bool == false
|
|
662
|
+
withTransaction(transaction) { self.model.popoverAnchor = nil }
|
|
663
|
+
}
|
|
664
|
+
// Before onAppear there will be no onDisappear to finish cleanup.
|
|
665
|
+
if !host.anchoredVisible {
|
|
666
|
+
let completion = self.model.popoverPresented
|
|
667
|
+
self.model.popoverPresented = nil
|
|
668
|
+
self.model.popoverHost = nil
|
|
669
|
+
completion?(false)
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
var transaction = Transaction()
|
|
673
|
+
transaction.disablesAnimations = host.options?["animated"] as? Bool == false
|
|
674
|
+
withTransaction(transaction) { model.popoverAnchor = anchor }
|
|
675
|
+
return true
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
@MainActor func detach(duration: TimeInterval = 0) {
|
|
679
|
+
let host = model.popoverHost
|
|
680
|
+
let presented = model.popoverPresented
|
|
681
|
+
model.popoverPresented = nil
|
|
682
|
+
model.popoverAnchor = nil
|
|
683
|
+
model.popoverHost = nil
|
|
684
|
+
if controller.parent != nil {
|
|
685
|
+
controller.willMove(toParent: nil)
|
|
686
|
+
NativeUICrossfade.retire(container, duration: duration)
|
|
687
|
+
controller.view.removeFromSuperview()
|
|
688
|
+
controller.removeFromParent()
|
|
689
|
+
}
|
|
690
|
+
owner = nil
|
|
691
|
+
host?.dismissAnchored = nil
|
|
692
|
+
host?.cancelAnchored = nil
|
|
693
|
+
presented?(false)
|
|
694
|
+
host?.completeDismissal()
|
|
695
|
+
}
|
|
696
|
+
}
|