-
Notifications
You must be signed in to change notification settings - Fork 47
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: flexible SwiftUI preferredContentSize calc [UI-7665] #320
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -97,14 +97,15 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon | |
var swiftUIScreenSizingOptions: SwiftUIScreenSizingOptions { | ||
didSet { | ||
updateSizingOptionsIfNeeded() | ||
|
||
if !hasLaidOutOnce { | ||
if isViewLoaded { | ||
setNeedsLayoutBeforeFirstLayoutIfNeeded() | ||
} | ||
} | ||
} | ||
|
||
private var hasLaidOutOnce = false | ||
private var maxFrameWidth: CGFloat = 0 | ||
private var maxFrameHeight: CGFloat = 0 | ||
|
||
init( | ||
setModel: @escaping (Model) -> Void, | ||
|
@@ -132,10 +133,9 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon | |
override func viewDidLoad() { | ||
super.viewDidLoad() | ||
|
||
// `UIHostingController`'s provides a system background color by default. In order to | ||
// support `ObervableModelScreen`s being composed in contexts where it is composed within another | ||
// view controller where a transparent background is more desirable, we set the background | ||
// to clear to allow this kind of flexibility. | ||
// `UIHostingController` provides a system background color by default. We set the | ||
// background to clear to support contexts where it is composed within another view | ||
// controller. | ||
view.backgroundColor = .clear | ||
|
||
setNeedsLayoutBeforeFirstLayoutIfNeeded() | ||
|
@@ -146,21 +146,31 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon | |
|
||
defer { hasLaidOutOnce = true } | ||
|
||
if #available(iOS 16.0, *) { | ||
// Handled in initializer, but set it on first layout to resolve a bug where the PCS is | ||
// not updated appropriately after the first layout. | ||
// UI-5797 | ||
if !hasLaidOutOnce, | ||
swiftUIScreenSizingOptions.contains(.preferredContentSize) | ||
{ | ||
let size = view.sizeThatFits(view.frame.size) | ||
|
||
if preferredContentSize != size { | ||
preferredContentSize = size | ||
} | ||
} | ||
} else if swiftUIScreenSizingOptions.contains(.preferredContentSize) { | ||
let size = view.sizeThatFits(view.frame.size) | ||
if swiftUIScreenSizingOptions.contains(.preferredContentSize) { | ||
// Use the largest frame ever laid out in as a constraint for preferredContentSize | ||
// measurements. | ||
let width = max(view.frame.width, maxFrameWidth) | ||
let height = max(view.frame.height, maxFrameHeight) | ||
|
||
maxFrameWidth = width | ||
maxFrameHeight = height | ||
|
||
let fixedSize = CGSize(width: width, height: height) | ||
|
||
// Measure a few different ways to account for ScrollView behavior. ScrollViews will | ||
// always greedily fill the space available, but will report the natural content size | ||
// when given an infinite size. By combining the results of these measurements we can | ||
// deduce the natural size of content that scrolls in either direction, or both, or | ||
// neither. | ||
|
||
let fixedResult = view.sizeThatFits(fixedSize) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any idea what the perf cost is for these repeated measurements? I wonder if we could introduce our own sizing options that would allow us to do fewer calls based on more specific sizing configurations. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. also, is the new logic specifically for scroll views? if so, do we want to do some of these extra calculations conditionally? or does it have to account for them being embedded as descendant views? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Unknown, but my suspicion is it's fine due to SwiftUI layout being much better than what we're used to.
We do need to calculate PCS regardless. The multiple measurements are to account for scroll views. Unfortunately we have to account for there being a scroll view anywhere in the content, and there's just no way to know. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
It'd be a science experiment, but I wonder how feasible it'd be to push the measurements onto the SwiftUI side with a custom Layout and then bubble up context like 'hasScrollView'/'hasMultilineText' via preferences so we could be slightly smarter with our measurements. I think I'd rather spend the time submitting feedback to Apple to hopefully make this better directly out of the box with the hosting controller though! |
||
let unboundedHorizontalResult = view.sizeThatFits(CGSize(width: .infinity, height: fixedSize.height)) | ||
let unboundedVerticalResult = view.sizeThatFits(CGSize(width: fixedSize.width, height: .infinity)) | ||
|
||
let size = CGSize( | ||
width: min(fixedResult.width, unboundedHorizontalResult.width), | ||
height: min(fixedResult.height, unboundedVerticalResult.height) | ||
) | ||
Comment on lines
+170
to
+173
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. any chance we need to (or should) handle weird/unexpected values here? is There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
It's not guaranteed to do anything by API contract, but based on observation it seems like SwiftUI will return sensible values. |
||
|
||
if preferredContentSize != size { | ||
preferredContentSize = size | ||
|
@@ -175,10 +185,6 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon | |
} | ||
|
||
private func updateSizingOptionsIfNeeded() { | ||
if #available(iOS 16.0, *) { | ||
self.sizingOptions = swiftUIScreenSizingOptions.uiHostingControllerSizingOptions | ||
} | ||
|
||
if !swiftUIScreenSizingOptions.contains(.preferredContentSize), | ||
preferredContentSize != .zero | ||
{ | ||
|
@@ -187,7 +193,7 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon | |
} | ||
|
||
private func setNeedsLayoutBeforeFirstLayoutIfNeeded() { | ||
if swiftUIScreenSizingOptions.contains(.preferredContentSize) { | ||
if swiftUIScreenSizingOptions.contains(.preferredContentSize), !hasLaidOutOnce { | ||
// Without manually calling setNeedsLayout here it was observed that a call to | ||
// layoutIfNeeded() immediately after loading the view would not perform a layout, and | ||
// therefore would not update the preferredContentSize in viewDidLayoutSubviews(). | ||
|
@@ -203,17 +209,4 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon | |
} | ||
} | ||
|
||
extension SwiftUIScreenSizingOptions { | ||
@available(iOS 16.0, *) | ||
fileprivate var uiHostingControllerSizingOptions: UIHostingControllerSizingOptions { | ||
var options = UIHostingControllerSizingOptions() | ||
|
||
if contains(.preferredContentSize) { | ||
options.insert(.preferredContentSize) | ||
} | ||
|
||
return options | ||
} | ||
} | ||
|
||
#endif |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,123 @@ | ||
#if canImport(UIKit) | ||
|
||
import SwiftUI | ||
import Workflow | ||
import WorkflowSwiftUI | ||
import XCTest | ||
|
||
final class PreferredContentSizeTests: XCTestCase { | ||
func test_preferredContentSize() { | ||
let maxWidth: CGFloat = 50 | ||
let maxHeight: CGFloat = 50 | ||
|
||
func assertPreferredContentSize(in axes: Axis.Set) { | ||
let screen = TestScreen(model: .constant(state: State(axes: axes))) | ||
let viewController = screen.buildViewController(in: .empty) | ||
viewController.view.frame = CGRect(x: 0, y: 0, width: maxWidth, height: maxHeight) | ||
viewController.view.layoutIfNeeded() | ||
|
||
func assertContentSize( | ||
_ contentSize: CGSize, | ||
expected: CGSize? = nil, | ||
file: StaticString = #filePath, | ||
line: UInt = #line | ||
) { | ||
let state = State(width: contentSize.width, height: contentSize.height, axes: axes) | ||
let screen = TestScreen(model: .constant(state: state)) | ||
screen.update(viewController: viewController, with: .empty) | ||
|
||
viewController.view.layoutIfNeeded() | ||
let pcs = viewController.preferredContentSize | ||
|
||
XCTAssertEqual( | ||
pcs, | ||
expected ?? contentSize, | ||
"Axes: \(axes.testDescription)", | ||
file: file, | ||
line: line | ||
) | ||
} | ||
|
||
assertContentSize(CGSize(width: 20, height: 20)) | ||
assertContentSize(CGSize(width: 40, height: 20)) | ||
assertContentSize(CGSize(width: 20, height: 40)) | ||
assertContentSize( | ||
CGSize(width: 100, height: 100), | ||
expected: CGSize( | ||
width: axes.contains(.horizontal) ? maxWidth : 100, | ||
height: axes.contains(.vertical) ? maxHeight : 100 | ||
) | ||
) | ||
} | ||
|
||
assertPreferredContentSize(in: []) | ||
assertPreferredContentSize(in: .horizontal) | ||
assertPreferredContentSize(in: .vertical) | ||
assertPreferredContentSize(in: [.horizontal, .vertical]) | ||
} | ||
} | ||
|
||
extension Axis.Set { | ||
var testDescription: String { | ||
switch self { | ||
case .horizontal: "[horizontal]" | ||
case .vertical: "[vertical]" | ||
case [.horizontal, .vertical]: "[horizontal, vertical]" | ||
default: "[]" | ||
} | ||
} | ||
} | ||
|
||
@ObservableState | ||
private struct State { | ||
var width: CGFloat = 0 | ||
var height: CGFloat = 0 | ||
var axes: Axis.Set = [] | ||
} | ||
|
||
private struct TestWorkflow: Workflow { | ||
typealias Rendering = StateAccessor<State> | ||
|
||
func makeInitialState() -> State { | ||
State() | ||
} | ||
|
||
func render(state: State, context: RenderContext<TestWorkflow>) -> Rendering { | ||
context.makeStateAccessor(state: state) | ||
} | ||
} | ||
|
||
private struct TestScreen: ObservableScreen { | ||
typealias Model = StateAccessor<State> | ||
|
||
var model: Model | ||
|
||
var sizingOptions: SwiftUIScreenSizingOptions = .preferredContentSize | ||
|
||
@ViewBuilder | ||
static func makeView(store: Store<Model>) -> some View { | ||
TestView(store: store) | ||
} | ||
} | ||
|
||
private struct TestView: View { | ||
var store: Store<StateAccessor<State>> | ||
|
||
var body: some View { | ||
WithPerceptionTracking { | ||
if store.axes.isEmpty { | ||
box | ||
} else { | ||
ScrollView(store.axes) { | ||
box | ||
} | ||
} | ||
} | ||
} | ||
Comment on lines
+103
to
+116
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd love if multiline text was also included in the test cases. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would be hard to do without making the test fragile. |
||
|
||
var box: some View { | ||
Color.red.frame(width: store.width, height: store.height) | ||
} | ||
} | ||
|
||
#endif |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -98,14 +98,15 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon | |
var swiftUIScreenSizingOptions: SwiftUIScreenSizingOptions { | ||
didSet { | ||
updateSizingOptionsIfNeeded() | ||
|
||
if !hasLaidOutOnce { | ||
if isViewLoaded { | ||
setNeedsLayoutBeforeFirstLayoutIfNeeded() | ||
} | ||
} | ||
} | ||
|
||
private var hasLaidOutOnce = false | ||
private var maxFrameWidth: CGFloat = 0 | ||
private var maxFrameHeight: CGFloat = 0 | ||
|
||
init( | ||
modelSink: Sink<Model>, | ||
|
@@ -130,10 +131,9 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon | |
override func viewDidLoad() { | ||
super.viewDidLoad() | ||
|
||
// `UIHostingController`'s provides a system background color by default. In order to | ||
// support `SwiftUIScreen`s being composed in contexts where it is composed within another | ||
// view controller where a transparent background is more desirable, we set the background | ||
// to clear to allow this kind of flexibility. | ||
// `UIHostingController` provides a system background color by default. We set the | ||
// background to clear to support contexts where it is composed within another view | ||
// controller. | ||
view.backgroundColor = .clear | ||
|
||
setNeedsLayoutBeforeFirstLayoutIfNeeded() | ||
|
@@ -144,21 +144,31 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon | |
|
||
defer { hasLaidOutOnce = true } | ||
|
||
if #available(iOS 16.0, *) { | ||
// Handled in initializer, but set it on first layout to resolve a bug where the PCS is | ||
// not updated appropriately after the first layout. | ||
// UI-5797 | ||
if !hasLaidOutOnce, | ||
swiftUIScreenSizingOptions.contains(.preferredContentSize) | ||
{ | ||
let size = view.sizeThatFits(view.frame.size) | ||
|
||
if preferredContentSize != size { | ||
preferredContentSize = size | ||
} | ||
} | ||
} else if swiftUIScreenSizingOptions.contains(.preferredContentSize) { | ||
let size = view.sizeThatFits(view.frame.size) | ||
if swiftUIScreenSizingOptions.contains(.preferredContentSize) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if this is the path we want to go, shall we extract the duplicate calculation logic? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm following the rule of 3 here, since these two don't currently share any common place to extract to. Plus we'll probably deprecate |
||
// Use the largest frame ever laid out in as a constraint for preferredContentSize | ||
// measurements. | ||
let width = max(view.frame.width, maxFrameWidth) | ||
let height = max(view.frame.height, maxFrameHeight) | ||
|
||
maxFrameWidth = width | ||
maxFrameHeight = height | ||
|
||
let fixedSize = CGSize(width: width, height: height) | ||
|
||
// Measure a few different ways to account for ScrollView behavior. ScrollViews will | ||
// always greedily fill the space available, but will report the natural content size | ||
// when given an infinite size. By combining the results of these measurements we can | ||
// deduce the natural size of content that scrolls in either direction, or both, or | ||
// neither. | ||
|
||
let fixedResult = view.sizeThatFits(fixedSize) | ||
let unboundedHorizontalResult = view.sizeThatFits(CGSize(width: .infinity, height: fixedSize.height)) | ||
let unboundedVerticalResult = view.sizeThatFits(CGSize(width: fixedSize.width, height: .infinity)) | ||
|
||
let size = CGSize( | ||
width: min(fixedResult.width, unboundedHorizontalResult.width), | ||
height: min(fixedResult.height, unboundedVerticalResult.height) | ||
) | ||
|
||
if preferredContentSize != size { | ||
preferredContentSize = size | ||
|
@@ -173,10 +183,6 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon | |
} | ||
|
||
private func updateSizingOptionsIfNeeded() { | ||
if #available(iOS 16.0, *) { | ||
self.sizingOptions = swiftUIScreenSizingOptions.uiHostingControllerSizingOptions | ||
} | ||
|
||
if !swiftUIScreenSizingOptions.contains(.preferredContentSize), | ||
preferredContentSize != .zero | ||
{ | ||
|
@@ -185,7 +191,7 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon | |
} | ||
|
||
private func setNeedsLayoutBeforeFirstLayoutIfNeeded() { | ||
if swiftUIScreenSizingOptions.contains(.preferredContentSize) { | ||
if swiftUIScreenSizingOptions.contains(.preferredContentSize), !hasLaidOutOnce { | ||
// Without manually calling setNeedsLayout here it was observed that a call to | ||
// layoutIfNeeded() immediately after loading the view would not perform a layout, and | ||
// therefore would not update the preferredContentSize in viewDidLayoutSubviews(). | ||
|
@@ -201,17 +207,4 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon | |
} | ||
} | ||
|
||
extension SwiftUIScreenSizingOptions { | ||
@available(iOS 16.0, *) | ||
fileprivate var uiHostingControllerSizingOptions: UIHostingControllerSizingOptions { | ||
var options = UIHostingControllerSizingOptions() | ||
|
||
if contains(.preferredContentSize) { | ||
options.insert(.preferredContentSize) | ||
} | ||
|
||
return options | ||
} | ||
} | ||
|
||
#endif |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
was the availability change intentional?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep. Previously we were letting
UIHostingController
handle PCS on iOS 16+, except for the first layout. Thisif
handled the first layout case, and theelse
was for ≤15. Now we're handling PCS in all cases.