@swiftbrowser/native 0.18.3 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -11
- package/bin/swiftbrowser-native.mjs +47 -0
- package/dist/blob.d.ts +85 -11
- package/dist/blob.d.ts.map +1 -1
- package/dist/blob.js +184 -34
- package/dist/blob.js.map +1 -1
- package/dist/bridge.d.ts +66 -8
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/encoding.d.ts +13 -0
- package/dist/encoding.d.ts.map +1 -1
- package/dist/encoding.js +24 -0
- package/dist/encoding.js.map +1 -1
- package/dist/fetch.d.ts +27 -13
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +117 -45
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +24 -6
- package/dist/hostRequests.d.ts.map +1 -1
- package/dist/hostRequests.js +108 -15
- package/dist/hostRequests.js.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -2
- package/dist/index.js.map +1 -1
- package/dist/ios.d.ts +79 -0
- package/dist/ios.d.ts.map +1 -0
- package/dist/ios.js +181 -0
- package/dist/ios.js.map +1 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +16 -7
- package/dist/runtime.js.map +1 -1
- package/dist/subtle.d.ts +1 -1
- package/dist/subtle.d.ts.map +1 -1
- package/dist/subtle.js +9 -5
- package/dist/subtle.js.map +1 -1
- package/dist/webSocket.d.ts +12 -5
- package/dist/webSocket.d.ts.map +1 -1
- package/dist/webSocket.js +37 -11
- package/dist/webSocket.js.map +1 -1
- package/package.json +7 -3
- package/src/blob.ts +215 -34
- package/src/bridge.ts +56 -8
- package/src/encoding.ts +22 -0
- package/src/fetch.ts +111 -48
- package/src/hostRequests.ts +129 -22
- package/src/index.ts +1 -2
- package/src/ios.ts +253 -0
- package/src/runtime.ts +23 -8
- package/src/subtle.ts +10 -6
- package/src/webSocket.ts +37 -13
- package/swift/Packages/SwiftBrowserDSP/Package.swift +31 -0
- package/swift/Packages/SwiftBrowserDSP/README.md +42 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +306 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ScopeView.swift +79 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +1561 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +483 -0
- package/swift/Packages/SwiftBrowserLocalModel/Package.swift +35 -0
- package/swift/Packages/SwiftBrowserLocalModel/README.md +66 -0
- package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LlamaEngine.swift +223 -0
- package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LocalLanguageModel.swift +409 -0
- package/swift/Packages/SwiftBrowserShader/Package.swift +31 -0
- package/swift/Packages/SwiftBrowserShader/README.md +45 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderRenderer.swift +103 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderView.swift +168 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShaderCore/ShaderProps.swift +120 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineBridge.swift +921 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineNodes.swift +70 -0
- package/swift/Sources/SwiftBrowserNative/Host/BodyStream.swift +166 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +144 -10
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +151 -27
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +139 -40
- package/swift/Sources/SwiftBrowserNative/Host/JSHost.swift +116 -0
- package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +141 -2
- package/swift/Sources/SwiftBrowserNative/Host/LanguageModelBridge.swift +511 -0
- package/swift/Sources/SwiftBrowserNative/Host/LanguageModelProvider.swift +195 -0
- package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +24 -11
- package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +3 -2
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeFunctions.swift +1 -1
- package/swift/Sources/SwiftBrowserNative/Runtime/ViewRegistry.swift +57 -0
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +12 -7
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +18 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Drawing.swift +378 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +41 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +88 -17
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +87 -21
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PinchTracker.swift +200 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +103 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +3 -2
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +146 -0
- package/swift/Sources/SwiftBrowserProtocol/Drawing.swift +306 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +31 -2
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +24 -3
- package/swift/Sources/SwiftBrowserProtocol/Style.swift +87 -0
- package/swift/Sources/SwiftBrowserTree/OpTree.swift +25 -0
- package/wasm/SwiftBrowserNative.wasm +2 -2
- package/dist/native.d.ts +0 -19
- package/dist/native.d.ts.map +0 -1
- package/dist/native.js +0 -17
- package/dist/native.js.map +0 -1
- package/src/native.ts +0 -35
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeRegistry.swift +0 -53
|
@@ -7,9 +7,10 @@ import SwiftUI
|
|
|
7
7
|
/// `navstack`: the children are `navscreen`s, bottom to top. The protocol
|
|
8
8
|
/// expresses push and pop purely as children appearing and disappearing;
|
|
9
9
|
/// natively the path is derived from them. A native pop (the back button, an
|
|
10
|
-
/// edge swipe) sends `tap` with the popped screen's id, as the protocol says
|
|
11
|
-
///
|
|
12
|
-
///
|
|
10
|
+
/// edge swipe) sends `tap` with the popped screen's id, as the protocol says
|
|
11
|
+
/// (PopTap.swift: once UIKit's pop transition has finished, and not for a
|
|
12
|
+
/// cancelled swipe), and the app removes the child, so the next read of the
|
|
13
|
+
/// path agrees with the screen.
|
|
13
14
|
struct NavigationStackElement: View {
|
|
14
15
|
let node: ElementNode
|
|
15
16
|
@Environment(\.nativeApp) private var app
|
|
@@ -27,11 +28,14 @@ struct NavigationStackElement: View {
|
|
|
27
28
|
let screens = node?.children.filter { $0.kind == "navscreen" } ?? []
|
|
28
29
|
let pushed = screens.dropFirst().map(\.id)
|
|
29
30
|
let path = Binding<[Int]>(
|
|
30
|
-
|
|
31
|
+
// A screen whose pop is under way is left out, as SwiftUI's own
|
|
32
|
+
// path has it, while the tree keeps it until the pop has finished.
|
|
33
|
+
get: { node?.children.filter { $0.kind == "navscreen" }.dropFirst().map(\.id).filter { !PopTap.pending.contains($0) } ?? [] },
|
|
31
34
|
set: { next in
|
|
32
|
-
// Only pops come from here (pushes come from the app
|
|
35
|
+
// Only pops come from here (pushes come from the app; a
|
|
36
|
+
// cancelled swipe puts its id back).
|
|
33
37
|
for id in pushed where !next.contains(id) {
|
|
34
|
-
|
|
38
|
+
PopTap.send(id, app: app)
|
|
35
39
|
}
|
|
36
40
|
}
|
|
37
41
|
)
|
|
@@ -43,10 +47,14 @@ struct NavigationStackElement: View {
|
|
|
43
47
|
}
|
|
44
48
|
}
|
|
45
49
|
.navigationDestination(for: Int.self) { id in
|
|
46
|
-
|
|
50
|
+
// A screen the app has popped already (on the tap a back
|
|
51
|
+
// swipe or button sent, before the pop's transition has
|
|
52
|
+
// run) is still drawn as it was, from the tree's record
|
|
53
|
+
// of it, so the pop slides it away rather than dropping
|
|
54
|
+
// its content in one frame.
|
|
55
|
+
if let screen = node?.children.first(where: { $0.id == id }) ?? app?.tree.poppedScreen(id) {
|
|
47
56
|
NavigationScreenElement(node: screen)
|
|
48
57
|
} else {
|
|
49
|
-
// Popped by the app already; SwiftUI is on its way out.
|
|
50
58
|
Color.clear
|
|
51
59
|
}
|
|
52
60
|
}
|
|
@@ -106,6 +114,16 @@ struct NavigationSplitElement: View {
|
|
|
106
114
|
struct NavigationScreenElement: View {
|
|
107
115
|
let node: ElementNode
|
|
108
116
|
@Environment(\.nativeApp) private var app
|
|
117
|
+
/// The bar's items as the push began, shown until it settles (`held`
|
|
118
|
+
/// below): iOS sizes an item's glass as a push starts and lays it out
|
|
119
|
+
/// again only once the push ends, so an item that changes meanwhile (an
|
|
120
|
+
/// Edit button arriving with the rows) would sit clipped in the old shape
|
|
121
|
+
/// until then. Held, the change shows after the push, laid out whole.
|
|
122
|
+
@State private var held: [ToolbarSnapshot]?
|
|
123
|
+
@State private var settled = false
|
|
124
|
+
|
|
125
|
+
/// How long a push takes, with a margin.
|
|
126
|
+
private static let pushSettles: Duration = .milliseconds(600)
|
|
109
127
|
|
|
110
128
|
var body: some View {
|
|
111
129
|
let props = NavScreenProps(props: node.props)
|
|
@@ -122,10 +140,22 @@ struct NavigationScreenElement: View {
|
|
|
122
140
|
let content = ZStack {
|
|
123
141
|
ForEach(node.children(except: ["toolbar", "searchfield"])) { ElementView(node: $0) }
|
|
124
142
|
}
|
|
143
|
+
let live = toolbars.map(ToolbarSnapshot.init)
|
|
144
|
+
let shown = props.depth > 0 && !settled ? (held ?? live) : live
|
|
125
145
|
let titled = content
|
|
126
146
|
.modifier(KeyboardBarInset(toolbars: keyboardToolbars))
|
|
127
147
|
.navigationTitle(props.title)
|
|
128
|
-
.toolbar { ScreenToolbar(toolbars:
|
|
148
|
+
.toolbar { ScreenToolbar(toolbars: shown) }
|
|
149
|
+
.onAppear {
|
|
150
|
+
// A pushed screen, appearing: its bar as it is now, until the push has settled.
|
|
151
|
+
guard props.depth > 0, !settled, held == nil else { return }
|
|
152
|
+
held = live
|
|
153
|
+
Task { @MainActor in
|
|
154
|
+
try? await Task.sleep(for: Self.pushSettles)
|
|
155
|
+
settled = true
|
|
156
|
+
held = nil
|
|
157
|
+
}
|
|
158
|
+
}
|
|
129
159
|
let searched: AnyView = {
|
|
130
160
|
guard let search else { return AnyView(titled) }
|
|
131
161
|
let searchProps = SearchFieldProps(props: search.props)
|
|
@@ -135,8 +165,13 @@ struct NavigationScreenElement: View {
|
|
|
135
165
|
))
|
|
136
166
|
}()
|
|
137
167
|
#if os(iOS) || canImport(FoundationBridge)
|
|
168
|
+
// `.toolbar(.hidden, for:)` and `.statusBarHidden`: SwiftUI hides the
|
|
169
|
+
// bars (animated in an animated commit); `.automatic` leaves a shown one as it is.
|
|
138
170
|
let modeTitled = searched
|
|
139
171
|
.navigationBarTitleDisplayMode(props.displayMode == "inline" ? .inline : props.displayMode == "large" ? .large : .automatic)
|
|
172
|
+
.toolbar(props.navigationBarHidden ? .hidden : .automatic, for: .navigationBar)
|
|
173
|
+
.toolbar(props.bottomBarHidden ? .hidden : .automatic, for: .bottomBar)
|
|
174
|
+
.statusBarHidden(props.statusBarHidden)
|
|
140
175
|
#if canImport(FoundationBridge)
|
|
141
176
|
modeTitled // the shim has no `navigationBarBackButtonHidden` (nor does the protocol carry it)
|
|
142
177
|
#else
|
|
@@ -188,8 +223,19 @@ struct NavigationScreenElement: View {
|
|
|
188
223
|
/// group (and share the bar's glass) as the app's do. A toolbar content
|
|
189
224
|
/// builder takes no loop, so the elements go in by index, eight to a chunk
|
|
190
225
|
/// and the rest in the next.
|
|
226
|
+
/// A `toolbar` element with the items it had at one moment (`NavigationScreenElement.held`), or has now.
|
|
227
|
+
struct ToolbarSnapshot {
|
|
228
|
+
let node: ElementNode
|
|
229
|
+
let items: [ElementNode]
|
|
230
|
+
|
|
231
|
+
init(node: ElementNode) {
|
|
232
|
+
self.node = node
|
|
233
|
+
self.items = node.children
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
191
237
|
struct ScreenToolbar: ToolbarContent {
|
|
192
|
-
let toolbars: [
|
|
238
|
+
let toolbars: [ToolbarSnapshot]
|
|
193
239
|
var from = 0
|
|
194
240
|
|
|
195
241
|
var body: some ToolbarContent {
|
|
@@ -209,7 +255,7 @@ struct ScreenToolbar: ToolbarContent {
|
|
|
209
255
|
@ToolbarContentBuilder
|
|
210
256
|
private func item(_ offset: Int) -> some ToolbarContent {
|
|
211
257
|
if from + offset < toolbars.count {
|
|
212
|
-
ToolbarElement(
|
|
258
|
+
ToolbarElement(toolbar: toolbars[from + offset])
|
|
213
259
|
}
|
|
214
260
|
}
|
|
215
261
|
}
|
|
@@ -221,9 +267,10 @@ struct ScreenToolbar: ToolbarContent {
|
|
|
221
267
|
/// `ToolbarItem` iOS 26 shows only the first), without the bar's shared
|
|
222
268
|
/// glass when `sharedBackground` is `hidden` (iOS 26).
|
|
223
269
|
struct ToolbarElement: ToolbarContent {
|
|
224
|
-
let
|
|
270
|
+
let toolbar: ToolbarSnapshot
|
|
225
271
|
|
|
226
272
|
var body: some ToolbarContent {
|
|
273
|
+
let node = toolbar.node
|
|
227
274
|
let props = ToolbarProps(props: node.props)
|
|
228
275
|
let placement = NavigationScreenElement.placement(props.placement)
|
|
229
276
|
if let spacer = props.spacer {
|
|
@@ -234,12 +281,12 @@ struct ToolbarElement: ToolbarContent {
|
|
|
234
281
|
}
|
|
235
282
|
} else if props.sharedBackground == "hidden" {
|
|
236
283
|
if #available(iOS 26, macOS 26, *) {
|
|
237
|
-
ToolbarItemGroup(placement: placement) {
|
|
284
|
+
ToolbarItemGroup(placement: placement) { ForEach(toolbar.items) { ElementView(node: $0) } }.sharedBackgroundVisibility(.hidden)
|
|
238
285
|
} else {
|
|
239
|
-
ToolbarItemGroup(placement: placement) {
|
|
286
|
+
ToolbarItemGroup(placement: placement) { ForEach(toolbar.items) { ElementView(node: $0) } }
|
|
240
287
|
}
|
|
241
288
|
} else {
|
|
242
|
-
ToolbarItemGroup(placement: placement) {
|
|
289
|
+
ToolbarItemGroup(placement: placement) { ForEach(toolbar.items) { ElementView(node: $0) } }
|
|
243
290
|
}
|
|
244
291
|
}
|
|
245
292
|
}
|
|
@@ -250,6 +297,10 @@ struct ToolbarElement: ToolbarContent {
|
|
|
250
297
|
struct NavigationLinkElement: View {
|
|
251
298
|
let node: ElementNode
|
|
252
299
|
@Environment(\.nativeApp) private var app
|
|
300
|
+
@Environment(\.listSelectsRows) private var listSelects
|
|
301
|
+
#if os(iOS) || canImport(FoundationBridge)
|
|
302
|
+
@Environment(\.editMode) private var editMode
|
|
303
|
+
#endif
|
|
253
304
|
|
|
254
305
|
/// The wrappers a row's link may sit in and still be the row (the web
|
|
255
306
|
/// engine's transparent kinds): any other kind between it and the list
|
|
@@ -267,12 +318,24 @@ struct NavigationLinkElement: View {
|
|
|
267
318
|
return false
|
|
268
319
|
}
|
|
269
320
|
|
|
321
|
+
/// Whether the list around is in edit mode with a selection: a link row
|
|
322
|
+
/// is then selected by its tap (the row's `tag`, ListRowElement), not
|
|
323
|
+
/// followed, as iOS selects a `NavigationLink` row then. A split view
|
|
324
|
+
/// sidebar's link (`selects`) is its row's selection always: the cell
|
|
325
|
+
/// takes the touch and selects itself before the app pushes, so the
|
|
326
|
+
/// list does not change under the push's transition.
|
|
327
|
+
private func selecting(_ props: NavLinkProps) -> Bool {
|
|
328
|
+
#if os(iOS) || canImport(FoundationBridge)
|
|
329
|
+
listSelects && (props.selects || editMode?.wrappedValue.isEditing == true)
|
|
330
|
+
#else
|
|
331
|
+
listSelects && props.selects
|
|
332
|
+
#endif
|
|
333
|
+
}
|
|
334
|
+
|
|
270
335
|
var body: some View {
|
|
271
336
|
let props = NavLinkProps(props: node.props)
|
|
272
337
|
let inList = Self.isListRow(node)
|
|
273
|
-
|
|
274
|
-
app?.dispatch(.tap(node.id))
|
|
275
|
-
} label: {
|
|
338
|
+
let label = Group {
|
|
276
339
|
if inList {
|
|
277
340
|
// The disclosure chevron in the trailing 22 points, as measured
|
|
278
341
|
// against iOS's row: 13 pt semibold, 2 in from the content's edge
|
|
@@ -291,9 +354,12 @@ struct NavigationLinkElement: View {
|
|
|
291
354
|
ChildrenView(node: node)
|
|
292
355
|
}
|
|
293
356
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
357
|
+
let tappable: AnyView = inList && selecting(props)
|
|
358
|
+
? AnyView(label)
|
|
359
|
+
: AnyView(Button { app?.dispatch(.tap(node.id)) } label: { label }.buttonStyle(.plain))
|
|
360
|
+
tappable
|
|
361
|
+
.foregroundStyle(inList || props.plain ? AnyShapeStyle(HierarchicalShapeStyle.primary) : AnyShapeStyle(TintShapeStyle()))
|
|
362
|
+
.listRowBackground(props.selected ? Color.accentColor.opacity(0.15) : nil)
|
|
297
363
|
}
|
|
298
364
|
}
|
|
299
365
|
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#if canImport(SwiftUI)
|
|
2
|
+
import Foundation
|
|
3
|
+
import SwiftUI
|
|
4
|
+
|
|
5
|
+
/// A pinch taken from the fingers themselves (iOS 18's `SpatialEventGesture`,
|
|
6
|
+
/// Views/Kinds/More.swift), as the web renderer takes one from two pointers
|
|
7
|
+
/// (Web/src/pinch.ts): the scale is the distance between the first two
|
|
8
|
+
/// fingers over their distance when the second came down, the rotation the
|
|
9
|
+
/// turn of the line between them (unwrapped past a half turn), the location
|
|
10
|
+
/// their midpoint. The pinch begins the moment the scale has moved
|
|
11
|
+
/// `minimumScaleDelta` or the angle `minimumAngleDelta` (with no minimum, on
|
|
12
|
+
/// the first move), where SwiftUI's `MagnifyGesture` waits for the system's
|
|
13
|
+
/// recognizer to make up its mind, a good way into the spread. A third
|
|
14
|
+
/// finger waits; lifting one of the pair hands the pinch to it, the scale
|
|
15
|
+
/// unbroken, and lifting the last two ends it. A report that leaves a finger
|
|
16
|
+
/// out keeps it where it was. A reference, so a move redraws nothing.
|
|
17
|
+
final class PinchTracker {
|
|
18
|
+
struct Report: Equatable {
|
|
19
|
+
var phase: String
|
|
20
|
+
var scale: Double
|
|
21
|
+
/// Degrees.
|
|
22
|
+
var rotation: Double
|
|
23
|
+
var scaleVelocity: Double
|
|
24
|
+
var rotationVelocity: Double
|
|
25
|
+
var startLocation: CGPoint
|
|
26
|
+
var location: CGPoint
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// The window the velocities are taken over, as the web renderer's.
|
|
30
|
+
static let velocityWindow: TimeInterval = 0.1
|
|
31
|
+
|
|
32
|
+
let magnify: Bool
|
|
33
|
+
let rotate: Bool
|
|
34
|
+
let minimumScaleDelta: Double
|
|
35
|
+
/// Degrees.
|
|
36
|
+
let minimumAngleDelta: Double
|
|
37
|
+
|
|
38
|
+
private struct Base {
|
|
39
|
+
var a: AnyHashable
|
|
40
|
+
var b: AnyHashable
|
|
41
|
+
var distance: Double
|
|
42
|
+
var angle: Double
|
|
43
|
+
var midpoint: CGPoint
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// A finger on the element: where it was last reported.
|
|
47
|
+
struct Finger: Equatable {
|
|
48
|
+
var id: AnyHashable
|
|
49
|
+
var location: CGPoint
|
|
50
|
+
/// `active`, or `ended` / `cancelled` as the system reported it.
|
|
51
|
+
var active: Bool
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private var base: Base?
|
|
55
|
+
/// The fingers down, in order of arrival, at their last reported location.
|
|
56
|
+
private var fingers: [(id: AnyHashable, location: CGPoint)] = []
|
|
57
|
+
private var began = false
|
|
58
|
+
private var scale = 1.0
|
|
59
|
+
private var rotation = 0.0
|
|
60
|
+
private var lastAngle = 0.0
|
|
61
|
+
private var location = CGPoint.zero
|
|
62
|
+
private var samples: [(t: TimeInterval, scale: Double, rotation: Double)] = []
|
|
63
|
+
private var reported: Report?
|
|
64
|
+
|
|
65
|
+
init(magnify: Bool, rotate: Bool, minimumScaleDelta: Double, minimumAngleDelta: Double) {
|
|
66
|
+
self.magnify = magnify
|
|
67
|
+
self.rotate = rotate
|
|
68
|
+
self.minimumScaleDelta = max(0, minimumScaleDelta)
|
|
69
|
+
self.minimumAngleDelta = max(0, minimumAngleDelta)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// Whether two fingers are being followed (a pinch, begun or not).
|
|
73
|
+
var tracking: Bool { base != nil }
|
|
74
|
+
|
|
75
|
+
/// Takes the fingers now down (in the element's coordinates) at `time`;
|
|
76
|
+
/// what to send, or nil when there is nothing to say yet.
|
|
77
|
+
func update(active: [(id: AnyHashable, location: CGPoint)], cancelled: Bool, at time: TimeInterval) -> Report? {
|
|
78
|
+
update(fingers: active.map { Finger(id: $0.id, location: $0.location, active: true) }, cancelled: cancelled, at: time)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Takes the fingers the system reported at `time`: a finger it reports
|
|
82
|
+
/// as ended or cancelled has lifted, one it leaves out is where it was
|
|
83
|
+
/// (a report need not name every finger). What to send, or nil when
|
|
84
|
+
/// there is nothing to say yet.
|
|
85
|
+
func update(fingers reports: [Finger], cancelled: Bool, at time: TimeInterval) -> Report? {
|
|
86
|
+
for finger in reports {
|
|
87
|
+
if !finger.active {
|
|
88
|
+
fingers.removeAll { $0.id == finger.id }
|
|
89
|
+
} else if let index = fingers.firstIndex(where: { $0.id == finger.id }) {
|
|
90
|
+
fingers[index].location = finger.location
|
|
91
|
+
} else {
|
|
92
|
+
fingers.append((finger.id, finger.location))
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
guard let base else {
|
|
96
|
+
guard fingers.count >= 2 else { return nil }
|
|
97
|
+
let a = fingers[0]
|
|
98
|
+
let b = fingers[1]
|
|
99
|
+
let distance = max(Self.distance(a.location, b.location), 1)
|
|
100
|
+
let angle = Self.angle(a.location, b.location)
|
|
101
|
+
let midpoint = Self.midpoint(a.location, b.location)
|
|
102
|
+
self.base = Base(a: a.id, b: b.id, distance: distance, angle: angle, midpoint: midpoint)
|
|
103
|
+
scale = 1
|
|
104
|
+
rotation = 0
|
|
105
|
+
lastAngle = angle
|
|
106
|
+
location = midpoint
|
|
107
|
+
samples = [(time, 1, 0)]
|
|
108
|
+
return nil
|
|
109
|
+
}
|
|
110
|
+
var a = fingers.first { $0.id == base.a }?.location
|
|
111
|
+
var b = fingers.first { $0.id == base.b }?.location
|
|
112
|
+
if a == nil || b == nil {
|
|
113
|
+
// A finger of the pair lifted. With two fingers still down (the
|
|
114
|
+
// system gave one a new identity, or a third was waiting) the
|
|
115
|
+
// pinch carries on from where it is, its scale unbroken.
|
|
116
|
+
guard fingers.count >= 2 else { return end(cancelled: cancelled) }
|
|
117
|
+
let next = (fingers[0], fingers[1])
|
|
118
|
+
let distance = Self.distance(next.0.location, next.1.location)
|
|
119
|
+
let angle = Self.angle(next.0.location, next.1.location)
|
|
120
|
+
self.base = Base(a: next.0.id, b: next.1.id, distance: max(distance / max(scale, 0.0001), 1), angle: base.angle, midpoint: base.midpoint)
|
|
121
|
+
lastAngle = angle
|
|
122
|
+
a = next.0.location
|
|
123
|
+
b = next.1.location
|
|
124
|
+
}
|
|
125
|
+
guard let a, let b, let base = self.base else { return nil }
|
|
126
|
+
scale = Self.distance(a, b) / base.distance
|
|
127
|
+
let angle = Self.angle(a, b)
|
|
128
|
+
var delta = angle - lastAngle
|
|
129
|
+
if delta > 180 { delta -= 360 } else if delta <= -180 { delta += 360 }
|
|
130
|
+
rotation += delta
|
|
131
|
+
lastAngle = angle
|
|
132
|
+
location = Self.midpoint(a, b)
|
|
133
|
+
samples.append((time, scale, rotation))
|
|
134
|
+
while samples.count > 2, let first = samples.first, time - first.t > Self.velocityWindow { samples.removeFirst() }
|
|
135
|
+
if !began {
|
|
136
|
+
let scaled = magnify && abs(scale - 1) >= minimumScaleDelta
|
|
137
|
+
let turned = rotate && abs(rotation) >= minimumAngleDelta
|
|
138
|
+
guard scaled || turned else { return nil }
|
|
139
|
+
began = true
|
|
140
|
+
return report("began")
|
|
141
|
+
}
|
|
142
|
+
let previous = reported
|
|
143
|
+
let next = report("changed")
|
|
144
|
+
if let previous, previous.scale == next.scale, previous.rotation == next.rotation, previous.location == next.location { return nil }
|
|
145
|
+
return next
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// Every finger up (or the gesture cancelled): the end of a pinch that began.
|
|
149
|
+
func end(cancelled: Bool) -> Report? {
|
|
150
|
+
defer {
|
|
151
|
+
base = nil
|
|
152
|
+
fingers = []
|
|
153
|
+
began = false
|
|
154
|
+
samples = []
|
|
155
|
+
reported = nil
|
|
156
|
+
}
|
|
157
|
+
guard began else { return nil }
|
|
158
|
+
return report(cancelled ? "cancelled" : "ended")
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private func report(_ phase: String) -> Report {
|
|
162
|
+
let first = samples.first
|
|
163
|
+
let last = samples.last
|
|
164
|
+
let dt = first.map { f in (last?.t ?? f.t) - f.t } ?? 0
|
|
165
|
+
let scaleVelocity = first != nil && last != nil && dt > 0 ? (last!.scale - first!.scale) / dt : 0
|
|
166
|
+
let rotationVelocity = first != nil && last != nil && dt > 0 ? (last!.rotation - first!.rotation) / dt : 0
|
|
167
|
+
let out = Report(
|
|
168
|
+
phase: phase,
|
|
169
|
+
scale: Self.round(scale, 4),
|
|
170
|
+
rotation: Self.round(rotation, 2),
|
|
171
|
+
scaleVelocity: Self.round(scaleVelocity, 4),
|
|
172
|
+
rotationVelocity: Self.round(rotationVelocity, 2),
|
|
173
|
+
startLocation: base?.midpoint ?? .zero,
|
|
174
|
+
location: location
|
|
175
|
+
)
|
|
176
|
+
reported = out
|
|
177
|
+
return out
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private static func distance(_ a: CGPoint, _ b: CGPoint) -> Double {
|
|
181
|
+
let dx = Double(b.x - a.x)
|
|
182
|
+
let dy = Double(b.y - a.y)
|
|
183
|
+
return (dx * dx + dy * dy).squareRoot()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/// Degrees, as `atan2` gives them (counterclockwise on screen is negative, as the web's).
|
|
187
|
+
private static func angle(_ a: CGPoint, _ b: CGPoint) -> Double {
|
|
188
|
+
atan2(Double(b.y - a.y), Double(b.x - a.x)) * 180 / .pi
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private static func midpoint(_ a: CGPoint, _ b: CGPoint) -> CGPoint {
|
|
192
|
+
CGPoint(x: (a.x + b.x) / 2, y: (a.y + b.y) / 2)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private static func round(_ value: Double, _ places: Int) -> Double {
|
|
196
|
+
let factor = pow(10.0, Double(places))
|
|
197
|
+
return (value * factor).rounded() / factor
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
#endif
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#if canImport(SwiftUI)
|
|
2
|
+
import Foundation
|
|
3
|
+
import SwiftUI
|
|
4
|
+
#if os(iOS) && !canImport(FoundationBridge)
|
|
5
|
+
import UIKit
|
|
6
|
+
#endif
|
|
7
|
+
|
|
8
|
+
/// The `tap` a native pop sends with the popped screen's id (Navigation.swift),
|
|
9
|
+
/// timed to the pop. SwiftUI sets the stack's path as UIKit's pop begins (a
|
|
10
|
+
/// back button's tap, or the start of an edge swipe, before anyone knows
|
|
11
|
+
/// whether it will go through); the tap waits until the transition has
|
|
12
|
+
/// finished, and a cancelled swipe sends none. Meanwhile the screen is
|
|
13
|
+
/// `pending`: the stack's path leaves it out, as SwiftUI's own has, while
|
|
14
|
+
/// the tree still has it, so the departing screen is drawn as it is, title
|
|
15
|
+
/// included. Removed at once, it was drawn from the tree's record of it
|
|
16
|
+
/// (`OpTree.poppedScreen`) and UIKit dropped its large title in the pop's
|
|
17
|
+
/// first frame. Without a transition (a programmatic pop, or no navigation
|
|
18
|
+
/// controller to ask) the tap goes at once.
|
|
19
|
+
enum PopTap {
|
|
20
|
+
/// The screens whose pop is under way: their taps wait for its end.
|
|
21
|
+
@MainActor private(set) static var pending: Set<Int> = []
|
|
22
|
+
|
|
23
|
+
@MainActor
|
|
24
|
+
static func send(_ id: Int, app: NativeApp?) {
|
|
25
|
+
#if os(iOS) && !canImport(FoundationBridge)
|
|
26
|
+
if let coordinator = runningTransition() {
|
|
27
|
+
send(id, during: CoordinatorTransition(coordinator: coordinator)) { app?.dispatch(.tap($0)) }
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
#endif
|
|
31
|
+
app?.dispatch(.tap(id))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// The tap through the pop's transition: once it has finished, and only
|
|
35
|
+
/// when the pop went through; at once without one.
|
|
36
|
+
@MainActor
|
|
37
|
+
static func send(_ id: Int, during transition: (any PopTransition)?, dispatch: @escaping @MainActor (Int) -> Void) {
|
|
38
|
+
guard let transition else {
|
|
39
|
+
dispatch(id)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
guard !pending.contains(id) else { return }
|
|
43
|
+
pending.insert(id)
|
|
44
|
+
transition.whenFinished { cancelled in
|
|
45
|
+
pending.remove(id)
|
|
46
|
+
if !cancelled { dispatch(id) }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#if os(iOS) && !canImport(FoundationBridge)
|
|
51
|
+
/// The transition of a navigation controller in the app's windows, if one is running.
|
|
52
|
+
@MainActor
|
|
53
|
+
private static func runningTransition() -> (any UIViewControllerTransitionCoordinator)? {
|
|
54
|
+
let windows = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.flatMap(\.windows)
|
|
55
|
+
for window in windows {
|
|
56
|
+
for nav in navigationControllers(in: window) {
|
|
57
|
+
if let coordinator = nav.transitionCoordinator { return coordinator }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return nil
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// The navigation controllers behind the views in `window`, by their responder chains.
|
|
64
|
+
@MainActor
|
|
65
|
+
static func navigationControllers(in window: UIWindow) -> [UINavigationController] {
|
|
66
|
+
var found: [UINavigationController] = []
|
|
67
|
+
func visit(_ view: UIView) {
|
|
68
|
+
var responder: UIResponder? = view.next
|
|
69
|
+
while let r = responder {
|
|
70
|
+
if let nav = r as? UINavigationController, !found.contains(where: { $0 === nav }) { found.append(nav) }
|
|
71
|
+
responder = r.next
|
|
72
|
+
}
|
|
73
|
+
for subview in view.subviews { visit(subview) }
|
|
74
|
+
}
|
|
75
|
+
visit(window)
|
|
76
|
+
return found
|
|
77
|
+
}
|
|
78
|
+
#endif
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// A pop's transition as `PopTap` reads it: word of its end, and whether
|
|
82
|
+
/// the pop went through (UIKit's transition coordinator, or a test's).
|
|
83
|
+
@MainActor
|
|
84
|
+
protocol PopTransition {
|
|
85
|
+
/// Calls `handler` once the transition has finished, with whether the pop was cancelled.
|
|
86
|
+
func whenFinished(_ handler: @escaping @MainActor (_ cancelled: Bool) -> Void)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#if os(iOS) && !canImport(FoundationBridge)
|
|
90
|
+
/// UIKit's transition coordinator as a `PopTransition`.
|
|
91
|
+
@MainActor
|
|
92
|
+
struct CoordinatorTransition: PopTransition {
|
|
93
|
+
let coordinator: any UIViewControllerTransitionCoordinator
|
|
94
|
+
|
|
95
|
+
func whenFinished(_ handler: @escaping @MainActor (_ cancelled: Bool) -> Void) {
|
|
96
|
+
coordinator.animate(alongsideTransition: nil) { context in
|
|
97
|
+
let cancelled = context.isCancelled
|
|
98
|
+
MainActor.assumeIsolated { handler(cancelled) }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
#endif
|
|
103
|
+
#endif
|
|
@@ -204,6 +204,7 @@ struct ScrollViewElement: View {
|
|
|
204
204
|
let scroll = ScrollView(axes, showsIndicators: props.showsIndicators) {
|
|
205
205
|
ChildrenView(node: node)
|
|
206
206
|
}
|
|
207
|
+
.scrollDismissesKeyboard(props.dismissesKeyboard == "interactively" ? .interactively : props.dismissesKeyboard == "immediately" ? .immediately : .automatic)
|
|
207
208
|
let anchored = anchored(AnyView(scroll), props: props)
|
|
208
209
|
let refreshed: AnyView = props.refreshable ? AnyView(anchored.refreshable { app?.dispatch(.refresh(node.id)) }) : anchored
|
|
209
210
|
let snapped: AnyView = props.snap == "paging" ? AnyView(refreshed.scrollTargetBehavior(.paging))
|
|
@@ -382,7 +383,7 @@ struct ScrollViewElement: View {
|
|
|
382
383
|
var order: [Int: Int] = [:]
|
|
383
384
|
func visit(_ element: ElementNode) {
|
|
384
385
|
for child in element.children {
|
|
385
|
-
|
|
386
|
+
guard child.kind != "scrollview", child.kind != "list" else { continue }
|
|
386
387
|
if child.kind == "styled", child.style.scrollID { order[child.id] = order.count }
|
|
387
388
|
visit(child)
|
|
388
389
|
}
|
|
@@ -404,7 +405,7 @@ struct ScrollViewElement: View {
|
|
|
404
405
|
var best: ScrollRequest?
|
|
405
406
|
func visit(_ element: ElementNode) {
|
|
406
407
|
for child in element.children {
|
|
407
|
-
|
|
408
|
+
guard child.kind != "scrollview", child.kind != "list" else { continue }
|
|
408
409
|
if child.kind == "styled", let request = child.style.scrollTo, best == nil || request.serial > best!.serial {
|
|
409
410
|
best = ScrollRequest(serial: request.serial, id: child.id, anchor: request.anchor.map(UnitPoint.init(spec:)), axes: request.axes)
|
|
410
411
|
}
|