@swiftbrowser/native 0.11.0 → 0.13.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 +38 -1
- package/bin/swiftbrowser-native.mjs +27 -8
- package/dist/bridge.d.ts +7 -0
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/build.d.ts +22 -2
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +25 -8
- package/dist/build.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/runtime.d.ts +7 -3
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +16 -3
- package/dist/runtime.js.map +1 -1
- package/dist/site.d.ts +8 -0
- package/dist/site.d.ts.map +1 -1
- package/dist/site.js +24 -5
- package/dist/site.js.map +1 -1
- package/dist/worker.d.ts +87 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +213 -0
- package/dist/worker.js.map +1 -0
- package/package.json +3 -3
- package/src/bridge.ts +12 -0
- package/src/build.ts +40 -8
- package/src/index.ts +1 -0
- package/src/runtime.ts +16 -3
- package/src/site.ts +31 -5
- package/src/worker.ts +236 -0
- package/swift/Package.swift +1 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +46 -2
- package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +2 -1
- package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +279 -0
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +8 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Charts.swift +340 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Inspector.swift +29 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +8 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +58 -6
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +25 -7
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +170 -0
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +2 -0
- package/swift/Sources/SwiftBrowserProtocol/Style.swift +41 -0
- package/wasm/SwiftBrowserNative.wasm +2 -2
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
#if canImport(SwiftUI)
|
|
2
|
+
import Foundation
|
|
3
|
+
import SwiftBrowserProtocol
|
|
4
|
+
import SwiftBrowserTree
|
|
5
|
+
import SwiftUI
|
|
6
|
+
|
|
7
|
+
/// `chart`: Swift Charts as the protocol carries it, already resolved (the
|
|
8
|
+
/// marks in unit coordinates of the plot, the axes' grid lines, ticks and
|
|
9
|
+
/// formatted labels, the legend), drawn with paths and texts, following the
|
|
10
|
+
/// web renderer's layout rules (docs/ops-protocol.md, "Layout rules: chart"):
|
|
11
|
+
/// the legend row, the axis bands as wide as their labels, and the plot in
|
|
12
|
+
/// what remains. `chartitem` children are annotations against their mark's
|
|
13
|
+
/// rect, or axis label views at their tick.
|
|
14
|
+
struct ChartElement: View {
|
|
15
|
+
let node: ElementNode
|
|
16
|
+
|
|
17
|
+
var body: some View {
|
|
18
|
+
let props = ChartProps(props: node.props)
|
|
19
|
+
let items = node.children.filter { $0.kind == "chartitem" }
|
|
20
|
+
GeometryReader { geometry in
|
|
21
|
+
ChartCanvas(props: props, items: items, size: geometry.size)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// The chart's boxes in points.
|
|
27
|
+
struct ChartLayout {
|
|
28
|
+
var plot: CGRect
|
|
29
|
+
var xBand: CGRect?
|
|
30
|
+
var yBand: CGRect?
|
|
31
|
+
var legend: CGRect?
|
|
32
|
+
|
|
33
|
+
static let axisFontSize: CGFloat = 12
|
|
34
|
+
/// A caption glyph's average width, for the y band's width (the labels are not measured).
|
|
35
|
+
static let glyphWidth: CGFloat = 6.6
|
|
36
|
+
static let labelGap: CGFloat = 6
|
|
37
|
+
static let legendHeight: CGFloat = 16
|
|
38
|
+
static let legendGap: CGFloat = 8
|
|
39
|
+
|
|
40
|
+
init(props: ChartProps, size: CGSize) {
|
|
41
|
+
var plot = CGRect(origin: .zero, size: size)
|
|
42
|
+
if let legend = props.legend, !legend.items.isEmpty {
|
|
43
|
+
switch legend.position {
|
|
44
|
+
case "top":
|
|
45
|
+
self.legend = CGRect(x: 0, y: 0, width: size.width, height: Self.legendHeight)
|
|
46
|
+
plot.origin.y += Self.legendHeight + Self.legendGap
|
|
47
|
+
plot.size.height -= Self.legendHeight + Self.legendGap
|
|
48
|
+
case "leading", "trailing":
|
|
49
|
+
let width = legend.items.map { CGFloat($0.label.count) * Self.glyphWidth + 14 }.max() ?? 0
|
|
50
|
+
if legend.position == "leading" {
|
|
51
|
+
self.legend = CGRect(x: 0, y: 0, width: width, height: size.height)
|
|
52
|
+
plot.origin.x += width + Self.legendGap
|
|
53
|
+
} else {
|
|
54
|
+
self.legend = CGRect(x: size.width - width, y: 0, width: width, height: size.height)
|
|
55
|
+
}
|
|
56
|
+
plot.size.width -= width + Self.legendGap
|
|
57
|
+
default:
|
|
58
|
+
self.legend = CGRect(x: 0, y: size.height - Self.legendHeight, width: size.width, height: Self.legendHeight)
|
|
59
|
+
plot.size.height -= Self.legendHeight + Self.legendGap
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if let axis = props.yAxis, !axis.labels.isEmpty {
|
|
63
|
+
let width = (axis.labels.map { CGFloat($0.text.count) }.max() ?? 0) * Self.glyphWidth + Self.labelGap
|
|
64
|
+
if axis.position == "leading" {
|
|
65
|
+
yBand = CGRect(x: plot.minX, y: plot.minY, width: width, height: plot.height)
|
|
66
|
+
plot.origin.x += width
|
|
67
|
+
} else {
|
|
68
|
+
yBand = CGRect(x: plot.maxX - width, y: plot.minY, width: width, height: plot.height)
|
|
69
|
+
}
|
|
70
|
+
plot.size.width -= width
|
|
71
|
+
}
|
|
72
|
+
if let axis = props.xAxis, !axis.labels.isEmpty {
|
|
73
|
+
let height = Self.axisFontSize * 4 / 3 + Self.labelGap
|
|
74
|
+
if axis.position == "top" {
|
|
75
|
+
xBand = CGRect(x: plot.minX, y: plot.minY, width: plot.width, height: height)
|
|
76
|
+
plot.origin.y += height
|
|
77
|
+
} else {
|
|
78
|
+
xBand = CGRect(x: plot.minX, y: plot.maxY - height, width: plot.width, height: height)
|
|
79
|
+
}
|
|
80
|
+
plot.size.height -= height
|
|
81
|
+
}
|
|
82
|
+
self.plot = CGRect(x: plot.minX, y: plot.minY, width: max(plot.width, 0), height: max(plot.height, 0))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func point(x: Double, y: Double) -> CGPoint {
|
|
86
|
+
CGPoint(x: plot.minX + plot.width * x, y: plot.minY + plot.height * y)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
func rect(x0: Double, x1: Double, y0: Double, y1: Double) -> CGRect {
|
|
90
|
+
let a = point(x: min(x0, x1), y: min(y0, y1)), b = point(x: max(x0, x1), y: max(y0, y1))
|
|
91
|
+
return CGRect(x: a.x, y: a.y, width: b.x - a.x, height: b.y - a.y)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
struct ChartCanvas: View {
|
|
96
|
+
let props: ChartProps
|
|
97
|
+
let items: [ElementNode]
|
|
98
|
+
let size: CGSize
|
|
99
|
+
|
|
100
|
+
var body: some View {
|
|
101
|
+
let layout = ChartLayout(props: props, size: size)
|
|
102
|
+
ZStack(alignment: .topLeading) {
|
|
103
|
+
axisLines(props.xAxis, layout: layout, horizontal: true)
|
|
104
|
+
axisLines(props.yAxis, layout: layout, horizontal: false)
|
|
105
|
+
ForEach(Array(props.marks.enumerated()), id: \.offset) { _, mark in
|
|
106
|
+
ChartMarkView(mark: mark, layout: layout)
|
|
107
|
+
}
|
|
108
|
+
axisLabels(props.xAxis, layout: layout, horizontal: true)
|
|
109
|
+
axisLabels(props.yAxis, layout: layout, horizontal: false)
|
|
110
|
+
if let legend = props.legend, let box = layout.legend {
|
|
111
|
+
ChartLegendView(legend: legend)
|
|
112
|
+
.frame(width: box.width, height: box.height)
|
|
113
|
+
.position(x: box.midX, y: box.midY)
|
|
114
|
+
}
|
|
115
|
+
ForEach(items) { item in
|
|
116
|
+
ChartItemView(item: item, layout: layout)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
.frame(width: size.width, height: size.height, alignment: .topLeading)
|
|
120
|
+
.clipped()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// The grid lines across the plot and the ticks outside it.
|
|
124
|
+
@ViewBuilder
|
|
125
|
+
private func axisLines(_ axis: ChartAxisSpec?, layout: ChartLayout, horizontal: Bool) -> some View {
|
|
126
|
+
if let axis {
|
|
127
|
+
let plot = layout.plot
|
|
128
|
+
let grid = Path { path in
|
|
129
|
+
for line in axis.gridLines {
|
|
130
|
+
if horizontal {
|
|
131
|
+
let x = plot.minX + plot.width * line
|
|
132
|
+
path.move(to: CGPoint(x: x, y: plot.minY))
|
|
133
|
+
path.addLine(to: CGPoint(x: x, y: plot.maxY))
|
|
134
|
+
} else {
|
|
135
|
+
let y = plot.minY + plot.height * line
|
|
136
|
+
path.move(to: CGPoint(x: plot.minX, y: y))
|
|
137
|
+
path.addLine(to: CGPoint(x: plot.maxX, y: y))
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
let ticks = Path { path in
|
|
142
|
+
for tick in axis.ticks {
|
|
143
|
+
if horizontal {
|
|
144
|
+
let x = plot.minX + plot.width * tick
|
|
145
|
+
let y = axis.position == "top" ? plot.minY : plot.maxY
|
|
146
|
+
path.move(to: CGPoint(x: x, y: y))
|
|
147
|
+
path.addLine(to: CGPoint(x: x, y: axis.position == "top" ? y - 4 : y + 4))
|
|
148
|
+
} else {
|
|
149
|
+
let y = plot.minY + plot.height * tick
|
|
150
|
+
let x = axis.position == "leading" ? plot.minX : plot.maxX
|
|
151
|
+
path.move(to: CGPoint(x: x, y: y))
|
|
152
|
+
path.addLine(to: CGPoint(x: axis.position == "leading" ? x - 4 : x + 4, y: y))
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
let separator = Color.secondary.opacity(0.3)
|
|
157
|
+
grid.stroke(separator, style: StrokeStyle(lineWidth: 1, dash: axis.dashed ? [2, 2] : []))
|
|
158
|
+
ticks.stroke(separator, lineWidth: 1)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// The default text labels, centered on their ticks in the band.
|
|
163
|
+
@ViewBuilder
|
|
164
|
+
private func axisLabels(_ axis: ChartAxisSpec?, layout: ChartLayout, horizontal: Bool) -> some View {
|
|
165
|
+
if let axis {
|
|
166
|
+
ForEach(Array(axis.labels.enumerated()), id: \.offset) { _, label in
|
|
167
|
+
let text = Text(verbatim: label.text)
|
|
168
|
+
.font(.system(size: ChartLayout.axisFontSize))
|
|
169
|
+
.foregroundStyle(.secondary)
|
|
170
|
+
.lineLimit(1)
|
|
171
|
+
.fixedSize()
|
|
172
|
+
if horizontal, let band = layout.xBand {
|
|
173
|
+
let x = layout.plot.minX + layout.plot.width * label.at
|
|
174
|
+
if label.anchor == "leading" {
|
|
175
|
+
text.frame(width: max(band.width, 1), alignment: .leading).position(x: x + band.width / 2, y: band.midY)
|
|
176
|
+
} else {
|
|
177
|
+
text.position(x: min(max(x, layout.plot.minX), layout.plot.maxX), y: band.midY)
|
|
178
|
+
}
|
|
179
|
+
} else if !horizontal, let band = layout.yBand {
|
|
180
|
+
let y = layout.plot.minY + layout.plot.height * label.at
|
|
181
|
+
text.frame(width: max(band.width - ChartLayout.labelGap, 1), alignment: axis.position == "leading" ? .trailing : .leading)
|
|
182
|
+
.position(x: band.midX + (axis.position == "leading" ? -ChartLayout.labelGap / 2 : ChartLayout.labelGap / 2), y: min(max(y, layout.plot.minY), layout.plot.maxY))
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/// One mark, in the plot's points.
|
|
190
|
+
struct ChartMarkView: View {
|
|
191
|
+
let mark: ChartMarkSpec
|
|
192
|
+
let layout: ChartLayout
|
|
193
|
+
|
|
194
|
+
var body: some View {
|
|
195
|
+
let style = mark.style.map(AnyShapeStyle.init(spec:)) ?? AnyShapeStyle(Color.accentColor)
|
|
196
|
+
Group {
|
|
197
|
+
switch mark.kind {
|
|
198
|
+
case "rect":
|
|
199
|
+
let unit = layout.rect(x0: mark.x0, x1: mark.x1, y0: mark.y0, y1: mark.y1)
|
|
200
|
+
let width = mark.fixedWidth.map { CGFloat($0) } ?? unit.width
|
|
201
|
+
RoundedRectangle(cornerRadius: mark.cornerRadius, style: .continuous)
|
|
202
|
+
.fill(style)
|
|
203
|
+
.frame(width: max(width, 0), height: max(unit.height, 0))
|
|
204
|
+
.position(x: unit.midX, y: unit.midY)
|
|
205
|
+
case "line":
|
|
206
|
+
let points = mark.points.map { layout.point(x: $0.x, y: $0.y) }
|
|
207
|
+
Path { path in
|
|
208
|
+
if let first = points.first {
|
|
209
|
+
path.move(to: first)
|
|
210
|
+
path.addLines(Array(points.dropFirst()))
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
.stroke(style, style: StrokeStyle(lineWidth: mark.lineWidth, lineCap: .round, lineJoin: .round))
|
|
214
|
+
if let symbol = mark.symbol {
|
|
215
|
+
ForEach(Array(points.enumerated()), id: \.offset) { _, point in
|
|
216
|
+
ChartSymbolView(symbol: symbol, size: mark.symbolSize, style: style).position(point)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
case "area":
|
|
220
|
+
let upper = mark.points.map { layout.point(x: $0.x, y: $0.y0) }
|
|
221
|
+
let lower = mark.points.reversed().map { layout.point(x: $0.x, y: $0.y1) }
|
|
222
|
+
Path { path in
|
|
223
|
+
if let first = upper.first {
|
|
224
|
+
path.move(to: first)
|
|
225
|
+
path.addLines(Array(upper.dropFirst()))
|
|
226
|
+
path.addLines(lower)
|
|
227
|
+
path.closeSubpath()
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
.fill(style)
|
|
231
|
+
case "point":
|
|
232
|
+
ChartSymbolView(symbol: mark.symbol ?? "circle", size: mark.symbolSize, style: style)
|
|
233
|
+
.position(layout.point(x: mark.x0, y: mark.y0))
|
|
234
|
+
case "rule":
|
|
235
|
+
Path { path in
|
|
236
|
+
path.move(to: layout.point(x: mark.x0, y: mark.y0))
|
|
237
|
+
path.addLine(to: layout.point(x: mark.x1, y: mark.y1))
|
|
238
|
+
}
|
|
239
|
+
.stroke(style, lineWidth: mark.lineWidth)
|
|
240
|
+
default:
|
|
241
|
+
EmptyView()
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
.opacity(mark.opacity)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/// A mark's symbol glyph: a circle, a square, or a diamond for the rest.
|
|
249
|
+
struct ChartSymbolView: View {
|
|
250
|
+
let symbol: String
|
|
251
|
+
let size: Double
|
|
252
|
+
let style: AnyShapeStyle
|
|
253
|
+
|
|
254
|
+
var body: some View {
|
|
255
|
+
switch symbol {
|
|
256
|
+
case "square":
|
|
257
|
+
Rectangle().fill(style).frame(width: size, height: size)
|
|
258
|
+
case "circle":
|
|
259
|
+
Circle().fill(style).frame(width: size, height: size)
|
|
260
|
+
default:
|
|
261
|
+
Rectangle().fill(style).frame(width: size * 0.8, height: size * 0.8).rotationEffect(.degrees(45))
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/// The legend's items: a symbol (or a short line) and the label, flowing in a row.
|
|
267
|
+
struct ChartLegendView: View {
|
|
268
|
+
let legend: ChartLegendSpec
|
|
269
|
+
|
|
270
|
+
var body: some View {
|
|
271
|
+
let items = Array(legend.items.enumerated())
|
|
272
|
+
if legend.position == "leading" || legend.position == "trailing" {
|
|
273
|
+
VStack(alignment: .leading, spacing: 4) {
|
|
274
|
+
ForEach(items, id: \.offset) { _, item in row(item) }
|
|
275
|
+
}
|
|
276
|
+
} else {
|
|
277
|
+
HStack(spacing: 12) {
|
|
278
|
+
ForEach(items, id: \.offset) { _, item in row(item) }
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private func row(_ item: ChartLegendItemSpec) -> some View {
|
|
284
|
+
let style = item.style.map(AnyShapeStyle.init(spec:)) ?? AnyShapeStyle(Color.accentColor)
|
|
285
|
+
return HStack(spacing: 4) {
|
|
286
|
+
if let symbol = item.symbol {
|
|
287
|
+
ChartSymbolView(symbol: symbol, size: 10, style: style)
|
|
288
|
+
} else {
|
|
289
|
+
Rectangle().fill(style).frame(width: 18, height: 3)
|
|
290
|
+
}
|
|
291
|
+
Text(verbatim: item.label).font(.system(size: ChartLayout.axisFontSize)).foregroundStyle(.secondary).lineLimit(1)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/// A `chartitem`: its one child against its mark's rect (an annotation) or
|
|
297
|
+
/// at its tick in the band (an axis label view). An annotation beside the
|
|
298
|
+
/// rect sits in a frame the rect's size, aligned to the edge it leaves from,
|
|
299
|
+
/// so it overflows outward by its own size plus the spacing.
|
|
300
|
+
struct ChartItemView: View {
|
|
301
|
+
let item: ElementNode
|
|
302
|
+
let layout: ChartLayout
|
|
303
|
+
|
|
304
|
+
var body: some View {
|
|
305
|
+
let props = ChartItemProps(props: item.props)
|
|
306
|
+
let content = ZStack { ChildrenView(node: item) }
|
|
307
|
+
switch props.role {
|
|
308
|
+
case "xLabel":
|
|
309
|
+
let band = layout.xBand ?? CGRect(x: layout.plot.minX, y: layout.plot.maxY, width: layout.plot.width, height: 0)
|
|
310
|
+
content.position(x: layout.plot.minX + layout.plot.width * props.x0, y: band.midY)
|
|
311
|
+
case "yLabel":
|
|
312
|
+
let band = layout.yBand ?? CGRect(x: layout.plot.maxX, y: layout.plot.minY, width: 0, height: layout.plot.height)
|
|
313
|
+
content.position(x: band.midX, y: layout.plot.minY + layout.plot.height * props.y0)
|
|
314
|
+
default:
|
|
315
|
+
let rect = layout.rect(x0: props.x0, x1: props.x1, y0: props.y0, y1: props.y1)
|
|
316
|
+
let spacing = CGFloat(props.spacing)
|
|
317
|
+
let across = Alignment(name: props.alignment)
|
|
318
|
+
Group {
|
|
319
|
+
switch props.position {
|
|
320
|
+
case "top":
|
|
321
|
+
VStack(spacing: spacing) { content; Color.clear.frame(width: rect.width, height: rect.height) }
|
|
322
|
+
.frame(width: rect.width, height: rect.height, alignment: Alignment(horizontal: across.horizontal, vertical: .bottom))
|
|
323
|
+
case "bottom":
|
|
324
|
+
VStack(spacing: spacing) { Color.clear.frame(width: rect.width, height: rect.height); content }
|
|
325
|
+
.frame(width: rect.width, height: rect.height, alignment: Alignment(horizontal: across.horizontal, vertical: .top))
|
|
326
|
+
case "leading":
|
|
327
|
+
HStack(spacing: spacing) { content; Color.clear.frame(width: rect.width, height: rect.height) }
|
|
328
|
+
.frame(width: rect.width, height: rect.height, alignment: Alignment(horizontal: .trailing, vertical: across.vertical))
|
|
329
|
+
case "trailing":
|
|
330
|
+
HStack(spacing: spacing) { Color.clear.frame(width: rect.width, height: rect.height); content }
|
|
331
|
+
.frame(width: rect.width, height: rect.height, alignment: Alignment(horizontal: .leading, vertical: across.vertical))
|
|
332
|
+
default:
|
|
333
|
+
content.frame(width: rect.width, height: rect.height, alignment: across)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
.position(x: rect.midX, y: rect.midY)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
#endif
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#if canImport(SwiftUI)
|
|
2
|
+
import Foundation
|
|
3
|
+
import SwiftBrowserProtocol
|
|
4
|
+
import SwiftBrowserTree
|
|
5
|
+
import SwiftUI
|
|
6
|
+
|
|
7
|
+
/// `inspector`: `.inspector(isPresented:)` in a regular width. The children
|
|
8
|
+
/// are the modified view's elements and, last, an `inspectorpanel` holding
|
|
9
|
+
/// the inspector's content (its `inspectorColumnWidth` hint rides the
|
|
10
|
+
/// panel content's style). The app owns the binding: `presented` is what it
|
|
11
|
+
/// holds, and the protocol has no dismiss from this side (in a compact width
|
|
12
|
+
/// the app presents a sheet instead and the element never appears).
|
|
13
|
+
struct InspectorElement: View {
|
|
14
|
+
let node: ElementNode
|
|
15
|
+
|
|
16
|
+
var body: some View {
|
|
17
|
+
let props = InspectorProps(props: node.props)
|
|
18
|
+
let panel = node.children.last { $0.kind == "inspectorpanel" }
|
|
19
|
+
ZStack {
|
|
20
|
+
ForEach(node.children.filter { $0.kind != "inspectorpanel" }) { ElementView(node: $0) }
|
|
21
|
+
}
|
|
22
|
+
.inspector(isPresented: Binding(get: { props.presented }, set: { _ in })) {
|
|
23
|
+
if let panel {
|
|
24
|
+
ZStack { ChildrenView(node: panel) }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
#endif
|
|
@@ -155,7 +155,14 @@ struct NavigationScreenElement: View {
|
|
|
155
155
|
case "principal": return .principal
|
|
156
156
|
case "secondary": return .secondaryAction
|
|
157
157
|
case "bottom": return bottomPlacement
|
|
158
|
-
|
|
158
|
+
case "largeTitle":
|
|
159
|
+
// Beside the large title: the shim's own placement; SwiftUI has none, so the bar's trailing edge.
|
|
160
|
+
#if canImport(FoundationBridge)
|
|
161
|
+
return .largeTitle
|
|
162
|
+
#else
|
|
163
|
+
return trailingPlacement
|
|
164
|
+
#endif
|
|
165
|
+
default: return trailingPlacement
|
|
159
166
|
}
|
|
160
167
|
}
|
|
161
168
|
}
|
|
@@ -11,6 +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
15
|
}
|
|
15
16
|
}
|
|
16
17
|
|
|
@@ -32,6 +33,7 @@ struct HStackElement: View {
|
|
|
32
33
|
HStack(alignment: VerticalAlignment(name: props.alignment), spacing: props.spacing.map { CGFloat($0) }) {
|
|
33
34
|
ChildrenView(node: node)
|
|
34
35
|
}
|
|
36
|
+
.scrollTargetLayout(isEnabled: props.scrollTarget)
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
39
|
}
|
|
@@ -106,21 +108,71 @@ struct GeometryElement: View {
|
|
|
106
108
|
}
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
/// `scrollview`: its axes and indicators; `refreshable` sends `refresh`;
|
|
112
|
+
/// `snap` is its scroll target behavior (the stack inside marked
|
|
113
|
+
/// `scrollTarget` is the layout whose children snap); `reportsPosition`
|
|
114
|
+
/// is `.scrollPosition(id:)`, which sends `scrollposition` with the marked
|
|
115
|
+
/// element at the anchor once the view settles. A `ScrollViewReader`'s
|
|
116
|
+
/// `proxy.scrollTo(x)` arrives as a `scrollTo` hint, with a new serial each
|
|
117
|
+
/// time, on the marked element it names (`scrollID`, `.id(x)`): the view
|
|
118
|
+
/// finds the latest in its content and scrolls to it.
|
|
109
119
|
struct ScrollViewElement: View {
|
|
110
120
|
let node: ElementNode
|
|
111
121
|
@Environment(\.nativeApp) private var app
|
|
122
|
+
@State private var position: Int?
|
|
123
|
+
@State private var served: Double = -1
|
|
112
124
|
|
|
113
125
|
var body: some View {
|
|
114
126
|
let props = ScrollViewProps(props: node.props)
|
|
115
127
|
let axes: Axis.Set = props.axes == "horizontal" ? .horizontal : props.axes == "both" ? [.horizontal, .vertical] : .vertical
|
|
116
|
-
let
|
|
117
|
-
|
|
128
|
+
let request = Self.scrollRequest(in: node)
|
|
129
|
+
ScrollViewReader { proxy in
|
|
130
|
+
let scroll = ScrollView(axes, showsIndicators: props.showsIndicators) {
|
|
131
|
+
ChildrenView(node: node)
|
|
132
|
+
}
|
|
133
|
+
let refreshed: AnyView = props.refreshable ? AnyView(scroll.refreshable { app?.dispatch(.refresh(node.id)) }) : AnyView(scroll)
|
|
134
|
+
let snapped: AnyView = props.snap == "paging" ? AnyView(refreshed.scrollTargetBehavior(.paging))
|
|
135
|
+
: props.snap == "viewAligned" ? AnyView(refreshed.scrollTargetBehavior(.viewAligned)) : refreshed
|
|
136
|
+
let positioned: AnyView = props.reportsPosition
|
|
137
|
+
? AnyView(snapped.scrollPosition(id: Binding(get: { position }, set: { next in
|
|
138
|
+
guard next != position else { return }
|
|
139
|
+
position = next
|
|
140
|
+
if let next { app?.dispatch(.scrollPosition(next)) }
|
|
141
|
+
}), anchor: props.positionAnchor.map(UnitPoint.init(spec:))))
|
|
142
|
+
: snapped
|
|
143
|
+
positioned
|
|
144
|
+
.onAppear { perform(request, with: proxy) }
|
|
145
|
+
.onChange(of: request?.serial) { _, _ in perform(request, with: proxy) }
|
|
118
146
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private func perform(_ request: ScrollRequest?, with proxy: ScrollViewProxy) {
|
|
150
|
+
guard let request, request.serial > served else { return }
|
|
151
|
+
served = request.serial
|
|
152
|
+
position = request.id
|
|
153
|
+
proxy.scrollTo(request.id, anchor: request.anchor)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
struct ScrollRequest: Equatable {
|
|
157
|
+
var serial: Double
|
|
158
|
+
var id: Int
|
|
159
|
+
var anchor: UnitPoint?
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// The newest `scrollTo` among the content's marked elements (not those of a scroll view nested inside).
|
|
163
|
+
static func scrollRequest(in node: ElementNode) -> ScrollRequest? {
|
|
164
|
+
var best: ScrollRequest?
|
|
165
|
+
func visit(_ element: ElementNode) {
|
|
166
|
+
for child in element.children {
|
|
167
|
+
if child.kind == "scrollview" || child.kind == "list" { continue }
|
|
168
|
+
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:)))
|
|
170
|
+
}
|
|
171
|
+
visit(child)
|
|
172
|
+
}
|
|
123
173
|
}
|
|
174
|
+
visit(node)
|
|
175
|
+
return best
|
|
124
176
|
}
|
|
125
177
|
}
|
|
126
178
|
|
|
@@ -11,7 +11,7 @@ import SwiftUI
|
|
|
11
11
|
/// Type-erased step by step: a chain of sixty optional modifiers would
|
|
12
12
|
/// otherwise be one type the compiler has to name.
|
|
13
13
|
extension View {
|
|
14
|
-
func styled(_ style: Style) -> AnyView {
|
|
14
|
+
func styled(_ style: Style, id: Int? = nil) -> AnyView {
|
|
15
15
|
var view = AnyView(self)
|
|
16
16
|
|
|
17
17
|
// Text attributes, which flow down the environment. A font with only a
|
|
@@ -71,16 +71,31 @@ extension View {
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
if let frame = style.frame {
|
|
74
|
-
|
|
74
|
+
// Per axis, a fixed length wins over a min/max, as the web renderer
|
|
75
|
+
// lays it out: `{ maxWidth: "infinity", height: 1 }` is
|
|
76
|
+
// `.frame(height: 1).frame(maxWidth: .infinity)`, a rule across
|
|
77
|
+
// the width.
|
|
78
|
+
let alignment = Alignment(name: frame.alignment)
|
|
79
|
+
if frame.width != nil || frame.height != nil {
|
|
80
|
+
view = AnyView(view.frame(width: frame.width.map { CGFloat($0) }, height: frame.height.map { CGFloat($0) }, alignment: alignment))
|
|
81
|
+
}
|
|
82
|
+
let flexibleWidth = frame.width == nil && (frame.minWidth != nil || frame.maxWidth != nil)
|
|
83
|
+
let flexibleHeight = frame.height == nil && (frame.minHeight != nil || frame.maxHeight != nil)
|
|
84
|
+
if flexibleWidth || flexibleHeight {
|
|
75
85
|
view = AnyView(view.frame(
|
|
76
|
-
minWidth: CGFloat(dimension: frame.minWidth), maxWidth: CGFloat(dimension: frame.maxWidth),
|
|
77
|
-
minHeight: CGFloat(dimension: frame.minHeight), maxHeight: CGFloat(dimension: frame.maxHeight),
|
|
78
|
-
alignment:
|
|
86
|
+
minWidth: flexibleWidth ? CGFloat(dimension: frame.minWidth) : nil, maxWidth: flexibleWidth ? CGFloat(dimension: frame.maxWidth) : nil,
|
|
87
|
+
minHeight: flexibleHeight ? CGFloat(dimension: frame.minHeight) : nil, maxHeight: flexibleHeight ? CGFloat(dimension: frame.maxHeight) : nil,
|
|
88
|
+
alignment: alignment
|
|
79
89
|
))
|
|
80
|
-
} else {
|
|
81
|
-
view = AnyView(view.frame(width: frame.width.map { CGFloat($0) }, height: frame.height.map { CGFloat($0) }, alignment: Alignment(name: frame.alignment)))
|
|
82
90
|
}
|
|
83
91
|
}
|
|
92
|
+
if let relative = style.containerRelativeFrame {
|
|
93
|
+
var axes: Axis.Set = []
|
|
94
|
+
if relative.horizontal { axes.insert(.horizontal) }
|
|
95
|
+
if relative.vertical { axes.insert(.vertical) }
|
|
96
|
+
let a = relative.a, b = relative.b
|
|
97
|
+
view = AnyView(view.containerRelativeFrame(axes, alignment: Alignment(name: relative.alignment)) { length, _ in a * length + b })
|
|
98
|
+
}
|
|
84
99
|
if let ratio = style.aspectRatio {
|
|
85
100
|
let mode: ContentMode = style.aspectRatioContentMode == "fill" ? .fill : .fit
|
|
86
101
|
view = AnyView(view.aspectRatio(ratio.map { CGFloat($0) }, contentMode: mode))
|
|
@@ -127,6 +142,9 @@ extension View {
|
|
|
127
142
|
if style.hitTesting == false { view = AnyView(view.allowsHitTesting(false)) }
|
|
128
143
|
if style.hidden { view = AnyView(view.hidden()) }
|
|
129
144
|
if let help = style.help { view = AnyView(view.help(help)) }
|
|
145
|
+
// A `ScrollViewReader`'s `.id(x)`: the element's own id names it to a `scrollTo` and a scroll position.
|
|
146
|
+
if style.scrollID, let id { view = AnyView(view.id(id)) }
|
|
147
|
+
if let width = style.raw.double("inspectorColumnWidth") { view = AnyView(view.inspectorColumnWidth(width)) }
|
|
130
148
|
|
|
131
149
|
// Lists.
|
|
132
150
|
if let badge = style.badge { view = AnyView(view.badge(Text(badge))) }
|