@swiftbrowser/swift 0.0.0-stage → 0.1.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.swift +23 -0
- package/README.md +12 -2
- package/Sources/Charts/Charts.swift +65 -0
- package/Sources/FoundationBridge/FoundationBridge.swift +77 -0
- package/Sources/FoundationBridge/Locale.swift +86 -0
- package/Sources/FoundationBridge/Timer.swift +48 -0
- package/Sources/FoundationBridge/UITextChecker.swift +30 -0
- package/Sources/FoundationLite/Common.swift +3 -0
- package/Sources/FoundationLite/FoundationLite.swift +171 -0
- package/Sources/FoundationLite/Interaction.swift +146 -0
- package/Sources/FoundationLite/Timer.swift +48 -0
- package/Sources/SwiftUI/API/Accessibility.swift +132 -0
- package/Sources/SwiftUI/API/Alert.swift +221 -0
- package/Sources/SwiftUI/API/Animation.swift +253 -0
- package/Sources/SwiftUI/API/App.swift +57 -0
- package/Sources/SwiftUI/API/Color.swift +161 -0
- package/Sources/SwiftUI/API/Controls.swift +268 -0
- package/Sources/SwiftUI/API/CustomLayout.swift +403 -0
- package/Sources/SwiftUI/API/Decorations.swift +92 -0
- package/Sources/SwiftUI/API/EditMode.swift +269 -0
- package/Sources/SwiftUI/API/Effects.swift +290 -0
- package/Sources/SwiftUI/API/Environment.swift +355 -0
- package/Sources/SwiftUI/API/Focus.swift +129 -0
- package/Sources/SwiftUI/API/Font.swift +131 -0
- package/Sources/SwiftUI/API/Gauge.swift +289 -0
- package/Sources/SwiftUI/API/Geometry.swift +89 -0
- package/Sources/SwiftUI/API/GeometryReader.swift +124 -0
- package/Sources/SwiftUI/API/Gestures.swift +342 -0
- package/Sources/SwiftUI/API/Grid.swift +188 -0
- package/Sources/SwiftUI/API/LabelStyle.swift +117 -0
- package/Sources/SwiftUI/API/Layout.swift +165 -0
- package/Sources/SwiftUI/API/Lists.swift +276 -0
- package/Sources/SwiftUI/API/Menu.swift +51 -0
- package/Sources/SwiftUI/API/Modifiers.swift +243 -0
- package/Sources/SwiftUI/API/Navigation.swift +371 -0
- package/Sources/SwiftUI/API/ObservableObject.swift +279 -0
- package/Sources/SwiftUI/API/OnChange.swift +48 -0
- package/Sources/SwiftUI/API/Phase8Controls.swift +394 -0
- package/Sources/SwiftUI/API/Picker.swift +176 -0
- package/Sources/SwiftUI/API/Presentation.swift +109 -0
- package/Sources/SwiftUI/API/Primitives.swift +390 -0
- package/Sources/SwiftUI/API/Publishers.swift +107 -0
- package/Sources/SwiftUI/API/RowActions.swift +83 -0
- package/Sources/SwiftUI/API/ScrollTargets.swift +104 -0
- package/Sources/SwiftUI/API/ShapeStyle.swift +311 -0
- package/Sources/SwiftUI/API/State.swift +127 -0
- package/Sources/SwiftUI/API/StrokeStyle.swift +30 -0
- package/Sources/SwiftUI/API/Tabs.swift +119 -0
- package/Sources/SwiftUI/API/Tasks.swift +35 -0
- package/Sources/SwiftUI/API/Timeline.swift +212 -0
- package/Sources/SwiftUI/API/Toolbar.swift +215 -0
- package/Sources/SwiftUI/API/View.swift +207 -0
- package/Sources/SwiftUI/API/ViewModifier.swift +40 -0
- package/Sources/SwiftUI/API/VisualEffect.swift +82 -0
- package/Sources/SwiftUI/Charts/Axis.swift +319 -0
- package/Sources/SwiftUI/Charts/Chart.swift +646 -0
- package/Sources/SwiftUI/Charts/ChartContent.swift +376 -0
- package/Sources/SwiftUI/Charts/Marks.swift +204 -0
- package/Sources/SwiftUI/Charts/Plottable.swift +99 -0
- package/Sources/SwiftUI/Charts/Scales.swift +326 -0
- package/Sources/SwiftUI/Runtime/Executor.swift +83 -0
- package/Sources/SwiftUI/Runtime/Exports.swift +65 -0
- package/Sources/SwiftUI/Runtime/JSON.swift +71 -0
- package/Sources/SwiftUI/Runtime/JSONParser.swift +443 -0
- package/Sources/SwiftUI/Runtime/Ops.swift +106 -0
- package/Sources/SwiftUI/Runtime/Reconciler.swift +210 -0
- package/Sources/SwiftUI/Runtime/Runtime.swift +1367 -0
- package/Sources/_FoundationCommon/Bundle.swift +82 -0
- package/Sources/_FoundationCommon/UserDefaults.swift +170 -0
- package/Sources/_FoundationCommon/_FoundationCommon.swift +10 -0
- package/index.d.ts +2 -0
- package/index.mjs +16 -0
- package/package.json +28 -4
- package/sync-sources.mjs +19 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// MARK: - TextField
|
|
2
|
+
|
|
3
|
+
public protocol TextFieldStyle {
|
|
4
|
+
var _name: String { get }
|
|
5
|
+
}
|
|
6
|
+
public struct DefaultTextFieldStyle: TextFieldStyle {
|
|
7
|
+
public init() {}
|
|
8
|
+
public var _name: String { "plain" }
|
|
9
|
+
}
|
|
10
|
+
public struct PlainTextFieldStyle: TextFieldStyle {
|
|
11
|
+
public init() {}
|
|
12
|
+
public var _name: String { "plain" }
|
|
13
|
+
}
|
|
14
|
+
public struct RoundedBorderTextFieldStyle: TextFieldStyle {
|
|
15
|
+
public init() {}
|
|
16
|
+
public var _name: String { "roundedBorder" }
|
|
17
|
+
}
|
|
18
|
+
extension TextFieldStyle where Self == DefaultTextFieldStyle {
|
|
19
|
+
public static var automatic: DefaultTextFieldStyle { DefaultTextFieldStyle() }
|
|
20
|
+
}
|
|
21
|
+
extension TextFieldStyle where Self == PlainTextFieldStyle {
|
|
22
|
+
public static var plain: PlainTextFieldStyle { PlainTextFieldStyle() }
|
|
23
|
+
}
|
|
24
|
+
extension TextFieldStyle where Self == RoundedBorderTextFieldStyle {
|
|
25
|
+
public static var roundedBorder: RoundedBorderTextFieldStyle { RoundedBorderTextFieldStyle() }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
extension View {
|
|
29
|
+
public func textFieldStyle<S: TextFieldStyle>(_ style: S) -> some View {
|
|
30
|
+
environment(\._textFieldStyle, style._name)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// Formats a value as text and parses text back (`Text(_:format:)`,
|
|
35
|
+
/// `TextField(_:value:format:)`). `FoundationLite` conforms its format styles
|
|
36
|
+
/// (`.number`, `.currency(code:)`); the shim itself has none.
|
|
37
|
+
public protocol _TextFormat {
|
|
38
|
+
associatedtype Input
|
|
39
|
+
func _formatText(_ value: Input) -> String
|
|
40
|
+
func _parseText(_ text: String) -> Input?
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// What the user typed into a formatted field, and the formatted form of the
|
|
44
|
+
/// value it parsed to. The field shows the typed text while the bound value
|
|
45
|
+
/// still formats to that form; an outside change to the value replaces it.
|
|
46
|
+
struct _TextDraft {
|
|
47
|
+
var typed: String
|
|
48
|
+
var formatted: String
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public struct TextField<Label: View> {
|
|
52
|
+
let placeholder: String
|
|
53
|
+
let text: Binding<String>
|
|
54
|
+
let secure: Bool
|
|
55
|
+
/// Persistent storage for `TextField(_:value:format:)` (unused otherwise);
|
|
56
|
+
/// a `State` so the runtime keeps it across re-renders.
|
|
57
|
+
let draft: State<_TextDraft?>
|
|
58
|
+
|
|
59
|
+
init(placeholder: String, text: Binding<String>, secure: Bool) {
|
|
60
|
+
self.placeholder = placeholder
|
|
61
|
+
self.text = text
|
|
62
|
+
self.secure = secure
|
|
63
|
+
draft = State(wrappedValue: nil)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
extension TextField where Label == Text {
|
|
68
|
+
public init<S: StringProtocol>(_ title: S, text: Binding<String>) {
|
|
69
|
+
self.init(placeholder: String(title), text: text, secure: false)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// The prompt, when given, is the placeholder; the title names the field.
|
|
73
|
+
public init<S: StringProtocol>(_ title: S, text: Binding<String>, prompt: Text?) {
|
|
74
|
+
self.init(placeholder: prompt?.content ?? String(title), text: text, secure: false)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
public init(_ titleKey: LocalizedStringKey, text: Binding<String>, prompt: Text? = nil) {
|
|
78
|
+
self.init(placeholder: prompt?.content ?? titleKey.key, text: text, secure: false)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// A field bound to a value through a format: it shows the formatted value
|
|
82
|
+
/// and, as the user types, parses the text and sets the value when parsing
|
|
83
|
+
/// succeeds. The typed text stays visible while the value matches it.
|
|
84
|
+
public init<S: StringProtocol, F: _TextFormat>(_ title: S, value: Binding<F.Input>, format: F,
|
|
85
|
+
prompt: Text? = nil) {
|
|
86
|
+
self.init(_placeholder: prompt?.content ?? String(title), value: value, format: format)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
public init<F: _TextFormat>(_ titleKey: LocalizedStringKey, value: Binding<F.Input>, format: F,
|
|
90
|
+
prompt: Text? = nil) {
|
|
91
|
+
self.init(_placeholder: prompt?.content ?? titleKey.key, value: value, format: format)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// The formatted field with its placeholder resolved; public so a bridge
|
|
95
|
+
/// module can wrap other format protocols (FoundationBridge wraps
|
|
96
|
+
/// Foundation's `ParseableFormatStyle`) without re-entering the public
|
|
97
|
+
/// initializers above.
|
|
98
|
+
public init<F: _TextFormat>(_placeholder placeholder: String, value: Binding<F.Input>, format: F) {
|
|
99
|
+
let draft = State<_TextDraft?>(wrappedValue: nil)
|
|
100
|
+
let text = Binding<String>(
|
|
101
|
+
get: {
|
|
102
|
+
let formatted = format._formatText(value.wrappedValue)
|
|
103
|
+
if let current = draft.wrappedValue, current.formatted == formatted { return current.typed }
|
|
104
|
+
return formatted
|
|
105
|
+
},
|
|
106
|
+
set: { typed in
|
|
107
|
+
if let parsed = format._parseText(typed) {
|
|
108
|
+
draft.wrappedValue = _TextDraft(typed: typed, formatted: format._formatText(parsed))
|
|
109
|
+
value.wrappedValue = parsed
|
|
110
|
+
} else {
|
|
111
|
+
let formatted = draft.wrappedValue?.formatted ?? format._formatText(value.wrappedValue)
|
|
112
|
+
draft.wrappedValue = _TextDraft(typed: typed, formatted: formatted)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
self.placeholder = placeholder
|
|
117
|
+
self.text = text
|
|
118
|
+
secure = false
|
|
119
|
+
self.draft = draft
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
extension TextField: _PrimitiveView {}
|
|
123
|
+
extension TextField: _HostView {
|
|
124
|
+
var _kind: String { "textfield" }
|
|
125
|
+
func _props(in environment: EnvironmentValues) -> _JSON {
|
|
126
|
+
var props: [String: _JSON] = [
|
|
127
|
+
"text": .string(text.wrappedValue),
|
|
128
|
+
"placeholder": .string(placeholder),
|
|
129
|
+
"style": .string(environment._textFieldStyle),
|
|
130
|
+
"secure": .bool(secure),
|
|
131
|
+
]
|
|
132
|
+
// Phase 12: `.focused(...)` binds the field's focus; `.onSubmit` wants
|
|
133
|
+
// the Enter key; `.submitLabel` names the return key.
|
|
134
|
+
if let focus = environment._focus { props["focused"] = .bool(focus.isFocused()) }
|
|
135
|
+
if environment._onSubmit != nil { props["submit"] = .bool(true) }
|
|
136
|
+
if let label = environment._submitLabel { props["submitLabel"] = .string(label) }
|
|
137
|
+
return .object(props)
|
|
138
|
+
}
|
|
139
|
+
var _handler: ((_JSON) -> Void)? { _handler(in: EnvironmentValues()) }
|
|
140
|
+
}
|
|
141
|
+
extension TextField: _EnvironmentHandling {
|
|
142
|
+
/// Text (`"value"` as a string), `focus` (`{type, value: Bool}`) and `submit` (`{type}`) events.
|
|
143
|
+
func _handler(in environment: EnvironmentValues) -> ((_JSON) -> Void)? {
|
|
144
|
+
let text = self.text
|
|
145
|
+
let focus = environment._focus
|
|
146
|
+
let onSubmit = environment._onSubmit
|
|
147
|
+
return { payload in
|
|
148
|
+
switch payload {
|
|
149
|
+
case .string(let value):
|
|
150
|
+
text.wrappedValue = value
|
|
151
|
+
case .object(let fields):
|
|
152
|
+
switch fields["type"] {
|
|
153
|
+
case .string("focus")?:
|
|
154
|
+
if case .bool(let focused)? = fields["value"] { focus?.setFocused(focused) }
|
|
155
|
+
case .string("submit")?:
|
|
156
|
+
onSubmit?()
|
|
157
|
+
default:
|
|
158
|
+
break
|
|
159
|
+
}
|
|
160
|
+
default:
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
public struct SecureField<Label: View> {
|
|
168
|
+
let field: TextField<Label>
|
|
169
|
+
}
|
|
170
|
+
extension SecureField where Label == Text {
|
|
171
|
+
public init<S: StringProtocol>(_ title: S, text: Binding<String>) {
|
|
172
|
+
field = TextField(placeholder: String(title), text: text, secure: true)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
extension SecureField: _PrimitiveView {}
|
|
176
|
+
extension SecureField: _HostView {
|
|
177
|
+
var _kind: String { "textfield" }
|
|
178
|
+
func _props(in environment: EnvironmentValues) -> _JSON { field._props(in: environment) }
|
|
179
|
+
var _handler: ((_JSON) -> Void)? { field._handler }
|
|
180
|
+
}
|
|
181
|
+
extension SecureField: _EnvironmentHandling {
|
|
182
|
+
func _handler(in environment: EnvironmentValues) -> ((_JSON) -> Void)? { field._handler(in: environment) }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// MARK: - Toggle
|
|
186
|
+
|
|
187
|
+
public struct Toggle<Label: View> {
|
|
188
|
+
let isOn: Binding<Bool>
|
|
189
|
+
let label: Label
|
|
190
|
+
|
|
191
|
+
public init(isOn: Binding<Bool>, @ViewBuilder label: () -> Label) {
|
|
192
|
+
self.isOn = isOn
|
|
193
|
+
self.label = label()
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
extension Toggle where Label == Text {
|
|
197
|
+
public init<S: StringProtocol>(_ title: S, isOn: Binding<Bool>) {
|
|
198
|
+
self.init(isOn: isOn) { Text(title) }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
extension Toggle: _PrimitiveView {}
|
|
202
|
+
extension Toggle: _HostView {
|
|
203
|
+
var _kind: String { "toggle" }
|
|
204
|
+
func _props(in environment: EnvironmentValues) -> _JSON { .object(["isOn": .bool(isOn.wrappedValue)]) }
|
|
205
|
+
var _content: [any View] { [label] }
|
|
206
|
+
var _handler: ((_JSON) -> Void)? {
|
|
207
|
+
let isOn = self.isOn
|
|
208
|
+
return { payload in
|
|
209
|
+
if case .bool(let value) = payload { isOn.wrappedValue = value }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// MARK: - Label
|
|
215
|
+
|
|
216
|
+
/// A title and an icon, laid out like SwiftUI's default label style.
|
|
217
|
+
public struct Label<Title: View, Icon: View>: View {
|
|
218
|
+
let title: Title
|
|
219
|
+
let icon: Icon
|
|
220
|
+
|
|
221
|
+
public init(@ViewBuilder title: () -> Title, @ViewBuilder icon: () -> Icon) {
|
|
222
|
+
self.title = title()
|
|
223
|
+
self.icon = icon()
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
public var body: some View {
|
|
227
|
+
_StyledLabel(title: title, icon: icon)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
extension Label where Title == Text, Icon == Image {
|
|
232
|
+
public init<S: StringProtocol>(_ title: S, systemImage: String) {
|
|
233
|
+
self.title = Text(title)
|
|
234
|
+
self.icon = Image(systemName: systemImage)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
public init(_ titleKey: LocalizedStringKey, systemImage: String) {
|
|
238
|
+
self.title = Text(titleKey)
|
|
239
|
+
self.icon = Image(systemName: systemImage)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
public init<S: StringProtocol>(_ title: S, image name: String) {
|
|
243
|
+
self.title = Text(title)
|
|
244
|
+
self.icon = Image(name)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// MARK: - Enabling
|
|
249
|
+
|
|
250
|
+
extension View {
|
|
251
|
+
public func disabled(_ disabled: Bool) -> some View {
|
|
252
|
+
_styled { $0.disabled = disabled ? true : nil }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/// Runs `action` once, right after the view is first rendered.
|
|
256
|
+
public func onAppear(perform action: (@MainActor () -> Void)? = nil) -> some View {
|
|
257
|
+
_OnAppearView(content: self, action: action)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
struct _OnAppearView<Content: View> {
|
|
262
|
+
let content: Content
|
|
263
|
+
let action: (@MainActor () -> Void)?
|
|
264
|
+
}
|
|
265
|
+
extension _OnAppearView: _PrimitiveView {}
|
|
266
|
+
extension _OnAppearView: _GroupView {
|
|
267
|
+
var _children: [any View] { [content] }
|
|
268
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
// The `Layout` protocol, measured by the host. The Swift side has no text
|
|
2
|
+
// metrics, so a custom layout cannot measure its children itself: the
|
|
3
|
+
// renderer measures them (minimum, ideal and maximum size for the proposal
|
|
4
|
+
// it places the `layout` element with) and reports the results in a `layout`
|
|
5
|
+
// event. The runtime then runs `sizeThatFits` / `placeSubviews` and sends the
|
|
6
|
+
// resulting size and frames back as props. See docs/ops-protocol.md (Phase 6).
|
|
7
|
+
|
|
8
|
+
// MARK: - Public API
|
|
9
|
+
|
|
10
|
+
/// A size proposed to a view; a `nil` dimension means "whatever you want".
|
|
11
|
+
public struct ProposedViewSize: Equatable, Sendable {
|
|
12
|
+
public var width: CGFloat?
|
|
13
|
+
public var height: CGFloat?
|
|
14
|
+
|
|
15
|
+
public init(width: CGFloat?, height: CGFloat?) {
|
|
16
|
+
self.width = width
|
|
17
|
+
self.height = height
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public init(_ size: CGSize) {
|
|
21
|
+
self.init(width: size.width, height: size.height)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public static let zero = ProposedViewSize(width: 0, height: 0)
|
|
25
|
+
public static let unspecified = ProposedViewSize(width: nil, height: nil)
|
|
26
|
+
public static let infinity = ProposedViewSize(width: .infinity, height: .infinity)
|
|
27
|
+
|
|
28
|
+
public func replacingUnspecifiedDimensions(by size: CGSize = CGSize(width: 10, height: 10)) -> CGSize {
|
|
29
|
+
CGSize(width: width ?? size.width, height: height ?? size.height)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
var _json: _JSON {
|
|
33
|
+
.object(["width": _dimensionJSON(width), "height": _dimensionJSON(height)])
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// The size of a view, as `LayoutSubview.dimensions(in:)` reports it.
|
|
38
|
+
public struct ViewDimensions: Equatable, Sendable {
|
|
39
|
+
public var width: CGFloat
|
|
40
|
+
public var height: CGFloat
|
|
41
|
+
|
|
42
|
+
init(width: CGFloat, height: CGFloat) {
|
|
43
|
+
self.width = width
|
|
44
|
+
self.height = height
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public subscript(guide: HorizontalAlignment) -> CGFloat {
|
|
48
|
+
switch guide.name {
|
|
49
|
+
case "leading": 0
|
|
50
|
+
case "trailing": width
|
|
51
|
+
default: width / 2
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public subscript(guide: VerticalAlignment) -> CGFloat {
|
|
56
|
+
switch guide.name {
|
|
57
|
+
case "top": 0
|
|
58
|
+
case "bottom": height
|
|
59
|
+
default: height / 2
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// Preferred spacing around a view. The shim has no per-view spacing
|
|
65
|
+
/// preferences: every distance is the system default (8 points).
|
|
66
|
+
public struct ViewSpacing: Equatable, Sendable {
|
|
67
|
+
public init() {}
|
|
68
|
+
public static let zero = ViewSpacing()
|
|
69
|
+
|
|
70
|
+
public func distance(to next: ViewSpacing, along axis: Axis) -> CGFloat { 8 }
|
|
71
|
+
public mutating func formUnion(_ other: ViewSpacing, edges: Edge.Set = .all) {}
|
|
72
|
+
public func union(_ other: ViewSpacing, edges: Edge.Set = .all) -> ViewSpacing { self }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// Characteristics of a layout the system may take into account.
|
|
76
|
+
public struct LayoutProperties: Sendable {
|
|
77
|
+
public var stackOrientation: Axis?
|
|
78
|
+
public init() {}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// A key for values a subview exposes to its layout via `layoutValue(key:value:)`.
|
|
82
|
+
/// The shim does not propagate such values; the layout always sees the default.
|
|
83
|
+
public protocol LayoutValueKey {
|
|
84
|
+
associatedtype Value
|
|
85
|
+
static var defaultValue: Value { get }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
extension View {
|
|
89
|
+
/// Accepted for source compatibility: custom layouts read `K.defaultValue`.
|
|
90
|
+
public func layoutValue<K: LayoutValueKey>(key: K.Type, value: K.Value) -> some View { self }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// A proxy for one child of a custom layout.
|
|
94
|
+
///
|
|
95
|
+
/// The sizes come from the renderer's measurement of the child: its minimum
|
|
96
|
+
/// (proposal 0), ideal (proposal `nil`) and maximum (proposal infinity).
|
|
97
|
+
/// `sizeThatFits` answers from those: the ideal for an unspecified axis,
|
|
98
|
+
/// otherwise the proposal clamped to the minimum and maximum.
|
|
99
|
+
public struct LayoutSubview {
|
|
100
|
+
let index: Int
|
|
101
|
+
let min: CGSize
|
|
102
|
+
let ideal: CGSize
|
|
103
|
+
let max: CGSize
|
|
104
|
+
let placements: _LayoutPlacements
|
|
105
|
+
|
|
106
|
+
public func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize {
|
|
107
|
+
CGSize(
|
|
108
|
+
width: Self.fit(proposal.width, min: min.width, ideal: ideal.width, max: max.width),
|
|
109
|
+
height: Self.fit(proposal.height, min: min.height, ideal: ideal.height, max: max.height)
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
static func fit(_ proposed: CGFloat?, min: CGFloat, ideal: CGFloat, max: CGFloat) -> CGFloat {
|
|
114
|
+
guard let proposed else { return ideal }
|
|
115
|
+
let lower = Swift.min(min, max)
|
|
116
|
+
return Swift.max(lower, Swift.min(proposed, max))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
public func dimensions(in proposal: ProposedViewSize) -> ViewDimensions {
|
|
120
|
+
let size = sizeThatFits(proposal)
|
|
121
|
+
return ViewDimensions(width: size.width, height: size.height)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
public var spacing: ViewSpacing { ViewSpacing() }
|
|
125
|
+
|
|
126
|
+
public var priority: Double { 0 }
|
|
127
|
+
|
|
128
|
+
public subscript<K: LayoutValueKey>(key: K.Type) -> K.Value { K.defaultValue }
|
|
129
|
+
|
|
130
|
+
/// Records where the child goes. Its size is `sizeThatFits(proposal)` and
|
|
131
|
+
/// `anchor` says which point of that size sits at `position`.
|
|
132
|
+
public func place(at position: CGPoint, anchor: UnitPoint = .topLeading, proposal: ProposedViewSize) {
|
|
133
|
+
let size = sizeThatFits(proposal)
|
|
134
|
+
let origin = CGPoint(x: position.x - anchor.x * size.width, y: position.y - anchor.y * size.height)
|
|
135
|
+
placements.frames[index] = CGRect(origin: origin, size: size)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/// Frames recorded by `LayoutSubview.place`, shared by every proxy of one pass.
|
|
140
|
+
public final class _LayoutPlacements {
|
|
141
|
+
var frames: [Int: CGRect] = [:]
|
|
142
|
+
init() {}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/// The children of a custom layout.
|
|
146
|
+
public struct LayoutSubviews: RandomAccessCollection, Equatable {
|
|
147
|
+
public typealias Element = LayoutSubview
|
|
148
|
+
public typealias Index = Int
|
|
149
|
+
public typealias SubSequence = LayoutSubviews
|
|
150
|
+
|
|
151
|
+
let subviews: [LayoutSubview]
|
|
152
|
+
public let startIndex: Int
|
|
153
|
+
public let endIndex: Int
|
|
154
|
+
|
|
155
|
+
init(_ subviews: [LayoutSubview]) {
|
|
156
|
+
self.subviews = subviews
|
|
157
|
+
startIndex = 0
|
|
158
|
+
endIndex = subviews.count
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private init(_ subviews: [LayoutSubview], range: Range<Int>) {
|
|
162
|
+
self.subviews = subviews
|
|
163
|
+
startIndex = range.lowerBound
|
|
164
|
+
endIndex = range.upperBound
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
public subscript(position: Int) -> LayoutSubview { subviews[position] }
|
|
168
|
+
|
|
169
|
+
public subscript(bounds: Range<Int>) -> LayoutSubviews {
|
|
170
|
+
precondition(bounds.lowerBound >= startIndex && bounds.upperBound <= endIndex, "LayoutSubviews index out of range")
|
|
171
|
+
return LayoutSubviews(subviews, range: bounds)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
public static func == (lhs: LayoutSubviews, rhs: LayoutSubviews) -> Bool {
|
|
175
|
+
lhs.startIndex == rhs.startIndex && lhs.endIndex == rhs.endIndex
|
|
176
|
+
&& lhs.subviews.count == rhs.subviews.count
|
|
177
|
+
&& lhs.subviews.indices.allSatisfy {
|
|
178
|
+
lhs.subviews[$0].min == rhs.subviews[$0].min
|
|
179
|
+
&& lhs.subviews[$0].ideal == rhs.subviews[$0].ideal
|
|
180
|
+
&& lhs.subviews[$0].max == rhs.subviews[$0].max
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/// A custom container layout. Conforming types compile unchanged against
|
|
186
|
+
/// Apple's SwiftUI; see the file comment for how measurement works here.
|
|
187
|
+
public protocol Layout {
|
|
188
|
+
associatedtype Cache = Void
|
|
189
|
+
typealias Subviews = LayoutSubviews
|
|
190
|
+
|
|
191
|
+
static var layoutProperties: LayoutProperties { get }
|
|
192
|
+
|
|
193
|
+
func makeCache(subviews: Subviews) -> Cache
|
|
194
|
+
func updateCache(_ cache: inout Cache, subviews: Subviews)
|
|
195
|
+
func spacing(subviews: Subviews, cache: inout Cache) -> ViewSpacing
|
|
196
|
+
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize
|
|
197
|
+
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache)
|
|
198
|
+
func explicitAlignment(of guide: HorizontalAlignment, in bounds: CGRect, proposal: ProposedViewSize,
|
|
199
|
+
subviews: Subviews, cache: inout Cache) -> CGFloat?
|
|
200
|
+
func explicitAlignment(of guide: VerticalAlignment, in bounds: CGRect, proposal: ProposedViewSize,
|
|
201
|
+
subviews: Subviews, cache: inout Cache) -> CGFloat?
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
extension Layout {
|
|
205
|
+
public static var layoutProperties: LayoutProperties { LayoutProperties() }
|
|
206
|
+
|
|
207
|
+
public func updateCache(_ cache: inout Cache, subviews: Subviews) {
|
|
208
|
+
cache = makeCache(subviews: subviews)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
public func spacing(subviews: Subviews, cache: inout Cache) -> ViewSpacing { ViewSpacing() }
|
|
212
|
+
|
|
213
|
+
public func explicitAlignment(of guide: HorizontalAlignment, in bounds: CGRect, proposal: ProposedViewSize,
|
|
214
|
+
subviews: Subviews, cache: inout Cache) -> CGFloat? { nil }
|
|
215
|
+
|
|
216
|
+
public func explicitAlignment(of guide: VerticalAlignment, in bounds: CGRect, proposal: ProposedViewSize,
|
|
217
|
+
subviews: Subviews, cache: inout Cache) -> CGFloat? { nil }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
extension Layout where Cache == Void {
|
|
221
|
+
public func makeCache(subviews: Subviews) -> Cache { () }
|
|
222
|
+
public func updateCache(_ cache: inout Cache, subviews: Subviews) {}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
extension Layout {
|
|
226
|
+
/// Uses the layout as a container: `MyLayout { child; child }`.
|
|
227
|
+
public func callAsFunction<V: View>(@ViewBuilder _ content: () -> V) -> some View {
|
|
228
|
+
_LayoutView(layout: self, content: content())
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// MARK: - Runtime
|
|
233
|
+
|
|
234
|
+
/// The result of running a layout for one host measurement; what the `layout`
|
|
235
|
+
/// element's props carry. Values are rounded to 2 decimals when stored so an
|
|
236
|
+
/// unchanged measurement compares equal and emits no `update`.
|
|
237
|
+
struct _LayoutResult: Equatable {
|
|
238
|
+
var proposal: ProposedViewSize
|
|
239
|
+
var size: CGSize
|
|
240
|
+
var frames: [CGRect]
|
|
241
|
+
/// The layout's own flexibility: `sizeThatFits` for a zero, unspecified and
|
|
242
|
+
/// infinite proposal. The host reads these when a parent measures the
|
|
243
|
+
/// layout with a proposal other than the one it answered, instead of
|
|
244
|
+
/// guessing from the answered size.
|
|
245
|
+
var sizes: _LayoutSizes
|
|
246
|
+
|
|
247
|
+
var _json: [String: _JSON] {
|
|
248
|
+
[
|
|
249
|
+
"proposal": proposal._json,
|
|
250
|
+
"size": .object(["width": .number(size.width), "height": .number(size.height)]),
|
|
251
|
+
"frames": .array(frames.map {
|
|
252
|
+
.object(["x": .number($0.minX), "y": .number($0.minY),
|
|
253
|
+
"width": .number($0.width), "height": .number($0.height)])
|
|
254
|
+
}),
|
|
255
|
+
"sizes": sizes._json,
|
|
256
|
+
]
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/// A layout's min / ideal / max sizes. Infinite dimensions serialize as
|
|
261
|
+
/// `"infinity"`, like proposals do.
|
|
262
|
+
struct _LayoutSizes: Equatable {
|
|
263
|
+
var min: CGSize
|
|
264
|
+
var ideal: CGSize
|
|
265
|
+
var max: CGSize
|
|
266
|
+
|
|
267
|
+
var _json: _JSON {
|
|
268
|
+
func size(_ s: CGSize) -> _JSON {
|
|
269
|
+
.object(["width": _dimensionJSON(s.width), "height": _dimensionJSON(s.height)])
|
|
270
|
+
}
|
|
271
|
+
return .object(["min": size(min), "ideal": size(ideal), "max": size(max)])
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/// Holds a layout's cache across measurements without triggering renders.
|
|
276
|
+
final class _LayoutCacheBox {
|
|
277
|
+
var cache: Any?
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/// The element a custom layout renders as: kind `layout`, the children
|
|
281
|
+
/// flattened inside it like a `zstack`'s. The host measures the children and
|
|
282
|
+
/// sends a `layout` event; the handler runs the layout and stores the result
|
|
283
|
+
/// in `State`, so it persists across re-renders and invalidates on change.
|
|
284
|
+
struct _LayoutView<L: Layout, Content: View> {
|
|
285
|
+
let layout: L
|
|
286
|
+
let content: Content
|
|
287
|
+
@State var result: _LayoutResult? = nil
|
|
288
|
+
@State var cacheBox = _LayoutCacheBox()
|
|
289
|
+
|
|
290
|
+
init(layout: L, content: Content) {
|
|
291
|
+
self.layout = layout
|
|
292
|
+
self.content = content
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
extension _LayoutView: _PrimitiveView {}
|
|
296
|
+
extension _LayoutView: _HostView {
|
|
297
|
+
var _kind: String { "layout" }
|
|
298
|
+
|
|
299
|
+
func _props(in environment: EnvironmentValues) -> _JSON {
|
|
300
|
+
.object(result?._json ?? ["proposal": .null, "size": .null, "frames": .null])
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
var _content: [any View] { [content] }
|
|
304
|
+
|
|
305
|
+
var _handler: ((_JSON) -> Void)? {
|
|
306
|
+
let layout = self.layout
|
|
307
|
+
let resultBox = _result.box
|
|
308
|
+
let cacheBox = self.cacheBox
|
|
309
|
+
return { event in
|
|
310
|
+
guard let (proposal, subviews) = _parseLayoutEvent(event) else { return }
|
|
311
|
+
let computed = _runLayout(layout, proposal: proposal, subviews: subviews, cacheBox: cacheBox)
|
|
312
|
+
if computed != resultBox.value { resultBox.value = computed }
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/// Parses `{"proposal": {...}, "children": [{"min", "ideal", "max"}]}`.
|
|
318
|
+
func _parseLayoutEvent(_ event: _JSON) -> (ProposedViewSize, LayoutSubviews)? {
|
|
319
|
+
guard case .object(let fields) = event else { return nil }
|
|
320
|
+
let proposal: ProposedViewSize
|
|
321
|
+
if case .object(let p)? = fields["proposal"] {
|
|
322
|
+
proposal = ProposedViewSize(width: _dimension(p["width"]), height: _dimension(p["height"]))
|
|
323
|
+
} else {
|
|
324
|
+
proposal = .unspecified
|
|
325
|
+
}
|
|
326
|
+
guard case .array(let children)? = fields["children"] else { return nil }
|
|
327
|
+
let placements = _LayoutPlacements()
|
|
328
|
+
let subviews = children.enumerated().map { index, child in
|
|
329
|
+
func size(_ key: String) -> CGSize {
|
|
330
|
+
guard case .object(let c) = child, case .object(let s)? = c[key] else { return .zero }
|
|
331
|
+
return CGSize(width: _dimension(s["width"]) ?? 0, height: _dimension(s["height"]) ?? 0)
|
|
332
|
+
}
|
|
333
|
+
return LayoutSubview(index: index, min: size("min"), ideal: size("ideal"), max: size("max"),
|
|
334
|
+
placements: placements)
|
|
335
|
+
}
|
|
336
|
+
return (proposal, LayoutSubviews(subviews))
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/// Runs `sizeThatFits` and `placeSubviews`; unplaced subviews are centered at
|
|
340
|
+
/// their ideal size, like SwiftUI does.
|
|
341
|
+
func _runLayout<L: Layout>(_ layout: L, proposal: ProposedViewSize, subviews: LayoutSubviews,
|
|
342
|
+
cacheBox: _LayoutCacheBox) -> _LayoutResult {
|
|
343
|
+
var cache: L.Cache
|
|
344
|
+
if let existing = cacheBox.cache as? L.Cache {
|
|
345
|
+
cache = existing
|
|
346
|
+
layout.updateCache(&cache, subviews: subviews)
|
|
347
|
+
} else {
|
|
348
|
+
cache = layout.makeCache(subviews: subviews)
|
|
349
|
+
}
|
|
350
|
+
// The flexibility probes run before the answer so `placeSubviews` sees the
|
|
351
|
+
// cache state `sizeThatFits` left for the real proposal.
|
|
352
|
+
let sizes = _LayoutSizes(
|
|
353
|
+
min: _rounded(layout.sizeThatFits(proposal: .zero, subviews: subviews, cache: &cache)),
|
|
354
|
+
ideal: _rounded(layout.sizeThatFits(proposal: .unspecified, subviews: subviews, cache: &cache)),
|
|
355
|
+
max: _rounded(layout.sizeThatFits(proposal: .infinity, subviews: subviews, cache: &cache))
|
|
356
|
+
)
|
|
357
|
+
let size = layout.sizeThatFits(proposal: proposal, subviews: subviews, cache: &cache)
|
|
358
|
+
let bounds = CGRect(origin: .zero, size: size)
|
|
359
|
+
layout.placeSubviews(in: bounds, proposal: proposal, subviews: subviews, cache: &cache)
|
|
360
|
+
cacheBox.cache = cache
|
|
361
|
+
|
|
362
|
+
let placements = subviews.first?.placements
|
|
363
|
+
let frames = subviews.map { subview -> CGRect in
|
|
364
|
+
if let frame = placements?.frames[subview.index] { return _rounded(frame) }
|
|
365
|
+
let ideal = subview.ideal
|
|
366
|
+
return _rounded(CGRect(x: bounds.midX - ideal.width / 2, y: bounds.midY - ideal.height / 2,
|
|
367
|
+
width: ideal.width, height: ideal.height))
|
|
368
|
+
}
|
|
369
|
+
return _LayoutResult(
|
|
370
|
+
proposal: proposal,
|
|
371
|
+
size: CGSize(width: _round2(size.width), height: _round2(size.height)),
|
|
372
|
+
frames: frames,
|
|
373
|
+
sizes: sizes
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/// A dimension from an event: a number, the string `"infinity"`, or nothing.
|
|
378
|
+
func _dimension(_ json: _JSON?) -> CGFloat? {
|
|
379
|
+
switch json {
|
|
380
|
+
case .number(let n)?: n
|
|
381
|
+
case .string("infinity")?: .infinity
|
|
382
|
+
default: nil
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
func _dimensionJSON(_ value: CGFloat?) -> _JSON {
|
|
387
|
+
guard let value else { return .null }
|
|
388
|
+
return value == .infinity ? .string("infinity") : .number(_round2(value))
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/// Rounds to 2 decimals so float noise never flips a snapshot.
|
|
392
|
+
func _round2(_ value: CGFloat) -> CGFloat {
|
|
393
|
+
guard value.isFinite else { return value }
|
|
394
|
+
return (value * 100).rounded() / 100
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private func _rounded(_ rect: CGRect) -> CGRect {
|
|
398
|
+
CGRect(x: _round2(rect.minX), y: _round2(rect.minY), width: _round2(rect.width), height: _round2(rect.height))
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private func _rounded(_ size: CGSize) -> CGSize {
|
|
402
|
+
CGSize(width: _round2(size.width), height: _round2(size.height))
|
|
403
|
+
}
|