Skip to content

feat: flexible SwiftUI preferredContentSize calc [UI-7665] #320

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

Merged
merged 3 commits into from
Jan 31, 2025

Conversation

watt
Copy link
Collaborator

@watt watt commented Jan 28, 2025

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.

If we like this I'll follow up with tests that validate PCS in various permutations of size and scrolling axes. tests added!

Checklist

  • Unit Tests
  • UI Tests
  • Snapshot Tests (iOS only)
  • I have made corresponding changes to the documentation

@watt watt requested review from a team as code owners January 28, 2025 22:02
// deduce the natural size of content that scrolls in either direction, or both, or
// neither.

let fixedResult = view.sizeThatFits(fixedSize)
Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any idea what the perf cost is for these repeated measurements

Unknown, but my suspicion is it's fine due to SwiftUI layout being much better than what we're used to.

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?

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.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately we have to account for there being a scroll view anywhere in the content, and there's just no way to know.

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!

@@ -146,21 +147,31 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon

defer { hasLaidOutOnce = true }

if #available(iOS 16.0, *) {
Copy link
Contributor

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?

Copy link
Collaborator Author

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. This if handled the first layout case, and the else was for ≤15. Now we're handling PCS in all cases.

Comment on lines +171 to +174
let size = CGSize(
width: min(fixedResult.width, unboundedHorizontalResult.width),
height: min(fixedResult.height, unboundedVerticalResult.height)
)
Copy link
Contributor

Choose a reason for hiding this comment

The 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 sizeThatFits guaranteed to return sensical results? i guess the concern would be if we end up assigning a preferred content size that doesn't 'make sense' – would that matter?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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 sizeThatFits guaranteed to return sensical results? i guess the concern would be if we end up assigning a preferred content size that doesn't 'make sense' – would that matter?

It's not guaranteed to do anything by API contract, but based on observation it seems like SwiftUI will return sensible values.

}
} else if swiftUIScreenSizingOptions.contains(.preferredContentSize) {
let size = view.sizeThatFits(view.frame.size)
if swiftUIScreenSizingOptions.contains(.preferredContentSize) {
Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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 SwiftUIScreen once ObservableScreen is more established.

// deduce the natural size of content that scrolls in either direction, or both, or
// neither.

let fixedResult = view.sizeThatFits(fixedSize)
Copy link
Contributor

Choose a reason for hiding this comment

The 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?

@watt watt force-pushed the awatt/swiftui-screen-pcs branch from 6fc3869 to 7ee2295 Compare January 30, 2025 01:29
Copy link
Contributor

@johnnewman-square johnnewman-square left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After trying out these changes in the Market Catalog app, SwiftUI Screens size themselves much better than before 🎉. I also didn't experience performance hiccups on a physical device with dialogs and sheets. I think this is a good solution to merge in!

Copy link
Contributor

@jamieQ jamieQ left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changes all look reasonable to me!

Copy link
Member

@robmaceachern robmaceachern left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working through this!

@@ -133,7 +134,7 @@ private final class ModeledHostingController<Model, Content: View>: UIHostingCon
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
// support `SwiftUIScreen`s being composed in contexts where it is composed within another
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revert this line?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whoops. I feel like this line is self-explanatory but apparently not.

Comment on lines +103 to +116
private struct TestView: View {
var store: Store<StateAccessor<State>>

var body: some View {
WithPerceptionTracking {
if store.axes.isEmpty {
box
} else {
ScrollView(store.axes) {
box
}
}
}
}
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be hard to do without making the test fragile.

// deduce the natural size of content that scrolls in either direction, or both, or
// neither.

let fixedResult = view.sizeThatFits(fixedSize)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately we have to account for there being a scroll view anywhere in the content, and there's just no way to know.

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!

@watt watt enabled auto-merge (squash) January 31, 2025 00:52
@watt watt merged commit 8ef3944 into main Jan 31, 2025
7 checks passed
@watt watt deleted the awatt/swiftui-screen-pcs branch January 31, 2025 00:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants