Skip to content

Commit 94ff1cc

Browse files
committed
Use new SizeProposal type instead of SIMD2<Int> in View.computeLayout
This makes it possible to request ideal sizing without calling computeLayout twice.
1 parent 5cc10b6 commit 94ff1cc

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+539
-340
lines changed

Sources/AppKitBackend/AppKitBackend.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ public final class AppKitBackend: AppBackend {
3636
scrollerStyle: NSScroller.preferredScrollerStyle
3737
).rounded(.awayFromZero)
3838
)
39-
print(result)
4039
return result
4140
}
4241

Sources/AppKitBackend/NSViewRepresentable.swift

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -142,22 +142,27 @@ extension View where Self: NSViewRepresentable {
142142
public func computeLayout<Backend: AppBackend>(
143143
_ widget: Backend.Widget,
144144
children: any ViewGraphNodeChildren,
145-
proposedSize: SIMD2<Int>,
145+
proposedSize: SizeProposal,
146146
environment: EnvironmentValues,
147-
backend: Backend
147+
backend _: Backend
148148
) -> ViewLayoutResult {
149-
guard backend is AppKitBackend else {
150-
fatalError("NSViewRepresentable updated by \(Backend.self)")
151-
}
152-
149+
// We update the underlying view in computeLayout because we don't expect
150+
// UIViews to have their layout computations decoupled from their content.
153151
let representingWidget = widget as! RepresentingWidget<Self>
154152
representingWidget.update(with: environment)
155153

156-
let size = representingWidget.representable.determineViewSize(
157-
for: proposedSize,
154+
// TODO: Pass size proposal through to XRepresentable views
155+
var size = representingWidget.representable.determineViewSize(
156+
for: proposedSize.evaluated(withIdealSize: SIMD2(10, 10)),
158157
nsView: representingWidget.subview,
159158
context: representingWidget.context!
160159
)
160+
if proposedSize.width == nil {
161+
size.size.x = size.idealWidthForProposedHeight
162+
}
163+
if proposedSize.height == nil {
164+
size.size.y = size.idealHeightForProposedWidth
165+
}
161166

162167
return ViewLayoutResult.leafView(size: size)
163168
}

Sources/SwiftCrossUI/Environment/EnvironmentValues.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,13 +118,12 @@ public struct EnvironmentValues {
118118
backend.activate(window: window as! Backend.Window)
119119
}
120120
activate(with: backend)
121-
print("Activated")
122121
}
123122

124123
/// The backend's representation of the window that the current view is
125124
/// in, if any. This is a very internal detail that should never get
126125
/// exposed to users.
127-
package var window: Any?
126+
public var window: Any?
128127
/// The backend in use. Mustn't change throughout the app's lifecycle.
129128
let backend: any AppBackend
130129

@@ -192,7 +191,7 @@ public struct EnvironmentValues {
192191
}
193192

194193
/// Creates the default environment.
195-
init<Backend: AppBackend>(backend: Backend) {
194+
public init<Backend: AppBackend>(backend: Backend) {
196195
self.backend = backend
197196

198197
onResize = { _ in }

Sources/SwiftCrossUI/Layout/LayoutSystem.swift

Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import Foundation
2+
13
public enum LayoutSystem {
24
static func width(forHeight height: Int, aspectRatio: Double) -> Int {
35
roundSize(Double(height) * aspectRatio)
@@ -11,6 +13,18 @@ public enum LayoutSystem {
1113
Int(size.rounded(.towardZero))
1214
}
1315

16+
/// Clamps a value to a range given optional lower and upper bounds.
17+
static func clamp<T: Comparable>(_ value: T, minimum: T? = nil, maximum: T? = nil) -> T {
18+
var value = value
19+
if let minimum {
20+
value = max(value, minimum)
21+
}
22+
if let maximum {
23+
value = min(value, maximum)
24+
}
25+
return value
26+
}
27+
1428
static func aspectRatio(of frame: SIMD2<Double>) -> Double {
1529
if frame.x == 0 || frame.y == 0 {
1630
// Even though we could technically compute an aspect ratio when the
@@ -47,15 +61,20 @@ public enum LayoutSystem {
4761
public struct LayoutableChild {
4862
private var computeLayout:
4963
@MainActor (
50-
_ proposedSize: SIMD2<Int>,
64+
_ proposedSize: SizeProposal,
5165
_ environment: EnvironmentValues
5266
) -> ViewLayoutResult
67+
5368
private var _commit: @MainActor () -> ViewLayoutResult
69+
5470
var tag: String?
5571

5672
public init(
57-
computeLayout: @escaping @MainActor (SIMD2<Int>, EnvironmentValues) -> ViewLayoutResult,
58-
commit: @escaping @MainActor () -> ViewLayoutResult,
73+
computeLayout: @escaping @MainActor (
74+
SizeProposal,
75+
EnvironmentValues
76+
) -> ViewLayoutResult,
77+
commit: @escaping () -> ViewLayoutResult,
5978
tag: String? = nil
6079
) {
6180
self.computeLayout = computeLayout
@@ -65,7 +84,7 @@ public enum LayoutSystem {
6584

6685
@MainActor
6786
public func computeLayout(
68-
proposedSize: SIMD2<Int>,
87+
proposedSize: SizeProposal,
6988
environment: EnvironmentValues,
7089
dryRun: Bool = false
7190
) -> ViewLayoutResult {
@@ -87,7 +106,7 @@ public enum LayoutSystem {
87106
public static func computeStackLayout<Backend: AppBackend>(
88107
container: Backend.Widget,
89108
children: [LayoutableChild],
90-
proposedSize: SIMD2<Int>,
109+
proposedSize: SizeProposal,
91110
environment: EnvironmentValues,
92111
backend: Backend,
93112
inheritStackLayoutParticipation: Bool = false
@@ -106,7 +125,7 @@ public enum LayoutSystem {
106125
var isHidden = [Bool](repeating: false, count: children.count)
107126
let flexibilities = children.enumerated().map { i, child in
108127
let result = child.computeLayout(
109-
proposedSize: proposedSize,
128+
proposedSize: .ideal,
110129
environment: environment
111130
)
112131
isHidden[i] = !result.participatesInStackLayouts
@@ -121,11 +140,19 @@ public enum LayoutSystem {
121140
!hidden
122141
}.count
123142
let totalSpacing = max(visibleChildrenCount - 1, 0) * spacing
124-
let sortedChildren = zip(children.enumerated(), flexibilities)
125-
.sorted { first, second in
126-
first.1 <= second.1
127-
}
128-
.map(\.0)
143+
144+
let sortedChildren: [(offset: Int, element: LayoutSystem.LayoutableChild)]
145+
if orientation == .vertical && proposedSize.height == nil
146+
|| orientation == .horizontal && proposedSize.width == nil
147+
{
148+
sortedChildren = Array(children.enumerated())
149+
} else {
150+
sortedChildren = zip(children.enumerated(), flexibilities)
151+
.sorted { first, second in
152+
first.1 <= second.1
153+
}
154+
.map(\.0)
155+
}
129156

130157
var spaceUsedAlongStackAxis = 0
131158
var childrenRemaining = visibleChildrenCount
@@ -152,25 +179,39 @@ public enum LayoutSystem {
152179
continue
153180
}
154181

155-
let proposedWidth: Double
156-
let proposedHeight: Double
182+
let proposedWidth: Double?
183+
let proposedHeight: Double?
157184
switch orientation {
158185
case .horizontal:
159-
proposedWidth =
160-
Double(max(proposedSize.x - spaceUsedAlongStackAxis - totalSpacing, 0))
161-
/ Double(childrenRemaining)
162-
proposedHeight = Double(proposedSize.y)
186+
if let parentProposedWidth = proposedSize.width {
187+
proposedWidth =
188+
Double(
189+
max(
190+
parentProposedWidth - spaceUsedAlongStackAxis - totalSpacing,
191+
0
192+
)) / Double(childrenRemaining)
193+
} else {
194+
proposedWidth = nil
195+
}
196+
proposedHeight = proposedSize.height.map(Double.init)
163197
case .vertical:
164-
proposedHeight =
165-
Double(max(proposedSize.y - spaceUsedAlongStackAxis - totalSpacing, 0))
166-
/ Double(childrenRemaining)
167-
proposedWidth = Double(proposedSize.x)
198+
if let parentProposedHeight = proposedSize.height {
199+
proposedHeight =
200+
Double(
201+
max(
202+
parentProposedHeight - spaceUsedAlongStackAxis - totalSpacing,
203+
0
204+
)) / Double(childrenRemaining)
205+
} else {
206+
proposedHeight = nil
207+
}
208+
proposedWidth = proposedSize.width.map(Double.init)
168209
}
169210

170211
let childResult = child.computeLayout(
171-
proposedSize: SIMD2<Int>(
172-
Int(proposedWidth.rounded(.towardZero)),
173-
Int(proposedHeight.rounded(.towardZero))
212+
proposedSize: SizeProposal(
213+
proposedWidth.map { Int($0.rounded(.towardZero)) },
214+
proposedHeight.map { Int($0.rounded(.towardZero)) }
174215
),
175216
environment: environment
176217
)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/// A proposed view size. Uses `nil` to indicate when the ideal size should
2+
/// be used along a given axis.
3+
public struct SizeProposal: Hashable, Sendable {
4+
/// An empty proposal.
5+
public static let zero = Self(0, 0)
6+
7+
/// A proposal for the view to take on its ideal size.
8+
static let ideal = Self(nil, nil)
9+
10+
/// The proposed width. If `nil`, the view should take on its ideal
11+
/// width for the proposed height.
12+
public var width: Int?
13+
/// The proposed height. If `nil`, the view should take on its ideal
14+
/// height for the proposed width.
15+
public var height: Int?
16+
17+
/// The proposal as a concrete size if both dimensions are non-nil,
18+
/// otherwise nil.
19+
var concrete: SIMD2<Int>? {
20+
guard let width, let height else {
21+
return nil
22+
}
23+
return SIMD2(width, height)
24+
}
25+
26+
/// Creates a new size proposal. Use `nil` for dimensions that the view
27+
/// should take on its ideal size along.
28+
public init(_ width: Int?, _ height: Int?) {
29+
self.width = width
30+
self.height = height
31+
}
32+
33+
/// Creates a new size proposal from a concrete size.
34+
public init(_ size: SIMD2<Int>) {
35+
self.width = size.x
36+
self.height = size.y
37+
}
38+
39+
/// Evaluates the size proposal with a view's ideal size.
40+
package func evaluated(withIdealSize idealSize: SIMD2<Int>) -> SIMD2<Int> {
41+
SIMD2(
42+
width ?? idealSize.x,
43+
height ?? idealSize.y
44+
)
45+
}
46+
}

Sources/SwiftCrossUI/Values/Color.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,14 +71,15 @@ extension Color: ElementaryView {
7171

7272
func computeLayout<Backend: AppBackend>(
7373
_ widget: Backend.Widget,
74-
proposedSize: SIMD2<Int>,
74+
proposedSize: SizeProposal,
7575
environment: EnvironmentValues,
7676
backend: Backend
7777
) -> ViewLayoutResult {
78-
ViewLayoutResult.leafView(
78+
let idealSize = SIMD2(10, 10)
79+
return ViewLayoutResult.leafView(
7980
size: ViewSize(
80-
size: proposedSize,
81-
idealSize: SIMD2(10, 10),
81+
size: proposedSize.evaluated(withIdealSize: idealSize),
82+
idealSize: idealSize,
8283
minimumWidth: 0,
8384
minimumHeight: 0,
8485
maximumWidth: nil,

Sources/SwiftCrossUI/ViewGraph/AnyViewGraphNode.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public class AnyViewGraphNode<NodeView: View> {
1616
private var _computeLayoutWithNewView:
1717
(
1818
_ newView: NodeView?,
19-
_ proposedSize: SIMD2<Int>,
19+
_ proposedSize: SizeProposal,
2020
_ environment: EnvironmentValues
2121
) -> ViewLayoutResult
2222
/// The node's type-erased commit method.
@@ -70,7 +70,7 @@ public class AnyViewGraphNode<NodeView: View> {
7070
/// the given size proposal already has a cached result.
7171
public func computeLayout(
7272
with newView: NodeView?,
73-
proposedSize: SIMD2<Int>,
73+
proposedSize: SizeProposal,
7474
environment: EnvironmentValues
7575
) -> ViewLayoutResult {
7676
_computeLayoutWithNewView(newView, proposedSize, environment)

Sources/SwiftCrossUI/ViewGraph/ErasedViewGraphNode.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ public struct ErasedViewGraphNode {
1111
public var computeLayoutWithNewView:
1212
(
1313
_ newView: Any?,
14-
_ proposedSize: SIMD2<Int>,
14+
_ proposedSize: SizeProposal,
1515
_ environment: EnvironmentValues
1616
) -> (viewTypeMatched: Bool, size: ViewLayoutResult)
1717
/// The underlying view graph node's commit method.

Sources/SwiftCrossUI/ViewGraph/ViewGraph.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ public class ViewGraph<Root: View> {
6363
if dryRun {
6464
result = rootNode.computeLayout(
6565
with: newView ?? view,
66-
proposedSize: proposedSize,
66+
proposedSize: SizeProposal(proposedSize),
6767
environment: parentEnvironment
6868
)
6969
} else {

Sources/SwiftCrossUI/ViewGraph/ViewGraphNode.swift

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,10 @@ public class ViewGraphNode<NodeView: View, Backend: AppBackend>: Sendable {
4040
public var currentLayout: ViewLayoutResult?
4141
/// A cache of update results keyed by the proposed size they were for. Gets cleared before the
4242
/// results' sizes become invalid.
43-
var resultCache: [SIMD2<Int>: ViewLayoutResult]
43+
var resultCache: [SizeProposal: ViewLayoutResult]
4444
/// The most recent size proposed by the parent view. Used when updating the wrapped
4545
/// view as a result of a state change rather than the parent view updating.
46-
private var lastProposedSize: SIMD2<Int>
46+
private var lastProposedSize: SizeProposal
4747

4848
/// A cancellable handle to the view's state property observations.
4949
private var cancellables: [Cancellable]
@@ -164,7 +164,7 @@ public class ViewGraphNode<NodeView: View, Backend: AppBackend>: Sendable {
164164
/// state.
165165
public func computeLayout(
166166
with newView: NodeView? = nil,
167-
proposedSize: SIMD2<Int>,
167+
proposedSize: SizeProposal,
168168
environment: EnvironmentValues
169169
) -> ViewLayoutResult {
170170
// Defensively ensure that all future scene implementations obey this
@@ -189,15 +189,29 @@ public class ViewGraphNode<NodeView: View, Backend: AppBackend>: Sendable {
189189
// current view state.
190190
if let currentLayout, !resultCache.isEmpty {
191191
// If both the previous and current proposed sizes are larger than
192-
// the view's previously computed maximum size, reuse the previous
193-
// result (currentResult).
194-
if ((Double(lastProposedSize.x) >= currentLayout.size.maximumWidth
195-
&& Double(proposedSize.x) >= currentLayout.size.maximumWidth)
196-
|| proposedSize.x == lastProposedSize.x)
197-
&& ((Double(lastProposedSize.y) >= currentLayout.size.maximumHeight
198-
&& Double(proposedSize.y) >= currentLayout.size.maximumHeight)
199-
|| proposedSize.y == lastProposedSize.y)
192+
// the view's previously computed maximum size or equal to each other,
193+
// reuse the previous result (currentResult).
194+
var isHorizontalCacheHit = lastProposedSize.width == proposedSize.width
195+
if !isHorizontalCacheHit,
196+
let lastProposedWidth = lastProposedSize.width,
197+
let proposedWidth = proposedSize.width
200198
{
199+
isHorizontalCacheHit =
200+
(Double(lastProposedWidth) >= currentLayout.size.maximumWidth
201+
&& Double(proposedWidth) >= currentLayout.size.maximumWidth)
202+
}
203+
204+
var isVerticalCacheHit = lastProposedSize.height == proposedSize.height
205+
if !isVerticalCacheHit,
206+
let lastProposedHeight = lastProposedSize.height,
207+
let proposedHeight = proposedSize.height
208+
{
209+
isVerticalCacheHit =
210+
(Double(lastProposedHeight) >= currentLayout.size.maximumHeight
211+
&& Double(proposedHeight) >= currentLayout.size.maximumHeight)
212+
}
213+
214+
if isHorizontalCacheHit && isVerticalCacheHit {
201215
return currentLayout
202216
}
203217

0 commit comments

Comments
 (0)