@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,119 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
import WebKit
|
|
3
|
+
|
|
4
|
+
/// Intercepts only a prepared blank window. Other requests keep Capacitor's behavior.
|
|
5
|
+
final class NativeUIOverlayController: NSObject, WKUIDelegate {
|
|
6
|
+
private weak var source: WKWebView?
|
|
7
|
+
private weak var owner: UIViewController?
|
|
8
|
+
private let original: WKUIDelegate?
|
|
9
|
+
private let automaticallyOpensWindows: Bool
|
|
10
|
+
private var prepared: String?
|
|
11
|
+
private let event: (String, String) -> Void
|
|
12
|
+
private var id: String?
|
|
13
|
+
private var host: NativeUIOverlayHost?
|
|
14
|
+
|
|
15
|
+
init(source: WKWebView, owner: UIViewController, event: @escaping (String, String) -> Void) {
|
|
16
|
+
self.source = source
|
|
17
|
+
self.owner = owner
|
|
18
|
+
self.event = event
|
|
19
|
+
original = source.uiDelegate
|
|
20
|
+
automaticallyOpensWindows = source.configuration.preferences.javaScriptCanOpenWindowsAutomatically
|
|
21
|
+
super.init()
|
|
22
|
+
source.uiDelegate = self
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
override func responds(to selector: Selector!) -> Bool {
|
|
26
|
+
super.responds(to: selector) || original?.responds(to: selector) == true
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
override func forwardingTarget(for selector: Selector!) -> Any? {
|
|
30
|
+
original?.responds(to: selector) == true ? original : super.forwardingTarget(for: selector)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func prepare(_ id: String) -> Bool {
|
|
34
|
+
guard prepared == nil, host == nil else { return false }
|
|
35
|
+
prepared = id
|
|
36
|
+
source?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = true
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
|
|
41
|
+
for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
|
|
42
|
+
// WebKit percent-encodes '#' inside about: URLs; compare the decoded form.
|
|
43
|
+
guard webView === source, navigationAction.sourceFrame.isMainFrame,
|
|
44
|
+
let prepared, navigationAction.targetFrame == nil,
|
|
45
|
+
navigationAction.request.url?.absoluteString.removingPercentEncoding == "about:blank#\(prepared)" else {
|
|
46
|
+
return original?.webView?(webView, createWebViewWith: configuration,
|
|
47
|
+
for: navigationAction, windowFeatures: windowFeatures)
|
|
48
|
+
}
|
|
49
|
+
self.prepared = nil
|
|
50
|
+
source?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = automaticallyOpensWindows
|
|
51
|
+
id = prepared
|
|
52
|
+
let host = NativeUIOverlayHost(configuration: configuration, options: nil) { [weak self] action in
|
|
53
|
+
self?.event(prepared, action)
|
|
54
|
+
}
|
|
55
|
+
host.webView.uiDelegate = self
|
|
56
|
+
host.webView.frame = source?.bounds ?? .zero
|
|
57
|
+
self.host = host
|
|
58
|
+
return host.webView
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func present(_ id: String, options: [String: Any]?, anchored: (String, NativeUIOverlayHost, @escaping (Bool) -> Void) -> Bool,
|
|
62
|
+
completion: @escaping (Bool) -> Void) {
|
|
63
|
+
guard self.id == id, let host, let owner,
|
|
64
|
+
owner.presentedViewController == nil else { completion(false); return }
|
|
65
|
+
host.apply(options)
|
|
66
|
+
var settled = false
|
|
67
|
+
var timeout: DispatchWorkItem?
|
|
68
|
+
let complete: (Bool) -> Void = { presented in
|
|
69
|
+
guard !settled else { return }
|
|
70
|
+
settled = true
|
|
71
|
+
timeout?.cancel()
|
|
72
|
+
timeout = nil
|
|
73
|
+
completion(presented)
|
|
74
|
+
}
|
|
75
|
+
timeout = DispatchWorkItem { [weak host] in
|
|
76
|
+
host?.cancelAnchored?()
|
|
77
|
+
complete(false)
|
|
78
|
+
}
|
|
79
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + NativeUIOverlayHost.completionTimeout, execute: timeout!)
|
|
80
|
+
if host.options?["kind"] as? String == "popover",
|
|
81
|
+
let anchor = host.options?["anchorId"] as? String,
|
|
82
|
+
anchored(anchor, host, complete) { return }
|
|
83
|
+
complete(false)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Keep the WebView alive until JavaScript has restored the adopted nodes and listeners.
|
|
87
|
+
func dismiss(_ id: String, animated: Bool, completion: @escaping () -> Void) {
|
|
88
|
+
guard self.id == id, let host else { completion(); return }
|
|
89
|
+
host.dismissPopover(animated: animated, completion: completion)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func close(_ id: String, completion: @escaping () -> Void) {
|
|
93
|
+
if prepared == id {
|
|
94
|
+
prepared = nil
|
|
95
|
+
source?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = automaticallyOpensWindows
|
|
96
|
+
}
|
|
97
|
+
guard self.id == id, let host else { completion(); return }
|
|
98
|
+
let finish = { [self] in
|
|
99
|
+
host.webView.uiDelegate = nil
|
|
100
|
+
self.host = nil
|
|
101
|
+
self.id = nil
|
|
102
|
+
completion()
|
|
103
|
+
}
|
|
104
|
+
host.dismissPopover(animated: false, completion: finish)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func stop(completion: @escaping () -> Void) {
|
|
108
|
+
let finish = { [self] in
|
|
109
|
+
prepared = nil
|
|
110
|
+
if let source, source.uiDelegate === self {
|
|
111
|
+
source.uiDelegate = original
|
|
112
|
+
source.configuration.preferences.javaScriptCanOpenWindowsAutomatically = automaticallyOpensWindows
|
|
113
|
+
}
|
|
114
|
+
completion()
|
|
115
|
+
}
|
|
116
|
+
if let id { close(id, completion: finish) }
|
|
117
|
+
else { finish() }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
import WebKit
|
|
3
|
+
|
|
4
|
+
/// Hosts only the live content of a Vertical Bars popover.
|
|
5
|
+
final class NativeUIOverlayHost: UIViewController {
|
|
6
|
+
let webView: WKWebView
|
|
7
|
+
private(set) var options: [String: Any]?
|
|
8
|
+
let event: (String) -> Void
|
|
9
|
+
// Settle native presentation before the JS bridge's five-second deadline.
|
|
10
|
+
static let completionTimeout: TimeInterval = 4
|
|
11
|
+
var dismissAnchored: (() -> Void)?
|
|
12
|
+
var cancelAnchored: (() -> Void)?
|
|
13
|
+
var anchoredVisible = false
|
|
14
|
+
private var dismissalCompletions: [() -> Void]?
|
|
15
|
+
private var dismissalTimeout: DispatchWorkItem?
|
|
16
|
+
|
|
17
|
+
func completeDismissal() {
|
|
18
|
+
dismissalTimeout?.cancel()
|
|
19
|
+
dismissalTimeout = nil
|
|
20
|
+
anchoredVisible = false
|
|
21
|
+
let completions = dismissalCompletions ?? []
|
|
22
|
+
dismissalCompletions = nil
|
|
23
|
+
completions.forEach { $0() }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Dismiss, close and stop may overlap while SwiftUI is still disappearing.
|
|
27
|
+
// Start dismissal once and deliver its completion to every waiting caller.
|
|
28
|
+
func dismissPopover(animated: Bool, completion: @escaping () -> Void) {
|
|
29
|
+
if dismissalCompletions != nil {
|
|
30
|
+
dismissalCompletions?.append(completion)
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
dismissalCompletions = [completion]
|
|
34
|
+
let finish = { [self] in completeDismissal() }
|
|
35
|
+
let timeout = DispatchWorkItem { [weak self] in
|
|
36
|
+
guard let self, self.dismissalCompletions != nil else { return }
|
|
37
|
+
// Remove the owning rail if SwiftUI never reports disappearance.
|
|
38
|
+
// Its detach path settles waiters before any replacement can attach.
|
|
39
|
+
self.cancelAnchored?()
|
|
40
|
+
self.completeDismissal()
|
|
41
|
+
}
|
|
42
|
+
dismissalTimeout = timeout
|
|
43
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Self.completionTimeout, execute: timeout)
|
|
44
|
+
if let dismissAnchored {
|
|
45
|
+
if anchoredVisible { dismissAnchored() }
|
|
46
|
+
else { dismissAnchored(); finish() }
|
|
47
|
+
} else if presentingViewController != nil {
|
|
48
|
+
dismiss(animated: animated, completion: finish)
|
|
49
|
+
} else { finish() }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
init(configuration: WKWebViewConfiguration, options: [String: Any]?, event: @escaping (String) -> Void) {
|
|
53
|
+
webView = WKWebView(frame: .zero, configuration: configuration)
|
|
54
|
+
self.event = event
|
|
55
|
+
super.init(nibName: nil, bundle: nil)
|
|
56
|
+
apply(options)
|
|
57
|
+
}
|
|
58
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
59
|
+
|
|
60
|
+
func apply(_ options: [String: Any]?) {
|
|
61
|
+
guard let options else { return }
|
|
62
|
+
self.options = options
|
|
63
|
+
preferredContentSize = CGSize(width: options["width"] as? Double ?? 280, height: options["height"] as? Double ?? 200)
|
|
64
|
+
overrideUserInterfaceStyle = options["dark"] as? Bool == true ? .dark : .light
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
override func loadView() {
|
|
68
|
+
webView.isOpaque = false
|
|
69
|
+
webView.backgroundColor = .clear
|
|
70
|
+
webView.scrollView.backgroundColor = .clear
|
|
71
|
+
webView.scrollView.contentInsetAdjustmentBehavior = .never
|
|
72
|
+
view = webView
|
|
73
|
+
view.accessibilityViewIsModal = true
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
final class NativeUIControlRendering {
|
|
4
|
+
private var images: [String: UIImage] = [:]
|
|
5
|
+
func clear() { images.removeAll() }
|
|
6
|
+
|
|
7
|
+
func image(_ item: NativeUIItemContent) -> UIImage? {
|
|
8
|
+
guard let data = item.icon, let width = item.iconWidth else { return nil }
|
|
9
|
+
let template = item.iconTemplate == true
|
|
10
|
+
let key = "\(width):" + (template ? "template:" : "original:") + data
|
|
11
|
+
if let cached = images[key] { return cached }
|
|
12
|
+
guard let result = NativeUIRendering.image(NativeUIImage(data: data, width: width, height: item.iconHeight ?? width, template: template)) else { return nil }
|
|
13
|
+
if images.count >= 128 { images.removeAll() }
|
|
14
|
+
images[key] = result
|
|
15
|
+
return result
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func color(_ css: String?) -> UIColor {
|
|
19
|
+
NativeUIRendering.color(css)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
@MainActor enum NativeUICrossfade {
|
|
4
|
+
static func duration(_ milliseconds: Double?) -> TimeInterval {
|
|
5
|
+
UIAccessibility.isReduceMotionEnabled ? 0 : (milliseconds ?? 0) / 1000
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
static func enter(_ view: UIView, duration: TimeInterval) {
|
|
9
|
+
guard duration > 0 else { return }
|
|
10
|
+
view.alpha = 0
|
|
11
|
+
UIView.animate(withDuration: duration, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) {
|
|
12
|
+
view.alpha = 1
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static func retire(_ view: UIView, duration: TimeInterval) {
|
|
17
|
+
// The outgoing copy is visual only: it cannot keep accepting actions or
|
|
18
|
+
// VoiceOver focus once the Web source has been restored.
|
|
19
|
+
if duration > 0, let host = view.superview, let parent = host.superview,
|
|
20
|
+
!view.isHidden, !host.isHidden, let copy = view.snapshotView(afterScreenUpdates: false) {
|
|
21
|
+
copy.frame = host.convert(view.frame, to: parent)
|
|
22
|
+
copy.alpha = CGFloat(view.layer.presentation()?.opacity ?? Float(view.alpha))
|
|
23
|
+
copy.isUserInteractionEnabled = false
|
|
24
|
+
copy.isAccessibilityElement = false
|
|
25
|
+
copy.accessibilityElementsHidden = true
|
|
26
|
+
parent.insertSubview(copy, aboveSubview: host)
|
|
27
|
+
UIView.animate(withDuration: duration, delay: 0, options: [.curveEaseInOut]) {
|
|
28
|
+
copy.alpha = 0
|
|
29
|
+
} completion: { _ in
|
|
30
|
+
copy.removeFromSuperview()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
view.removeFromSuperview()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import CoreGraphics
|
|
3
|
+
|
|
4
|
+
// Standard renderer names are independent of DOM tags and Web frameworks.
|
|
5
|
+
enum NativeUIComponent: String, Decodable {
|
|
6
|
+
case `extension`
|
|
7
|
+
case button, buttons = "button-group"
|
|
8
|
+
case backButton = "back-button", menuButton = "menu-button"
|
|
9
|
+
case tabBar = "tab-bar", segment, fab = "floating-action-button"
|
|
10
|
+
|
|
11
|
+
init(from decoder: Decoder) throws {
|
|
12
|
+
let container = try decoder.singleValueContainer()
|
|
13
|
+
let value = try container.decode(String.self)
|
|
14
|
+
// Accept previous clients during native/Web bundle upgrades.
|
|
15
|
+
let legacy: [String: NativeUIComponent] = [
|
|
16
|
+
"ion-button": .button, "ion-buttons": .buttons,
|
|
17
|
+
"ion-back-button": .backButton, "ion-menu-button": .menuButton,
|
|
18
|
+
"ion-tab-bar": .tabBar, "ion-segment": .segment, "ion-fab": .fab
|
|
19
|
+
]
|
|
20
|
+
guard let kind = NativeUIComponent(rawValue: value) ?? legacy[value] else {
|
|
21
|
+
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unknown native control kind")
|
|
22
|
+
}
|
|
23
|
+
self = kind
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
struct NativeUISnapshot: Decodable {
|
|
28
|
+
let revision: Int
|
|
29
|
+
let transitionDuration: Double?
|
|
30
|
+
let viewportWidth: Double
|
|
31
|
+
let verticalBarFrame: NativeUIFrame?
|
|
32
|
+
let verticalBarEdge: String?
|
|
33
|
+
let controls: [NativeUIControl]
|
|
34
|
+
|
|
35
|
+
var isValid: Bool {
|
|
36
|
+
revision >= 0 && (transitionDuration.map { $0.isFinite && $0 >= 0 && $0 <= 500 } ?? true) && viewportWidth.isFinite && viewportWidth > 0 && controls.count <= 100 &&
|
|
37
|
+
(verticalBarFrame.map { $0.isValid } ?? true) &&
|
|
38
|
+
(verticalBarEdge == nil || verticalBarEdge == "left" || verticalBarEdge == "right") &&
|
|
39
|
+
Set(controls.map(\.id)).count == controls.count && controls.allSatisfy(\.isValid)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Frames and content decode from the same flat wire object. Keeping them separate
|
|
44
|
+
// lets FABs compare artwork without treating movement or visibility as new content.
|
|
45
|
+
struct NativeUIFrame: Decodable, Equatable {
|
|
46
|
+
let x: Double
|
|
47
|
+
let y: Double
|
|
48
|
+
let width: Double
|
|
49
|
+
let height: Double
|
|
50
|
+
var rect: CGRect { CGRect(x: x, y: y, width: width, height: height) }
|
|
51
|
+
var isValid: Bool { [x, y, width, height].allSatisfy(\.isFinite) && width > 0 && height > 0 }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
struct NativeUIControl: Decodable, Equatable {
|
|
55
|
+
enum Placement: String, Decodable { case verticalBars = "vertical-bars" }
|
|
56
|
+
enum ToolbarSlot: String, Decodable { case start, end }
|
|
57
|
+
let id: String
|
|
58
|
+
let kind: NativeUIComponent
|
|
59
|
+
let placement: Placement?
|
|
60
|
+
let toolbarSlot: ToolbarSlot?
|
|
61
|
+
let frame: NativeUIFrame
|
|
62
|
+
let items: [NativeUIItem]
|
|
63
|
+
let dark: Bool
|
|
64
|
+
let rtl: Bool
|
|
65
|
+
let tabBarAnchor: NativeUITabBar.Anchor?
|
|
66
|
+
let search: NativeUISearch?
|
|
67
|
+
let extensionContent: NativeUIExtensionContent?
|
|
68
|
+
|
|
69
|
+
private enum CodingKeys: String, CodingKey {
|
|
70
|
+
case id, kind, placement, toolbarSlot, items, dark, rtl, tabBarAnchor, search
|
|
71
|
+
case extensionContent = "extension"
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
init(from decoder: Decoder) throws {
|
|
75
|
+
let values = try decoder.container(keyedBy: CodingKeys.self)
|
|
76
|
+
id = try values.decode(String.self, forKey: .id)
|
|
77
|
+
kind = try values.decode(NativeUIComponent.self, forKey: .kind)
|
|
78
|
+
placement = try values.decodeIfPresent(Placement.self, forKey: .placement)
|
|
79
|
+
toolbarSlot = try values.decodeIfPresent(ToolbarSlot.self, forKey: .toolbarSlot)
|
|
80
|
+
frame = try NativeUIFrame(from: decoder)
|
|
81
|
+
items = try values.decode([NativeUIItem].self, forKey: .items)
|
|
82
|
+
dark = try values.decode(Bool.self, forKey: .dark)
|
|
83
|
+
rtl = try values.decode(Bool.self, forKey: .rtl)
|
|
84
|
+
tabBarAnchor = try values.decodeIfPresent(NativeUITabBar.Anchor.self, forKey: .tabBarAnchor)
|
|
85
|
+
search = try values.decodeIfPresent(NativeUISearch.self, forKey: .search)
|
|
86
|
+
extensionContent = try values.decodeIfPresent(NativeUIExtensionContent.self, forKey: .extensionContent)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
var isValid: Bool {
|
|
90
|
+
if kind == .extension {
|
|
91
|
+
return !id.isEmpty && frame.isValid && items.isEmpty && placement == nil && toolbarSlot == nil &&
|
|
92
|
+
search == nil && tabBarAnchor == nil && extensionContent?.isValid == true
|
|
93
|
+
}
|
|
94
|
+
return extensionContent == nil && NativeUIComponents.supported.contains(kind) && !id.isEmpty && frame.isValid && !items.isEmpty && items.count <= 30 &&
|
|
95
|
+
(!NativeUIButton.kinds.contains(kind) || items.count == 1) &&
|
|
96
|
+
(toolbarSlot == nil || (placement == .verticalBars && [.button, .buttons, .menuButton].contains(kind))) &&
|
|
97
|
+
Set(items.map(\.id)).count == items.count && items.allSatisfy(\.isValid) &&
|
|
98
|
+
(tabBarAnchor.map { kind == .tabBar && $0.isValid } ?? true) &&
|
|
99
|
+
(search.map { kind == .tabBar && $0.isValid } ?? true)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
struct NativeUIItem: Decodable, Equatable {
|
|
104
|
+
let frame: NativeUIFrame
|
|
105
|
+
let content: NativeUIItemContent
|
|
106
|
+
let visible: Bool?
|
|
107
|
+
var id: String { content.id }
|
|
108
|
+
|
|
109
|
+
private enum CodingKeys: String, CodingKey { case visible }
|
|
110
|
+
|
|
111
|
+
init(from decoder: Decoder) throws {
|
|
112
|
+
frame = try NativeUIFrame(from: decoder)
|
|
113
|
+
content = try NativeUIItemContent(from: decoder)
|
|
114
|
+
visible = try decoder.container(keyedBy: CodingKeys.self).decodeIfPresent(Bool.self, forKey: .visible)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
var isValid: Bool { frame.isValid && content.isValid }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
struct NativeUIBadge: Decodable, Equatable {
|
|
121
|
+
let value: String
|
|
122
|
+
let color: String
|
|
123
|
+
let textColor: String
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
struct NativeUIItemContent: Decodable, Equatable {
|
|
127
|
+
enum IconPosition: String, Decodable { case leading, trailing, top }
|
|
128
|
+
enum ButtonFill: String, Decodable { case clear, solid, outline }
|
|
129
|
+
let id: String
|
|
130
|
+
var label: String
|
|
131
|
+
let accessibilityLabel: String
|
|
132
|
+
let disabled: Bool
|
|
133
|
+
let selected: Bool
|
|
134
|
+
let fontSize: Double
|
|
135
|
+
let fontWeight: Double
|
|
136
|
+
let color: String
|
|
137
|
+
let buttonFill: ButtonFill?
|
|
138
|
+
let backgroundColor: String?
|
|
139
|
+
let borderColor: String?
|
|
140
|
+
let borderWidth: CGFloat?
|
|
141
|
+
let badge: NativeUIBadge?
|
|
142
|
+
var icon: String?
|
|
143
|
+
var iconWidth: Double?
|
|
144
|
+
var iconHeight: Double?
|
|
145
|
+
let iconPosition: IconPosition?
|
|
146
|
+
let imagePadding: Double?
|
|
147
|
+
let contentInsetLeading: Double?
|
|
148
|
+
let contentInsetTrailing: Double?
|
|
149
|
+
let iconTemplate: Bool?
|
|
150
|
+
let closeIcon: String?
|
|
151
|
+
let closeIconWidth: Double?
|
|
152
|
+
let closeIconHeight: Double?
|
|
153
|
+
let iconTransition: Double?
|
|
154
|
+
|
|
155
|
+
var isValid: Bool {
|
|
156
|
+
!id.isEmpty && fontSize.isFinite && fontSize >= 0 && fontWeight.isFinite &&
|
|
157
|
+
[iconWidth, iconHeight, closeIconWidth, closeIconHeight].compactMap { $0 }.allSatisfy { $0.isFinite && $0 > 0 } &&
|
|
158
|
+
[imagePadding, contentInsetLeading, contentInsetTrailing].compactMap { $0 }.allSatisfy { $0.isFinite } &&
|
|
159
|
+
(iconTransition.map { $0.isFinite && $0 >= 0 } ?? true)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Reads public accessibility geometry after the system has laid out its rail.
|
|
4
|
+
/// IDs bind measurements to current DOM controls without private class names.
|
|
5
|
+
/// Compact square ancestors approximate the system button surface; this remains
|
|
6
|
+
/// a layout heuristic and invalid measurements fall back to Web CSS.
|
|
7
|
+
enum NativeUIVerticalBarsGeometry {
|
|
8
|
+
static func measure(in root: UIView, relativeTo webView: UIView, ids: Set<String>) -> [String: CGRect] {
|
|
9
|
+
guard let window = root.window, window === webView.window else { return [:] }
|
|
10
|
+
root.layoutIfNeeded()
|
|
11
|
+
var result: [String: CGRect] = [:]
|
|
12
|
+
var visited = Set<ObjectIdentifier>()
|
|
13
|
+
func visit(_ object: NSObject, depth: Int) {
|
|
14
|
+
guard depth < 40, visited.count < 2000, visited.insert(ObjectIdentifier(object)).inserted else { return }
|
|
15
|
+
if let id = (object as? UIView)?.accessibilityIdentifier ?? (object as? UIAccessibilityElement)?.accessibilityIdentifier, ids.contains(id) {
|
|
16
|
+
let screenFrame = object.accessibilityFrame
|
|
17
|
+
let frame = (object as? UIView).map { view -> CGRect in
|
|
18
|
+
var surface = view
|
|
19
|
+
var ancestor = view.superview
|
|
20
|
+
while let current = ancestor, current !== root,
|
|
21
|
+
current.bounds.width < 100, current.bounds.height < 100 {
|
|
22
|
+
if current.bounds.width >= max(surface.bounds.width, 1), current.bounds.height >= max(surface.bounds.height, 1),
|
|
23
|
+
abs(current.bounds.width - current.bounds.height) < 1 { surface = current }
|
|
24
|
+
ancestor = current.superview
|
|
25
|
+
}
|
|
26
|
+
return surface.convert(surface.bounds, to: webView)
|
|
27
|
+
} ?? webView.convert(screenFrame, from: window.screen.coordinateSpace)
|
|
28
|
+
if !frame.isNull, !frame.isInfinite, frame.width > 0, frame.height > 0,
|
|
29
|
+
webView.bounds.intersects(frame), frame.width < webView.bounds.width * 0.75 {
|
|
30
|
+
// UIKit may expose a label and its larger button with the same ID.
|
|
31
|
+
if result[id] == nil || frame.width * frame.height > result[id]!.width * result[id]!.height {
|
|
32
|
+
result[id] = frame
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if let view = object as? UIView { view.subviews.forEach { visit($0, depth: depth + 1) } }
|
|
37
|
+
if object === webView { return }
|
|
38
|
+
if let elements = object.accessibilityElements {
|
|
39
|
+
elements.compactMap { $0 as? NSObject }.forEach { visit($0, depth: depth + 1) }
|
|
40
|
+
} else {
|
|
41
|
+
let count = object.accessibilityElementCount()
|
|
42
|
+
if count > 0 && count < 200 {
|
|
43
|
+
for index in 0..<count {
|
|
44
|
+
if let element = object.accessibilityElement(at: index) as? NSObject { visit(element, depth: depth + 1) }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
visit(root, depth: 0)
|
|
50
|
+
return result
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
import SwiftUI
|
|
3
|
+
import WebKit
|
|
4
|
+
@testable import NativeUIPlugin
|
|
5
|
+
|
|
6
|
+
final class NativeUIExtensionTests: XCTestCase {
|
|
7
|
+
struct Props: Decodable { let title: String }
|
|
8
|
+
|
|
9
|
+
private func context(width: Double = 120, dark: Bool = false, accessory: Bool = false) throws -> NativeUIContext {
|
|
10
|
+
var content: [String: Any] = ["name": "fixture", "props": "{}", "actions": [:]]
|
|
11
|
+
if accessory { content["tabBarId"] = "tabs" }
|
|
12
|
+
let json: [String: Any] = ["id": "source", "kind": "extension", "x": -4, "y": 30,
|
|
13
|
+
"width": width, "height": 44, "dark": dark, "rtl": dark, "items": [], "extension": content]
|
|
14
|
+
let node = try JSONDecoder().decode(NativeUIControl.self, from: JSONSerialization.data(withJSONObject: json))
|
|
15
|
+
return NativeUIContext(node: node, viewportWidth: 600, pointsPerCSSPixel: 0.5)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@MainActor func testContextUpdatesGeometryEnvironmentAndAccessoryPlacement() throws {
|
|
19
|
+
let name = UUID().uuidString
|
|
20
|
+
var received: [NativeUIContext] = []
|
|
21
|
+
try NativeUI.register(name) { (props: Props, actions: NativeUIActions, context: NativeUIContext) in
|
|
22
|
+
received.append(context)
|
|
23
|
+
XCTAssertTrue(actions.isAvailable("open"))
|
|
24
|
+
XCTAssertFalse(actions.isAvailable("disabled"))
|
|
25
|
+
return Text(props.title)
|
|
26
|
+
}
|
|
27
|
+
let data = NativeUIExtensionContent(name: name, props: "{\"title\":\"Ready\"}", actions: ["open": "button"], tabBarId: nil)
|
|
28
|
+
let actions = NativeUIActions(availableNames: ["open"]) { _ in }
|
|
29
|
+
_ = try NativeUI.content(data, actions: actions, context: context())
|
|
30
|
+
_ = try NativeUI.content(data, actions: actions, context: context(width: 220, dark: true, accessory: true))
|
|
31
|
+
XCTAssertEqual(received.count, 2)
|
|
32
|
+
XCTAssertEqual(received[0].sourceID, "source")
|
|
33
|
+
XCTAssertEqual(received[0].sourceFrame, CGRect(x: -4, y: 30, width: 120, height: 44))
|
|
34
|
+
XCTAssertEqual(received[0].sourceFrameInWebView, CGRect(x: -2, y: 15, width: 60, height: 22))
|
|
35
|
+
XCTAssertEqual(received[0].placement, .element)
|
|
36
|
+
XCTAssertEqual(received[0].colorScheme, .light)
|
|
37
|
+
XCTAssertEqual(received[0].layoutDirection, .leftToRight)
|
|
38
|
+
XCTAssertEqual(received[1].sourceFrameInWebView.width, 110)
|
|
39
|
+
XCTAssertEqual(received[1].viewportWidth, 600)
|
|
40
|
+
XCTAssertEqual(received[1].pointsPerCSSPixel, 0.5)
|
|
41
|
+
XCTAssertEqual(received[1].placement, .tabAccessory)
|
|
42
|
+
XCTAssertEqual(received[1].colorScheme, .dark)
|
|
43
|
+
XCTAssertEqual(received[1].layoutDirection, .rightToLeft)
|
|
44
|
+
XCTAssertThrowsError(try NativeUI.register(name) { (_: Props, _: NativeUIActions) in Text("Duplicate") })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@MainActor func testPublicRenderingSharesArtworkAndComputedColors() throws {
|
|
48
|
+
let image = UIGraphicsImageRenderer(size: CGSize(width: 16, height: 16)).image { context in
|
|
49
|
+
UIColor.red.setFill()
|
|
50
|
+
context.fill(CGRect(x: 0, y: 0, width: 16, height: 16))
|
|
51
|
+
}
|
|
52
|
+
let data = try XCTUnwrap(image.pngData()).base64EncodedString()
|
|
53
|
+
let artwork = NativeUIImage(data: data, width: 16, height: 16, template: true)
|
|
54
|
+
let decoded = try XCTUnwrap(NativeUIRendering.image(artwork))
|
|
55
|
+
XCTAssertEqual(decoded.size, CGSize(width: 16, height: 16))
|
|
56
|
+
XCTAssertEqual(decoded.renderingMode, .alwaysTemplate)
|
|
57
|
+
let legacy: [String: Any] = ["id": "icon", "label": "Icon", "accessibilityLabel": "Icon",
|
|
58
|
+
"disabled": false, "selected": false, "fontSize": 16, "fontWeight": 400,
|
|
59
|
+
"color": "rgb(0, 0, 0)", "icon": data, "iconWidth": 16]
|
|
60
|
+
let item = try JSONDecoder().decode(NativeUIItemContent.self, from: JSONSerialization.data(withJSONObject: legacy))
|
|
61
|
+
XCTAssertEqual(try XCTUnwrap(NativeUIControlRendering().image(item)).size, CGSize(width: 16, height: 16))
|
|
62
|
+
XCTAssertNil(NativeUIRendering.image(NativeUIImage(data: "invalid", width: 16, height: 16)))
|
|
63
|
+
XCTAssertNil(NativeUIRendering.image(NativeUIImage(data: data, width: .nan, height: 16)))
|
|
64
|
+
let color = NativeUIRendering.color("rgba(255, 0, 128, 0.5)")
|
|
65
|
+
var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
|
|
66
|
+
XCTAssertTrue(color.getRed(&red, green: &green, blue: &blue, alpha: &alpha))
|
|
67
|
+
XCTAssertEqual(red, 1)
|
|
68
|
+
XCTAssertEqual(blue, 128.0 / 255.0, accuracy: 0.001)
|
|
69
|
+
XCTAssertEqual(alpha, 0.5)
|
|
70
|
+
XCTAssertEqual(NativeUIRendering.color("currentColor", fallback: .red), .red)
|
|
71
|
+
XCTAssertEqual(NativeUIControlRendering().color("rgba(255, 0, 128, 0.5)"), color)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@MainActor func testLocalRegistrationDecodesPropsAndRejectsDuplicateAndMissingRenderers() throws {
|
|
75
|
+
let name = UUID().uuidString
|
|
76
|
+
var title = ""
|
|
77
|
+
try NativeUI.register(name) { (props: Props, _: NativeUIActions) in
|
|
78
|
+
title = props.title
|
|
79
|
+
return Text(props.title)
|
|
80
|
+
}
|
|
81
|
+
let actions = NativeUIActions { _ in }
|
|
82
|
+
let context = try context()
|
|
83
|
+
_ = try NativeUI.content(NativeUIExtensionContent(name: name, props: "{\"title\":\"Local View\"}", actions: [:], tabBarId: nil), actions: actions, context: context)
|
|
84
|
+
XCTAssertEqual(title, "Local View")
|
|
85
|
+
XCTAssertThrowsError(try NativeUI.register(name) { (props: Props, _: NativeUIActions) in Text(props.title) })
|
|
86
|
+
XCTAssertThrowsError(try NativeUI.content(NativeUIExtensionContent(name: name, props: "{}", actions: [:], tabBarId: nil), actions: actions, context: context))
|
|
87
|
+
XCTAssertThrowsError(try NativeUI.content(NativeUIExtensionContent(name: "missing", props: "{}", actions: [:], tabBarId: nil), actions: actions, context: context))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@MainActor func testExtensionSnapshotAcceptsEmptyActionsButRejectsInvalidProps() throws {
|
|
91
|
+
func decode(_ props: String) throws -> NativeUIControl {
|
|
92
|
+
let json: [String: Any] = ["id": "counter", "kind": "extension", "x": 0, "y": 0, "width": 100, "height": 44,
|
|
93
|
+
"dark": false, "rtl": false, "items": [], "extension": ["name": "app.counter", "props": props, "actions": [:]]]
|
|
94
|
+
return try JSONDecoder().decode(NativeUIControl.self, from: JSONSerialization.data(withJSONObject: json))
|
|
95
|
+
}
|
|
96
|
+
XCTAssertTrue(try decode("{\"title\":\"Ready\"}").isValid)
|
|
97
|
+
XCTAssertFalse(try decode("[]").isValid)
|
|
98
|
+
XCTAssertFalse(try decode("broken").isValid)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@MainActor func testActionWaitsForWebAcknowledgementAndIgnoresDuplicateReplies() async throws {
|
|
102
|
+
let broker = NativeUIExtensionActions()
|
|
103
|
+
var request: String?
|
|
104
|
+
var completed = false
|
|
105
|
+
let sent = expectation(description: "Web action sent")
|
|
106
|
+
let task = Task { @MainActor in
|
|
107
|
+
try await broker.perform(owner: "source") { request = $0; sent.fulfill() }
|
|
108
|
+
completed = true
|
|
109
|
+
}
|
|
110
|
+
defer { task.cancel() }
|
|
111
|
+
await fulfillment(of: [sent], timeout: 2)
|
|
112
|
+
let requestID = try XCTUnwrap(request)
|
|
113
|
+
XCTAssertFalse(completed)
|
|
114
|
+
broker.finish(requestID)
|
|
115
|
+
broker.finish(requestID, error: NativeUIError.rejected)
|
|
116
|
+
try await task.value
|
|
117
|
+
XCTAssertTrue(completed)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@MainActor func testRetirementRejectsPendingCallsOnlyForThatSource() async throws {
|
|
121
|
+
let broker = NativeUIExtensionActions()
|
|
122
|
+
var requests: [String: String] = [:]
|
|
123
|
+
let sent = expectation(description: "Both actions sent")
|
|
124
|
+
sent.expectedFulfillmentCount = 2
|
|
125
|
+
let first = Task { @MainActor in try await broker.perform(owner: "first") { requests["first"] = $0; sent.fulfill() } }
|
|
126
|
+
let second = Task { @MainActor in try await broker.perform(owner: "second") { requests["second"] = $0; sent.fulfill() } }
|
|
127
|
+
defer { first.cancel(); second.cancel() }
|
|
128
|
+
await fulfillment(of: [sent], timeout: 2)
|
|
129
|
+
broker.cancel(owner: "first")
|
|
130
|
+
do { try await first.value; XCTFail("Retired source must reject") } catch NativeUIError.unavailable {} catch { XCTFail("\(error)") }
|
|
131
|
+
broker.finish(try XCTUnwrap(requests["second"]))
|
|
132
|
+
try await second.value
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@MainActor func testBridgeLossAndTaskCancellationAlwaysSettle() async {
|
|
136
|
+
let broker = NativeUIExtensionActions()
|
|
137
|
+
do {
|
|
138
|
+
try await broker.perform(owner: "lost", timeout: 0.01) { _ in }
|
|
139
|
+
XCTFail("Lost bridge must time out")
|
|
140
|
+
} catch NativeUIError.timedOut {} catch { XCTFail("\(error)") }
|
|
141
|
+
let sent = expectation(description: "Cancelable action sent")
|
|
142
|
+
let task = Task { @MainActor in try await broker.perform(owner: "canceled") { _ in sent.fulfill() } }
|
|
143
|
+
await fulfillment(of: [sent], timeout: 2)
|
|
144
|
+
task.cancel()
|
|
145
|
+
do { try await task.value; XCTFail("Canceled task must reject") } catch is CancellationError {} catch { XCTFail("\(error)") }
|
|
146
|
+
broker.cancel()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
@MainActor func testHostedViewUsesControllerContainmentAndDetaches() {
|
|
150
|
+
let owner = UIViewController()
|
|
151
|
+
owner.view = WKWebView()
|
|
152
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 400, height: 800))
|
|
153
|
+
window.rootViewController = owner
|
|
154
|
+
window.makeKeyAndVisible()
|
|
155
|
+
defer { window.isHidden = true }
|
|
156
|
+
let host = NativeUIExtensionController(content: AnyView(Text("Local content")), owner: owner)
|
|
157
|
+
XCTAssertTrue(host.view.superview === owner.view)
|
|
158
|
+
XCTAssertTrue(host.controller.parent === owner)
|
|
159
|
+
host.update(AnyView(Text("Updated")))
|
|
160
|
+
host.detach(duration: 0)
|
|
161
|
+
XCTAssertNil(host.controller.parent)
|
|
162
|
+
XCTAssertNil(host.view.superview)
|
|
163
|
+
XCTAssertTrue(owner.children.isEmpty)
|
|
164
|
+
}
|
|
165
|
+
}
|