18

I'm trying to add a SwiftUI view to UIKit view using UIHostingController and it shows extra spacing(This sample is made to simulate an issue on a production app). Here is the screenshot.

enter image description here

Layout overview:

View
  UIStackView
    UIImageView
    UIView(red)
    UIHostingController
    UIView(blue)

Issue: The swift UI view (UIHostingController) is shown between the red and blue views, it shows extra spacing after the divider. The spacing changes depending on the size of the SwiftUI view.

If I reduce the number of rows (Hello World texts) or reduce the spacing, it seems working fine.

Here is full source code(https://www.sendspace.com/file/ux0xt7):

ViewController.swift(main view)

import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var mainStackView: UIStackView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        
        addView()
    }


    private func addView() {
        mainStackView.spacing = 0
        mainStackView.alignment = .fill
        
        let imageView = UIImageView()
        imageView.image = UIImage(named: "mountain")
        imageView.contentMode = .scaleAspectFill
        imageView.clipsToBounds = true
        imageView.heightAnchor.constraint(equalToConstant: 260).isActive = true
        mainStackView.addArrangedSubview(imageView)
        
        let redView = UIView()
        redView.backgroundColor = .red
        redView.heightAnchor.constraint(equalToConstant: 100).isActive = true
        mainStackView.addArrangedSubview(redView)
        
        
        
        let sampleVC = SampleViewController()
        //let size = sampleVC.view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
        //sampleVC.view.heightAnchor.constraint(equalToConstant: size.height).isActive = true
        mainStackView.addArrangedSubview(sampleVC.view)
        
        
        let blueView = UIView()
        blueView.backgroundColor = .blue
        blueView.heightAnchor.constraint(equalToConstant: 100).isActive = true
        mainStackView.addArrangedSubview(blueView)
    }
}

SampleView.swift

import SwiftUI

struct SampleView: View {
    var body: some View {
        VStack(spacing: 0) {
            Text("Title")

            VStack (alignment: .leading, spacing: 30) {
                Text("Hello World1")
                Text("Hello World2")
                Text("Hello World3")
                Text("Hello World4")
                Text("Hello World5")
                Text("Hello World6")
                Text("Hello World7")
                Text("Hello World8")
                Text("Hello World9")
                Text("Hello World10")
            }
            Divider()
        }
        
    }
}



struct SampleView_Previews: PreviewProvider {

    
    static var previews: some View {
        Group {
            SampleView()
        }
    }
}

SampleViewController.swift

import UIKit
import SwiftUI

class SampleViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        addView()
    }
    
    private func addView() {
        let hostingController = UIHostingController(rootView: SampleView())
        
        hostingController.view.backgroundColor = .clear
        hostingController.view.translatesAutoresizingMaskIntoConstraints = false
        addChild(hostingController)
        view.addSubview(hostingController.view)
        hostingController.didMove(toParent: self)
        
        NSLayoutConstraint.activate([
            hostingController.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
            hostingController.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
            hostingController.view.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 0),
            hostingController.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor)
        ])
    }
}

Thanks in advance!

isherwood
  • 58,414
  • 16
  • 114
  • 157
Daniel
  • 507
  • 6
  • 22
  • It looks like a bug - hosting view thinks that it is in root and applies `edgeAreaInsets` to internal SwiftUI content. – Asperi Nov 22 '21 at 15:43

7 Answers7

20

I had a similar problem, and this is, like @Asperi mentions, due to extra safe area insets applied to the SwiftUI view.

It sadly does not work to simply add edgesIgnoringSafeArea() to the SwiftUI view.

Instead, you can fix this with the following UIHostingController extension:

extension UIHostingController {
    convenience public init(rootView: Content, ignoreSafeArea: Bool) {
        self.init(rootView: rootView)
        
        if ignoreSafeArea {
            disableSafeArea()
        }
    }
    
    func disableSafeArea() {
        guard let viewClass = object_getClass(view) else { return }
        
        let viewSubclassName = String(cString: class_getName(viewClass)).appending("_IgnoreSafeArea")
        if let viewSubclass = NSClassFromString(viewSubclassName) {
            object_setClass(view, viewSubclass)
        }
        else {
            guard let viewClassNameUtf8 = (viewSubclassName as NSString).utf8String else { return }
            guard let viewSubclass = objc_allocateClassPair(viewClass, viewClassNameUtf8, 0) else { return }
            
            if let method = class_getInstanceMethod(UIView.self, #selector(getter: UIView.safeAreaInsets)) {
                let safeAreaInsets: @convention(block) (AnyObject) -> UIEdgeInsets = { _ in
                    return .zero
                }
                class_addMethod(viewSubclass, #selector(getter: UIView.safeAreaInsets), imp_implementationWithBlock(safeAreaInsets), method_getTypeEncoding(method))
            }
            
            objc_registerClassPair(viewSubclass)
            object_setClass(view, viewSubclass)
        }
    }
}

And use it like this:

let hostingController = UIHostingController(rootView: SampleView(), ignoreSafeArea: true)

Solution credits: https://defagos.github.io/swiftui_collection_part3/#fixing-cell-frames

Alexander Sandberg
  • 1,265
  • 11
  • 19
  • This is quite deep, does it pass App Store Review? After several days of trying and researching, this is the only way I found to snapshot a small view in SwiftUI. The methods suggested [here](https://stackoverflow.com/q/69034515) work for larger views, but leave 31 pixel rows of white space above smaller views. – pommy Feb 05 '22 at 17:17
  • 2
    @pommy We're using this hack in production and have not run into any issues with the App Store reviews (so far, at least). – Alexander Sandberg Feb 06 '22 at 10:51
  • 1
    Thanks for this solution! Big help. What confused me at first was the issue that sometimes this offset was being applied but not in others. But is seems that its connected with `UITableView/UICollectionView` and sometimes when a SwiftUI UIHostingController is added to the hierarchy, it happens to be at the boundary of a safeArea. [Related Radar](https://openradar.appspot.com/FB8176223) – Hendrix Jul 27 '22 at 21:12
  • this doesn't work unless you also override the `keyboardWillShowWithNotification:` method. See this gist https://gist.github.com/steipete/da72299613dcc91e8d729e48b4bb582c – bze12 Oct 17 '22 at 19:33
8

While the solution from Alexander: https://stackoverflow.com/a/70339424/3390353 worked for me. Swizzling/ changing things on runtime always makes me a little nervous to do in a production environment.

So I went with an approach of subClass of UIHostingController and when the bottom safe area inset is changed I can use the additionalSafeAreaInsets to "add" the negative of the current bottom SafeArea Inset. With a check to only do this if safeAreaInsets.bottom > 0.

class OverrideSafeAreaBottomInsetHostingController<ContentView: SwiftUI.View>: UIHostingController<ContentView> {

     override func viewSafeAreaInsetsDidChange() {
         super.viewSafeAreaInsetsDidChange()

         if view.safeAreaInsets.bottom > 0 {
             additionalSafeAreaInsets = .init(top: 0, left: 0, bottom: -view.safeAreaInsets.bottom, right: 0)
        }
    }
}
Hendrix
  • 151
  • 2
  • 4
1

Let the hosting controller's view update its layout once again:

class SampleViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        addView()
    }
    
    private func addView() {
        let hostingController = UIHostingController(rootView: SampleView())
        
        hostingController.view.backgroundColor = .clear
        hostingController.view.translatesAutoresizingMaskIntoConstraints = false
        addChild(hostingController)
        view.addSubview(hostingController.view)
        hostingController.didMove(toParent: self)
        
        NSLayoutConstraint.activate([
            hostingController.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
            hostingController.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
            hostingController.view.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 0),
            hostingController.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor)
        ])
        
        hostingController.view.layoutIfNeeded()
    }

}
valeCocoa
  • 344
  • 1
  • 8
1

Swift 5.5 Solution

Try

UIHostingController(rootView: SampleView().edgesIgnoringSafeArea(.all))
1

Maybe you can try like this.

import SwiftUI

// Ignore safearea for UIHostingController when wrapped in UICollectionViewCell
class SafeAreaIgnoredHostingController<Content: View>: UIHostingController<Content> {
    override func viewSafeAreaInsetsDidChange() {
        super.viewSafeAreaInsetsDidChange()

        // Adjust only when top safeAreaInset is not equal to bottom
        guard view.safeAreaInsets.top != view.safeAreaInsets.bottom else {
            return
        }

        // Set additionalSafeAreaInsets to .zero before adjust to prevent accumulation
        guard additionalSafeAreaInsets == .zero else {
            additionalSafeAreaInsets = .zero
            return
        }

        // Use gap between top and bottom safeAreaInset to adjust top inset
        additionalSafeAreaInsets.top = view.safeAreaInsets.bottom - view.safeAreaInsets.top
    }
}
0

Your mileage may vary, but try setting .insetsLayoutMarginsFromSafeArea = false on the UIView and UIHostingController views.

Ben Guild
  • 4,881
  • 7
  • 34
  • 60
-1

Instead having UIView(red) you can simply use Color.red same for UIView(blue) :

struct SampleView: View {
    var body: some View {
        
        VStack(spacing: .zero) {
            
            Color.red
            
            Text("Title")

            VStack (alignment: .leading, spacing: 30) {
                Text("Hello World1")
                Text("Hello World2")
                Text("Hello World3")
                Text("Hello World4")
                Text("Hello World5")
                Text("Hello World6")
                Text("Hello World7")
                Text("Hello World8")
                Text("Hello World9")
                Text("Hello World10")
            }
            
            Divider()
            
            Color.blue.opacity(0.1)
        }
 
    }
}

Result:

enter image description here

ios coder
  • 1
  • 4
  • 31
  • 91
  • It was a simple demo app that simulated the issue from a real app. If you replace the Color.red or blue views with other views like images, you can see the layout breaks easily. – Daniel Nov 30 '21 at 05:36
  • I believe any thing that you want is replaceable with the red and blue color without any issue! maybe you are using wrong way! @Danny – ios coder Nov 30 '21 at 05:41
  • The only swift ui view is the middle view with white background. The above red and blue views are UIKit views in my sample app. It occurs when swift ui view is mixed with UIKit views inside a scroll view. – Daniel Nov 30 '21 at 07:29
  • And it doesn't occur if you use a handful amount of views only. – Daniel Nov 30 '21 at 07:31
  • No problem, it is your design and plan that you say NO to something which is possible and the best way without using it or using in right way. – ios coder Nov 30 '21 at 08:41
  • I'm just finding a workaround that doesn't break the layout regardless of what SwiftUI views I host in UIKit view. Currently, it highly depends on the SwiftUI view. – Daniel Nov 30 '21 at 16:44