36

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()
    }
}
Community
  • 1
  • 1
Potassium Ion
  • 2,075
  • 1
  • 22
  • 39
  • 1
    Any luck with this? I've run across the same issue myself. – AverageHelper Nov 10 '19 at 04:54
  • 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. – AverageHelper Nov 10 '19 at 05:38
  • I'm running into this issue now. Did anyone find a solution for this in the past months? – Rengers Jun 08 '20 at 12:10
  • @Rengers Just posted my workaround for a very similar issue, might be worth seeing if the same approach works for you: https://stackoverflow.com/a/62263294/642233 – jjoelson Jun 08 '20 at 13:33

6 Answers6

32

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.

roperklacks
  • 1,081
  • 11
  • 13
  • 1
    I got an Auto Layout loop with it. Better solution here https://stackoverflow.com/a/70585841/9024807 – Nikaaner Jan 04 '22 at 22:37
  • It does generate an infinite loop in iPadOs 14.8.1 Generally not a good idea to resize the view inside viewDidLayoutSubviews – Petru Lutenco May 23 '22 at 19:54
  • 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 UIHostViewContoller – Jin Oct 19 '22 at 00:50
15

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

Apptek Studios
  • 512
  • 6
  • 9
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.

jjoelson
  • 5,771
  • 5
  • 31
  • 51
  • 2
    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. – Rengers Jun 08 '20 at 13:37
  • 2
    Glad you found a way! Hopefully we get some SwiftUI enhancements and fixes this month that'll let us avoid workarounds like this. – jjoelson Jun 08 '20 at 14:08
  • 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) – Romy Ilano Mar 10 '21 at 23:13
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
    }
}
YichenBman
  • 5,011
  • 8
  • 46
  • 65
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()
Nikaaner
  • 1,022
  • 16
  • 19
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.

cicerocamargo
  • 1,046
  • 9
  • 9