Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

UIHostingController should expand to fit contents

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()
    }
}
like image 873
Potassium Ion Avatar asked Oct 15 '19 16:10

Potassium Ion


4 Answers

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.

like image 89
roperklacks Avatar answered Nov 10 '22 10:11

roperklacks


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.

like image 41
jjoelson Avatar answered Nov 10 '22 09:11

jjoelson


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
    }
}
like image 8
YichenBman Avatar answered Nov 10 '22 11:11

YichenBman


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()
like image 6
Nikaaner Avatar answered Nov 10 '22 09:11

Nikaaner