SwiftUI View and UIHostingController in UIScrollView breaks scrolling
Asked Answered
E

4

8

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

Empirical answered 1/9, 2020 at 15:47 Comment(1)
Would you prepare reproducible example? Provided code snapshot is not enough for testing.Silvie
E
11

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
    }
}
Empirical answered 1/9, 2020 at 16:49 Comment(5)
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)Snuggery
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?Danais
I never did.. I moved away from that project and have just decided to avoid SwiftUI until at least iOS 14Empirical
There's a really good solution here: defagos.github.io/swiftui_collection_part3Ankh
Call fixedSafeAreaInsets() method in viewDidAppear not in viewDidLoadForepart
M
7

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
        }
    }
Misbegotten answered 8/8, 2021 at 9:26 Comment(0)
L
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 = []
}
Lackaday answered 24/3, 2022 at 17:29 Comment(0)
R
0

I believe the issue had been addressed in iOS 16.4 with the new safeAreaRegions property of UIHostingController. Its default value is .all which causes those SwiftUI shifts when touching safe areas. So the workaround would look like:

let vc = UIHostingController(rootView: YOUR_VIEW)
vc.safeAreaRegions = []
Rosariarosario answered 16/4 at 20:12 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.