Child ViewController safe area inset doesn't update if partially offscreen
Asked Answered
W

3

19

I am having difficulty with the Safe Area when sliding a child ViewController around on an iPhone X in landscape.

I have a root ViewController, and one of its views is moveable and contains an embedded child ViewController. The real app is a sliding sidebar menu UI. The sample code here is a stripped-down version.

If the table is positioned at the left of the screen, the Safe Area layout rules push its cell contentView inset to the right to allow for the notch. Correct. But then if I move the child ViewController away from the left edge of the screen, the child's insets do not update to relayout the content.

I have realised that in fact everything works well if the child ViewController is fully onscreen in its final position. If any part of it is offscreen, the Safe Area update doesn't happen.

Here is sample code to show the issue. This works with the standard Single View App Xcode template project: replace the ViewController file code with the code shown. When run, swiping the table right moves it from the left edge to the right edge of the screen.

See the "constraint(..., multiplier: 0.5)" line. This sets the width of the movable view relative to the screen. At 0.5, the table fits on the screen fully and the Safe Area updates as it moves. When docked left, the table cells respect the Safe Area inset, and when docked right the table cells have no extra inset which is correct.

As soon as the multiplier exceeds 0.5, even at 0.51, when slid right part of the table is offscreen. In this case no safe area update happens and so the table cell content inset is way too big -- it still has the 44 pixel safe area inset even though the table left edge is now nowhere near the Safe Area.

To compound the puzzle, the layout seems to work fine on UIViews if they are not a UIViewController's view. But I need it to work with UIViewControllers.

Can anyone explain how to get the child ViewController to respect the correct Safe Area? Thanks.

Screen shots of the problem

Code to reproduce:

class ViewController: UIViewController {

    var leftEdgeConstraint : NSLayoutConstraint!
    var viewThatMoves : UIView!
    var myEmbeddedVC : UIViewController!

    override func viewDidLoad() {
        super.viewDidLoad()

        self.view.backgroundColor = UIColor.gray

        self.viewThatMoves = UIView()
        self.viewThatMoves.translatesAutoresizingMaskIntoConstraints = false
        self.view.addSubview(self.viewThatMoves)

        // Relayout during animation work with multiplier = 0.5
        // With any greater value, like 0.51 (meaning part of the view is offscreen), relayout does not happen
        self.viewThatMoves.widthAnchor.constraint(equalTo: self.view.widthAnchor, multiplier: 0.5).isActive = true
        self.viewThatMoves.heightAnchor.constraint(equalTo: self.view.heightAnchor).isActive = true
        self.viewThatMoves.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true
        self.leftEdgeConstraint = self.viewThatMoves.leftAnchor.constraint(equalTo: self.view.leftAnchor, constant: 0)
        self.leftEdgeConstraint.isActive = true

        // Embed child ViewController
        self.myEmbeddedVC = MyTableViewController()

        self.addChildViewController(self.myEmbeddedVC)
        self.myEmbeddedVC.view.translatesAutoresizingMaskIntoConstraints = false
        self.viewThatMoves.addSubview(self.myEmbeddedVC.view)
        self.myEmbeddedVC.didMove(toParentViewController: self)

        // Fill containing view
        self.myEmbeddedVC.view.leftAnchor.constraint(equalTo: self.viewThatMoves.leftAnchor).isActive = true
        self.myEmbeddedVC.view.rightAnchor.constraint(equalTo: self.viewThatMoves.rightAnchor).isActive = true
        self.myEmbeddedVC.view.topAnchor.constraint(equalTo: self.viewThatMoves.topAnchor).isActive = true
        self.myEmbeddedVC.view.bottomAnchor.constraint(equalTo: self.viewThatMoves.bottomAnchor).isActive = true

        let swipeLeftRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe(recognizer:)))
        swipeLeftRecognizer.direction = .left
        let swipeRightRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe(recognizer:)))
        swipeRightRecognizer.direction = .right

        self.viewThatMoves.addGestureRecognizer(swipeLeftRecognizer)
        self.viewThatMoves.addGestureRecognizer(swipeRightRecognizer)
    }

    @objc func handleSwipe(recognizer:UISwipeGestureRecognizer) {
        UIView.animate(withDuration: 1) {

            if recognizer.direction == .left {
                self.leftEdgeConstraint.constant = 0
            }
            else if recognizer.direction == .right {
                self.leftEdgeConstraint.constant = self.viewThatMoves.frame.size.width
            }

            self.view.setNeedsLayout()
            self.view.layoutIfNeeded()

            // Tried this: has no effect
            // self.myEmbeddedVC.viewSafeAreaInsetsDidChange()
        }
    }
}

class MyTableViewController: UITableViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        self.view.backgroundColor = UIColor.blue
        self.title = "Test Table"

        // Uncomment the following line to preserve selection between presentations
        // self.clearsSelectionOnViewWillAppear = false

        self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Left", style: .plain, target: nil, action: nil)
        self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Right", style: .plain, target: nil, action: nil)
    }

    // MARK: - Table view data source

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 25
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
        cell.contentView.backgroundColor = UIColor.green
        cell.backgroundColor = UIColor.yellow
        cell.textLabel?.text = "This is row \(indexPath.row)"
        cell.textLabel?.backgroundColor = UIColor.clear

        return cell
    }
}
Word answered 23/11, 2017 at 21:14 Comment(4)
Have you found a solution? I'm looking for an opposite effect though, to keep the original tableView safeArea when it's completely on screen.Defame
No, sorry @zh. I filed an Apple bug and it is still open, a year later, with no comment from Apple.Word
Affecting me too in iOS 13Chantal
Thank you for posting this, been trying to fix something similar for about 3 days. This is truly the most confusing stupidest bug I've encountered on iOS. Thanks againCellini
B
6

Semi related, I wanted a contained view controller to always have the same insets as another view. In my case, it was always full screen so I just used the window, but this approach would work with any other view.

private final class InsetView: UIView {
    override var safeAreaInsets: UIEdgeInsets {
        window?.safeAreaInsets ?? .zero
    }
}

final class MyViewController: UIViewController {
    override func loadView() {
        view = InsetView()
    }
}

This works flawlessly for me. Originally I was trying to calculate additionalSafeAreaInsets in safeAreaInsetsDidChange by making the additional ones be the difference between what the view controller should have had and actually had but this was really jittery in a scroll view.

Benedetto answered 24/10, 2020 at 0:12 Comment(2)
One of similar issue was fixed using this.Overtax
Nice fix - working on iOS 15 too. Can also use within the storyboard, replacing the VC's standard view with this custom InsetView. Remember to make it public and add the module.Hanoi
C
5

I've managed to 'fix' this with an ugly hack. In the parent view controller, you need to do this:

- (void) viewSafeAreaInsetsDidChange {
  [super viewSafeAreaInsetsDidChange];

  // Fix for child controllers not receiving an update on safe area insets
  // when they're partially not showing
  for (UIViewController *childController in self.childViewControllers) {
    UIEdgeInsets prevInsets = childController.additionalSafeAreaInsets;
    childController.additionalSafeAreaInsets = self.view.safeAreaInsets;
    childController.additionalSafeAreaInsets = prevInsets;
  }
}

This forces the child view controllers to update their safeAreaInsets correctly.

Chantal answered 9/10, 2019 at 23:58 Comment(2)
Not a huge fan of the code either, but this did fix the problem for me. Thanks!Strop
Sadly no longer working building against iOS 15 for me.Hanoi
M
-1

It appears to be a system behavior: if a view of a child view controller appears to be not fully in bounds of it's parent view controller in vertical or/and horizontal direction, corresponding directions of it's safe area are not updating.

Sounds like, if you want child view controller to be out of it's parent view controller bounds, you should use transform property, but safe still would not behave entirely correct, however, it will update on rotation.

Maziar answered 25/4, 2018 at 18:21 Comment(1)
That won't work - the problem is that safe area insets aren't updated for the child view controller, which means sub-sub-sub views within the child view controller (UITableViewCells in my case) do not get their safe area insets updated, and so they appear incorrectly onscreen.Chantal

© 2022 - 2024 — McMap. All rights reserved.