Home > Enterprise >  Layout not working when container view controller dynamically changes child view controller
Layout not working when container view controller dynamically changes child view controller

Time:06-03

I have a MainViewController that contains a ContainerViewController.

The ContainerViewController starts out showing childViewControllerA, and dynamically switches it out to childViewControllerB when a button in the childViewControllerA is clicked:

func showNextViewContoller() {

    let childViewControllerB = ChildViewControllerB()

    container.addViewController(childViewControllerB)
    container.children.first?.remove()  // Remove childViewControllerA
}

Here's a diagram:

flow diagram for app

The second view controller (ViewControllerB) has an image view that I'd like to show in the center. So I assigned it the following constraints:

imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFill
   
    NSLayoutConstraint.activate([
        imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
        imageView.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: 0.6),
    ])

The problem I'm running into is the imageView is not centered vertically: It's lower than it should be.

When I run the app so that ContainerVeiwController shows childViewControllerB first, then it works as intended. The issue occurs only when childViewControllerB is switched in dynamically after childViewControllerA:

enter image description here

To help debug, I added the following code to all three ViewControllers:

override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
        print("MainViewController bounds = \(self.view.bounds)")
    }

And this gave an interesting print out (running this on an iPhone 13 mini simulator):

MainViewController bounds = (0.0, 0.0, 375.0, 812.0) //iPhone 13 mini screen is 375 x 812
ChildViewControllerA bounds = (0.0, 0.0, 375.0,738.0). // 812 - 50 safety margin - 24 titlebar = 738.

Now, the switch happens after the button was clicked and childViewControllerB is added:

ChildViewControllerB bounds = (0.0, 0.0, 375.0, 812.0)

It seems like ChildViewControllerB is assuming a full screen size and ignoring the bounds of it's parent view controller (ContainerViewController). So, the imageView's hightAnchor is based on the full screen height, causing it to appear off center.

So, I changed the constraints on the imageView to:

NSLayoutConstraint.activate([
        imageView.centerYAnchor.constraint(equalTo: super.view.centerYAnchor),
        imageView.centerXAnchor.constraint(equalTo: super.view.centerXAnchor),
        imageView.heightAnchor.constraint(equalTo: super.view.heightAnchor, multiplier: 0.6),
    ])

Next, I tried to force a layout update by adding any of these lines after the switch happens in showNextViewController() function above:

container.children.first?.view.layoutSubviews()

//and

container.children.first?.view.setNeedsLayout()

None of them worked.

How do I get ChildViewControllerB to respect the bounds of ContainerViewController?

If it helps, the imageView only needs to be in the center initially. It'll eventually have a pan, pinch and rotate gesture attached, so the user can move it anywhere they want.

Edit 01:

This is how I'm adding and removing a child view controller:

extension UIViewController {
    func addViewController(_ child: UIViewController) {
        addChild(child)
        view.addSubview(child.view)
        child.didMove(toParent: self)
    }
    
    func remove() {
        guard parent != nil else { return }
        willMove(toParent: nil)
        view.removeFromSuperview()
        removeFromParent()
    }
}

Edit 02:

On recommendation by a few commentators, I updated the addViewController() function:

func addViewController(_ child: UIViewController) {
        
        addChild(child)
        view.addSubview(child.view)
        
        child.view.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            child.view.topAnchor.constraint(equalTo: self.view.topAnchor),
            child.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
            child.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
            child.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
        ])
        
        
        child.didMove(toParent: self)
        
    }

This didn't seem to work, I got errors saying "Unable to simultaneously satisfy constraints." Unfortunately I have very little knowledge on how to decipher the error messages...

Edit 03: Simplified Project:

Here's a simplified project. There are four files plus AppDelegate (I'm not using a storyboard):

  • MainViewController
  • ViewControllerA
  • ViewControllerB
  • Utilities
  • AppDelegate

MainViewController:

import UIKit

class MainViewController: UIViewController {
    
    let titleBarView = UIView(frame: .zero)
    let container = UIViewController()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setup()
        layout()
    }
    
    func setup() {
        
        titleBarView.backgroundColor = .gray
        view.addSubview(titleBarView)
        
        addViewController(container)
        showViewControllerA()
    }
    
    func layout() {
        titleBarView.translatesAutoresizingMaskIntoConstraints = false
        container.view.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            titleBarView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            titleBarView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            titleBarView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            titleBarView.heightAnchor.constraint(equalToConstant: 24),

            container.view.topAnchor.constraint(equalTo: titleBarView.bottomAnchor, constant: 0),
            container.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            container.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            container.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])
    }
    
    func showViewControllerA() {
        let viewControllerA = ViewControllerA()
        viewControllerA.delegate = self
        
        container.children.first?.remove()
        container.addViewController(viewControllerA)
    }
    
    func showViewControllerB() {
        let viewControllerB = ViewControllerB()
        
        container.children.first?.remove()
        container.addViewController(viewControllerB)
    }
}

extension MainViewController: ViewControllerADelegate {
    func nextViewController() {
        showViewControllerB()
    }
}

ViewController A:

protocol ViewControllerADelegate: AnyObject {
    func nextViewController()
}

class ViewControllerA: UIViewController {
    
    let nextButton = UIButton()
    
    weak var delegate: ViewControllerADelegate?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setup()
        layout()
        view.backgroundColor = .gray
    }
    
    func setup() {
        nextButton.setTitle("next", for: .normal)
        nextButton.addTarget(self, action: #selector(nextButtonPressed), for: .primaryActionTriggered)
        view.addSubview(nextButton)
    }
    
    func layout() {
        nextButton.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            nextButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            nextButton.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }
    
    @objc func nextButtonPressed() {
        delegate?.nextViewController()
    }  
}

ViewController B:

import UIKit

class ViewControllerB: UIViewController {
    
    let imageView = UIImageView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setup()
        layout()
    }
    
    func setup() {
        view.addSubview(imageView)
        blankImage()
    }
    
    func layout() {
        imageView.translatesAutoresizingMaskIntoConstraints = false
        imageView.contentMode = .scaleAspectFill
        imageView.layer.magnificationFilter = CALayerContentsFilter.nearest;
       

        NSLayoutConstraint.activate([
            imageView.centerYAnchor.constraint(equalTo: super.view.centerYAnchor),
            imageView.centerXAnchor.constraint(equalTo: super.view.centerXAnchor),
            imageView.heightAnchor.constraint(equalTo: super.view.heightAnchor, multiplier: 0.6),
        ])
        
        view.layoutSubviews()

    }
    
    func blankImage() {
        let ciImage = CIImage(cgImage: createBlankCGImage(width: 32, height: 64)!)
        imageView.image = cIImageToUIImage(ciimage: ciImage, context: CIContext())
    }
}

Utilities:

import Foundation
import UIKit

func createBlankCGImage(width: Int, height: Int) -> CGImage? {
    
    let bounds = CGRect(x: 0, y:0, width: width, height: height)
    let intWidth = Int(ceil(bounds.width))
    let intHeight = Int(ceil(bounds.height))
    let bitmapContext = CGContext(data: nil,
                                  width: intWidth, height: intHeight,
                                  bitsPerComponent: 8,
                                  bytesPerRow: 0,
                                  space: CGColorSpace(name: CGColorSpace.sRGB)!,
                                  bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)

    if let cgContext = bitmapContext {
        cgContext.saveGState()
        let r = CGFloat.random(in: 0...1)
        let g = CGFloat.random(in: 0...1)
        let b = CGFloat.random(in: 0...1)
        
        cgContext.setFillColor(red: r, green: g, blue: b, alpha: 1)
        cgContext.fill(bounds)
        cgContext.restoreGState()
        
        return cgContext.makeImage()
    }
    
    return nil
}

func cIImageToUIImage(ciimage: CIImage, context: CIContext) -> UIImage? {
    if let cgimg = context.createCGImage(ciimage, from: ciimage.extent) {
        return UIImage(cgImage: cgimg)
    }
    return nil
}


extension UIViewController {
    
    func addViewController(_ child: UIViewController) {
        addChild(child)
        view.addSubview(child.view)
        child.didMove(toParent: self)
    }
    
    func remove() {
        guard parent != nil else { return }
        willMove(toParent: nil)
        view.removeFromSuperview()
        removeFromParent()
    }
}

AppDelegate:

import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        window = UIWindow(frame: UIScreen.main.bounds)
        window?.backgroundColor = .white
        window?.makeKeyAndVisible()
        
        window?.rootViewController = MainViewController()
        
        return true
    }
}

CodePudding user response:

Update as following:

func addViewController(_ child: UIViewController) {
    addChild(child)
    view.addSubview(child.view)
    child.didMove(toParent: self)
        
    child.view.translatesAutoresizingMaskIntoConstraints = false

    NSLayoutConstraint.activate([
        child.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
        child.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        child.view.topAnchor.constraint(equalTo: view.topAnchor),
        child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)])
}
    

In MainViewViewController update the layout() function:

func layout() {
    titleBarView.translatesAutoresizingMaskIntoConstraints = false
    container.view.translatesAutoresizingMaskIntoConstraints = false
        
    NSLayoutConstraint.activate([
        titleBarView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
        titleBarView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
        titleBarView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        titleBarView.heightAnchor.constraint(equalToConstant: 24),

        //container.view.topAnchor.constraint(equalTo: titleBarView.bottomAnchor, constant: 0),
        //container.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
        //container.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        //container.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
    ])
        
}

CodePudding user response:

Asteroid is on the right track, but couple other issues...

You are not giving the views of the child controllers any constraints, so they load at their "native" size.

Changing your addViewController(...) func as advised by Asteroid solves the A and B missing constraints, but...

You are calling that same func for your container controller and adding constraints to its view in layout(), so you end up with conflicting constraints.

One solution would be to change your addViewController func to this:

func addViewController(_ child: UIViewController, constrainToSuperview: Bool = true) {
    addChild(child)
    view.addSubview(child.view)
    
    if constrainToSuperview {
        child.view.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            child.view.topAnchor.constraint(equalTo: view.topAnchor),
            child.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            child.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])
    }
    
    child.didMove(toParent: self)
}

then in setup():

func setup() {
    
    titleBarView.backgroundColor = .red
    view.addSubview(titleBarView)

    // change this
    //addViewController(container)
    
    // to this
    addViewController(container, constrainToSuperview: false)

    showViewControllerA()
}

while leaving your other "add view controller" calls like this:

container.addViewController(viewControllerA)
container.addViewController(viewControllerB)

The other thing that may throw you off is the extraneous super. in your image view constraints:

    NSLayoutConstraint.activate([
        // change this
        //imageView.centerYAnchor.constraint(equalTo: super.view.centerYAnchor),
        //imageView.centerXAnchor.constraint(equalTo: super.view.centerXAnchor),
        //imageView.heightAnchor.constraint(equalTo: super.view.heightAnchor, multiplier: 0.6),

        // to this
        imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
        imageView.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: 0.6),
    ])
    
  • Related