Skip to content
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

[UIKitBackend] UIViewControllerRepresentable and iPad-only split views #104

Open
wants to merge 7 commits into
base: main
Choose a base branch
from

Conversation

bbrk24
Copy link
Contributor

@bbrk24 bbrk24 commented Jan 18, 2025

This PR contains a significant restructuring to allow widgets to be UIViews or UIViewControllers. I used this to implement two features: UIViewControllerRepresentable, and split views. While implementing UIViewControllerRepresentable I discovered a way to improve the sizing algorithm for UIViewRepresentable so that is also in this PR.

Split views are currently only supported on iPad, where they actually work.

  • On Apple TV and landscape-orientation iPhones, the sidebar has an additional safe area inset on the left. Since we haven't figured out how to make the layout engine aware of safe area insets, this meant that the sidebar was pushed to the right and covered some content of the main view.
  • On portait-orientation iPhones and iPod touch in all orientations, it is broken for almost exactly the reason you would expect -- iOS refuses to render a sidebar on a screen that narrow. Instead, the sidebar takes up the entire screen, and the main view is hidden.

To avoid these issues for now, createSplitView crashes if it detects it's running on an unsupported system. We may revisit split views on mobile & TV in the future.

Since the scroll widget is now a view controller, I debated making it the UIScollView's delegate, but I decided against that as we wouldn't have any need to implement any of the delegate methods yet.

Copy link
Owner

@stackotter stackotter left a comment

Choose a reason for hiding this comment

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

Just a few smallish things to change, but overall looking great!

Comment on lines 31 to 43
func updateLeftConstraint() {
leftConstraint?.isActive = false
guard let superview = view.superview else { return }
leftConstraint = view.leftAnchor.constraint(
equalTo: superview.safeAreaLayoutGuide.leftAnchor, constant: CGFloat(x))
// Set the constraint priority for leftConstraint (and topConstraint) to just under
// "required" so that we don't get warnings about unsatisfiable constraints from
// scroll views, which position relative to their contentLayoutGuide instead.
// This *should* be high enough that it won't cause any problems unless there was
// a constraint conflict anyways.
leftConstraint!.priority = .init(UILayoutPriority.required.rawValue - 1.0)
leftConstraint!.isActive = true
}
Copy link
Owner

Choose a reason for hiding this comment

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

I reckon it'd be better to update the existing constraint if it already exists, rather than just making it inactive and creating a new one. Over time the view will probably build up a lot of inactive constraints which would be taking up memory and probably time in skipping over them too. And if UIKit stores the constraints in some sort of weak reference array thing then the overhead is in allocations and shuffling constraints in and out rather than memory.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The documentation backs this up:

Setting the constant on an existing constraint performs much better than removing the constraint and adding a new one that's exactly like the old except that it has a different constant.

I will update this accordingly, though it's slightly more annoying than it seems at first as I still have to deactivate the old constraint (and possibly create a new one) in some cases.

// scroll views, which position relative to their contentLayoutGuide instead.
// This *should* be high enough that it won't cause any problems unless there was
// a constraint conflict anyways.
leftConstraint!.priority = .init(UILayoutPriority.required.rawValue - 1.0)
Copy link
Owner

Choose a reason for hiding this comment

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

I generally use an intermediate non-optional variable in situations like this to avoid the force unwraps (cause the unwraps make it look scary and like it needs extra attention, when in this case it's actually never gonna be nil). I know it probably seems silly given how many force unwraps are scattered throughout the backends lol, but generally I try to limit the force unwraps in backend implementations to situations where the alternative would be a bunch of error handling code that would probably just exit the program anyway (someday it'd probably make sense to get rid of all the force unwraps and replace them with code that displays a dialog and then exits, but that'd be a future thing).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Did this as part of the refactor mentioned in the previous comment.

Comment on lines 58 to 59
///
/// The default implementation does nothing.
Copy link
Owner

Choose a reason for hiding this comment

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

I generally put the discussion above the parameters (with a blank line between the discussion and the summary). It's possible that DocC supports this too, but best to keep things consistent.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I did it in this order because this is the order the compiled documentation renders in, but yeah I can definitely change it.

Comment on lines 40 to 45
/// property is what frame the view will actually be rendered with if the current layout
/// pass is not a dry run, while the other properties are used to inform the layout engine
/// how big or small the view can be. The ``SwiftCrossUI/ViewSize/idealSize`` property
/// should not vary with the `proposal`, and should only depend on the view's contents.
/// Pass `nil` for the maximum width/height if the view has no maximum size (and therefore
/// may occupy the entire screen).
Copy link
Owner

Choose a reason for hiding this comment

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

Indent the wrapped lines by 2 spaces

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not 100% sure what you meant by this, did I do the correct thing here?

innerChild.rightAnchor.constraint(equalTo: child.contentLayoutGuide.rightAnchor),
])
override func viewWillLayoutSubviews() {
NSLayoutConstraint.activate(contentLayoutGuideConstraints)
Copy link
Owner

Choose a reason for hiding this comment

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

activate probably only has to be called once at initialisation, and I'm not entirely certain on this but viewWillLayoutSubviews probably runs more than once. Could maybe move this to loadView?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

From what I remember, I tried putting it in loadView, and it crashed because the view hierarchy was insufficiently assembled at that point. I can double-check if it works in viewDidLoad, but ultimately activating the same instance of NSLayoutConstraint multiple times is harmless.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Confirmed. Moving this to viewDidLoad causes the app to crash, with

Thread 1: "Unable to activate constraint with anchors <NSLayoutXAxisAnchor:0x60000178c7c0 \"_UIScrollViewLayoutGuide:0x600003b38e00'UIScrollView-contentLayoutGuide'.leading\"> and <NSLayoutXAxisAnchor:0x60000178c800 \"UIKitBackend.BaseViewWidget:0x10f912fd0.leading\"> because they have no common ancestor. Does the constraint or its anchors reference items in different view hierarchies? That's illegal."

However, updateViewConstraints also works, is called less frequently, and seems more appropriate, so I'll move it to there.

@bbrk24 bbrk24 requested a review from stackotter February 3, 2025 04:54
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.

2 participants