@rdlabo/capacitor-native-ui 0.0.0-stage → 2.0.0-1
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/LICENSE +21 -0
- package/Package.swift +23 -0
- package/README.md +66 -2
- package/RdlaboCapacitorNativeUi.podspec +17 -0
- package/android/build.gradle +69 -0
- package/android/proguard-rules.pro +0 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUI.kt +51 -0
- package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUIPlugin.kt +192 -0
- package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUISnapshot.kt +57 -0
- package/dist/_virtual/_rolldown/runtime.js +13 -0
- package/dist/core.d.ts +4 -0
- package/dist/core.js +3 -0
- package/dist/native/activation.d.ts +14 -0
- package/dist/native/activation.d.ts.map +1 -0
- package/dist/native/activation.js +305 -0
- package/dist/native/activation.js.map +1 -0
- package/dist/native/components/index.d.ts +19 -0
- package/dist/native/components/index.d.ts.map +1 -0
- package/dist/native/components/index.js +38 -0
- package/dist/native/components/index.js.map +1 -0
- package/dist/native/components/ion-back-button.d.ts +10 -0
- package/dist/native/components/ion-back-button.d.ts.map +1 -0
- package/dist/native/components/ion-back-button.js +21 -0
- package/dist/native/components/ion-back-button.js.map +1 -0
- package/dist/native/components/ion-button.d.ts +10 -0
- package/dist/native/components/ion-button.d.ts.map +1 -0
- package/dist/native/components/ion-button.js +25 -0
- package/dist/native/components/ion-button.js.map +1 -0
- package/dist/native/components/ion-buttons.d.ts +11 -0
- package/dist/native/components/ion-buttons.d.ts.map +1 -0
- package/dist/native/components/ion-buttons.js +30 -0
- package/dist/native/components/ion-buttons.js.map +1 -0
- package/dist/native/components/ion-fab.d.ts +10 -0
- package/dist/native/components/ion-fab.d.ts.map +1 -0
- package/dist/native/components/ion-fab.js +132 -0
- package/dist/native/components/ion-fab.js.map +1 -0
- package/dist/native/components/ion-menu-button.d.ts +13 -0
- package/dist/native/components/ion-menu-button.d.ts.map +1 -0
- package/dist/native/components/ion-menu-button.js +44 -0
- package/dist/native/components/ion-menu-button.js.map +1 -0
- package/dist/native/components/ion-segment.d.ts +11 -0
- package/dist/native/components/ion-segment.d.ts.map +1 -0
- package/dist/native/components/ion-segment.js +30 -0
- package/dist/native/components/ion-segment.js.map +1 -0
- package/dist/native/components/ion-tab-bar.d.ts +11 -0
- package/dist/native/components/ion-tab-bar.d.ts.map +1 -0
- package/dist/native/components/ion-tab-bar.js +50 -0
- package/dist/native/components/ion-tab-bar.js.map +1 -0
- package/dist/native/components/searchable-tabs.d.ts +20 -0
- package/dist/native/components/searchable-tabs.d.ts.map +1 -0
- package/dist/native/components/searchable-tabs.js +413 -0
- package/dist/native/components/searchable-tabs.js.map +1 -0
- package/dist/native/definitions.d.ts +237 -0
- package/dist/native/definitions.d.ts.map +1 -0
- package/dist/native/definitions.js +0 -0
- package/dist/native/extensions.d.ts +31 -0
- package/dist/native/extensions.d.ts.map +1 -0
- package/dist/native/extensions.js +122 -0
- package/dist/native/extensions.js.map +1 -0
- package/dist/native/foldable/fold-state.d.ts +5 -0
- package/dist/native/foldable/fold-state.d.ts.map +1 -0
- package/dist/native/foldable/fold-state.js +15 -0
- package/dist/native/foldable/fold-state.js.map +1 -0
- package/dist/native/foldable/types.d.ts +12 -0
- package/dist/native/foldable/types.d.ts.map +1 -0
- package/dist/native/foldable/types.js +0 -0
- package/dist/native/html.d.ts +9 -0
- package/dist/native/html.d.ts.map +1 -0
- package/dist/native/html.js +197 -0
- package/dist/native/html.js.map +1 -0
- package/dist/native/index.d.ts +10 -0
- package/dist/native/index.d.ts.map +1 -0
- package/dist/native/index.js +19 -0
- package/dist/native/index.js.map +1 -0
- package/dist/native/legacy-layout.d.ts +5 -0
- package/dist/native/legacy-layout.d.ts.map +1 -0
- package/dist/native/legacy-layout.js +27 -0
- package/dist/native/legacy-layout.js.map +1 -0
- package/dist/native/overlays/content.d.ts +4 -0
- package/dist/native/overlays/content.d.ts.map +1 -0
- package/dist/native/overlays/content.js +51 -0
- package/dist/native/overlays/content.js.map +1 -0
- package/dist/native/overlays/controller.d.ts +8 -0
- package/dist/native/overlays/controller.d.ts.map +1 -0
- package/dist/native/overlays/controller.js +285 -0
- package/dist/native/overlays/controller.js.map +1 -0
- package/dist/native/overlays/dialog-animation.d.ts +9 -0
- package/dist/native/overlays/dialog-animation.d.ts.map +1 -0
- package/dist/native/overlays/dialog-animation.js +53 -0
- package/dist/native/overlays/dialog-animation.js.map +1 -0
- package/dist/native/overlays/dismiss-animation.d.ts +4 -0
- package/dist/native/overlays/dismiss-animation.d.ts.map +1 -0
- package/dist/native/overlays/dismiss-animation.js +18 -0
- package/dist/native/overlays/dismiss-animation.js.map +1 -0
- package/dist/native/overlays/focus.d.ts +5 -0
- package/dist/native/overlays/focus.d.ts.map +1 -0
- package/dist/native/overlays/focus.js +59 -0
- package/dist/native/overlays/focus.js.map +1 -0
- package/dist/native/overlays/popover.d.ts +12 -0
- package/dist/native/overlays/popover.d.ts.map +1 -0
- package/dist/native/overlays/popover.js +110 -0
- package/dist/native/overlays/popover.js.map +1 -0
- package/dist/native/overlays/styles.d.ts +11 -0
- package/dist/native/overlays/styles.d.ts.map +1 -0
- package/dist/native/overlays/styles.js +167 -0
- package/dist/native/overlays/styles.js.map +1 -0
- package/dist/native/prehide.d.ts +8 -0
- package/dist/native/prehide.d.ts.map +1 -0
- package/dist/native/prehide.js +238 -0
- package/dist/native/prehide.js.map +1 -0
- package/dist/native/runtime.d.ts +7 -0
- package/dist/native/runtime.d.ts.map +1 -0
- package/dist/native/runtime.js +776 -0
- package/dist/native/runtime.js.map +1 -0
- package/dist/native/shared/bridge.d.ts +6 -0
- package/dist/native/shared/bridge.d.ts.map +1 -0
- package/dist/native/shared/bridge.js +18 -0
- package/dist/native/shared/bridge.js.map +1 -0
- package/dist/native/shared/candidate.d.ts +25 -0
- package/dist/native/shared/candidate.d.ts.map +1 -0
- package/dist/native/shared/candidate.js +106 -0
- package/dist/native/shared/candidate.js.map +1 -0
- package/dist/native/shared/clipping.d.ts +5 -0
- package/dist/native/shared/clipping.d.ts.map +1 -0
- package/dist/native/shared/clipping.js +26 -0
- package/dist/native/shared/clipping.js.map +1 -0
- package/dist/native/shared/control-options.d.ts +6 -0
- package/dist/native/shared/control-options.d.ts.map +1 -0
- package/dist/native/shared/control-options.js +17 -0
- package/dist/native/shared/control-options.js.map +1 -0
- package/dist/native/shared/crossfade.d.ts +12 -0
- package/dist/native/shared/crossfade.d.ts.map +1 -0
- package/dist/native/shared/crossfade.js +67 -0
- package/dist/native/shared/crossfade.js.map +1 -0
- package/dist/native/shared/diagnostics.d.ts +4 -0
- package/dist/native/shared/diagnostics.d.ts.map +1 -0
- package/dist/native/shared/diagnostics.js +18 -0
- package/dist/native/shared/diagnostics.js.map +1 -0
- package/dist/native/shared/dom.d.ts +46 -0
- package/dist/native/shared/dom.d.ts.map +1 -0
- package/dist/native/shared/dom.js +180 -0
- package/dist/native/shared/dom.js.map +1 -0
- package/dist/native/shared/events.d.ts +15 -0
- package/dist/native/shared/events.d.ts.map +1 -0
- package/dist/native/shared/events.js +17 -0
- package/dist/native/shared/events.js.map +1 -0
- package/dist/native/shared/icons.d.ts +8 -0
- package/dist/native/shared/icons.d.ts.map +1 -0
- package/dist/native/shared/icons.js +61 -0
- package/dist/native/shared/icons.js.map +1 -0
- package/dist/native/shared/modal.d.ts +10 -0
- package/dist/native/shared/modal.d.ts.map +1 -0
- package/dist/native/shared/modal.js +160 -0
- package/dist/native/shared/modal.js.map +1 -0
- package/dist/native/shared/projection-id.d.ts +4 -0
- package/dist/native/shared/projection-id.d.ts.map +1 -0
- package/dist/native/shared/projection-id.js +7 -0
- package/dist/native/shared/projection-id.js.map +1 -0
- package/dist/native/shared/toolbar-options.d.ts +13 -0
- package/dist/native/shared/toolbar-options.d.ts.map +1 -0
- package/dist/native/shared/toolbar-options.js +28 -0
- package/dist/native/shared/toolbar-options.js.map +1 -0
- package/dist/native/shared/vertical-bars-geometry.d.ts +10 -0
- package/dist/native/shared/vertical-bars-geometry.d.ts.map +1 -0
- package/dist/native/shared/vertical-bars-geometry.js +85 -0
- package/dist/native/shared/vertical-bars-geometry.js.map +1 -0
- package/dist/native/startup.d.ts +8 -0
- package/dist/native/startup.d.ts.map +1 -0
- package/dist/native/startup.js +70 -0
- package/dist/native/startup.js.map +1 -0
- package/dist/native/vertical-bars-web-search.d.ts +10 -0
- package/dist/native/vertical-bars-web-search.d.ts.map +1 -0
- package/dist/native/vertical-bars-web-search.js +90 -0
- package/dist/native/vertical-bars-web-search.js.map +1 -0
- package/dist/native/vertical-bars-web.d.ts +5 -0
- package/dist/native/vertical-bars-web.d.ts.map +1 -0
- package/dist/native/vertical-bars-web.js +422 -0
- package/dist/native/vertical-bars-web.js.map +1 -0
- package/dist/native-integration/index.d.ts +25 -0
- package/dist/native-integration/index.d.ts.map +1 -0
- package/dist/native-integration/index.js +33 -0
- package/dist/native-integration/index.js.map +1 -0
- package/dist/vertical-bars.d.ts +6 -0
- package/dist/vertical-bars.js +5 -0
- package/docs/appearance.md +45 -0
- package/docs/configuration.md +219 -0
- package/docs/framework-independent.md +107 -0
- package/docs/ionic-vertical-bars.md +173 -0
- package/docs/migration.md +184 -0
- package/docs/native-ui-extensions.md +394 -0
- package/docs/native-ui.md +528 -0
- package/docs/setup.md +224 -0
- package/docs/vertical-bars.md +431 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUIButton.swift +71 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUIButtons.swift +24 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUIComponents.swift +19 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUIFab.swift +80 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUISearchController.swift +439 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUISegment.swift +91 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUITabBar.swift +218 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift +590 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalSearch.swift +118 -0
- package/ios/Sources/NativeUIPlugin/Extensions/NativeUI.swift +178 -0
- package/ios/Sources/NativeUIPlugin/Extensions/NativeUIRendering.swift +38 -0
- package/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift +615 -0
- package/ios/Sources/NativeUIPlugin/Overlays/NativeUIOverlayController.swift +119 -0
- package/ios/Sources/NativeUIPlugin/Overlays/NativeUIOverlayHost.swift +75 -0
- package/ios/Sources/NativeUIPlugin/Shared/NativeUIControlRendering.swift +21 -0
- package/ios/Sources/NativeUIPlugin/Shared/NativeUICrossfade.swift +35 -0
- package/ios/Sources/NativeUIPlugin/Shared/NativeUIHost.swift +8 -0
- package/ios/Sources/NativeUIPlugin/Shared/NativeUISnapshot.swift +161 -0
- package/ios/Sources/NativeUIPlugin/Shared/NativeUIVerticalBarsGeometry.swift +52 -0
- package/ios/Tests/NativeUIPluginTests/NativeUIExtensionTests.swift +165 -0
- package/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +805 -0
- package/package.json +89 -4
- package/src/core.ts +14 -0
- package/src/native/activation.ts +385 -0
- package/src/native/components/index.ts +45 -0
- package/src/native/components/ion-back-button.ts +22 -0
- package/src/native/components/ion-button.ts +25 -0
- package/src/native/components/ion-buttons.ts +37 -0
- package/src/native/components/ion-fab.ts +151 -0
- package/src/native/components/ion-menu-button.ts +37 -0
- package/src/native/components/ion-segment.ts +27 -0
- package/src/native/components/ion-tab-bar.ts +50 -0
- package/src/native/components/searchable-tabs.ts +499 -0
- package/src/native/definitions.ts +274 -0
- package/src/native/extensions.ts +176 -0
- package/src/native/foldable/fold-state.ts +10 -0
- package/src/native/foldable/types.ts +4 -0
- package/src/native/html.ts +199 -0
- package/src/native/index.ts +32 -0
- package/src/native/legacy-layout.ts +21 -0
- package/src/native/overlays/content.ts +51 -0
- package/src/native/overlays/controller.ts +349 -0
- package/src/native/overlays/dialog-animation.ts +53 -0
- package/src/native/overlays/dismiss-animation.ts +15 -0
- package/src/native/overlays/focus.ts +64 -0
- package/src/native/overlays/popover.ts +120 -0
- package/src/native/overlays/styles.ts +181 -0
- package/src/native/prehide.ts +285 -0
- package/src/native/runtime.ts +979 -0
- package/src/native/shared/bridge.ts +18 -0
- package/src/native/shared/candidate.ts +162 -0
- package/src/native/shared/clipping.ts +24 -0
- package/src/native/shared/control-options.ts +20 -0
- package/src/native/shared/crossfade.ts +67 -0
- package/src/native/shared/diagnostics.ts +13 -0
- package/src/native/shared/dom.ts +304 -0
- package/src/native/shared/events.ts +14 -0
- package/src/native/shared/icons.ts +57 -0
- package/src/native/shared/modal.ts +157 -0
- package/src/native/shared/projection-id.ts +2 -0
- package/src/native/shared/toolbar-options.ts +49 -0
- package/src/native/shared/vertical-bars-geometry.ts +80 -0
- package/src/native/startup.ts +68 -0
- package/src/native/vertical-bars-web-search.ts +86 -0
- package/src/native/vertical-bars-web.ts +500 -0
- package/src/native-integration/index.ts +49 -0
- package/src/vertical-bars.ts +12 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
enum NativeUIButton {
|
|
4
|
+
private final class BadgeLabel: UILabel {}
|
|
5
|
+
// These Ionic components share the same native UIButton presentation.
|
|
6
|
+
static let kinds: [NativeUIComponent] = [.button, .backButton, .menuButton]
|
|
7
|
+
|
|
8
|
+
@available(iOS 26.0, *)
|
|
9
|
+
static func make(_ node: NativeUIControl, rendering: NativeUIControlRendering, activate: @escaping (String) -> Void) -> UIButton? {
|
|
10
|
+
guard node.items.count == 1, let item = node.items.first else { return nil }
|
|
11
|
+
let control = render(item.content, glass: true, rendering: rendering, activate: activate)
|
|
12
|
+
control.semanticContentAttribute = node.rtl ? .forceRightToLeft : .forceLeftToRight
|
|
13
|
+
return control
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@available(iOS 26.0, *)
|
|
17
|
+
static func render(_ item: NativeUIItemContent, glass: Bool, existing: UIButton? = nil, rendering: NativeUIControlRendering, activate: @escaping (String) -> Void) -> UIButton {
|
|
18
|
+
var configuration: UIButton.Configuration = glass ? .glass() : .plain()
|
|
19
|
+
configuration.title = item.label
|
|
20
|
+
configuration.image = rendering.image(item)
|
|
21
|
+
configuration.imagePadding = item.imagePadding ?? 4
|
|
22
|
+
configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: item.contentInsetLeading ?? 4,
|
|
23
|
+
bottom: 0, trailing: item.contentInsetTrailing ?? 4)
|
|
24
|
+
configuration.imagePlacement = item.iconPosition == .trailing ? .trailing : .leading
|
|
25
|
+
configuration.baseForegroundColor = rendering.color(item.color)
|
|
26
|
+
configuration.titleLineBreakMode = .byTruncatingTail
|
|
27
|
+
let size = item.fontSize
|
|
28
|
+
let weight = item.fontWeight
|
|
29
|
+
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
|
30
|
+
var outgoing = incoming
|
|
31
|
+
outgoing.font = UIFont.systemFont(ofSize: size, weight: weight >= 600 ? .semibold : weight >= 500 ? .medium : .regular)
|
|
32
|
+
return outgoing
|
|
33
|
+
}
|
|
34
|
+
let button = existing ?? UIButton(configuration: configuration)
|
|
35
|
+
if existing != nil { button.configuration = configuration }
|
|
36
|
+
button.titleLabel?.numberOfLines = 1
|
|
37
|
+
// Web and UIKit can differ by a fraction of a point in text measurement.
|
|
38
|
+
// Absorb that rounding without widening the projected button.
|
|
39
|
+
button.titleLabel?.adjustsFontSizeToFitWidth = true
|
|
40
|
+
button.titleLabel?.minimumScaleFactor = 0.99
|
|
41
|
+
button.isEnabled = !item.disabled
|
|
42
|
+
button.accessibilityLabel = item.accessibilityLabel
|
|
43
|
+
button.accessibilityIdentifier = item.id
|
|
44
|
+
if item.selected { button.accessibilityTraits.insert(.selected) }
|
|
45
|
+
button.subviews.filter { $0 is BadgeLabel }.forEach { $0.removeFromSuperview() }
|
|
46
|
+
button.accessibilityValue = item.badge?.value
|
|
47
|
+
if let badge = item.badge {
|
|
48
|
+
let label = BadgeLabel()
|
|
49
|
+
label.text = badge.value
|
|
50
|
+
label.font = .systemFont(ofSize: 11, weight: .semibold)
|
|
51
|
+
label.textColor = rendering.color(badge.textColor)
|
|
52
|
+
label.backgroundColor = rendering.color(badge.color)
|
|
53
|
+
label.textAlignment = .center
|
|
54
|
+
label.layer.cornerRadius = 8
|
|
55
|
+
label.clipsToBounds = true
|
|
56
|
+
label.isAccessibilityElement = false
|
|
57
|
+
label.translatesAutoresizingMaskIntoConstraints = false
|
|
58
|
+
button.addSubview(label)
|
|
59
|
+
NSLayoutConstraint.activate([
|
|
60
|
+
label.centerXAnchor.constraint(equalTo: button.centerXAnchor, constant: 14),
|
|
61
|
+
label.topAnchor.constraint(equalTo: button.topAnchor, constant: 1),
|
|
62
|
+
label.widthAnchor.constraint(greaterThanOrEqualToConstant: max(16, label.intrinsicContentSize.width + 6)),
|
|
63
|
+
label.heightAnchor.constraint(equalToConstant: 16)
|
|
64
|
+
])
|
|
65
|
+
}
|
|
66
|
+
if existing == nil {
|
|
67
|
+
button.addAction(UIAction { _ in activate(item.id) }, for: .touchUpInside)
|
|
68
|
+
}
|
|
69
|
+
return button
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
enum NativeUIButtons {
|
|
4
|
+
static let kind = NativeUIComponent.buttons
|
|
5
|
+
|
|
6
|
+
@available(iOS 26.0, *)
|
|
7
|
+
static func make(_ node: NativeUIControl, scale: CGFloat, rendering: NativeUIControlRendering, activate: @escaping (String) -> Void) -> UIView {
|
|
8
|
+
let items = node.items
|
|
9
|
+
let rtl = node.rtl
|
|
10
|
+
let effect = UIGlassEffect(style: .regular)
|
|
11
|
+
let control = UIVisualEffectView(effect: effect)
|
|
12
|
+
control.layer.cornerRadius = node.frame.height * scale / 2
|
|
13
|
+
control.clipsToBounds = true
|
|
14
|
+
control.accessibilityIdentifier = node.id
|
|
15
|
+
for item in items {
|
|
16
|
+
let button = NativeUIButton.render(item.content, glass: false, rendering: rendering, activate: activate)
|
|
17
|
+
button.semanticContentAttribute = rtl ? .forceRightToLeft : .forceLeftToRight
|
|
18
|
+
let rect = item.frame.rect
|
|
19
|
+
button.frame = CGRect(x: rect.minX * scale, y: rect.minY * scale, width: rect.width * scale, height: rect.height * scale)
|
|
20
|
+
control.contentView.addSubview(button)
|
|
21
|
+
}
|
|
22
|
+
return control
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
// Static composition of supported wire kinds. Component files own their names and rendering.
|
|
4
|
+
enum NativeUIComponents {
|
|
5
|
+
static let supported = Set(NativeUIButton.kinds + [NativeUIButtons.kind, NativeUITabBar.kind, NativeUISegment.kind, NativeUIFab.kind])
|
|
6
|
+
|
|
7
|
+
@available(iOS 26.0, *)
|
|
8
|
+
static func make(_ node: NativeUIControl, scale: CGFloat, rendering: NativeUIControlRendering,
|
|
9
|
+
tabDelegate: UITabBarDelegate, activate: @escaping (String) -> Void) -> UIView? {
|
|
10
|
+
switch node.kind {
|
|
11
|
+
case .button, .backButton, .menuButton: return NativeUIButton.make(node, rendering: rendering, activate: activate)
|
|
12
|
+
case .buttons: return NativeUIButtons.make(node, scale: scale, rendering: rendering, activate: activate)
|
|
13
|
+
case .tabBar: return NativeUITabBar.make(node, rendering: rendering, delegate: tabDelegate)
|
|
14
|
+
case .segment: return NativeUISegment.make(node, scale: scale, rendering: rendering, activate: activate)
|
|
15
|
+
case .fab: return NativeUIFab()
|
|
16
|
+
case .extension: return nil // App-local SwiftUI content is hosted by the shell.
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
final class NativeUIFab: UIView {
|
|
4
|
+
static let kind = NativeUIComponent.fab
|
|
5
|
+
var buttons: [String: UIButton] = [:]
|
|
6
|
+
var content: [String: NativeUIItemContent] = [:]
|
|
7
|
+
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
8
|
+
let hit = super.hitTest(point, with: event)
|
|
9
|
+
return hit === self ? nil : hit
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
@available(iOS 26.0, *)
|
|
13
|
+
func apply(_ node: NativeUIControl, scale: CGFloat, rendering: NativeUIControlRendering, activate: @escaping (String) -> Void) {
|
|
14
|
+
let items = node.items
|
|
15
|
+
let retained = Set(items.map(\.id))
|
|
16
|
+
var lostFocus = false
|
|
17
|
+
for id in Array(self.buttons.keys) where !retained.contains(id) {
|
|
18
|
+
lostFocus = lostFocus || self.buttons[id]?.accessibilityElementIsFocused() == true
|
|
19
|
+
self.buttons.removeValue(forKey: id)?.removeFromSuperview()
|
|
20
|
+
self.content.removeValue(forKey: id)
|
|
21
|
+
}
|
|
22
|
+
self.accessibilityIdentifier = node.id
|
|
23
|
+
for item in items {
|
|
24
|
+
let id = item.id
|
|
25
|
+
let artwork = item.content.fabArtwork
|
|
26
|
+
let previous = self.buttons[id]
|
|
27
|
+
let button = previous ?? NativeUIButton.render(artwork, glass: true, rendering: rendering, activate: activate)
|
|
28
|
+
if previous == nil {
|
|
29
|
+
self.buttons[id] = button
|
|
30
|
+
self.addSubview(button)
|
|
31
|
+
} else if self.content[id] != artwork {
|
|
32
|
+
let changedIcon = self.content[id]?.selected != item.content.selected
|
|
33
|
+
let duration = UIAccessibility.isReduceMotionEnabled ? 0 : min(item.content.iconTransition ?? 0, 1)
|
|
34
|
+
if changedIcon, let imageView = button.imageView, duration > 0 {
|
|
35
|
+
UIView.transition(with: imageView, duration: duration,
|
|
36
|
+
options: [.transitionCrossDissolve, .beginFromCurrentState, .allowUserInteraction]) {
|
|
37
|
+
_ = NativeUIButton.render(artwork, glass: true, existing: button, rendering: rendering, activate: activate)
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
UIView.performWithoutAnimation { _ = NativeUIButton.render(artwork, glass: true, existing: button, rendering: rendering, activate: activate) }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
self.content[id] = artwork
|
|
44
|
+
button.accessibilityTraits.remove(.selected)
|
|
45
|
+
if id == items.first?.id && items.count > 1 {
|
|
46
|
+
button.accessibilityValue = item.content.selected
|
|
47
|
+
? NSLocalizedString("Expanded", comment: "FAB accessibility state")
|
|
48
|
+
: NSLocalizedString("Collapsed", comment: "FAB accessibility state")
|
|
49
|
+
} else {
|
|
50
|
+
button.accessibilityValue = nil
|
|
51
|
+
}
|
|
52
|
+
let hidden = item.visible == false
|
|
53
|
+
if hidden && !button.isHidden && button.accessibilityElementIsFocused() { lostFocus = true }
|
|
54
|
+
button.isHidden = hidden
|
|
55
|
+
button.isUserInteractionEnabled = button.isEnabled && !hidden
|
|
56
|
+
button.semanticContentAttribute = node.rtl ? .forceRightToLeft : .forceLeftToRight
|
|
57
|
+
let rect = item.frame.rect
|
|
58
|
+
let frame = CGRect(x: rect.minX * scale, y: rect.minY * scale, width: rect.width * scale, height: rect.height * scale)
|
|
59
|
+
if button.frame != frame { button.frame = frame }
|
|
60
|
+
}
|
|
61
|
+
let ordered = items.compactMap { self.buttons[$0.id] }.filter { !$0.isHidden }
|
|
62
|
+
self.accessibilityElements = ordered
|
|
63
|
+
if lostFocus, let main = ordered.first, main.isEnabled, self.window != nil {
|
|
64
|
+
UIAccessibility.post(notification: .layoutChanged, argument: main)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
extension NativeUIItemContent {
|
|
70
|
+
var fabArtwork: Self {
|
|
71
|
+
var artwork = self
|
|
72
|
+
if selected {
|
|
73
|
+
artwork.icon = closeIcon
|
|
74
|
+
artwork.iconWidth = closeIconWidth
|
|
75
|
+
artwork.iconHeight = closeIconHeight
|
|
76
|
+
artwork.label = ""
|
|
77
|
+
}
|
|
78
|
+
return artwork
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
struct NativeUISearch: Decodable, Equatable {
|
|
4
|
+
let id: String
|
|
5
|
+
let field: NativeUIItemContent
|
|
6
|
+
let trigger: NativeUIItem
|
|
7
|
+
let closeId: String
|
|
8
|
+
let active: Bool
|
|
9
|
+
let available: Bool
|
|
10
|
+
let focused: Bool
|
|
11
|
+
let value: String
|
|
12
|
+
let placeholder: String
|
|
13
|
+
let disabled: Bool
|
|
14
|
+
let editSequence: Int
|
|
15
|
+
let valueVersion: Int
|
|
16
|
+
|
|
17
|
+
var isValid: Bool {
|
|
18
|
+
!id.isEmpty && !closeId.isEmpty && field.isValid && trigger.isValid && editSequence >= 0 && valueVersion >= 0
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
enum NativeUISearchPhase: String { case input, focus, blur, clear, commit }
|
|
23
|
+
|
|
24
|
+
// A controller's empty content must not intercept the existing WebView.
|
|
25
|
+
final class NativeUISearchHost: UIView {
|
|
26
|
+
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
27
|
+
guard let hit = super.hitTest(point, with: event) else { return nil }
|
|
28
|
+
var ancestor: UIView? = hit
|
|
29
|
+
while let current = ancestor, current !== self {
|
|
30
|
+
if current is UIControl || current is UISearchBar || current is UITabBar { return hit }
|
|
31
|
+
ancestor = current.superview
|
|
32
|
+
}
|
|
33
|
+
return nil
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Preserve UISearchBar's delegate while returning its clear action to Ionic.
|
|
38
|
+
final class NativeUISearchInputDelegate: NSObject, UITextFieldDelegate {
|
|
39
|
+
weak var original: UITextFieldDelegate?
|
|
40
|
+
var clear: (() -> Void)?
|
|
41
|
+
override func responds(to selector: Selector!) -> Bool {
|
|
42
|
+
super.responds(to: selector) || original?.responds(to: selector) == true
|
|
43
|
+
}
|
|
44
|
+
override func forwardingTarget(for selector: Selector!) -> Any? { original }
|
|
45
|
+
func textFieldShouldClear(_ textField: UITextField) -> Bool { clear?(); return false }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Lets the plugin registry retain iOS 26-gated search controllers without
|
|
49
|
+
// availability-gated stored properties, mirroring NativeUIVerticalBarsControlling.
|
|
50
|
+
protocol NativeUISearchControlling: AnyObject {
|
|
51
|
+
var surface: NativeUISearchHost { get }
|
|
52
|
+
var ownsKeyboard: Bool { get }
|
|
53
|
+
var ownsKeyboardChrome: Bool { get }
|
|
54
|
+
var transitionCoordinator: UIViewControllerTransitionCoordinator? { get }
|
|
55
|
+
var activate: ((String) -> Void)? { get set }
|
|
56
|
+
var changed: ((String, NativeUISearchPhase, String, Bool, Int) -> Int)? { get set }
|
|
57
|
+
func attach(to parent: UIViewController, in container: UIView)
|
|
58
|
+
func detach()
|
|
59
|
+
func apply(_ snapshot: NativeUIControl, webFrame: CGRect, barFrame: CGRect, triggerFrame: CGRect, rendering: NativeUIControlRendering) -> Bool
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@available(iOS 26.0, *)
|
|
63
|
+
final class NativeUISearchController: UITabBarController, UITabBarControllerDelegate, UISearchBarDelegate, NativeUISearchControlling {
|
|
64
|
+
// Wire stays active+focused; local session drives chrome (idle / presented / focused).
|
|
65
|
+
private enum Session: Equatable { case idle, presented, focused }
|
|
66
|
+
|
|
67
|
+
let surface = NativeUISearchHost()
|
|
68
|
+
private var restingBarFrame: CGRect?
|
|
69
|
+
private var restingBarAnchor: NativeUITabBar.Anchor?
|
|
70
|
+
private var fittingTabBar = false
|
|
71
|
+
private let search = UISearchController(searchResultsController: nil)
|
|
72
|
+
private let inputDelegate = NativeUISearchInputDelegate()
|
|
73
|
+
private var searchTab: UISearchTab!
|
|
74
|
+
private var ordinary: [String: UITab] = [:]
|
|
75
|
+
private var configuration: NativeUISearch?
|
|
76
|
+
private var selectedID = ""
|
|
77
|
+
private var closing = false
|
|
78
|
+
private var editingSequence = 0
|
|
79
|
+
private var valueVersion = -1
|
|
80
|
+
private var lockedWebFrame: CGRect? // frozen while search is active (width changes re-lock)
|
|
81
|
+
private var session: Session = .idle
|
|
82
|
+
private var focusWork: DispatchWorkItem?
|
|
83
|
+
private var pendingSelection: NativeUITabBar.PendingSelection? // optimistic ordinary tab
|
|
84
|
+
private var pendingExpiryWork: DispatchWorkItem?
|
|
85
|
+
var ownsKeyboard: Bool { search.searchBar.searchTextField.isFirstResponder }
|
|
86
|
+
var ownsKeyboardChrome: Bool { session == .focused || ownsKeyboard }
|
|
87
|
+
var activate: ((String) -> Void)?
|
|
88
|
+
var changed: ((String, NativeUISearchPhase, String, Bool, Int) -> Int)?
|
|
89
|
+
|
|
90
|
+
override func viewDidLoad() {
|
|
91
|
+
super.viewDidLoad()
|
|
92
|
+
view.backgroundColor = .clear
|
|
93
|
+
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
94
|
+
surface.backgroundColor = .clear
|
|
95
|
+
surface.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
96
|
+
delegate = self
|
|
97
|
+
mode = .tabBar
|
|
98
|
+
NativeUITabBar.configureLayout(tabBar)
|
|
99
|
+
search.obscuresBackgroundDuringPresentation = false
|
|
100
|
+
search.hidesNavigationBarDuringPresentation = false
|
|
101
|
+
search.searchBar.delegate = self
|
|
102
|
+
search.searchBar.autocapitalizationType = .none
|
|
103
|
+
search.searchBar.autocorrectionType = .no
|
|
104
|
+
search.searchBar.spellCheckingType = .no
|
|
105
|
+
search.searchBar.searchTextField.clearButtonMode = .always
|
|
106
|
+
searchTab = UISearchTab { [weak self] _ in
|
|
107
|
+
let child = UIViewController()
|
|
108
|
+
child.view.backgroundColor = .clear
|
|
109
|
+
child.definesPresentationContext = true
|
|
110
|
+
child.navigationItem.searchController = self?.search
|
|
111
|
+
child.navigationItem.hidesSearchBarWhenScrolling = false
|
|
112
|
+
child.navigationItem.preferredSearchBarPlacement = .integrated
|
|
113
|
+
let navigation = UINavigationController(rootViewController: child)
|
|
114
|
+
navigation.view.backgroundColor = .clear
|
|
115
|
+
return navigation
|
|
116
|
+
}
|
|
117
|
+
searchTab.automaticallyActivatesSearch = false
|
|
118
|
+
inputDelegate.clear = { [weak self] in self?.emit(.clear) }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
deinit {
|
|
122
|
+
focusWork?.cancel()
|
|
123
|
+
pendingExpiryWork?.cancel()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private func wantedSession(active: Bool, focused: Bool) -> Session {
|
|
127
|
+
if !active { return .idle }
|
|
128
|
+
return focused ? .focused : .presented
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private func endEditing() {
|
|
132
|
+
focusWork?.cancel()
|
|
133
|
+
focusWork = nil
|
|
134
|
+
if search.searchBar.searchTextField.isFirstResponder {
|
|
135
|
+
search.searchBar.searchTextField.resignFirstResponder()
|
|
136
|
+
}
|
|
137
|
+
if search.isActive { search.isActive = false }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private func applySession(_ wanted: Session, selectingSearchTab: Bool) {
|
|
141
|
+
if wanted == session && !selectingSearchTab { return }
|
|
142
|
+
if wanted == .idle {
|
|
143
|
+
endEditing()
|
|
144
|
+
session = .idle
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
if selectingSearchTab || selectedTab !== searchTab { selectedTab = searchTab }
|
|
148
|
+
if wanted == .presented {
|
|
149
|
+
if session == .focused { endEditing() }
|
|
150
|
+
session = .presented
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
session = .focused
|
|
154
|
+
if !search.isActive { search.isActive = true }
|
|
155
|
+
guard !search.searchBar.searchTextField.isFirstResponder, focusWork == nil else { return }
|
|
156
|
+
let work = DispatchWorkItem { [weak self] in
|
|
157
|
+
guard let self else { return }
|
|
158
|
+
self.focusWork = nil
|
|
159
|
+
guard self.session == .focused, !self.search.searchBar.searchTextField.isFirstResponder else { return }
|
|
160
|
+
if !self.search.isActive { self.search.isActive = true }
|
|
161
|
+
_ = self.search.searchBar.searchTextField.becomeFirstResponder()
|
|
162
|
+
}
|
|
163
|
+
focusWork = work
|
|
164
|
+
DispatchQueue.main.async(execute: work)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private func armPendingSelection(_ id: String) {
|
|
168
|
+
pendingSelection = .start(id)
|
|
169
|
+
schedulePendingExpiry()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private func clearPendingSelection() {
|
|
173
|
+
pendingExpiryWork?.cancel()
|
|
174
|
+
pendingExpiryWork = nil
|
|
175
|
+
pendingSelection = nil
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private func schedulePendingExpiry() {
|
|
179
|
+
pendingExpiryWork?.cancel()
|
|
180
|
+
pendingExpiryWork = nil
|
|
181
|
+
guard let pending = pendingSelection else { return }
|
|
182
|
+
let delay = max(0, pending.until - CFAbsoluteTimeGetCurrent()) + 0.02
|
|
183
|
+
let work = DispatchWorkItem { [weak self] in
|
|
184
|
+
guard let self else { return }
|
|
185
|
+
self.pendingExpiryWork = nil
|
|
186
|
+
guard let pending = self.pendingSelection, CFAbsoluteTimeGetCurrent() >= pending.until else { return }
|
|
187
|
+
self.pendingSelection = nil
|
|
188
|
+
// Revert to the last DOM selection if Ionic did not accept the action.
|
|
189
|
+
if let selected = self.ordinary[self.selectedID], self.selectedTab !== selected {
|
|
190
|
+
self.selectedTab = selected
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
pendingExpiryWork = work
|
|
194
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private func resolveOrdinarySelection(_ items: [NativeUIItem], fallback: UITab?) {
|
|
198
|
+
let domSelected = ordinary[selectedID] ?? fallback
|
|
199
|
+
guard let pending = pendingSelection else {
|
|
200
|
+
pendingExpiryWork?.cancel()
|
|
201
|
+
pendingExpiryWork = nil
|
|
202
|
+
if selectedTab !== domSelected { selectedTab = domSelected }
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
let pendingTab = ordinary[pending.id]
|
|
206
|
+
let expired = CFAbsoluteTimeGetCurrent() >= pending.until
|
|
207
|
+
let unavailable = pendingTab == nil || (items.first { $0.id == pending.id }?.content.disabled ?? true)
|
|
208
|
+
if unavailable || expired {
|
|
209
|
+
clearPendingSelection()
|
|
210
|
+
selectedTab = domSelected
|
|
211
|
+
} else if selectedID == pending.id {
|
|
212
|
+
clearPendingSelection()
|
|
213
|
+
if selectedTab !== domSelected { selectedTab = domSelected }
|
|
214
|
+
} else if selectedTab !== pendingTab {
|
|
215
|
+
selectedTab = pendingTab
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
func attach(to parent: UIViewController, in container: UIView) {
|
|
220
|
+
parent.addChild(self)
|
|
221
|
+
container.addSubview(surface)
|
|
222
|
+
surface.addSubview(view)
|
|
223
|
+
didMove(toParent: parent)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
func detach() {
|
|
227
|
+
closing = true
|
|
228
|
+
lockedWebFrame = nil
|
|
229
|
+
clearPendingSelection()
|
|
230
|
+
applySession(.idle, selectingSearchTab: false)
|
|
231
|
+
willMove(toParent: nil)
|
|
232
|
+
restingBarFrame = nil
|
|
233
|
+
surface.removeFromSuperview()
|
|
234
|
+
view.removeFromSuperview()
|
|
235
|
+
removeFromParent()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
func apply(_ snapshot: NativeUIControl, webFrame: CGRect, barFrame: CGRect, triggerFrame: CGRect,
|
|
239
|
+
rendering: NativeUIControlRendering) -> Bool {
|
|
240
|
+
loadViewIfNeeded()
|
|
241
|
+
guard let configuration = snapshot.search else { return false }
|
|
242
|
+
if self.configuration?.id != configuration.id {
|
|
243
|
+
editingSequence = 0
|
|
244
|
+
valueVersion = -1
|
|
245
|
+
}
|
|
246
|
+
let wasActive = self.configuration.map { $0.available && $0.active } ?? false
|
|
247
|
+
self.configuration = configuration
|
|
248
|
+
let available = configuration.available
|
|
249
|
+
let active = available && configuration.active
|
|
250
|
+
let wanted = wantedSession(active: active, focused: configuration.focused)
|
|
251
|
+
let items = snapshot.items
|
|
252
|
+
let ids = items.map(\.id)
|
|
253
|
+
for id in Array(ordinary.keys) where !ids.contains(id) { ordinary.removeValue(forKey: id) }
|
|
254
|
+
for item in items {
|
|
255
|
+
let id = item.id
|
|
256
|
+
let tab = ordinary[id] ?? UITab(title: "", image: nil, identifier: id) { _ in
|
|
257
|
+
let child = UIViewController()
|
|
258
|
+
child.view.backgroundColor = .clear
|
|
259
|
+
return child
|
|
260
|
+
}
|
|
261
|
+
tab.accessibilityIdentifier = id
|
|
262
|
+
tab.title = item.content.label
|
|
263
|
+
tab.image = rendering.image(item.content)
|
|
264
|
+
tab.badgeValue = item.content.badge?.value
|
|
265
|
+
tab.isEnabled = !item.content.disabled
|
|
266
|
+
ordinary[id] = tab
|
|
267
|
+
if item.content.selected { selectedID = id }
|
|
268
|
+
}
|
|
269
|
+
let trigger = configuration.trigger
|
|
270
|
+
searchTab.accessibilityIdentifier = trigger.id
|
|
271
|
+
searchTab.image = rendering.image(trigger.content)
|
|
272
|
+
searchTab.isEnabled = !configuration.disabled
|
|
273
|
+
let requested = ids.compactMap { ordinary[$0] } + (available ? [searchTab!] : [])
|
|
274
|
+
if !active, tabs.isEmpty || tabs.map(\.identifier) != requested.map(\.identifier) {
|
|
275
|
+
tabs = requested
|
|
276
|
+
}
|
|
277
|
+
for item in items {
|
|
278
|
+
if let nativeItem = ordinary[item.id]?.viewController?.tabBarItem {
|
|
279
|
+
nativeItem.accessibilityLabel = item.content.accessibilityLabel
|
|
280
|
+
NativeUITabBar.applyTypography(item.content, to: nativeItem)
|
|
281
|
+
NativeUITabBar.applyBadge(item.content.badge, to: nativeItem, rendering: rendering)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
searchTab.viewController?.tabBarItem.accessibilityLabel = trigger.content.accessibilityLabel
|
|
285
|
+
let prominent = NSSelectorFromString("setProminentTabIdentifier:")
|
|
286
|
+
if responds(to: prominent) { setValue(available ? searchTab.identifier : nil, forKey: "prominentTabIdentifier") }
|
|
287
|
+
let field = configuration.field
|
|
288
|
+
search.searchBar.searchTextField.accessibilityIdentifier = configuration.id
|
|
289
|
+
search.searchBar.searchTextField.accessibilityLabel = field.accessibilityLabel
|
|
290
|
+
search.searchBar.setImage(rendering.image(field), for: .search, state: .normal)
|
|
291
|
+
search.searchBar.placeholder = configuration.placeholder
|
|
292
|
+
search.searchBar.searchTextField.isEnabled = !configuration.disabled
|
|
293
|
+
let nextValueVersion = configuration.valueVersion
|
|
294
|
+
if nextValueVersion != valueVersion || configuration.editSequence >= editingSequence {
|
|
295
|
+
valueVersion = nextValueVersion
|
|
296
|
+
let value = configuration.value
|
|
297
|
+
if search.searchBar.text != value { search.searchBar.text = value }
|
|
298
|
+
}
|
|
299
|
+
if search.searchBar.searchTextField.delegate !== inputDelegate {
|
|
300
|
+
inputDelegate.original = search.searchBar.searchTextField.delegate
|
|
301
|
+
search.searchBar.searchTextField.delegate = inputDelegate
|
|
302
|
+
}
|
|
303
|
+
// Resting keeps closing=true so hidden UISearchBar noise cannot emit; clear it while active.
|
|
304
|
+
closing = !active
|
|
305
|
+
surface.isHidden = false
|
|
306
|
+
surface.overrideUserInterfaceStyle = snapshot.dark ? .dark : .light
|
|
307
|
+
if active {
|
|
308
|
+
// UISearchTab session owns the bar + keyboard; freeze Web layout for the session.
|
|
309
|
+
if tabs.isEmpty || tabs.map(\.identifier) != requested.map(\.identifier) {
|
|
310
|
+
tabs = requested
|
|
311
|
+
}
|
|
312
|
+
restingBarFrame = nil
|
|
313
|
+
view.frame = surface.bounds
|
|
314
|
+
let nextLock = surface.bounds.isEmpty ? webFrame : surface.frame
|
|
315
|
+
if let locked = lockedWebFrame, abs(locked.width - webFrame.width) > 0.5 {
|
|
316
|
+
// Rotation / size-class change: adopt the new width while still ignoring keyboard shrink.
|
|
317
|
+
lockedWebFrame = webFrame
|
|
318
|
+
} else if lockedWebFrame == nil {
|
|
319
|
+
lockedWebFrame = nextLock
|
|
320
|
+
}
|
|
321
|
+
if let lockedWebFrame, surface.frame != lockedWebFrame { surface.frame = lockedWebFrame }
|
|
322
|
+
clearPendingSelection()
|
|
323
|
+
applySession(wanted, selectingSearchTab: !wasActive)
|
|
324
|
+
return true
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
surface.frame = webFrame
|
|
328
|
+
view.semanticContentAttribute = snapshot.rtl ? .forceRightToLeft : .forceLeftToRight
|
|
329
|
+
tabBar.semanticContentAttribute = snapshot.rtl ? .forceRightToLeft : .forceLeftToRight
|
|
330
|
+
tabBar.accessibilityIdentifier = snapshot.id
|
|
331
|
+
if lockedWebFrame != nil {
|
|
332
|
+
lockedWebFrame = nil
|
|
333
|
+
}
|
|
334
|
+
applySession(.idle, selectingSearchTab: false)
|
|
335
|
+
|
|
336
|
+
view.frame = surface.bounds
|
|
337
|
+
resolveOrdinarySelection(items, fallback: requested.first)
|
|
338
|
+
restingBarFrame = surface.convert(barFrame, from: surface.superview)
|
|
339
|
+
restingBarAnchor = snapshot.tabBarAnchor
|
|
340
|
+
view.setNeedsLayout()
|
|
341
|
+
view.layoutIfNeeded()
|
|
342
|
+
return fitRestingTabBar()
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
override func viewDidLayoutSubviews() {
|
|
346
|
+
super.viewDidLayoutSubviews()
|
|
347
|
+
if session == .idle { _ = fitRestingTabBar() }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/// Match the ordinary tab platter to the source ion-tab-bar by resizing
|
|
351
|
+
/// the controller's view, so UIKit can keep managing its tab bar through
|
|
352
|
+
/// search expansion and dismissal.
|
|
353
|
+
private func fitRestingTabBar() -> Bool {
|
|
354
|
+
guard let bounds = restingBarFrame, !fittingTabBar else { return true }
|
|
355
|
+
fittingTabBar = true
|
|
356
|
+
defer { fittingTabBar = false }
|
|
357
|
+
tabBar.layoutIfNeeded()
|
|
358
|
+
for _ in 0..<2 {
|
|
359
|
+
let regions = NativeUITabBar.contentViews(tabBar)
|
|
360
|
+
func controlCount(_ view: UIView) -> Int {
|
|
361
|
+
(view is UIControl ? 1 : 0) + view.subviews.reduce(0) { $0 + controlCount($1) }
|
|
362
|
+
}
|
|
363
|
+
// Ordinary tabs contain their item controls; Search contains one.
|
|
364
|
+
// For a single tab, UIKit places ordinary tabs at the leading edge.
|
|
365
|
+
let rtl = tabBar.effectiveUserInterfaceLayoutDirection == .rightToLeft
|
|
366
|
+
guard let region = regions.max(by: {
|
|
367
|
+
let lhs = controlCount($0), rhs = controlCount($1)
|
|
368
|
+
if lhs != rhs { return lhs < rhs }
|
|
369
|
+
return rtl ? $0.frame.midX < $1.frame.midX : $0.frame.midX > $1.frame.midX
|
|
370
|
+
}) else { return false }
|
|
371
|
+
let content = region.frame
|
|
372
|
+
let contents = regions.map { $0.frame }
|
|
373
|
+
let measured = tabBar.convert(content, to: surface)
|
|
374
|
+
let width = bounds.width - measured.width
|
|
375
|
+
let x = bounds.minX + (bounds.width - measured.width) * (restingBarAnchor?.x ?? 0) - measured.minX
|
|
376
|
+
let y = bounds.minY + (bounds.height - measured.height) * (restingBarAnchor?.y ?? 0) - measured.minY
|
|
377
|
+
// UIKit rounds the platter frame; a subpoint difference must not
|
|
378
|
+
// keep invalidating its layout during search transitions.
|
|
379
|
+
if abs(width) <= 0.5 && abs(x) <= 0.5 && abs(y) <= 0.5 { break }
|
|
380
|
+
var frame = view.frame
|
|
381
|
+
// The controller also contains the separate search button. Include
|
|
382
|
+
// that space in its width limit without enlarging capped tab groups.
|
|
383
|
+
let searchWidth = contents.filter { $0 != content }.reduce(CGFloat(0)) { $0 + $1.width }
|
|
384
|
+
frame.size.width = min(frame.width + width, surface.bounds.width + searchWidth)
|
|
385
|
+
frame.origin.x += x
|
|
386
|
+
frame.origin.y += y
|
|
387
|
+
if abs(frame.width - view.frame.width) <= 0.5 && abs(x) <= 0.5 && abs(y) <= 0.5 { break }
|
|
388
|
+
view.frame = frame
|
|
389
|
+
view.setNeedsLayout()
|
|
390
|
+
view.layoutIfNeeded()
|
|
391
|
+
}
|
|
392
|
+
// Fitting the ordinary platter can move UIKit's separate Search control
|
|
393
|
+
// outside the WebView, especially for centered or trailing source bars.
|
|
394
|
+
// Reject that layout so Search keeps its usable Web fallback.
|
|
395
|
+
let visibleBounds = surface.bounds.insetBy(dx: -0.5, dy: -0.5)
|
|
396
|
+
let regions = NativeUITabBar.contentViews(tabBar)
|
|
397
|
+
return !regions.isEmpty && regions.allSatisfy {
|
|
398
|
+
visibleBounds.contains(tabBar.convert($0.frame, to: surface))
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
func tabBarController(_ tabBarController: UITabBarController, shouldSelectTab tab: UITab) -> Bool {
|
|
403
|
+
guard let configuration else { return false }
|
|
404
|
+
if tab === searchTab {
|
|
405
|
+
restingBarFrame = nil
|
|
406
|
+
clearPendingSelection()
|
|
407
|
+
activate?(configuration.trigger.id)
|
|
408
|
+
return true
|
|
409
|
+
}
|
|
410
|
+
if configuration.active {
|
|
411
|
+
if tab.identifier == selectedID { activate?(configuration.closeId) }
|
|
412
|
+
else { activate?(tab.identifier) }
|
|
413
|
+
return false
|
|
414
|
+
}
|
|
415
|
+
armPendingSelection(tab.identifier)
|
|
416
|
+
if selectedTab !== tab { selectedTab = tab }
|
|
417
|
+
activate?(tab.identifier)
|
|
418
|
+
return true
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private func emit(_ phase: NativeUISearchPhase) {
|
|
422
|
+
guard !closing, let configuration else { return }
|
|
423
|
+
editingSequence = changed?(configuration.id, phase, search.searchBar.text ?? "", search.searchBar.searchTextField.markedTextRange != nil, valueVersion) ?? editingSequence
|
|
424
|
+
}
|
|
425
|
+
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { emit(.input) }
|
|
426
|
+
func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
|
|
427
|
+
if session != .idle { session = .focused }
|
|
428
|
+
emit(.focus)
|
|
429
|
+
}
|
|
430
|
+
func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
|
|
431
|
+
if session == .focused { session = .presented }
|
|
432
|
+
emit(.blur)
|
|
433
|
+
}
|
|
434
|
+
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { emit(.commit) }
|
|
435
|
+
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
|
|
436
|
+
closing = true
|
|
437
|
+
if let configuration { activate?(configuration.closeId) }
|
|
438
|
+
}
|
|
439
|
+
}
|