@swiftbrowser/native 0.17.0 → 0.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/native",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "type": "module",
5
5
  "description": "Run a @swiftbrowser/react app natively: the runtime a JavaScriptCore-hosted app needs (timers, console, fetch and the other web globals over the SwiftBrowser Native bridge), the `run` entry that renders into the native SwiftUI renderer, and the bundler that builds app.js for the Xcode target.",
6
6
  "repository": {
@@ -59,8 +59,8 @@
59
59
  },
60
60
  "dependencies": {
61
61
  "@babel/core": "^7.29.7",
62
- "@swiftbrowser/react": "0.17.0",
63
- "@swiftbrowser/web": "0.17.0",
62
+ "@swiftbrowser/react": "0.17.1",
63
+ "@swiftbrowser/web": "0.17.1",
64
64
  "esbuild": "^0.28.2",
65
65
  "react-refresh": "^0.17.0"
66
66
  },
@@ -13,23 +13,7 @@ struct ListElement: View {
13
13
 
14
14
  var body: some View {
15
15
  let props = ListProps(props: node.props)
16
- // Selectable rows are the list's selection, as `List(selection:)`
17
- // makes them: the whole cell takes the tap and shows the system's
18
- // highlight, and choosing a row sends its `tap`. In edit mode a row
19
- // keeps its own tap (several may be chosen).
20
- let rows = Self.selectableRows(in: node)
21
- let selects = !rows.isEmpty && props.editing != true
22
- let list: AnyView = selects
23
- ? AnyView(List(selection: Binding<Int?>(
24
- get: { rows.first { ListRowProps(props: $0.props).selected }?.id },
25
- set: { id in if let id { app?.dispatch(.tap(id)) } }
26
- )) {
27
- ListRowsView(children: node.children)
28
- }
29
- .environment(\.listSelectsRows, true))
30
- : AnyView(List {
31
- ListRowsView(children: node.children)
32
- })
16
+ let list = selectionList(props)
33
17
  let styled: AnyView = {
34
18
  switch props.style {
35
19
  case "plain": return AnyView(list.listStyle(.plain))
@@ -56,6 +40,51 @@ struct ListElement: View {
56
40
  #endif
57
41
  }
58
42
 
43
+ /// The `List`, with the selection its selectable rows make: as
44
+ /// `List(selection:)` makes them, the whole cell takes the tap and shows
45
+ /// the system's highlight, and a row the selection gains or loses sends
46
+ /// its `tap` (the app toggles or sets it). In edit mode a `Set`
47
+ /// selection is the system's multiple selection, with the checkmark
48
+ /// circles. Under the shim (the round trip) the shim's own list makes
49
+ /// rows of tags, so the rows keep their own taps there.
50
+ private func selectionList(_ props: ListProps) -> AnyView {
51
+ #if canImport(FoundationBridge)
52
+ return AnyView(List { ListRowsView(children: node.children) })
53
+ #else
54
+ let rows = Self.selectableRows(in: node)
55
+ let selected = rows.filter { ListRowProps(props: $0.props).selected }.map(\.id)
56
+ let editing = props.editing == true
57
+ if rows.isEmpty {
58
+ return AnyView(List { ListRowsView(children: node.children) })
59
+ }
60
+ if editing, props.multiSelect {
61
+ return AnyView(List(selection: Binding<Set<Int>>(
62
+ get: { Set(selected) },
63
+ set: { next in
64
+ for id in Set(selected).symmetricDifference(next) { app?.dispatch(.tap(id)) }
65
+ }
66
+ )) {
67
+ ListRowsView(children: node.children)
68
+ }
69
+ .environment(\.listSelectsRows, true))
70
+ }
71
+ return AnyView(List(selection: Binding<Int?>(
72
+ get: { selected.first },
73
+ set: { id in
74
+ if let id {
75
+ app?.dispatch(.tap(id))
76
+ } else if editing, let current = selected.first {
77
+ // Edit mode deselects a chosen row: the app clears it.
78
+ app?.dispatch(.tap(current))
79
+ }
80
+ }
81
+ )) {
82
+ ListRowsView(children: node.children)
83
+ }
84
+ .environment(\.listSelectsRows, true))
85
+ #endif
86
+ }
87
+
59
88
  /// The list's selectable rows (not those of a list inside it).
60
89
  static func selectableRows(in node: ElementNode) -> [ElementNode] {
61
90
  var rows: [ElementNode] = []
@@ -166,8 +195,8 @@ struct SectionElement: View {
166
195
  }
167
196
 
168
197
  /// `listrow`: the row's content with its edit-mode hints; a selectable
169
- /// row is the list's selection's tag (`ListElement`), or in edit mode sends
170
- /// `tap` itself.
198
+ /// row is the list's selection's tag (`ListElement`), or under the shim
199
+ /// sends `tap` itself.
171
200
  struct ListRowElement: View {
172
201
  let node: ElementNode
173
202
  @Environment(\.nativeApp) private var app
@@ -177,18 +177,41 @@ struct ScrollViewElement: View {
177
177
  private func perform(_ request: ScrollRequest?, props: ScrollViewProps, with proxy: ScrollViewProxy) {
178
178
  guard let request, request.serial > served else { return }
179
179
  served = request.serial
180
- position = request.id
180
+ if position == request.id {
181
+ // Already its position (the app, having put content in front,
182
+ // asks for the target again): the binding only scrolls on a
183
+ // change, so it lets go and takes the target back next turn.
184
+ position = nil
185
+ let id = request.id
186
+ Task { @MainActor in position = id }
187
+ } else {
188
+ position = request.id
189
+ }
190
+ if props.reportsPosition, Self.reportsSettled, request.id != reported {
191
+ // Where the request lands is the app's to hear once the scroll is
192
+ // over: an animated one ends in an idle phase (which reports);
193
+ // one without animation has no phase, so it is reported here.
194
+ let id = request.id
195
+ Task { @MainActor in
196
+ try? await Task.sleep(for: .milliseconds(450))
197
+ if idle { report(id) }
198
+ }
199
+ }
181
200
  // On its own axes, at its own anchor, the position binding scrolls this view alone; else the proxy, which scrolls every scroll view around the target.
182
201
  let ownAxes = request.axes == props.axes && props.axes != "both"
183
202
  if props.reportsPosition, ownAxes, request.anchor == nil || request.anchor == props.positionAnchor.map(UnitPoint.init(spec:)) { return }
184
203
  proxy.scrollTo(request.id, anchor: request.anchor)
185
204
  }
186
205
 
187
- /// Tells the app the target at the anchor, once per target.
206
+ /// Tells the app the target at the anchor, once per target, after the
207
+ /// pass that found it: the app answers synchronously, with a pass of its
208
+ /// own, which must not land in the middle of SwiftUI's update (as
209
+ /// GeometryElement's reports wait).
188
210
  private func report(_ id: Int?) {
189
211
  guard let id, id != reported else { return }
190
212
  reported = id
191
- app?.dispatch(.scrollPosition(id))
213
+ let app = app
214
+ Task { @MainActor in app?.dispatch(.scrollPosition(id)) }
192
215
  }
193
216
 
194
217
  /// Whether this OS reports the settled position (`reportingSettled`) rather than the binding's every change.
@@ -199,7 +222,12 @@ struct ScrollViewElement: View {
199
222
  return false
200
223
  }
201
224
 
202
- /// The visible target at the anchor, reported when the view is idle.
225
+ /// The visible target at the anchor, reported when a scroll ends (the
226
+ /// phase goes from a drag, its deceleration or an animation to idle).
227
+ /// A change of what is visible while idle is not a scroll of the
228
+ /// viewer's: the first layout, before a requested position applies, or
229
+ /// content the app put in front, which it scrolls back itself. Reporting
230
+ /// those would send the app after them, in a loop.
203
231
  private func reportingSettled(_ view: AnyView, anchor: UnitPoint?) -> AnyView {
204
232
  #if !canImport(FoundationBridge)
205
233
  if #available(iOS 18, macOS 15, *) {
@@ -207,11 +235,10 @@ struct ScrollViewElement: View {
207
235
  return AnyView(view
208
236
  .onScrollTargetVisibilityChange(idType: Int.self, threshold: 0.5) { ids in
209
237
  visible = ids.sorted { (order[$0] ?? .max) < (order[$1] ?? .max) }
210
- if idle { report(Self.target(at: anchor, among: visible)) }
211
238
  }
212
- .onScrollPhaseChange { _, phase in
239
+ .onScrollPhaseChange { old, phase in
213
240
  idle = phase == .idle
214
- if idle { report(Self.target(at: anchor, among: visible)) }
241
+ if idle, old != .idle { report(Self.target(at: anchor, among: visible)) }
215
242
  })
216
243
  }
217
244
  #endif
@@ -141,10 +141,13 @@ public struct ListProps: Equatable, Sendable {
141
141
  public var style: String
142
142
  public var refreshable: Bool
143
143
  public var editing: Bool?
144
+ /// `List(selection:)` over a `Set`: several rows may be selected.
145
+ public var multiSelect: Bool
144
146
  public init(props: JSONObject) {
145
147
  style = props.string("style") ?? "insetGrouped"
146
148
  refreshable = props.bool("refreshable") ?? false
147
149
  editing = props.bool("editing")
150
+ multiSelect = props.bool("multiSelect") ?? false
148
151
  }
149
152
  }
150
153
 
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/wasm/SwiftBrowserNative.wasm
3
- size: 49328700 bytes
4
- sha256: 50c0e8b62319e77f83cd71b486e85ddb76025a394c985b883e1b3c9fe7cda5f9
3
+ size: 49329004 bytes
4
+ sha256: 5ce7ef4d787494787133dc374d57de7d72e0173da56ce76e22d0030644a6b5f2