@swiftbrowser/native 0.16.0 → 0.17.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/package.json +3 -3
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +8 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +53 -6
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +20 -3
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +99 -9
- package/wasm/SwiftBrowserNative.wasm +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swiftbrowser/native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
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.
|
|
63
|
-
"@swiftbrowser/web": "0.
|
|
62
|
+
"@swiftbrowser/react": "0.17.0",
|
|
63
|
+
"@swiftbrowser/web": "0.17.0",
|
|
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":
|
|
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,23 @@ struct ListElement: View {
|
|
|
13
13
|
|
|
14
14
|
var body: some View {
|
|
15
15
|
let props = ListProps(props: node.props)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
+
})
|
|
19
33
|
let styled: AnyView = {
|
|
20
34
|
switch props.style {
|
|
21
35
|
case "plain": return AnyView(list.listStyle(.plain))
|
|
@@ -41,6 +55,31 @@ struct ListElement: View {
|
|
|
41
55
|
refreshable
|
|
42
56
|
#endif
|
|
43
57
|
}
|
|
58
|
+
|
|
59
|
+
/// The list's selectable rows (not those of a list inside it).
|
|
60
|
+
static func selectableRows(in node: ElementNode) -> [ElementNode] {
|
|
61
|
+
var rows: [ElementNode] = []
|
|
62
|
+
func visit(_ element: ElementNode) {
|
|
63
|
+
for child in element.children where child.kind != "list" {
|
|
64
|
+
if child.kind == "listrow", ListRowProps(props: child.props).selectable { rows.append(child) }
|
|
65
|
+
visit(child)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
visit(node)
|
|
69
|
+
return rows
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private struct ListSelectsRowsKey: EnvironmentKey {
|
|
74
|
+
static let defaultValue = false
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
extension EnvironmentValues {
|
|
78
|
+
/// Whether the list around is a `List(selection:)` over its selectable rows, which are then its tags.
|
|
79
|
+
var listSelectsRows: Bool {
|
|
80
|
+
get { self[ListSelectsRowsKey.self] }
|
|
81
|
+
set { self[ListSelectsRowsKey.self] = newValue }
|
|
82
|
+
}
|
|
44
83
|
}
|
|
45
84
|
|
|
46
85
|
/// The rows of a `list` or `section`: sections render as `Section`s, the rest
|
|
@@ -126,18 +165,26 @@ struct SectionElement: View {
|
|
|
126
165
|
}
|
|
127
166
|
}
|
|
128
167
|
|
|
129
|
-
/// `listrow`: the row's content with its edit-mode hints; a
|
|
130
|
-
///
|
|
168
|
+
/// `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.
|
|
131
171
|
struct ListRowElement: View {
|
|
132
172
|
let node: ElementNode
|
|
133
173
|
@Environment(\.nativeApp) private var app
|
|
174
|
+
@Environment(\.listSelectsRows) private var listSelects
|
|
134
175
|
|
|
135
176
|
var body: some View {
|
|
136
177
|
let props = ListRowProps(props: node.props)
|
|
137
178
|
// A row's children are one row: a stack when there are several.
|
|
138
179
|
let content: AnyView = node.children.count == 1 ? AnyView(ElementView(node: node.children[0])) : AnyView(HStack(spacing: 0) { ChildrenView(node: node) })
|
|
139
|
-
if props.selectable {
|
|
180
|
+
if props.selectable, listSelects {
|
|
181
|
+
// The list's selection: the cell is the target, the list highlights it.
|
|
182
|
+
content.tag(node.id)
|
|
183
|
+
} else if props.selectable {
|
|
184
|
+
// The whole row takes the tap, not only its content's width (a
|
|
185
|
+
// title alone stops short of the trailing edge).
|
|
140
186
|
let tappable = content
|
|
187
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
141
188
|
.contentShape(Rectangle())
|
|
142
189
|
.onTapGesture { app?.dispatch(.tap(node.id)) }
|
|
143
190
|
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.
|
|
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 =
|
|
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(
|
|
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(
|
|
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,92 @@ 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
|
-
|
|
141
|
-
|
|
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
180
|
position = request.id
|
|
181
|
+
// 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
|
+
let ownAxes = request.axes == props.axes && props.axes != "both"
|
|
183
|
+
if props.reportsPosition, ownAxes, request.anchor == nil || request.anchor == props.positionAnchor.map(UnitPoint.init(spec:)) { return }
|
|
153
184
|
proxy.scrollTo(request.id, anchor: request.anchor)
|
|
154
185
|
}
|
|
155
186
|
|
|
187
|
+
/// Tells the app the target at the anchor, once per target.
|
|
188
|
+
private func report(_ id: Int?) {
|
|
189
|
+
guard let id, id != reported else { return }
|
|
190
|
+
reported = id
|
|
191
|
+
app?.dispatch(.scrollPosition(id))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/// Whether this OS reports the settled position (`reportingSettled`) rather than the binding's every change.
|
|
195
|
+
static var reportsSettled: Bool {
|
|
196
|
+
#if !canImport(FoundationBridge)
|
|
197
|
+
if #available(iOS 18, macOS 15, *) { return true }
|
|
198
|
+
#endif
|
|
199
|
+
return false
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/// The visible target at the anchor, reported when the view is idle.
|
|
203
|
+
private func reportingSettled(_ view: AnyView, anchor: UnitPoint?) -> AnyView {
|
|
204
|
+
#if !canImport(FoundationBridge)
|
|
205
|
+
if #available(iOS 18, macOS 15, *) {
|
|
206
|
+
let order = Self.targetOrder(in: node)
|
|
207
|
+
return AnyView(view
|
|
208
|
+
.onScrollTargetVisibilityChange(idType: Int.self, threshold: 0.5) { ids in
|
|
209
|
+
visible = ids.sorted { (order[$0] ?? .max) < (order[$1] ?? .max) }
|
|
210
|
+
if idle { report(Self.target(at: anchor, among: visible)) }
|
|
211
|
+
}
|
|
212
|
+
.onScrollPhaseChange { _, phase in
|
|
213
|
+
idle = phase == .idle
|
|
214
|
+
if idle { report(Self.target(at: anchor, among: visible)) }
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
#endif
|
|
218
|
+
return view
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/// 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.
|
|
222
|
+
static func target(at anchor: UnitPoint?, among ids: [Int]) -> Int? {
|
|
223
|
+
guard !ids.isEmpty else { return nil }
|
|
224
|
+
let x = anchor?.x ?? 0
|
|
225
|
+
if x <= 0.25 { return ids.first }
|
|
226
|
+
if x >= 0.75 { return ids.last }
|
|
227
|
+
return ids[ids.count / 2]
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/// The marked targets' places in the scroll target layout, in content order.
|
|
231
|
+
static func targetOrder(in node: ElementNode) -> [Int: Int] {
|
|
232
|
+
var order: [Int: Int] = [:]
|
|
233
|
+
func visit(_ element: ElementNode) {
|
|
234
|
+
for child in element.children {
|
|
235
|
+
if child.kind == "scrollview" || child.kind == "list" { continue }
|
|
236
|
+
if child.kind == "styled", child.style.scrollID { order[child.id] = order.count }
|
|
237
|
+
visit(child)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
visit(node)
|
|
241
|
+
return order
|
|
242
|
+
}
|
|
243
|
+
|
|
156
244
|
struct ScrollRequest: Equatable {
|
|
157
245
|
var serial: Double
|
|
158
246
|
var id: Int
|
|
159
247
|
var anchor: UnitPoint?
|
|
248
|
+
/// `horizontal`, `vertical` or `both`.
|
|
249
|
+
var axes: String
|
|
160
250
|
}
|
|
161
251
|
|
|
162
252
|
/// The newest `scrollTo` among the content's marked elements (not those of a scroll view nested inside).
|
|
@@ -166,7 +256,7 @@ struct ScrollViewElement: View {
|
|
|
166
256
|
for child in element.children {
|
|
167
257
|
if child.kind == "scrollview" || child.kind == "list" { continue }
|
|
168
258
|
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:)))
|
|
259
|
+
best = ScrollRequest(serial: request.serial, id: child.id, anchor: request.anchor.map(UnitPoint.init(spec:)), axes: request.axes)
|
|
170
260
|
}
|
|
171
261
|
visit(child)
|
|
172
262
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
[diffend] Oversized file quarantined before diffing.
|
|
2
2
|
name: package/wasm/SwiftBrowserNative.wasm
|
|
3
|
-
size:
|
|
4
|
-
sha256:
|
|
3
|
+
size: 49328700 bytes
|
|
4
|
+
sha256: 50c0e8b62319e77f83cd71b486e85ddb76025a394c985b883e1b3c9fe7cda5f9
|