7

When I add a UIHostingController which contains a SwiftUI view as a childView, and then place that childView inside a UIScrollView, scrolling breaks.

Here I have my View

struct TestHeightView: View {
    let color: UIColor
    
    var body: some View {
        VStack {
            Text("THIS IS MY TEST")
                .frame(height: 90)
        }
            .fixedSize(horizontal: false, vertical: true)
            .background(Color(color))
            .edgesIgnoringSafeArea(.all)
    }
}

Then I have a UIViewController with a UIScrollView as the subView. Inside the UIScrollView there is a UIStackView that is correctly setup to allow loading UIViews and scrolling through them if the stack height becomes great enough. This works. If I were to load in 40 UILabels, it would scroll through them perfectly.

The problem arises when I add a plain old UIView, and then add a UIHostingController inside that container. I do so like this:

        let container = UIView()
        container.backgroundColor = color.0
        stackView.insertArrangedSubview(container, at: 0)
        let test = TestHeightView(color: color.1)
        let vc = UIHostingController(rootView: test)
        vc.view.backgroundColor = .clear

        add(child: vc, in: container)

    func add(child: UIViewController, in container: UIView) {
        addChild(child)
        container.addSubview(child.view)
        child.view.translatesAutoresizingMaskIntoConstraints = false

        child.view.topAnchor.constraint(equalTo: container.topAnchor, constant: 0).isActive = true
        child.view.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: 0).isActive = true
        child.view.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 0).isActive = true
        child.view.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: 0).isActive = true

        child.didMove(toParent: self)

    }

In my example I added 3 of these containerViews/UIHostingController and then one UIView (green) to demonstrate what is happening.

You can see that as I scroll, all views are suspended as a gap is formed. What is happening is that the containing UIView (light color) is expanding its height. Once the height reaches a certain value, scrolling continues as normal until the next container/UIHostingController reaches the top and it begins again.

enter image description here

I have worked on several different solutions .edgesIgnoringSafeArea(.all)

Does do something. I included it in my example because without it, the problem is exactly the same only more jarring and harder to explain using a video. Basically the same thing happens but without any animation, it just appears that the UIScrollView has stopped working, and then it works again

Edit:

I added another UIViewController just to make sure it wasn't children in general causing the issue. Nope. Only UIHostingControllers do this. Something in SwiftUI

YichenBman
  • 5,011
  • 8
  • 46
  • 65

3 Answers3

9

Unbelievably this is the only answer I can come up with:

I found it on Twitter here https://twitter.com/b3ll/status/1193747288302075906?s=20 by Adam Bell

 class EMHostingController<Content> : UIHostingController<Content> where Content : View {
    func fixedSafeAreaInsets() {
        guard let _class = view?.classForCoder else { return }
        
        let safeAreaInsets: @convention(block) (AnyObject) -> UIEdgeInsets = { (sself : AnyObject!) -> UIEdgeInsets in
            return .zero
        }
        
        guard let method = class_getInstanceMethod(_class.self, #selector(getter: UIView.safeAreaInsets)) else { return }
        class_replaceMethod(_class, #selector(getter: UIView.safeAreaInsets), imp_implementationWithBlock(safeAreaInsets), method_getTypeEncoding(method))
        
        let safeAreaLayoutGuide: @convention(block) (AnyObject) ->UILayoutGuide? = { (sself: AnyObject!) -> UILayoutGuide? in
            return nil
        }
        guard let method2 = class_getInstanceMethod(_class.self, #selector(getter: UIView.safeAreaLayoutGuide)) else { return }
        class_replaceMethod(_class, #selector(getter: UIView.safeAreaLayoutGuide), imp_implementationWithBlock(safeAreaLayoutGuide), method_getTypeEncoding(method2))
    }
    
    override var prefersStatusBarHidden: Bool {
        return true
    }
}
YichenBman
  • 5,011
  • 8
  • 46
  • 65
  • 1
    This is indeed quite frustrating. FWIW, it seems that you can circumvent these issues by not adding the hosting controller to a view controller hierarchy (tho im sure that will break other UIKit integrations, probably for instance routing etc) – Sean Danzeiser Nov 18 '20 at 23:22
  • Thank you for sharing a solution! It solved my problem lol! Did you come out with some other solution since it looks like a hacky workaround and I'm not sure if it's ready for production? – mkkrolik Dec 02 '20 at 00:01
  • 1
    I never did.. I moved away from that project and have just decided to avoid SwiftUI until at least iOS 14 – YichenBman Dec 03 '20 at 21:20
  • 1
    There's a really good solution here: https://defagos.github.io/swiftui_collection_part3/ – RopeySim Dec 27 '20 at 23:16
  • Call fixedSafeAreaInsets() method in viewDidAppear not in viewDidLoad – mishimay Jun 02 '22 at 07:15
6

Had the same issue recently, also confirm that safe area insets are breaking the scrolling. My fix on iOS 14+ with the ignoresSafeArea modifier:

    public var body: some View {
        if #available(iOS 14.0, *) {
            contentView
            .ignoresSafeArea()
        } else {
            contentView
        }
    }
shim
  • 9,289
  • 12
  • 69
  • 108
Jurasic
  • 1,845
  • 1
  • 23
  • 29
1

I had a very similar issue and found a fix by adding the following to my UIHostingController subclass:

override func viewDidLoad() {
    super.viewDidLoad()
    edgesForExtendedLayout = []
}
yannxou
  • 71
  • 2
  • 4