Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

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
    }
}
like image 989
Ben Avatar asked Nov 23 '17 21:11

Ben


2 Answers

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.

like image 178
Sam Soffes Avatar answered Oct 16 '22 22:10

Sam Soffes


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.

like image 44
strangetimes Avatar answered Oct 16 '22 23:10

strangetimes