Skip to content

Commit 8ef3944

Browse files
authored
feat: flexible SwiftUI preferredContentSize calc [UI-7665] (#320)
This is a possible solution for preferredContentSize of SwiftUI content, for discussion. A summary of the problem: - The system provided PCS calculation is unconstrained and not useful to us. We need to calculate this manually. - SwiftUI ScrollViews greedily fill the space available, but will report the natural content size when given an infinite size. This solution finds an appropriate PCS for the general case by measuring in a few different constraints and combining the results. This accommodates content with no scroll view, as well as content with scroll views in either axis or both.
1 parent 0811a5d commit 8ef3944

File tree

3 files changed

+187
-78
lines changed

3 files changed

+187
-78
lines changed

WorkflowSwiftUI/Sources/ObservableScreen.swift

+32-39
Original file line numberDiff line numberDiff line change
@@ -97,14 +97,15 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
9797
var swiftUIScreenSizingOptions: SwiftUIScreenSizingOptions {
9898
didSet {
9999
updateSizingOptionsIfNeeded()
100-
101-
if !hasLaidOutOnce {
100+
if isViewLoaded {
102101
setNeedsLayoutBeforeFirstLayoutIfNeeded()
103102
}
104103
}
105104
}
106105

107106
private var hasLaidOutOnce = false
107+
private var maxFrameWidth: CGFloat = 0
108+
private var maxFrameHeight: CGFloat = 0
108109

109110
init(
110111
setModel: @escaping (Model) -> Void,
@@ -132,10 +133,9 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
132133
override func viewDidLoad() {
133134
super.viewDidLoad()
134135

135-
// `UIHostingController`'s provides a system background color by default. In order to
136-
// support `ObervableModelScreen`s being composed in contexts where it is composed within another
137-
// view controller where a transparent background is more desirable, we set the background
138-
// to clear to allow this kind of flexibility.
136+
// `UIHostingController` provides a system background color by default. We set the
137+
// background to clear to support contexts where it is composed within another view
138+
// controller.
139139
view.backgroundColor = .clear
140140

141141
setNeedsLayoutBeforeFirstLayoutIfNeeded()
@@ -146,21 +146,31 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
146146

147147
defer { hasLaidOutOnce = true }
148148

149-
if #available(iOS 16.0, *) {
150-
// Handled in initializer, but set it on first layout to resolve a bug where the PCS is
151-
// not updated appropriately after the first layout.
152-
// UI-5797
153-
if !hasLaidOutOnce,
154-
swiftUIScreenSizingOptions.contains(.preferredContentSize)
155-
{
156-
let size = view.sizeThatFits(view.frame.size)
157-
158-
if preferredContentSize != size {
159-
preferredContentSize = size
160-
}
161-
}
162-
} else if swiftUIScreenSizingOptions.contains(.preferredContentSize) {
163-
let size = view.sizeThatFits(view.frame.size)
149+
if swiftUIScreenSizingOptions.contains(.preferredContentSize) {
150+
// Use the largest frame ever laid out in as a constraint for preferredContentSize
151+
// measurements.
152+
let width = max(view.frame.width, maxFrameWidth)
153+
let height = max(view.frame.height, maxFrameHeight)
154+
155+
maxFrameWidth = width
156+
maxFrameHeight = height
157+
158+
let fixedSize = CGSize(width: width, height: height)
159+
160+
// Measure a few different ways to account for ScrollView behavior. ScrollViews will
161+
// always greedily fill the space available, but will report the natural content size
162+
// when given an infinite size. By combining the results of these measurements we can
163+
// deduce the natural size of content that scrolls in either direction, or both, or
164+
// neither.
165+
166+
let fixedResult = view.sizeThatFits(fixedSize)
167+
let unboundedHorizontalResult = view.sizeThatFits(CGSize(width: .infinity, height: fixedSize.height))
168+
let unboundedVerticalResult = view.sizeThatFits(CGSize(width: fixedSize.width, height: .infinity))
169+
170+
let size = CGSize(
171+
width: min(fixedResult.width, unboundedHorizontalResult.width),
172+
height: min(fixedResult.height, unboundedVerticalResult.height)
173+
)
164174

165175
if preferredContentSize != size {
166176
preferredContentSize = size
@@ -175,10 +185,6 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
175185
}
176186

177187
private func updateSizingOptionsIfNeeded() {
178-
if #available(iOS 16.0, *) {
179-
self.sizingOptions = swiftUIScreenSizingOptions.uiHostingControllerSizingOptions
180-
}
181-
182188
if !swiftUIScreenSizingOptions.contains(.preferredContentSize),
183189
preferredContentSize != .zero
184190
{
@@ -187,7 +193,7 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
187193
}
188194

189195
private func setNeedsLayoutBeforeFirstLayoutIfNeeded() {
190-
if swiftUIScreenSizingOptions.contains(.preferredContentSize) {
196+
if swiftUIScreenSizingOptions.contains(.preferredContentSize), !hasLaidOutOnce {
191197
// Without manually calling setNeedsLayout here it was observed that a call to
192198
// layoutIfNeeded() immediately after loading the view would not perform a layout, and
193199
// therefore would not update the preferredContentSize in viewDidLayoutSubviews().
@@ -203,17 +209,4 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
203209
}
204210
}
205211

206-
extension SwiftUIScreenSizingOptions {
207-
@available(iOS 16.0, *)
208-
fileprivate var uiHostingControllerSizingOptions: UIHostingControllerSizingOptions {
209-
var options = UIHostingControllerSizingOptions()
210-
211-
if contains(.preferredContentSize) {
212-
options.insert(.preferredContentSize)
213-
}
214-
215-
return options
216-
}
217-
}
218-
219212
#endif
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
#if canImport(UIKit)
2+
3+
import SwiftUI
4+
import Workflow
5+
import WorkflowSwiftUI
6+
import XCTest
7+
8+
final class PreferredContentSizeTests: XCTestCase {
9+
func test_preferredContentSize() {
10+
let maxWidth: CGFloat = 50
11+
let maxHeight: CGFloat = 50
12+
13+
func assertPreferredContentSize(in axes: Axis.Set) {
14+
let screen = TestScreen(model: .constant(state: State(axes: axes)))
15+
let viewController = screen.buildViewController(in: .empty)
16+
viewController.view.frame = CGRect(x: 0, y: 0, width: maxWidth, height: maxHeight)
17+
viewController.view.layoutIfNeeded()
18+
19+
func assertContentSize(
20+
_ contentSize: CGSize,
21+
expected: CGSize? = nil,
22+
file: StaticString = #filePath,
23+
line: UInt = #line
24+
) {
25+
let state = State(width: contentSize.width, height: contentSize.height, axes: axes)
26+
let screen = TestScreen(model: .constant(state: state))
27+
screen.update(viewController: viewController, with: .empty)
28+
29+
viewController.view.layoutIfNeeded()
30+
let pcs = viewController.preferredContentSize
31+
32+
XCTAssertEqual(
33+
pcs,
34+
expected ?? contentSize,
35+
"Axes: \(axes.testDescription)",
36+
file: file,
37+
line: line
38+
)
39+
}
40+
41+
assertContentSize(CGSize(width: 20, height: 20))
42+
assertContentSize(CGSize(width: 40, height: 20))
43+
assertContentSize(CGSize(width: 20, height: 40))
44+
assertContentSize(
45+
CGSize(width: 100, height: 100),
46+
expected: CGSize(
47+
width: axes.contains(.horizontal) ? maxWidth : 100,
48+
height: axes.contains(.vertical) ? maxHeight : 100
49+
)
50+
)
51+
}
52+
53+
assertPreferredContentSize(in: [])
54+
assertPreferredContentSize(in: .horizontal)
55+
assertPreferredContentSize(in: .vertical)
56+
assertPreferredContentSize(in: [.horizontal, .vertical])
57+
}
58+
}
59+
60+
extension Axis.Set {
61+
var testDescription: String {
62+
switch self {
63+
case .horizontal: "[horizontal]"
64+
case .vertical: "[vertical]"
65+
case [.horizontal, .vertical]: "[horizontal, vertical]"
66+
default: "[]"
67+
}
68+
}
69+
}
70+
71+
@ObservableState
72+
private struct State {
73+
var width: CGFloat = 0
74+
var height: CGFloat = 0
75+
var axes: Axis.Set = []
76+
}
77+
78+
private struct TestWorkflow: Workflow {
79+
typealias Rendering = StateAccessor<State>
80+
81+
func makeInitialState() -> State {
82+
State()
83+
}
84+
85+
func render(state: State, context: RenderContext<TestWorkflow>) -> Rendering {
86+
context.makeStateAccessor(state: state)
87+
}
88+
}
89+
90+
private struct TestScreen: ObservableScreen {
91+
typealias Model = StateAccessor<State>
92+
93+
var model: Model
94+
95+
var sizingOptions: SwiftUIScreenSizingOptions = .preferredContentSize
96+
97+
@ViewBuilder
98+
static func makeView(store: Store<Model>) -> some View {
99+
TestView(store: store)
100+
}
101+
}
102+
103+
private struct TestView: View {
104+
var store: Store<StateAccessor<State>>
105+
106+
var body: some View {
107+
WithPerceptionTracking {
108+
if store.axes.isEmpty {
109+
box
110+
} else {
111+
ScrollView(store.axes) {
112+
box
113+
}
114+
}
115+
}
116+
}
117+
118+
var box: some View {
119+
Color.red.frame(width: store.width, height: store.height)
120+
}
121+
}
122+
123+
#endif

WorkflowSwiftUIExperimental/Sources/SwiftUIScreen.swift

+32-39
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,15 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
9898
var swiftUIScreenSizingOptions: SwiftUIScreenSizingOptions {
9999
didSet {
100100
updateSizingOptionsIfNeeded()
101-
102-
if !hasLaidOutOnce {
101+
if isViewLoaded {
103102
setNeedsLayoutBeforeFirstLayoutIfNeeded()
104103
}
105104
}
106105
}
107106

108107
private var hasLaidOutOnce = false
108+
private var maxFrameWidth: CGFloat = 0
109+
private var maxFrameHeight: CGFloat = 0
109110

110111
init(
111112
modelSink: Sink<Model>,
@@ -130,10 +131,9 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
130131
override func viewDidLoad() {
131132
super.viewDidLoad()
132133

133-
// `UIHostingController`'s provides a system background color by default. In order to
134-
// support `SwiftUIScreen`s being composed in contexts where it is composed within another
135-
// view controller where a transparent background is more desirable, we set the background
136-
// to clear to allow this kind of flexibility.
134+
// `UIHostingController` provides a system background color by default. We set the
135+
// background to clear to support contexts where it is composed within another view
136+
// controller.
137137
view.backgroundColor = .clear
138138

139139
setNeedsLayoutBeforeFirstLayoutIfNeeded()
@@ -144,21 +144,31 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
144144

145145
defer { hasLaidOutOnce = true }
146146

147-
if #available(iOS 16.0, *) {
148-
// Handled in initializer, but set it on first layout to resolve a bug where the PCS is
149-
// not updated appropriately after the first layout.
150-
// UI-5797
151-
if !hasLaidOutOnce,
152-
swiftUIScreenSizingOptions.contains(.preferredContentSize)
153-
{
154-
let size = view.sizeThatFits(view.frame.size)
155-
156-
if preferredContentSize != size {
157-
preferredContentSize = size
158-
}
159-
}
160-
} else if swiftUIScreenSizingOptions.contains(.preferredContentSize) {
161-
let size = view.sizeThatFits(view.frame.size)
147+
if swiftUIScreenSizingOptions.contains(.preferredContentSize) {
148+
// Use the largest frame ever laid out in as a constraint for preferredContentSize
149+
// measurements.
150+
let width = max(view.frame.width, maxFrameWidth)
151+
let height = max(view.frame.height, maxFrameHeight)
152+
153+
maxFrameWidth = width
154+
maxFrameHeight = height
155+
156+
let fixedSize = CGSize(width: width, height: height)
157+
158+
// Measure a few different ways to account for ScrollView behavior. ScrollViews will
159+
// always greedily fill the space available, but will report the natural content size
160+
// when given an infinite size. By combining the results of these measurements we can
161+
// deduce the natural size of content that scrolls in either direction, or both, or
162+
// neither.
163+
164+
let fixedResult = view.sizeThatFits(fixedSize)
165+
let unboundedHorizontalResult = view.sizeThatFits(CGSize(width: .infinity, height: fixedSize.height))
166+
let unboundedVerticalResult = view.sizeThatFits(CGSize(width: fixedSize.width, height: .infinity))
167+
168+
let size = CGSize(
169+
width: min(fixedResult.width, unboundedHorizontalResult.width),
170+
height: min(fixedResult.height, unboundedVerticalResult.height)
171+
)
162172

163173
if preferredContentSize != size {
164174
preferredContentSize = size
@@ -173,10 +183,6 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
173183
}
174184

175185
private func updateSizingOptionsIfNeeded() {
176-
if #available(iOS 16.0, *) {
177-
self.sizingOptions = swiftUIScreenSizingOptions.uiHostingControllerSizingOptions
178-
}
179-
180186
if !swiftUIScreenSizingOptions.contains(.preferredContentSize),
181187
preferredContentSize != .zero
182188
{
@@ -185,7 +191,7 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
185191
}
186192

187193
private func setNeedsLayoutBeforeFirstLayoutIfNeeded() {
188-
if swiftUIScreenSizingOptions.contains(.preferredContentSize) {
194+
if swiftUIScreenSizingOptions.contains(.preferredContentSize), !hasLaidOutOnce {
189195
// Without manually calling setNeedsLayout here it was observed that a call to
190196
// layoutIfNeeded() immediately after loading the view would not perform a layout, and
191197
// therefore would not update the preferredContentSize in viewDidLayoutSubviews().
@@ -201,17 +207,4 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
201207
}
202208
}
203209

204-
extension SwiftUIScreenSizingOptions {
205-
@available(iOS 16.0, *)
206-
fileprivate var uiHostingControllerSizingOptions: UIHostingControllerSizingOptions {
207-
var options = UIHostingControllerSizingOptions()
208-
209-
if contains(.preferredContentSize) {
210-
options.insert(.preferredContentSize)
211-
}
212-
213-
return options
214-
}
215-
}
216-
217210
#endif

0 commit comments

Comments
 (0)