2
votes

I've got a basic UITableView with some cells. I'm using a SwiftUI View as content for both my cells and section headers. Strangely, only the section header that appears to touch the bottom of the screen on an iPhone XS Max seems to get a rawSafeAreaInset of 16pts (checked Debug View Hierarchy). My cells are working as expected.

To see what's going one, I have added a dummy blue SwiftUI rectangle to the contentView, and then placed a red UIView on top, both views set to the same constraints. The UITableView has been set to use automatic dimensions for headers and cells.

public class SectionHeader: UITableViewHeaderFooterView {
  public static let reusableIdentifier = "Section"

  private var innerHostedViewController: UIHostingController<AnyView>!

  public override init(reuseIdentifier: String?) {
    super.init(reuseIdentifier: reuseIdentifier)

    setupHeader()
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  private func setupHeader() {
    self.backgroundView = UIView()
    self.backgroundView?.backgroundColor = UIColor.green.withAlphaComponent(0.2)

    innerHostedViewController = UIHostingController(rootView: AnyView(Rectangle().fill(Color.blue).frame(height: 48)))
    innerHostedViewController.view.translatesAutoresizingMaskIntoConstraints = false
    innerHostedViewController.view.frame = self.contentView.bounds
    contentView.addSubview(innerHostedViewController.view)
    innerHostedViewController.view.backgroundColor = .clear

    let vv = UIView()
    vv.translatesAutoresizingMaskIntoConstraints = false
    vv.backgroundColor = .red
    contentView.addSubview(vv)

    NSLayoutConstraint.activate([
      vv.topAnchor.constraint(equalTo: self.contentView.topAnchor),
      vv.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor),
      vv.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor),
      vv.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor),

      innerHostedViewController.view.topAnchor.constraint(equalTo: self.contentView.topAnchor),
      innerHostedViewController.view.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor),
      innerHostedViewController.view.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor),
      innerHostedViewController.view.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor),
    ])
  }
}

As you can see in the image below, the red overlay is visible for the top two headers (with empty cells for demonstration), but the last one on the screen has its blue SwiftUI rectangle shifted upwards!

enter image description here

This SwiftUI view seems to be getting some safeAreaInset somehow, and there seems to be no way to turn this off. The inset also does not go away if you scroll up. It stays there forever. I tried turning off safe area insets for the SwiftUI view, but that doesn't help either:

innerHostedViewController = UIHostingController(rootView: AnyView(Rectangle().fill(Color.blue).frame(height: 48).edgesIgnoringSafeArea(.all)))

How do I get rid of this inset? As I mentioned - it's only happening to UITableViewHeaderFooterViews and not UITableViewCells.

The debug view hierarchy reveals a bogus bottom padding modifier based on the safe area insets:

enter image description here

3
I believe this is a SwiftUI bug. I’ve reported it for now and re-written the layout in UIKit as it seems unavoidable for now. – strangetimes
Frustratingly there is a var disableSafeArea: Bool on UIHostingView, but since it's a private class we can't set it :( I assume it's what they are using in the implementation of List etc. – Apptek Studios

3 Answers

2
votes

Hosting the view in this subclassed UIHostingController did the trick for me!

/// https://twitter.com/b3ll/status/1193747288302075906
class FixSafeAreaInsetsHostingViewController<Content: SwiftUI.View>: UIHostingController<Content> {
    func fixApplied() -> Self {
        self.fixSafeAreaInsets()
        return self
    }

    func fixSafeAreaInsets() {
        guard let _class = view?.classForCoder else {
            fatalError()
        }

        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 false
    }
}
0
votes

I was struggling with similar issue with UICollectionView and fixed the following custom UIHostingController that has viewSafeAreaInsetsDidChange override method.

class TopInsetCounterHostingController<ContentView: SwiftUI.View>: UIHostingController<ContentView> {
    override func viewSafeAreaInsetsDidChange() {
        additionalSafeAreaInsets = .init(top: view.safeAreaInsets.bottom, left: 0, bottom: 0, right: 0)
    }
}

I found that the content view moved upwards as much as the safe area's bottom inset so tried to adjust the top inset with that value. and ended up getting the result I expected. the top inset made the view move back to expected place.

Unfortunately, I can't give you any clear explaination of how it works under the hood because of lack of my knowledge about that. It's just the result derived from a lot of experiments I had tried. hope it's useful for anyone struggling with it.

0
votes

When I do not add UIHostingController to a parent view controller, this problem disappears. I'm not saying this is the correct solution, but it might be better than the swizzling solution.