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.
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
}
}
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.
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With