UIHostingController should expand to fit contents
Asked Answered
M

7

40

I have a custom UIViewControllerRepresentable (layout-related code shown below). This tries to replicate the native SwiftUI ScrollView, except it scrolls from the bottom except the top.

View hierarchy

view: UIView
|
\- scrollView: UIScrollView
   |
   \- innerView: UIView
      |
      \- hostingController.view: SwiftUI hosting view

This all works as intended when the view is initialized. The hosting view is populated with its contents, and the constraints make sure that the scroll view's contentSize is set properly.

However, when the contents of the hosting view changes, the hostingController.view doesn't resize to fit its contents.

Screenshot of UI capture from app in Xcode. Shows contents of hosting controller expanding behind the bounds of the hosting view itself, without properly resizing.

Green: As intended, the scroll view matches the size of the hosting view controller.

Blue: The hosting view itself. It keeps the size it had when it was first loaded, and doesn't expend as it should.

Red: A stack view within the hosting view. In this screenshot, content was been added to the stack, causing it to expand. You can see the difference in size as a result.

The UIHostingController (blue) should expand to fit its contents (red).

The scroll view's content size is not explicitly set, because this is handled by auto layout.

Constraint code is shown below, if it helps.

class UIBottomScrollViewController<Content: View>: UIViewController, UIScrollViewDelegate {
    var hostingController: UIHostingController<Content>! = nil

    init(rootView: Content) {
        self.hostingController = UIHostingController<Content>(rootView: rootView)
        super.init(nibName: nil, bundle: nil)
    }
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    var scrollView: UIScrollView = UIScrollView()
    var innerView = UIView()

    override func loadView() {
        self.view = UIView()
        self.addChild(hostingController)
        view.addSubview(scrollView)
        scrollView.addSubview(innerView)
        innerView.addSubview(hostingController.view)

        scrollView.delegate = self
        scrollView.scrollsToTop = true
        scrollView.isScrollEnabled = true
        scrollView.clipsToBounds = false

        scrollView.layoutMargins = .zero
        scrollView.preservesSuperviewLayoutMargins = true

        scrollView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
        scrollView.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
        scrollView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true

        innerView.topAnchor.constraint(equalTo: scrollView.topAnchor).isActive = true
        innerView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
        innerView.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
        innerView.leftAnchor.constraint(equalTo: scrollView.leftAnchor).isActive = true
        innerView.rightAnchor.constraint(equalTo: scrollView.rightAnchor).isActive = true
        innerView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true


        hostingController.view.topAnchor.constraint(equalTo: innerView.topAnchor).isActive = true
        hostingController.view.leftAnchor.constraint(equalTo: innerView.leftAnchor).isActive = true
        hostingController.view.rightAnchor.constraint(equalTo: innerView.rightAnchor).isActive = true
        hostingController.view.bottomAnchor.constraint(equalTo: innerView.bottomAnchor).isActive = true


        hostingController.view.autoresizingMask = []
        hostingController.view.layoutMargins = .zero
        hostingController.view.insetsLayoutMarginsFromSafeArea = false
        hostingController.view.translatesAutoresizingMaskIntoConstraints = false

        scrollView.autoresizingMask = []
        scrollView.layoutMargins = .zero
        scrollView.insetsLayoutMarginsFromSafeArea = false
        scrollView.translatesAutoresizingMaskIntoConstraints = false

        innerView.autoresizingMask = []
        innerView.layoutMargins = .zero
        innerView.insetsLayoutMarginsFromSafeArea = false
        innerView.translatesAutoresizingMaskIntoConstraints = false

        hostingController.didMove(toParent: self)

        scrollView.keyboardDismissMode = .interactive
    }
}

struct BottomScrollView<Content: View>: UIViewControllerRepresentable {
    var content: () -> Content

    init(@ViewBuilder content: @escaping () -> Content) {
        self.content = content
    }

    func makeUIViewController(context: Context) -> UIBottomScrollViewController<Content> {
        let vc = UIBottomScrollViewController(rootView: self.content())
        return vc
    }
    func updateUIViewController(_ viewController: UIBottomScrollViewController<Content>, context: Context) {
        viewController.hostingController.rootView = self.content()
    }
}
Michelson answered 15/10, 2019 at 16:33 Comment(4)
Any luck with this? I've run across the same issue myself.Indivertible
In my own case, the best I've got so far is to just use one of the built-in SwiftUI scrollables (List comes to mind) instead of wrapping with a UIScrollView. I already tried a regular SwiftUI ScrollView, but that seems to be buggy with Buttons.Indivertible
I'm running into this issue now. Did anyone find a solution for this in the past months?Alvey
@Alvey Just posted my workaround for a very similar issue, might be worth seeing if the same approach works for you: https://mcmap.net/q/395936/-uihostingcontroller-should-expand-to-fit-contentsShapeless
S
8

I encountered the same issue with a similar-ish view hierarchy involving UIHostingController and scroll views, and found an ugly hack to make it work. Basically, I add a height constraint and update the constant manually:

private var heightConstraint: NSLayoutConstraint?

...

override func viewDidLoad() {
    ...


    heightConstraint = viewHost.view.heightAnchor.constraint(equalToConstant: 0)

    ...
}

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()

    // 😬
    viewHost.view.sizeToFit()
    heightConstraint?.constant = viewHost.view.bounds.height
    heightConstraint?.isActive = true
}

This is horrible code, but it's the only thing I found that made it work.

Shapeless answered 8/6, 2020 at 13:32 Comment(3)
Thanks for your answer! I've tried this as well, but unfortunately could not get this working because viewDidLayoutSubviews wasn't called when the SwiftUI view changed it's content. I did however find another workaround that involves using a GeometryReader to get the SwiftUI view size, and then passing that back to the UIViewController using a callback.Alvey
Glad you found a way! Hopefully we get some SwiftUI enhancements and fixes this month that'll let us avoid workarounds like this.Shapeless
This is a great solution! Unfortunately can't be used on a tableview/collectionview as I'm guessing since viewDidLayotuSubviews is expensive - freezes up on dealing with many cells over and over .(couldn't get any crashes in the debugger)Skell
B
41

For me the solution was much simpler than any other answer I see here (none of which worked), though it took me quite some time to find it.

All I did was create a thin subclass of UIHostingController that calls invalidateIntrinsicContentSize() on its view in response to viewDidLayoutSubviews()

class SelfSizingHostingController<Content>: UIHostingController<Content> where Content: View {

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        self.view.invalidateIntrinsicContentSize()
    }
}

Similar to the original question, I have a SwiftUI view that I am hosting inside of a UIViewController in a UIScrollView, which needs to be laid out with other views in the scrolling content view. The SwiftUI view's intrinsic size changes depending on its content and the user's chosen Dynamic Type size.

In my case it was really this simple. It works for me in iOS 14+ (not tested on iOS 13) where a change in the SwiftUI content that would result in a new intrinsic size correctly updates my autolayout-based UIKit layout in the scroll view. Honestly it feels like a bug that this isn't the implicit behavior of UIHostingController.

Bohol answered 6/10, 2021 at 4:53 Comment(3)
I got an Auto Layout loop with it. Better solution here https://mcmap.net/q/395936/-uihostingcontroller-should-expand-to-fit-contentsPlotinus
It does generate an infinite loop in iPadOs 14.8.1 Generally not a good idea to resize the view inside viewDidLayoutSubviewsAlmeda
Thank you, it really solves my issue, issue: when i increase the font size, the content of Text() won't be expanded, which is embedded in UIHostViewContollerDissection
R
23

Updated answer for iOS 16:

You can now simply set yourHostingController.sizingOptions = [.intrinsicContentSize] and it will automatically update/invalidate the intrinsic content size when the swiftUI view changes (even internal state changes).

If you're embedding in a popover check out the other sizing option (.preferredContentSize) to get properly resizing popovers

Router answered 22/10, 2022 at 11:15 Comment(0)
S
8

I encountered the same issue with a similar-ish view hierarchy involving UIHostingController and scroll views, and found an ugly hack to make it work. Basically, I add a height constraint and update the constant manually:

private var heightConstraint: NSLayoutConstraint?

...

override func viewDidLoad() {
    ...


    heightConstraint = viewHost.view.heightAnchor.constraint(equalToConstant: 0)

    ...
}

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()

    // 😬
    viewHost.view.sizeToFit()
    heightConstraint?.constant = viewHost.view.bounds.height
    heightConstraint?.isActive = true
}

This is horrible code, but it's the only thing I found that made it work.

Shapeless answered 8/6, 2020 at 13:32 Comment(3)
Thanks for your answer! I've tried this as well, but unfortunately could not get this working because viewDidLayoutSubviews wasn't called when the SwiftUI view changed it's content. I did however find another workaround that involves using a GeometryReader to get the SwiftUI view size, and then passing that back to the UIViewController using a callback.Alvey
Glad you found a way! Hopefully we get some SwiftUI enhancements and fixes this month that'll let us avoid workarounds like this.Shapeless
This is a great solution! Unfortunately can't be used on a tableview/collectionview as I'm guessing since viewDidLayotuSubviews is expensive - freezes up on dealing with many cells over and over .(couldn't get any crashes in the debugger)Skell
E
8

This plays off what @Rengers was saying, but wanted to include my solution that took me a fair amount of time to figure out.

Hopefully save some time

struct SizingView<T: View>: View {
    
    let view: T
    let updateSizeHandler: ((_ size: CGSize) -> Void)
    init(view: T, updateSizeHandler: @escaping (_ size: CGSize) -> Void) {
        self.view = view
        self.updateSizeHandler = updateSizeHandler
    }
    var body: some View {
        view.background(
            GeometryReader { proxy in
                Color.clear
                    .preference(key: SizePreferenceKey.self, value: proxy.size)
            }
        )
        .onPreferenceChange(SizePreferenceKey.self) { preferences in
            updateSizeHandler(preferences)
        }

    }
    
    func size(with view: T, geometry: GeometryProxy) -> T {
        updateSizeHandler?(geometry.size)
        return view
    }
}
Escapism answered 8/9, 2020 at 22:5 Comment(0)
P
8

I do not recommend using the SelfSizingHostingController. You can get an Auto Layout loop with it (I succeeded).

The best solution turned out to be to call invalidateIntrinsicContentSize() immediately after setting the content. Like here:

hostingController.rootView = content
hostingController.view.invalidateIntrinsicContentSize()
Plotinus answered 4/1, 2022 at 22:37 Comment(2)
Where do you place it?Stere
Here: developer.apple.com/documentation/swiftui/uiviewrepresentable/…Plotinus
D
2

I faced the same issue and none of the suggestions worked for me. Then I found the following class in the SwiftUIX project: https://github.com/SwiftUIX/SwiftUIX/blob/master/Sources/Intermodular/Helpers/UIKit/UIHostingView.swift

This worked perfectly, except for the SwiftUI animations that still work but don't look exactly the same as in a pure SwiftUI context.

Dynameter answered 12/5, 2021 at 21:57 Comment(1)
There is so many code to add swiftui view into uikitTeeters
M
0

Prior to iOS 16

We could take advantage of the new viewIsAppearing method which is called when view is added to parent view and layout is already set and accurate. We request a constraint update there for UIHostingViewController update its constraint.

class FloaterViewController: UIHostingController<YourSwiftUIView> {

override func viewIsAppearing(_ animated: Bool) {
        super.viewIsAppearing(animated)

        view.setNeedsUpdateConstraints()
    }
}

view.setNeedsUpdateConstraints()

For iOS 16 and above

we could set the sizingOptions field of the UIHostingController which will resize to fit the intrinsic content of the swiftui view.

uiHostingController.sizingOptions = [.intrinsicContentSize]

Marigolda answered 13/2 at 20:10 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.