21
votes

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()
    }
}
3
Any luck with this? I've run across the same issue myself.AverageHelper
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
I'm running into this issue now. Did anyone find a solution for this in the past months?Rengers
@Rengers Just posted my workaround for a very similar issue, might be worth seeing if the same approach works for you: stackoverflow.com/a/62263294/642233jjoelson

3 Answers

6
votes

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.

6
votes

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
    }
}
1
votes

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.