@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.
Files changed (102) hide show
  1. package/README.md +53 -11
  2. package/bin/swiftbrowser-native.mjs +47 -0
  3. package/dist/blob.d.ts +85 -11
  4. package/dist/blob.d.ts.map +1 -1
  5. package/dist/blob.js +184 -34
  6. package/dist/blob.js.map +1 -1
  7. package/dist/bridge.d.ts +66 -8
  8. package/dist/bridge.d.ts.map +1 -1
  9. package/dist/bridge.js.map +1 -1
  10. package/dist/encoding.d.ts +13 -0
  11. package/dist/encoding.d.ts.map +1 -1
  12. package/dist/encoding.js +24 -0
  13. package/dist/encoding.js.map +1 -1
  14. package/dist/fetch.d.ts +27 -13
  15. package/dist/fetch.d.ts.map +1 -1
  16. package/dist/fetch.js +117 -45
  17. package/dist/fetch.js.map +1 -1
  18. package/dist/hostRequests.d.ts +24 -6
  19. package/dist/hostRequests.d.ts.map +1 -1
  20. package/dist/hostRequests.js +108 -15
  21. package/dist/hostRequests.js.map +1 -1
  22. package/dist/index.d.ts +1 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -2
  25. package/dist/index.js.map +1 -1
  26. package/dist/ios.d.ts +79 -0
  27. package/dist/ios.d.ts.map +1 -0
  28. package/dist/ios.js +181 -0
  29. package/dist/ios.js.map +1 -0
  30. package/dist/runtime.d.ts.map +1 -1
  31. package/dist/runtime.js +16 -7
  32. package/dist/runtime.js.map +1 -1
  33. package/dist/subtle.d.ts +1 -1
  34. package/dist/subtle.d.ts.map +1 -1
  35. package/dist/subtle.js +9 -5
  36. package/dist/subtle.js.map +1 -1
  37. package/dist/webSocket.d.ts +12 -5
  38. package/dist/webSocket.d.ts.map +1 -1
  39. package/dist/webSocket.js +37 -11
  40. package/dist/webSocket.js.map +1 -1
  41. package/package.json +7 -3
  42. package/src/blob.ts +215 -34
  43. package/src/bridge.ts +56 -8
  44. package/src/encoding.ts +22 -0
  45. package/src/fetch.ts +111 -48
  46. package/src/hostRequests.ts +129 -22
  47. package/src/index.ts +1 -2
  48. package/src/ios.ts +253 -0
  49. package/src/runtime.ts +23 -8
  50. package/src/subtle.ts +10 -6
  51. package/src/webSocket.ts +37 -13
  52. package/swift/Packages/SwiftBrowserDSP/Package.swift +31 -0
  53. package/swift/Packages/SwiftBrowserDSP/README.md +42 -0
  54. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +306 -0
  55. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ScopeView.swift +79 -0
  56. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +1561 -0
  57. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +483 -0
  58. package/swift/Packages/SwiftBrowserLocalModel/Package.swift +35 -0
  59. package/swift/Packages/SwiftBrowserLocalModel/README.md +66 -0
  60. package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LlamaEngine.swift +223 -0
  61. package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LocalLanguageModel.swift +409 -0
  62. package/swift/Packages/SwiftBrowserShader/Package.swift +31 -0
  63. package/swift/Packages/SwiftBrowserShader/README.md +45 -0
  64. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderRenderer.swift +103 -0
  65. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderView.swift +168 -0
  66. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShaderCore/ShaderProps.swift +120 -0
  67. package/swift/Sources/SwiftBrowserNative/Host/AudioEngineBridge.swift +921 -0
  68. package/swift/Sources/SwiftBrowserNative/Host/AudioEngineNodes.swift +70 -0
  69. package/swift/Sources/SwiftBrowserNative/Host/BodyStream.swift +166 -0
  70. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +144 -10
  71. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +151 -27
  72. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +139 -40
  73. package/swift/Sources/SwiftBrowserNative/Host/JSHost.swift +116 -0
  74. package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +141 -2
  75. package/swift/Sources/SwiftBrowserNative/Host/LanguageModelBridge.swift +511 -0
  76. package/swift/Sources/SwiftBrowserNative/Host/LanguageModelProvider.swift +195 -0
  77. package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +24 -11
  78. package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +3 -2
  79. package/swift/Sources/SwiftBrowserNative/Runtime/NativeFunctions.swift +1 -1
  80. package/swift/Sources/SwiftBrowserNative/Runtime/ViewRegistry.swift +57 -0
  81. package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +12 -7
  82. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +18 -1
  83. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Drawing.swift +378 -0
  84. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +41 -1
  85. package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +88 -17
  86. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +87 -21
  87. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PinchTracker.swift +200 -0
  88. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +103 -0
  89. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +3 -2
  90. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +146 -0
  91. package/swift/Sources/SwiftBrowserProtocol/Drawing.swift +306 -0
  92. package/swift/Sources/SwiftBrowserProtocol/Props.swift +31 -2
  93. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +24 -3
  94. package/swift/Sources/SwiftBrowserProtocol/Style.swift +87 -0
  95. package/swift/Sources/SwiftBrowserTree/OpTree.swift +25 -0
  96. package/wasm/SwiftBrowserNative.wasm +2 -2
  97. package/dist/native.d.ts +0 -19
  98. package/dist/native.d.ts.map +0 -1
  99. package/dist/native.js +0 -17
  100. package/dist/native.js.map +0 -1
  101. package/src/native.ts +0 -35
  102. 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
- /// and the app removes the child in the same call, so the next read of the
12
- /// path already agrees with the screen.
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
- get: { node?.children.filter { $0.kind == "navscreen" }.dropFirst().map(\.id) ?? [] },
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
- app?.dispatch(.tap(id))
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
- if let screen = node?.children.first(where: { $0.id == id }) {
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: 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: [ElementNode]
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(node: toolbars[from + offset])
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 node: ElementNode
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) { ChildrenView(node: node) }.sharedBackgroundVisibility(.hidden)
284
+ ToolbarItemGroup(placement: placement) { ForEach(toolbar.items) { ElementView(node: $0) } }.sharedBackgroundVisibility(.hidden)
238
285
  } else {
239
- ToolbarItemGroup(placement: placement) { ChildrenView(node: node) }
286
+ ToolbarItemGroup(placement: placement) { ForEach(toolbar.items) { ElementView(node: $0) } }
240
287
  }
241
288
  } else {
242
- ToolbarItemGroup(placement: placement) { ChildrenView(node: node) }
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
- Button {
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
- .buttonStyle(.plain)
295
- .foregroundStyle(inList || props.plain ? AnyShapeStyle(HierarchicalShapeStyle.primary) : AnyShapeStyle(TintShapeStyle()))
296
- .listRowBackground(props.selected ? Color.accentColor.opacity(0.15) : nil)
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
- if child.kind == "scrollview" || child.kind == "list" { continue }
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
- if child.kind == "scrollview" || child.kind == "list" { continue }
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
  }