@webview-native-ui/engine 0.0.0-stage → 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 +18 -0
- package/NOTICE +39 -0
- package/README.md +623 -2
- package/WebViewNativeUIEngine.podspec +17 -0
- package/dist/bridge.d.ts +4 -0
- package/dist/bridge.js +15 -0
- package/dist/geometry.d.ts +2 -0
- package/dist/geometry.js +25 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +9 -0
- package/dist/ionic.d.ts +16 -0
- package/dist/ionic.js +31 -0
- package/dist/native-bootstrap.d.ts +8 -0
- package/dist/native-bootstrap.js +138 -0
- package/dist/native-session.d.ts +48 -0
- package/dist/native-session.js +519 -0
- package/dist/overlays.d.ts +42 -0
- package/dist/overlays.js +181 -0
- package/dist/projection-events.d.ts +12 -0
- package/dist/projection-events.js +44 -0
- package/dist/projection-placement.d.ts +14 -0
- package/dist/projection-placement.js +18 -0
- package/dist/runtime.d.ts +2 -0
- package/dist/runtime.js +11 -0
- package/dist/scroll-bindings.d.ts +11 -0
- package/dist/scroll-bindings.js +55 -0
- package/dist/scroll-placement.d.ts +5 -0
- package/dist/scroll-placement.js +85 -0
- package/dist/snapshot-styles.d.ts +2 -0
- package/dist/snapshot-styles.js +14 -0
- package/dist/types.d.ts +88 -0
- package/dist/types.js +1 -0
- package/dist/visibility.d.ts +6 -0
- package/dist/visibility.js +29 -0
- package/dist/web-projection-scope.d.ts +6 -0
- package/dist/web-projection-scope.js +8 -0
- package/dist/web-projection.d.ts +50 -0
- package/dist/web-projection.js +835 -0
- package/docs/cordova.md +125 -0
- package/ios/Sources/WebViewNativeUIEngine/ClippedView.swift +16 -0
- package/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +256 -0
- package/ios/Sources/WebViewNativeUIEngine/Frame.swift +15 -0
- package/ios/Sources/WebViewNativeUIEngine/NativeUIProjection.swift +311 -0
- package/ios/Sources/WebViewNativeUIEngine/Resources/native-ui-engine.js +14 -0
- package/ios/Sources/WebViewNativeUIEngine/ScrollBindings.swift +27 -0
- package/ios/Sources/WebViewNativeUIEngine/ScrollTracker.swift +117 -0
- package/package.json +64 -4
package/docs/cordova.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Cordova plugin integration
|
|
2
|
+
|
|
3
|
+
The same npm package, Swift library and Android AAR support Capacitor and Cordova. Engine is a library used by plugin maintainers, not a separately registered Cordova plugin. A Cordova application does not need `@capacitor/core` or a Capacitor native SDK.
|
|
4
|
+
|
|
5
|
+
The consuming plugin still owns its native view and the corresponding DOM, including placement, size and stacking order. Supply an existing selector such as `native-ad` or `#player`. The DOM may be added after initialization. Image preparation, scroll handling, overlays and restoration use the common Engine implementation.
|
|
6
|
+
|
|
7
|
+
## iOS
|
|
8
|
+
|
|
9
|
+
Use the same SwiftPM dependency described in the main README. In a Cordova SwiftPM plugin, declare both Cordova and Engine as dependencies of the plugin target; neither framework becomes a dependency of the Engine target.
|
|
10
|
+
|
|
11
|
+
```swift
|
|
12
|
+
import Cordova
|
|
13
|
+
import WebKit
|
|
14
|
+
import WebViewNativeUIEngine
|
|
15
|
+
|
|
16
|
+
@objc(ExamplePlugin)
|
|
17
|
+
class ExamplePlugin: CDVPlugin {
|
|
18
|
+
private var projection: NativeUIProjection?
|
|
19
|
+
private var nativeView: UIView?
|
|
20
|
+
|
|
21
|
+
override func pluginInitialize() {
|
|
22
|
+
do {
|
|
23
|
+
projection = try NativeUIProjection(
|
|
24
|
+
webView: webView as? WKWebView,
|
|
25
|
+
plugin: "ExamplePlugin",
|
|
26
|
+
projectionSelector: "#player",
|
|
27
|
+
receiveListeners: .init(
|
|
28
|
+
update: ["contentChanged"],
|
|
29
|
+
projection: .init(native: ["shown"], web: ["covered"], none: ["hidden"])
|
|
30
|
+
),
|
|
31
|
+
runtime: .cordova,
|
|
32
|
+
view: { [weak self] in self?.nativeView }
|
|
33
|
+
)
|
|
34
|
+
} catch {
|
|
35
|
+
// Keep the existing Web fallback when registration is unavailable.
|
|
36
|
+
NSLog("Native UI projection unavailable: %@", String(describing: error))
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
override func dispose() {
|
|
41
|
+
projection?.destroy()
|
|
42
|
+
projection = nil
|
|
43
|
+
super.dispose()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Forward the plugin's existing presentation/content notifications with `projection?.receive("shown")`, `receive("hidden")` or `receive("contentChanged")`. Put this alongside the existing callback/event delivery, or in the plugin's shared notification method. `receive` updates the Engine session only: it does not deliver a second application callback. A matching destination emitted before JS startup is retained for the initial connection.
|
|
49
|
+
|
|
50
|
+
Cordova iOS's built-in `WkWebView.convertFilePath` serves the prepared PNGs. Engine resolves this internally. No file plugin, additional permission or author-provided URL converter is required. The default allowed origins include `app://localhost`; pass the actual trusted origin if the app changes its scheme or hostname.
|
|
51
|
+
|
|
52
|
+
## Android
|
|
53
|
+
|
|
54
|
+
Declare the Maven dependency in the existing Cordova plugin Gradle configuration. Also declare `implementation 'androidx.webkit:webkit:1.14.0'` when using the `CordovaPluginPathHandler` signature; Cordova's internal WebKit dependency is not exposed on the plugin compile classpath. The plugin's `plugin.xml` must initialize it before the first WebView navigation:
|
|
55
|
+
|
|
56
|
+
```xml
|
|
57
|
+
<feature name="ExamplePlugin">
|
|
58
|
+
<param name="android-package" value="com.example.ExamplePlugin" />
|
|
59
|
+
<param name="onload" value="true" />
|
|
60
|
+
</feature>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```kotlin
|
|
64
|
+
class ExamplePlugin : CordovaPlugin() {
|
|
65
|
+
private var projection: NativeUIProjection? = null
|
|
66
|
+
private var nativeView: View? = null
|
|
67
|
+
|
|
68
|
+
override fun pluginInitialize() {
|
|
69
|
+
val web = webView.view as WebView
|
|
70
|
+
projection = runCatching {
|
|
71
|
+
// One origin-restricted channel per WebView, shared with later registrations.
|
|
72
|
+
NativeUIProjection.install(web)
|
|
73
|
+
NativeUIProjection(
|
|
74
|
+
window = cordova.activity.window,
|
|
75
|
+
webView = web,
|
|
76
|
+
plugin = serviceName,
|
|
77
|
+
projectionSelector = "#player",
|
|
78
|
+
receiveListeners = NativeUIProjection.ReceiveListeners(
|
|
79
|
+
update = listOf("contentChanged"),
|
|
80
|
+
projection = NativeUIProjection.ReceiveListeners.Projection(
|
|
81
|
+
native = listOf("shown"), web = listOf("covered"), none = listOf("hidden")
|
|
82
|
+
)
|
|
83
|
+
),
|
|
84
|
+
runtime = NativeUIProjection.Runtime.CORDOVA,
|
|
85
|
+
view = { nativeView }
|
|
86
|
+
)
|
|
87
|
+
}.onFailure {
|
|
88
|
+
android.util.Log.e(javaClass.simpleName, "Native UI projection unavailable", it)
|
|
89
|
+
}.getOrNull()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
override fun getPathHandler() = CordovaPluginPathHandler { path ->
|
|
93
|
+
projection?.handleResource(path)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
override fun onDestroy() {
|
|
97
|
+
projection?.close()
|
|
98
|
+
projection = null
|
|
99
|
+
super.onDestroy()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Use `projection?.receive(eventName, data)` at the existing notification point, as on iOS. Native view creation and placement remain on the UI thread.
|
|
105
|
+
|
|
106
|
+
The `PathHandler` delegates same-origin image requests to Engine. It serves only the registration's PNG files in app-private cache storage; unrelated paths return `null`. If the plugin already has a handler, combine them with `projection?.handleResource(path) ?: existingHandler.handle(path)`. For multiple registrations, delegate to the matching owner. Keep the handler installed for the WebView lifetime. No WebViewClient replacement or storage permission is needed.
|
|
107
|
+
|
|
108
|
+
The defaults support Cordova's `https://localhost` and `http://localhost`. If the hostname changes, pass the same explicit origin set to `install` and each registration. Wildcard origins and `file://` app origins are not supported by this integration.
|
|
109
|
+
|
|
110
|
+
## Lifetime and verification
|
|
111
|
+
|
|
112
|
+
Keep the registration for the WebView lifetime. Engine reconnects after a document reload and clears the old document's image state. If the plugin destroys its native UI in `onReset`, update its view provider and emit the appropriate destination; do not leave a registration pointing at a destroyed view. Dispose registrations when the plugin/WebView is destroyed.
|
|
113
|
+
|
|
114
|
+
Capacitor registrations retain their existing default. The Cordova integration requires the standard Cordova WebView resource handler. Running a Cordova plugin inside a Capacitor app is not covered by this integration; the host must use the Capacitor integration instead. Ionic overlay observation remains optional and shared through `enableSupportIonicOverlays`; plain Cordova apps need not enable it.
|
|
115
|
+
|
|
116
|
+
The integration fixture uses Cordova CLI 13, cordova-ios 8.1.1 and cordova-android 15.1.0 with their standard WebViews. It checks actual PNG handoff, native restoration and hidden-state retention across document reload, not merely JS bridge mocks. Older Cordova releases and custom WebView engines require separate verification.
|
|
117
|
+
|
|
118
|
+
Run the real consumer checks with an already booted simulator/emulator:
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
npm run test:cordova -- ios SIMULATOR_UDID
|
|
122
|
+
npm run test:cordova -- android emulator-5554
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The script creates a disposable app, uses local SwiftPM/Maven Engine artifacts, and records the application result in the printed temporary directory. It never publishes to npm or Maven. Android requires JDK 21+, SDK 36 and `ANDROID_HOME`; iOS requires Xcode.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
/// Keeps native content at full size while clipping hit testing and visible pixels.
|
|
3
|
+
public final class NativeUIClippedView: UIView {
|
|
4
|
+
public let content: UIView
|
|
5
|
+
public init(content: UIView) { self.content = content; super.init(frame: .zero); addSubview(content) }
|
|
6
|
+
@available(*, unavailable) required init?(coder: NSCoder) { fatalError() }
|
|
7
|
+
public func place(_ frame: CGRect, clip: CGRect? = nil) {
|
|
8
|
+
clipsToBounds = clip != nil
|
|
9
|
+
let visible = clip.map { frame.intersection($0) } ?? frame
|
|
10
|
+
isHidden = visible.isNull || visible.isEmpty
|
|
11
|
+
guard !isHidden else { return }
|
|
12
|
+
if self.frame != visible { self.frame = visible }
|
|
13
|
+
let local = CGRect(x: frame.minX - visible.minX, y: frame.minY - visible.minY, width: frame.width, height: frame.height)
|
|
14
|
+
if content.frame != local { content.frame = local }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Immutable, locally served images of controls that will be covered by a Web overlay.
|
|
4
|
+
/// Native interaction invalidates in-flight captures before Web receives the activation.
|
|
5
|
+
public final class NativeUICoveredSnapshots {
|
|
6
|
+
private struct Part {
|
|
7
|
+
let ids: [String]
|
|
8
|
+
let frame: CGRect
|
|
9
|
+
let image: UIImage
|
|
10
|
+
let file: URL
|
|
11
|
+
}
|
|
12
|
+
private struct Capture {
|
|
13
|
+
let size: CGSize
|
|
14
|
+
let webFrame: CGRect
|
|
15
|
+
let parts: [Part]
|
|
16
|
+
}
|
|
17
|
+
private let snapshotView: (UIWindow) -> UIView
|
|
18
|
+
public convenience init() {
|
|
19
|
+
self.init(snapshotView: { $0.screen.snapshotView(afterScreenUpdates: false) })
|
|
20
|
+
}
|
|
21
|
+
init(snapshotView: @escaping (UIWindow) -> UIView) {
|
|
22
|
+
self.snapshotView = snapshotView
|
|
23
|
+
}
|
|
24
|
+
private var captures: [String: Capture] = [:]
|
|
25
|
+
private var restoration: (token: String, views: [UIImageView], entry: Bool)?
|
|
26
|
+
private var hiddenControls: (token: String, restore: [() -> Void])?
|
|
27
|
+
private var generation = 0
|
|
28
|
+
private var touching = false
|
|
29
|
+
private weak var window: UIWindow?
|
|
30
|
+
private var observer: SnapshotTouchObserver?
|
|
31
|
+
private let directory = FileManager.default.temporaryDirectory.appendingPathComponent("native-ui-snapshots-\(UUID().uuidString)")
|
|
32
|
+
public private(set) var coveringToken: String?
|
|
33
|
+
public var covering: Bool { coveringToken != nil }
|
|
34
|
+
|
|
35
|
+
public func observe(_ window: UIWindow, changed: @escaping (Bool) -> Void) {
|
|
36
|
+
guard self.window !== window else { return }
|
|
37
|
+
if let observer { self.window?.removeGestureRecognizer(observer) }
|
|
38
|
+
self.window = window
|
|
39
|
+
let observer = SnapshotTouchObserver { [weak self] active in
|
|
40
|
+
guard let self else { return }
|
|
41
|
+
self.touching = active
|
|
42
|
+
if active { self.generation += 1 }
|
|
43
|
+
changed(active)
|
|
44
|
+
}
|
|
45
|
+
window.addGestureRecognizer(observer)
|
|
46
|
+
self.observer = observer
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Gesture release can keep changing SwiftUI button bounds after touchesEnded.
|
|
50
|
+
// Wait off the presentation path for unchanged rendered geometry, not a guessed delay.
|
|
51
|
+
public func prepare(frames: @escaping () -> [String: CGRect], in webView: UIView, viewportWidth: CGFloat,
|
|
52
|
+
complete: @escaping ([String: Any]) -> Void) {
|
|
53
|
+
let stamp = generation
|
|
54
|
+
let start = CACurrentMediaTime()
|
|
55
|
+
func sample(_ previous: [String: CGRect], _ stable: Int) {
|
|
56
|
+
guard generation == stamp, !touching, CACurrentMediaTime() - start < 1 else { complete([:]); return }
|
|
57
|
+
let current = frames()
|
|
58
|
+
let count = !current.isEmpty && current == previous ? stable + 1 : 0
|
|
59
|
+
if count >= 3 {
|
|
60
|
+
capture(frames: current, in: webView, viewportWidth: viewportWidth, complete: complete)
|
|
61
|
+
} else {
|
|
62
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0 / 60.0) { sample(current, count) }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
sample([:], 0)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private func capture(frames: [String: CGRect], in webView: UIView, viewportWidth: CGFloat,
|
|
69
|
+
complete: @escaping ([String: Any]) -> Void) {
|
|
70
|
+
guard !touching, let window = webView.window, !frames.isEmpty,
|
|
71
|
+
viewportWidth > 0, webView.bounds.width > 0 else { complete([:]); return }
|
|
72
|
+
let stamp = generation
|
|
73
|
+
let scale = window.screen.scale
|
|
74
|
+
let ratio = viewportWidth / webView.bounds.width
|
|
75
|
+
let size = window.bounds.size
|
|
76
|
+
let webFrame = webView.convert(webView.bounds, to: window)
|
|
77
|
+
// Each crop belongs to one source component. Overlapping crops share
|
|
78
|
+
// the same captured pixels and never combine unrelated components.
|
|
79
|
+
var regions: [(ids: [String], rect: CGRect)] = []
|
|
80
|
+
for (id, frame) in frames.sorted(by: { $0.key < $1.key }) {
|
|
81
|
+
var rect = webView.convert(frame, to: window).insetBy(dx: -12, dy: -12).intersection(window.bounds)
|
|
82
|
+
guard !rect.isNull, rect.width > 0, rect.height > 0 else { complete([:]); return }
|
|
83
|
+
rect = CGRect(x: floor(rect.minX * scale) / scale, y: floor(rect.minY * scale) / scale,
|
|
84
|
+
width: (ceil(rect.maxX * scale) - floor(rect.minX * scale)) / scale,
|
|
85
|
+
height: (ceil(rect.maxY * scale) - floor(rect.minY * scale)) / scale)
|
|
86
|
+
regions.append(([id], rect))
|
|
87
|
+
}
|
|
88
|
+
let bounds = regions.reduce(CGRect.null) { $0.union($1.rect) }
|
|
89
|
+
let format = UIGraphicsImageRendererFormat()
|
|
90
|
+
format.scale = scale
|
|
91
|
+
format.opaque = true
|
|
92
|
+
// Render the compositor's frozen screen, not UIKit's reconstruction of
|
|
93
|
+
// the hierarchy: the latter loses Liquid Glass background refraction.
|
|
94
|
+
// The replica remains detached and never changes visible UI or input.
|
|
95
|
+
let replica = snapshotView(window)
|
|
96
|
+
let screenBounds = window.convert(bounds, to: window.screen.coordinateSpace)
|
|
97
|
+
var drawn = false
|
|
98
|
+
let image = UIGraphicsImageRenderer(size: bounds.size, format: format).image { context in
|
|
99
|
+
context.cgContext.translateBy(x: -screenBounds.minX, y: -screenBounds.minY)
|
|
100
|
+
drawn = replica.drawHierarchy(in: window.screen.bounds, afterScreenUpdates: true)
|
|
101
|
+
}
|
|
102
|
+
guard drawn, let pixels = image.cgImage else { complete([:]); return }
|
|
103
|
+
let token = UUID().uuidString
|
|
104
|
+
let directory = self.directory
|
|
105
|
+
let crops = regions.compactMap { region -> (ids: [String], frame: CGRect, image: UIImage)? in
|
|
106
|
+
let crop = CGRect(x: (region.rect.minX - bounds.minX) * scale, y: (region.rect.minY - bounds.minY) * scale,
|
|
107
|
+
width: region.rect.width * scale, height: region.rect.height * scale)
|
|
108
|
+
guard let part = pixels.cropping(to: crop) else { return nil }
|
|
109
|
+
return (region.ids, window.convert(region.rect, to: webView), UIImage(cgImage: part, scale: scale, orientation: .up))
|
|
110
|
+
}
|
|
111
|
+
guard crops.count == regions.count else { complete([:]); return }
|
|
112
|
+
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
113
|
+
var parts: [Part] = []
|
|
114
|
+
do {
|
|
115
|
+
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
116
|
+
for (index, crop) in crops.enumerated() {
|
|
117
|
+
guard let png = crop.image.pngData() else { throw CocoaError(.fileWriteUnknown) }
|
|
118
|
+
let file = directory.appendingPathComponent("\(token)-\(index).png")
|
|
119
|
+
try png.write(to: file, options: .atomic)
|
|
120
|
+
parts.append(Part(ids: crop.ids, frame: crop.frame, image: crop.image, file: file))
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
parts.forEach { try? FileManager.default.removeItem(at: $0.file) }
|
|
124
|
+
parts = []
|
|
125
|
+
}
|
|
126
|
+
DispatchQueue.main.async {
|
|
127
|
+
guard let self, self.generation == stamp, !self.touching,
|
|
128
|
+
window.bounds.size == size, webView.convert(webView.bounds, to: window) == webFrame, parts.count == crops.count else {
|
|
129
|
+
parts.forEach { try? FileManager.default.removeItem(at: $0.file) }
|
|
130
|
+
complete([:]); return
|
|
131
|
+
}
|
|
132
|
+
self.captures[token] = Capture(size: size, webFrame: webFrame, parts: parts)
|
|
133
|
+
complete(["token": token, "parts": parts.map { part in
|
|
134
|
+
["ids": part.ids, "url": part.file.absoluteString,
|
|
135
|
+
"x": part.frame.minX * ratio, "y": part.frame.minY * ratio,
|
|
136
|
+
"width": part.frame.width * ratio, "height": part.frame.height * ratio] as [String: Any]
|
|
137
|
+
}])
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// Install prepared pixels before hiding live views, within the same main-thread turn.
|
|
143
|
+
/// A transient (moving) surface does not replace background covering ownership.
|
|
144
|
+
@discardableResult public func cover(_ token: String, in webView: UIView, views: [UIView], transient: Bool = false) -> Bool {
|
|
145
|
+
guard restore(in: webView, token: token, entry: true) else { return false }
|
|
146
|
+
if !transient { coveringToken = token }
|
|
147
|
+
hide(views, for: token)
|
|
148
|
+
return true
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
public var hidesNativeControls: Bool { hiddenControls != nil }
|
|
152
|
+
|
|
153
|
+
private func hide(_ views: [UIView], for token: String) {
|
|
154
|
+
generation += 1
|
|
155
|
+
restoreControls()
|
|
156
|
+
hiddenControls = (token, views.map { view in
|
|
157
|
+
let hidden = view.isHidden
|
|
158
|
+
view.isHidden = true
|
|
159
|
+
return { [weak view] in view?.isHidden = hidden }
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Undo only the entry handoff's mutation; normal update owns subsequent visibility.
|
|
164
|
+
public func restoreControls() {
|
|
165
|
+
hiddenControls?.restore.forEach { $0() }
|
|
166
|
+
hiddenControls = nil
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
public func identifiers(for token: String) -> Set<String> { Set(captures[token]?.parts.flatMap { $0.ids } ?? []) }
|
|
170
|
+
|
|
171
|
+
// Bridge the entry or return with the already prepared pixels until Web retires the copy.
|
|
172
|
+
// No capture, encoding, or decoding runs here.
|
|
173
|
+
public func restore(in webView: UIView, token explicitToken: String? = nil, frames: [String: CGRect]? = nil, entry: Bool = false) -> Bool {
|
|
174
|
+
guard let token = explicitToken ?? coveringToken, let capture = captures[token], let window = webView.window,
|
|
175
|
+
window.bounds.size == capture.size, webView.convert(webView.bounds, to: window) == capture.webFrame else { return false }
|
|
176
|
+
if let frames, capture.parts.contains(where: { part in !part.ids.allSatisfy { frames[$0] != nil } }) { return false }
|
|
177
|
+
clearRestoration()
|
|
178
|
+
let views = capture.parts.map { part in
|
|
179
|
+
let view = UIImageView(image: part.image)
|
|
180
|
+
view.frame = webView.convert(frames?[part.ids[0]] ?? part.frame, to: window)
|
|
181
|
+
view.isUserInteractionEnabled = false
|
|
182
|
+
view.isAccessibilityElement = false
|
|
183
|
+
window.addSubview(view)
|
|
184
|
+
return view
|
|
185
|
+
}
|
|
186
|
+
restoration = (token, views, entry)
|
|
187
|
+
// A lost cleanup message must never leave an obsolete cover on live UI.
|
|
188
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
189
|
+
guard self?.restoration?.views.first === views.first else { return }
|
|
190
|
+
self?.clearRestoration()
|
|
191
|
+
}
|
|
192
|
+
return true
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
public func retireCopy(_ token: String, entry: Bool = true) {
|
|
196
|
+
// A late entry acknowledgement cannot remove the copy used on return.
|
|
197
|
+
guard restoration?.entry == entry, restoration?.token == token else { return }
|
|
198
|
+
clearRestoration()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private func clearRestoration() {
|
|
202
|
+
restoration?.views.forEach { $0.removeFromSuperview() }
|
|
203
|
+
restoration = nil
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
public func finishCovering() { coveringToken = nil }
|
|
207
|
+
|
|
208
|
+
public func release(_ token: String) {
|
|
209
|
+
if hiddenControls?.token == token { restoreControls() }
|
|
210
|
+
if coveringToken == token { coveringToken = nil }
|
|
211
|
+
if restoration?.token == token { clearRestoration() }
|
|
212
|
+
captures.removeValue(forKey: token)?.parts.forEach { try? FileManager.default.removeItem(at: $0.file) }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
public func reset() {
|
|
216
|
+
restoreControls()
|
|
217
|
+
if let observer { window?.removeGestureRecognizer(observer) }
|
|
218
|
+
observer = nil
|
|
219
|
+
window = nil
|
|
220
|
+
touching = false
|
|
221
|
+
generation += 1
|
|
222
|
+
coveringToken = nil
|
|
223
|
+
Array(captures.keys).forEach(release)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
deinit {
|
|
227
|
+
if let observer { window?.removeGestureRecognizer(observer) }
|
|
228
|
+
try? FileManager.default.removeItem(at: directory)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/// Observes the start of interaction without recognizing or delaying the app's gestures.
|
|
233
|
+
private final class SnapshotTouchObserver: UIGestureRecognizer {
|
|
234
|
+
private let changed: (Bool) -> Void
|
|
235
|
+
private var touches = Set<UITouch>()
|
|
236
|
+
init(changed: @escaping (Bool) -> Void) {
|
|
237
|
+
self.changed = changed
|
|
238
|
+
super.init(target: nil, action: nil)
|
|
239
|
+
cancelsTouchesInView = false
|
|
240
|
+
delaysTouchesBegan = false
|
|
241
|
+
delaysTouchesEnded = false
|
|
242
|
+
}
|
|
243
|
+
override func canPrevent(_ other: UIGestureRecognizer) -> Bool { false }
|
|
244
|
+
override func canBePrevented(by other: UIGestureRecognizer) -> Bool { false }
|
|
245
|
+
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
|
|
246
|
+
let first = self.touches.isEmpty
|
|
247
|
+
self.touches.formUnion(touches)
|
|
248
|
+
if first { changed(true) }
|
|
249
|
+
}
|
|
250
|
+
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) { finish(touches) }
|
|
251
|
+
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) { finish(touches) }
|
|
252
|
+
private func finish(_ touches: Set<UITouch>) {
|
|
253
|
+
self.touches.subtract(touches)
|
|
254
|
+
if self.touches.isEmpty { changed(false); state = .failed }
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import CoreGraphics
|
|
2
|
+
public struct NativeUIFrame: Codable, Equatable {
|
|
3
|
+
public let x: Double
|
|
4
|
+
public let y: Double
|
|
5
|
+
public let width: Double
|
|
6
|
+
public let height: Double
|
|
7
|
+
public init(x: Double, y: Double, width: Double, height: Double) { self.x = x; self.y = y; self.width = width; self.height = height }
|
|
8
|
+
public var rect: CGRect { CGRect(x: x, y: y, width: width, height: height) }
|
|
9
|
+
public var isValid: Bool { [x, y, width, height].allSatisfy(\.isFinite) && width > 0 && height > 0 }
|
|
10
|
+
}
|
|
11
|
+
public struct NativeUIScrollNode: Decodable {
|
|
12
|
+
public let id: String
|
|
13
|
+
public let scroll: NativeUIScrollPlacement?
|
|
14
|
+
public init(id: String, scroll: NativeUIScrollPlacement?) { self.id = id; self.scroll = scroll }
|
|
15
|
+
}
|