@swiftbrowser/native 0.16.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.16.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.16.0",
63
- "@swiftbrowser/web": "0.16.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
  },
@@ -26,7 +26,14 @@ struct ElementView: View {
26
26
  case "zstack": return ZStackElement(node: node).reportingSize(of: node)
27
27
  case "spacer": return AnyView(Spacer(minLength: SpacerProps(props: node.props).minLength.map { CGFloat($0) }))
28
28
  case "divider": return AnyView(Divider())
29
- case "styled": return node.isSpinner ? AnyView(ProgressView()) : ChildrenView(node: node).styled(node.style, id: node.id)
29
+ case "styled":
30
+ if node.isSpinner { return AnyView(ProgressView()) }
31
+ // Its one child as itself, not through a `ForEach`: a `ForEach`'s
32
+ // element is the identity a scroll view's targets go by, which
33
+ // must stay the marked element's (the id the layout's own
34
+ // `ForEach` gives it), not the innermost element's.
35
+ if node.children.count == 1 { return ElementView(node: node.children[0]).styled(node.style, id: node.id) }
36
+ return ChildrenView(node: node).styled(node.style, id: node.id)
30
37
  case "decorated": return AnyView(DecoratedElement(node: node))
31
38
  case "geometry": return AnyView(GeometryElement(node: node))
32
39
  case "scrollview": return AnyView(ScrollViewElement(node: node))
@@ -13,9 +13,7 @@ struct ListElement: View {
13
13
 
14
14
  var body: some View {
15
15
  let props = ListProps(props: node.props)
16
- let list = List {
17
- ListRowsView(children: node.children)
18
- }
16
+ let list = selectionList(props)
19
17
  let styled: AnyView = {
20
18
  switch props.style {
21
19
  case "plain": return AnyView(list.listStyle(.plain))
@@ -41,6 +39,76 @@ struct ListElement: View {
41
39
  refreshable
42
40
  #endif
43
41
  }
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
+
88
+ /// The list's selectable rows (not those of a list inside it).
89
+ static func selectableRows(in node: ElementNode) -> [ElementNode] {
90
+ var rows: [ElementNode] = []
91
+ func visit(_ element: ElementNode) {
92
+ for child in element.children where child.kind != "list" {
93
+ if child.kind == "listrow", ListRowProps(props: child.props).selectable { rows.append(child) }
94
+ visit(child)
95
+ }
96
+ }
97
+ visit(node)
98
+ return rows
99
+ }
100
+ }
101
+
102
+ private struct ListSelectsRowsKey: EnvironmentKey {
103
+ static let defaultValue = false
104
+ }
105
+
106
+ extension EnvironmentValues {
107
+ /// Whether the list around is a `List(selection:)` over its selectable rows, which are then its tags.
108
+ var listSelectsRows: Bool {
109
+ get { self[ListSelectsRowsKey.self] }
110
+ set { self[ListSelectsRowsKey.self] = newValue }
111
+ }
44
112
  }
45
113
 
46
114
  /// The rows of a `list` or `section`: sections render as `Section`s, the rest
@@ -126,18 +194,26 @@ struct SectionElement: View {
126
194
  }
127
195
  }
128
196
 
129
- /// `listrow`: the row's content with its edit-mode hints; a tap on a
130
- /// selectable row sends `tap`.
197
+ /// `listrow`: the row's content with its edit-mode hints; a selectable
198
+ /// row is the list's selection's tag (`ListElement`), or under the shim
199
+ /// sends `tap` itself.
131
200
  struct ListRowElement: View {
132
201
  let node: ElementNode
133
202
  @Environment(\.nativeApp) private var app
203
+ @Environment(\.listSelectsRows) private var listSelects
134
204
 
135
205
  var body: some View {
136
206
  let props = ListRowProps(props: node.props)
137
207
  // A row's children are one row: a stack when there are several.
138
208
  let content: AnyView = node.children.count == 1 ? AnyView(ElementView(node: node.children[0])) : AnyView(HStack(spacing: 0) { ChildrenView(node: node) })
139
- if props.selectable {
209
+ if props.selectable, listSelects {
210
+ // The list's selection: the cell is the target, the list highlights it.
211
+ content.tag(node.id)
212
+ } else if props.selectable {
213
+ // The whole row takes the tap, not only its content's width (a
214
+ // title alone stops short of the trailing edge).
140
215
  let tappable = content
216
+ .frame(maxWidth: .infinity, alignment: .leading)
141
217
  .contentShape(Rectangle())
142
218
  .onTapGesture { app?.dispatch(.tap(node.id)) }
143
219
  if props.selected {
@@ -230,15 +230,32 @@ struct ToolbarElement: ToolbarContent {
230
230
  }
231
231
  }
232
232
 
233
- /// `navlink`: a tap on it sends `tap`, and the app pushes. In a list it is
234
- /// a row with a chevron, as the native link row is.
233
+ /// `navlink`: a tap on it sends `tap`, and the app pushes. As a list's row
234
+ /// it is a row with a chevron, as the native link row is; inside a row's
235
+ /// content (a stack, a scroll view) it is a plain button, as in the web renderer.
235
236
  struct NavigationLinkElement: View {
236
237
  let node: ElementNode
237
238
  @Environment(\.nativeApp) private var app
238
239
 
240
+ /// The wrappers a row's link may sit in and still be the row (the web
241
+ /// engine's transparent kinds): any other kind between it and the list
242
+ /// makes it content of the row.
243
+ private static let rowWrappers: Set<String> = ["styled", "section", "gesture", "swipeactions", "contextmenu", "listrow", "draggable", "dropdestination"]
244
+
245
+ /// Whether the link is a list's row: only row wrappers between it and the list.
246
+ static func isListRow(_ node: ElementNode) -> Bool {
247
+ var current = node.parent
248
+ while let parent = current {
249
+ if parent.kind == "list" { return true }
250
+ guard rowWrappers.contains(parent.kind) else { return false }
251
+ current = parent.parent
252
+ }
253
+ return false
254
+ }
255
+
239
256
  var body: some View {
240
257
  let props = NavLinkProps(props: node.props)
241
- let inList = node.ancestor(ofKind: "list") != nil
258
+ let inList = Self.isListRow(node)
242
259
  Button {
243
260
  app?.dispatch(.tap(node.id))
244
261
  } label: {
@@ -11,7 +11,7 @@ struct VStackElement: View {
11
11
  VStack(alignment: HorizontalAlignment(name: props.alignment), spacing: props.spacing.map { CGFloat($0) }) {
12
12
  ChildrenView(node: node)
13
13
  }
14
- .scrollTargetLayout(isEnabled: props.scrollTarget)
14
+ .scrollTargetLayout(when: props.scrollTarget)
15
15
  }
16
16
  }
17
17
 
@@ -33,7 +33,22 @@ struct HStackElement: View {
33
33
  HStack(alignment: VerticalAlignment(name: props.alignment), spacing: props.spacing.map { CGFloat($0) }) {
34
34
  ChildrenView(node: node)
35
35
  }
36
- .scrollTargetLayout(isEnabled: props.scrollTarget)
36
+ .scrollTargetLayout(when: props.scrollTarget)
37
+ }
38
+ }
39
+ }
40
+
41
+ extension View {
42
+ /// `.scrollTargetLayout()` on a stack marked `scrollTarget`, and nothing
43
+ /// on any other: `.scrollTargetLayout(isEnabled: false)` on a stack
44
+ /// around a scroll view (a list row's) turns off the layout of the
45
+ /// stack inside it too, and the scroll view neither snaps nor reports.
46
+ @ViewBuilder
47
+ func scrollTargetLayout(when enabled: Bool) -> some View {
48
+ if enabled {
49
+ scrollTargetLayout()
50
+ } else {
51
+ self
37
52
  }
38
53
  }
39
54
  }
@@ -116,16 +131,28 @@ struct GeometryElement: View {
116
131
  /// `proxy.scrollTo(x)` arrives as a `scrollTo` hint, with a new serial each
117
132
  /// time, on the marked element it names (`scrollID`, `.id(x)`): the view
118
133
  /// finds the latest in its content and scrolls to it.
134
+ ///
135
+ /// On iOS 18 and later the position is what the view settled on: the
136
+ /// visible target at the anchor once the scroll phase is idle, after a drag
137
+ /// or a `scrollTo` alike (so the app hears where a chevron's scroll ended),
138
+ /// and never mid-gesture. A request on the view's own axes moves only this
139
+ /// view, through its position, so a pager in a list leaves the list where
140
+ /// it is.
119
141
  struct ScrollViewElement: View {
120
142
  let node: ElementNode
121
143
  @Environment(\.nativeApp) private var app
122
144
  @State private var position: Int?
123
145
  @State private var served: Double = -1
146
+ /// The targets in view, as the last visibility change listed them.
147
+ @State private var visible: [Int] = []
148
+ @State private var idle = true
149
+ @State private var reported: Int?
124
150
 
125
151
  var body: some View {
126
152
  let props = ScrollViewProps(props: node.props)
127
153
  let axes: Axis.Set = props.axes == "horizontal" ? .horizontal : props.axes == "both" ? [.horizontal, .vertical] : .vertical
128
154
  let request = Self.scrollRequest(in: node)
155
+ let anchor = props.positionAnchor.map(UnitPoint.init(spec:))
129
156
  ScrollViewReader { proxy in
130
157
  let scroll = ScrollView(axes, showsIndicators: props.showsIndicators) {
131
158
  ChildrenView(node: node)
@@ -134,29 +161,119 @@ struct ScrollViewElement: View {
134
161
  let snapped: AnyView = props.snap == "paging" ? AnyView(refreshed.scrollTargetBehavior(.paging))
135
162
  : props.snap == "viewAligned" ? AnyView(refreshed.scrollTargetBehavior(.viewAligned)) : refreshed
136
163
  let positioned: AnyView = props.reportsPosition
137
- ? AnyView(snapped.scrollPosition(id: Binding(get: { position }, set: { next in
164
+ ? reportingSettled(AnyView(snapped.scrollPosition(id: Binding(get: { position }, set: { next in
138
165
  guard next != position else { return }
139
166
  position = next
140
- if let next { app?.dispatch(.scrollPosition(next)) }
141
- }), anchor: props.positionAnchor.map(UnitPoint.init(spec:))))
167
+ // Where the settled position is reported, the binding only follows the view.
168
+ if !Self.reportsSettled { report(next) }
169
+ }), anchor: anchor)), anchor: anchor)
142
170
  : snapped
143
171
  positioned
144
- .onAppear { perform(request, with: proxy) }
145
- .onChange(of: request?.serial) { _, _ in perform(request, with: proxy) }
172
+ .onAppear { perform(request, props: props, with: proxy) }
173
+ .onChange(of: request?.serial) { _, _ in perform(request, props: props, with: proxy) }
146
174
  }
147
175
  }
148
176
 
149
- private func perform(_ request: ScrollRequest?, with proxy: ScrollViewProxy) {
177
+ private func perform(_ request: ScrollRequest?, props: ScrollViewProps, with proxy: ScrollViewProxy) {
150
178
  guard let request, request.serial > served else { return }
151
179
  served = request.serial
152
- 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
+ }
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.
201
+ let ownAxes = request.axes == props.axes && props.axes != "both"
202
+ if props.reportsPosition, ownAxes, request.anchor == nil || request.anchor == props.positionAnchor.map(UnitPoint.init(spec:)) { return }
153
203
  proxy.scrollTo(request.id, anchor: request.anchor)
154
204
  }
155
205
 
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).
210
+ private func report(_ id: Int?) {
211
+ guard let id, id != reported else { return }
212
+ reported = id
213
+ let app = app
214
+ Task { @MainActor in app?.dispatch(.scrollPosition(id)) }
215
+ }
216
+
217
+ /// Whether this OS reports the settled position (`reportingSettled`) rather than the binding's every change.
218
+ static var reportsSettled: Bool {
219
+ #if !canImport(FoundationBridge)
220
+ if #available(iOS 18, macOS 15, *) { return true }
221
+ #endif
222
+ return false
223
+ }
224
+
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.
231
+ private func reportingSettled(_ view: AnyView, anchor: UnitPoint?) -> AnyView {
232
+ #if !canImport(FoundationBridge)
233
+ if #available(iOS 18, macOS 15, *) {
234
+ let order = Self.targetOrder(in: node)
235
+ return AnyView(view
236
+ .onScrollTargetVisibilityChange(idType: Int.self, threshold: 0.5) { ids in
237
+ visible = ids.sorted { (order[$0] ?? .max) < (order[$1] ?? .max) }
238
+ }
239
+ .onScrollPhaseChange { old, phase in
240
+ idle = phase == .idle
241
+ if idle, old != .idle { report(Self.target(at: anchor, among: visible)) }
242
+ })
243
+ }
244
+ #endif
245
+ return view
246
+ }
247
+
248
+ /// The target an anchor picks among the visible ones, in order: the first for a leading anchor (and none), the last for a trailing one, else the middle.
249
+ static func target(at anchor: UnitPoint?, among ids: [Int]) -> Int? {
250
+ guard !ids.isEmpty else { return nil }
251
+ let x = anchor?.x ?? 0
252
+ if x <= 0.25 { return ids.first }
253
+ if x >= 0.75 { return ids.last }
254
+ return ids[ids.count / 2]
255
+ }
256
+
257
+ /// The marked targets' places in the scroll target layout, in content order.
258
+ static func targetOrder(in node: ElementNode) -> [Int: Int] {
259
+ var order: [Int: Int] = [:]
260
+ func visit(_ element: ElementNode) {
261
+ for child in element.children {
262
+ if child.kind == "scrollview" || child.kind == "list" { continue }
263
+ if child.kind == "styled", child.style.scrollID { order[child.id] = order.count }
264
+ visit(child)
265
+ }
266
+ }
267
+ visit(node)
268
+ return order
269
+ }
270
+
156
271
  struct ScrollRequest: Equatable {
157
272
  var serial: Double
158
273
  var id: Int
159
274
  var anchor: UnitPoint?
275
+ /// `horizontal`, `vertical` or `both`.
276
+ var axes: String
160
277
  }
161
278
 
162
279
  /// The newest `scrollTo` among the content's marked elements (not those of a scroll view nested inside).
@@ -166,7 +283,7 @@ struct ScrollViewElement: View {
166
283
  for child in element.children {
167
284
  if child.kind == "scrollview" || child.kind == "list" { continue }
168
285
  if child.kind == "styled", let request = child.style.scrollTo, best == nil || request.serial > best!.serial {
169
- best = ScrollRequest(serial: request.serial, id: child.id, anchor: request.anchor.map(UnitPoint.init(spec:)))
286
+ best = ScrollRequest(serial: request.serial, id: child.id, anchor: request.anchor.map(UnitPoint.init(spec:)), axes: request.axes)
170
287
  }
171
288
  visit(child)
172
289
  }
@@ -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: 49302688 bytes
4
- sha256: 05f97ec252da1a85b64465ba5426d2ce1c620c78016ec4f2ae1bb7387a270be0
3
+ size: 49329004 bytes
4
+ sha256: 5ce7ef4d787494787133dc374d57de7d72e0173da56ce76e22d0030644a6b5f2