Skip to content

Commit a8b7629

Browse files
authored
Add ViewSpacing implementation (#246)
* Add ViewSpacing implementation * Add ViewSpacingTests
1 parent e88b997 commit a8b7629

File tree

3 files changed

+334
-0
lines changed

3 files changed

+334
-0
lines changed

Package.swift

+1
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ let openSwiftUISPITestTarget = Target.testTarget(
163163
let openSwiftUICoreTestTarget = Target.testTarget(
164164
name: "OpenSwiftUICoreTests",
165165
dependencies: [
166+
"OpenSwiftUI", // NOTE: For the Glue link logic only, do not call `import OpenSwiftUI` in this target
166167
"OpenSwiftUICore",
167168
.product(name: "Numerics", package: "swift-numerics"),
168169
],
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
//
2+
// ViewSpacing.swift
3+
// OpenSwiftUICore
4+
//
5+
// Audited for iOS 18.0
6+
// Status: Complete
7+
8+
public import Foundation
9+
10+
/// A collection of the geometric spacing preferences of a view.
11+
///
12+
/// This type represents how much space a view prefers to have between it and
13+
/// the next view in a layout. The type stores independent values
14+
/// for each of the top, bottom, leading, and trailing edges,
15+
/// and can also record different values for different kinds of adjacent
16+
/// views. For example, it might contain one value for the spacing to the next
17+
/// text view along the top and bottom edges, other values for the spacing to
18+
/// text views on other edges, and yet other values for other kinds of views.
19+
/// Spacing preferences can also vary by platform.
20+
///
21+
/// Your ``Layout`` type doesn't have to take preferred spacing into
22+
/// account, but if it does, you can use the ``LayoutSubview/spacing``
23+
/// preferences of the subviews in your layout container to:
24+
///
25+
/// * Add space between subviews when you implement the
26+
/// ``Layout/placeSubviews(in:proposal:subviews:cache:)`` method.
27+
/// * Create a spacing preferences instance for the container view by
28+
/// implementing the ``Layout/spacing(subviews:cache:)`` method.
29+
public struct ViewSpacing: Sendable {
30+
/// The underlying spacing implementation that stores the actual spacing values
31+
/// for each edge and direction.
32+
package var spacing: Spacing
33+
34+
/// The layout direction used to resolve relative edges (leading/trailing)
35+
/// to absolute edges (left/right).
36+
var layoutDirection: LayoutDirection?
37+
38+
/// Creates a new ViewSpacing instance with the specified spacing values.
39+
///
40+
/// - Parameter spacing: The spacing implementation to use.
41+
package init(_ spacing: Spacing) {
42+
self.spacing = spacing
43+
self.layoutDirection = nil
44+
}
45+
46+
/// Creates a new ViewSpacing instance with the specified spacing values and layout direction.
47+
///
48+
/// - Parameters:
49+
/// - spacing: The spacing implementation to use.
50+
/// - layoutDirection: The layout direction to use when resolving relative edges.
51+
package init(_ spacing: Spacing, layoutDirection: LayoutDirection) {
52+
self.spacing = spacing
53+
self.layoutDirection = layoutDirection
54+
}
55+
56+
/// A view spacing instance that contains zero on all edges.
57+
///
58+
/// You typically only use this value for an empty view.
59+
public static let zero: ViewSpacing = ViewSpacing(.zero)
60+
61+
/// Initializes an instance with default spacing values.
62+
///
63+
/// Use this initializer to create a spacing preferences instance with
64+
/// default values. Then use ``formUnion(_:edges:)`` to combine
65+
/// preferences from other views with the new instance. You typically
66+
/// do this in a custom layout's implementation of the
67+
/// ``Layout/spacing(subviews:cache:)`` method.
68+
public init() {
69+
self.spacing = Spacing(minima: [:])
70+
self.layoutDirection = nil
71+
}
72+
73+
/// Merges the spacing preferences of another spacing instance with this
74+
/// instance for a specified set of edges.
75+
///
76+
/// When you merge another spacing preference instance with this one,
77+
/// this instance ends up with the greater of its original value or the
78+
/// other instance's value for each of the specified edges.
79+
/// You can call the method repeatedly with each value in a collection to
80+
/// merge a collection of preferences. The result has the smallest
81+
/// preferences on each edge that meets the largest requirements of all
82+
/// the inputs for that edge.
83+
///
84+
/// If you want to merge preferences without modifying the original
85+
/// instance, use ``union(_:edges:)`` instead.
86+
///
87+
/// - Parameters:
88+
/// - other: Another spacing preferences instances to merge with this one.
89+
/// - edges: The edges to merge. Edges that you don't specify are
90+
/// unchanged after the method completes.
91+
public mutating func formUnion(_ other: ViewSpacing, edges: Edge.Set = .all) {
92+
let layoutDirection = layoutDirection ?? other.layoutDirection
93+
self.layoutDirection = layoutDirection
94+
spacing.incorporate(AbsoluteEdge.Set(edges, layoutDirection: layoutDirection ?? .leftToRight), of: other.spacing)
95+
}
96+
97+
/// Gets a new value that merges the spacing preferences of another spacing
98+
/// instance with this instance for a specified set of edges.
99+
///
100+
/// This method behaves like ``formUnion(_:edges:)``, except that it creates
101+
/// a copy of the original spacing preferences instance before merging,
102+
/// leaving the original instance unmodified.
103+
///
104+
/// - Parameters:
105+
/// - other: Another spacing preferences instance to merge with this one.
106+
/// - edges: The edges to merge. Edges that you don't specify are
107+
/// unchanged after the method completes.
108+
///
109+
/// - Returns: A new view spacing preferences instance with the merged
110+
/// values.
111+
public func union(_ other: ViewSpacing, edges: Edge.Set = .all) -> ViewSpacing {
112+
var copy = self
113+
copy.formUnion(other, edges: edges)
114+
return copy
115+
}
116+
117+
/// Gets the preferred spacing distance along the specified axis to the view
118+
/// that returns a specified spacing preference.
119+
///
120+
/// Call this method from your implementation of ``Layout`` protocol
121+
/// methods if you need to measure the default spacing between two
122+
/// views in a custom layout. Call the method on the first view's
123+
/// preferences instance, and provide the second view's preferences
124+
/// instance as input.
125+
///
126+
/// For example, consider two views that appear in a custom horizontal
127+
/// stack. The following distance call gets the preferred spacing between
128+
/// these views, where `spacing1` contains the preferences of a first
129+
/// view, and `spacing2` contains the preferences of a second view:
130+
///
131+
/// let distance = spacing1.distance(to: spacing2, axis: .horizontal)
132+
///
133+
/// The method first determines, based on the axis and the ordering, that
134+
/// the views abut on the trailing edge of the first view and the leading
135+
/// edge of the second. It then gets the spacing preferences for the
136+
/// corresponding edges of each view, and returns the greater of the two
137+
/// values. This results in the smallest value that provides enough space
138+
/// to satisfy the preferences of both views.
139+
///
140+
/// > Note: This method returns the default spacing between views, but a
141+
/// layout can choose to ignore the value and use custom spacing instead.
142+
///
143+
/// - Parameters:
144+
/// - next: The spacing preferences instance of the adjacent view.
145+
/// - axis: The axis that the two views align on.
146+
///
147+
/// - Returns: A floating point value that represents the smallest distance
148+
/// in points between two views that satisfies the spacing preferences
149+
/// of both this view and the adjacent views on their shared edge.
150+
public func distance(to next: ViewSpacing, along axis: Axis) -> CGFloat {
151+
guard let distance = spacing.distanceToSuccessorView(along: axis, layoutDirection: layoutDirection ?? .leftToRight, preferring: next.spacing) else {
152+
let defaultSpacingValue = defaultSpacingValue
153+
return axis == .horizontal ? defaultSpacingValue.width : defaultSpacingValue.height
154+
}
155+
return distance
156+
}
157+
}
158+
159+
@_spi(ForOpenSwiftUIOnly)
160+
extension ViewSpacing: CustomStringConvertible {
161+
public var description: String {
162+
spacing.description
163+
}
164+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
//
2+
// ViewSpacingTests.swift
3+
// OpenSwiftUICoreTests
4+
5+
import Numerics
6+
@_spi(ForOpenSwiftUIOnly)
7+
import OpenSwiftUICore
8+
import Testing
9+
10+
struct ViewSpacingTests {
11+
// MARK: - Initialization Tests
12+
13+
@Test
14+
func packageInitialization() {
15+
// Test package initializer with Spacing
16+
let spacing = Spacing.all(10)
17+
let viewSpacing = ViewSpacing(spacing)
18+
19+
#expect(viewSpacing.description == #"""
20+
Spacing [
21+
(default, top) : 10.0
22+
(default, left) : 10.0
23+
(default, bottom) : 10.0
24+
(default, right) : 10.0
25+
]
26+
"""#)
27+
28+
// Test package initializer with Spacing and layout direction
29+
let rtlViewSpacing = ViewSpacing(spacing, layoutDirection: .rightToLeft)
30+
let ltrViewSpacing = ViewSpacing(spacing, layoutDirection: .leftToRight)
31+
32+
// Different layout directions with symmetric spacing should behave the same
33+
let distance1 = rtlViewSpacing.distance(to: ViewSpacing.zero, along: .horizontal)
34+
let distance2 = ltrViewSpacing.distance(to: ViewSpacing.zero, along: .horizontal)
35+
#expect(distance1.isApproximatelyEqual(to: distance2))
36+
}
37+
38+
// MARK: - Layout Direction Tests
39+
40+
@Test
41+
func layoutDirection() {
42+
// Create asymmetric spacing
43+
let leftSpacing = Spacing(minima: [
44+
Spacing.Key(category: nil, edge: .left): .distance(2),
45+
Spacing.Key(category: nil, edge: .right): .distance(3),
46+
])
47+
48+
// Create view spacing with different layout directions
49+
let ltrViewSpacing = ViewSpacing(leftSpacing, layoutDirection: .leftToRight)
50+
let rtlViewSpacing = ViewSpacing(leftSpacing, layoutDirection: .rightToLeft)
51+
52+
// Test that distance calculation respects layout direction
53+
let targetSpacing = ViewSpacing(.zero)
54+
let ltrDistance = ltrViewSpacing.distance(to: targetSpacing, along: .horizontal)
55+
let rtlDistance = rtlViewSpacing.distance(to: targetSpacing, along: .horizontal)
56+
57+
// The distances should be different due to different layout directions
58+
#expect(ltrDistance.isApproximatelyEqual(to: 3))
59+
#expect(rtlDistance.isApproximatelyEqual(to: 2))
60+
}
61+
62+
// MARK: - Spacing Value Tests
63+
64+
@Test
65+
func spacingValues() {
66+
let spacing = Spacing(minima: [
67+
Spacing.Key(category: nil, edge: .top): .distance(10),
68+
Spacing.Key(category: nil, edge: .left): .distance(20),
69+
Spacing.Key(category: nil, edge: .bottom): .distance(30),
70+
Spacing.Key(category: nil, edge: .right): .distance(40),
71+
])
72+
73+
let viewSpacing = ViewSpacing(spacing)
74+
75+
let horizontalViewSpacing = ViewSpacing(.zero)
76+
let verticalViewSpacing = ViewSpacing(.zero)
77+
78+
let horizontalDistance = viewSpacing.distance(to: horizontalViewSpacing, along: .horizontal)
79+
let verticalDistance = viewSpacing.distance(to: verticalViewSpacing, along: .vertical)
80+
81+
#expect(horizontalDistance.isApproximatelyEqual(to: 40))
82+
#expect(verticalDistance.isApproximatelyEqual(to: 30))
83+
}
84+
85+
// MARK: - Default Spacing Value Tests
86+
87+
@Test
88+
func defaultSpacing() {
89+
// Create custom spacing with no common edges
90+
let spacing1 = Spacing(minima: [
91+
Spacing.Key(category: .textToText, edge: .top): .distance(888),
92+
])
93+
let spacing2 = Spacing(minima: [
94+
Spacing.Key(category: .textBaseline, edge: .bottom): .distance(999),
95+
])
96+
97+
let viewSpacing1 = ViewSpacing(spacing1)
98+
let viewSpacing2 = ViewSpacing(spacing2)
99+
100+
// Since there are no common edges, the default spacing value should be used
101+
let horizontalDistance = viewSpacing1.distance(to: viewSpacing2, along: .horizontal)
102+
let verticalDistance = viewSpacing1.distance(to: viewSpacing2, along: .vertical)
103+
104+
#expect(horizontalDistance.isApproximatelyEqual(to: defaultSpacingValue.width))
105+
#expect(verticalDistance.isApproximatelyEqual(to: defaultSpacingValue.height))
106+
}
107+
108+
// MARK: - Description Tests
109+
110+
@Test
111+
func description() {
112+
let zeroDescription = ViewSpacing.zero.description
113+
#expect(zeroDescription.description == #"""
114+
Spacing [
115+
(default, top) : 0.0
116+
(default, left) : 0.0
117+
(default, bottom) : 0.0
118+
(default, right) : 0.0
119+
]
120+
"""#)
121+
122+
let customSpacing = Spacing(minima: [
123+
Spacing.Key(category: nil, edge: .left): .distance(42),
124+
])
125+
#expect(ViewSpacing(customSpacing).description == #"""
126+
Spacing [
127+
(default, left) : 42.0
128+
]
129+
"""#)
130+
131+
let emptySpacing = Spacing(minima: [:])
132+
#expect(emptySpacing.description == #"""
133+
Spacing (empty)
134+
"""#)
135+
}
136+
137+
// MARK: - Edge Incorporation Tests
138+
139+
@Test
140+
func edgeIncorporation() {
141+
// Create spacing with values for specific edges
142+
let spacing1 = Spacing(minima: [
143+
Spacing.Key(category: nil, edge: .top): .distance(10),
144+
Spacing.Key(category: nil, edge: .left): .distance(20),
145+
])
146+
147+
let spacing2 = Spacing(minima: [
148+
Spacing.Key(category: nil, edge: .top): .distance(30),
149+
Spacing.Key(category: nil, edge: .right): .distance(40),
150+
])
151+
152+
var viewSpacing1 = ViewSpacing(spacing1)
153+
let viewSpacing2 = ViewSpacing(spacing2)
154+
155+
// Incorporate only the top edge
156+
viewSpacing1.formUnion(viewSpacing2, edges: .top)
157+
158+
// Top should be taken from spacing2 (larger), but left should remain from spacing1
159+
let result = viewSpacing1.spacing
160+
161+
let topValue = result.minima[Spacing.Key(category: nil, edge: .top)]
162+
let leftValue = result.minima[Spacing.Key(category: nil, edge: .left)]
163+
let rightValue = result.minima[Spacing.Key(category: nil, edge: .right)]
164+
165+
#expect(topValue?.value?.isApproximatelyEqual(to: 30.0) == true)
166+
#expect(leftValue?.value?.isApproximatelyEqual(to: 20.0) == true)
167+
#expect(rightValue == nil) // Right edge should not be incorporated
168+
}
169+
}

0 commit comments

Comments
 (0)