Jean-Baptiste
Jean-Baptiste

Reputation: 967

Pass data to View Controller embedded inside a Container View Controller

My view controller hierarchy is the following:

view controller hierarchy

The Content View Controller stores the letter to be shown as a property letter: String, which should be set before its view is pushed on screen.

class ContentViewController: UIViewController {

    var letter = "-"
    @IBOutlet private weak var label: UILabel!

    override func viewWillAppear(animated: Bool) {
        super.viewWillAppear(animated)
        label.text = letter
    }
}

On the contrary, the Container View Controller should not know anything about the letter (content-unaware), since I'm trying to build it as reusable as possible.

class ContainerViewController: UIViewController {

    var contentViewController: ContentViewController? {
        return childViewControllers.first as? ContentViewController
    }
}

I tried to write prepareForSegue() in my Table View Controller accordingly :

override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
    if let containerViewController = segue.destinationViewController as? ContainerViewController {
        let indexPath = tableView.indexPathForCell(sender as! UITableViewCell)!
        let letter = letterForIndexPath(indexPath)
        containerViewController.navigationItem.title = "Introducing \(letter)"
        // Not executed:
        containerViewController.contentViewController?.letter = letter
    }
}

but contentViewController is not yet created by the time this method is called, and the letter property is never set.

It is worth mentioning that this does work when the segue's destination view controller is set directly on the Content View Controller -- after updating prepareForSegue() accordingly.

Do you have any idea how to achieve this?

Upvotes: 4

Views: 3420

Answers (3)

Jean-Baptiste
Jean-Baptiste

Reputation: 967

Actually I feel like the correct solution is to rely on programmatic instantiation of the content view, and this is what I chose after careful and thorough thoughts.

Here are the steps that I followed:

  • The Table View Controller has a push segue set to ContainerViewController in the storyboard. It still gets performed when the user taps on a cell.

  • I removed the embed segue from the Container View to the ContentViewController in the storyboard, and I added an IB Outlet to that Container View in my class.

  • I set a storyboard ID to the Content View Controller, say… ContentViewController, so that we can instantiate it programmatically in due time.

  • I implemented a custom Container View Controller, as described in Apple's View Controller Programming Guide. Now my ContainerViewController.swift looks like (most of the code install and removes the layout constraints):

    class ContainerViewController: UIViewController {
    
        var contentViewController: UIViewController? {
            willSet {
                setContentViewController(newValue)
            }
        }
    
    
        @IBOutlet private weak var containerView: UIView!
        private var constraints = [NSLayoutConstraint]()
    
    
        override func viewDidLoad() {
            super.viewDidLoad()
            setContentViewController(contentViewController)
        }
    
        private func setContentViewController(newContentViewController: UIViewController?) {
            guard isViewLoaded() else { return }
            if let previousContentViewController = contentViewController {
                previousContentViewController.willMoveToParentViewController(nil)
                containerView.removeConstraints(constraints)
                previousContentViewController.view.removeFromSuperview()
                previousContentViewController.removeFromParentViewController()
            }
            if let newContentViewController = newContentViewController {
                let newView = newContentViewController.view
                addChildViewController(newContentViewController)
                containerView.addSubview(newView)
                newView.frame = containerView.bounds
                constraints.append(newView.leadingAnchor.constraintEqualToAnchor(containerView.leadingAnchor))
                constraints.append(newView.topAnchor.constraintEqualToAnchor(containerView.topAnchor))
                constraints.append(newView.trailingAnchor.constraintEqualToAnchor(containerView.trailingAnchor))
                constraints.append(newView.bottomAnchor.constraintEqualToAnchor(containerView.bottomAnchor))
                constraints.forEach { $0.active = true }
                newContentViewController.didMoveToParentViewController(self)
            }
        } }
    
  • In my LetterTableViewController class, I instantiate and setup my Content View Controller, which is added to the Container's child view controllers. Here is the code:

    override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
        if let containerViewController = segue.destinationViewController as? ContainerViewController {
            let indexPath = tableView.indexPathForCell(sender as! UITableViewCell)!
            let letter = letterForIndexPath(indexPath)
            containerViewController.navigationItem.title = "Introducing \(letter)"
            if let viewController = storyboard?.instantiateViewControllerWithIdentifier("ContentViewController"),
               let contentViewController = viewController as? ContentViewController {
                contentViewController.letter = letter
                containerViewController.contentViewController = contentViewController
            }
        }
    }
    

This works perfectly, with an entirely content-agnostic container view controller. By the way, it used to be the way one instantiated a UITabBarController or a UINavigationController along with its children, in the appDidFinishLaunching:withOptions: delegate method.

The only downside of this I can see: the UI flow ne longer appears explicitly on the storyboard.

Upvotes: 1

A-Live
A-Live

Reputation: 8944

At your current solution the presenting object itself is responsible for working both with the "container" and the "content", it doesn't have to be changed, but such solution not only has the issues like the one you described, but also makes the purpose of the "container" not very clear.

Look at the UIAlertController: you are not configuring its child view controller directly, you are not even supposed to know it exists when using the alert controller. Instead of configuring the "content", you are configuring the "container" which is aware of the content interfaces, lifecycle and behavior and doesn't expose it. Following this approach you achieve a properly divided responsibility of the container and content, minimal exposure of the "content" allows you to update the "container" without a need to update the way it is used.

In short, instead of trying to configure everything from a single place, make it so you configure only the "container" and let it configure the "content" when and where it is needed. E.g. in the scenario you described the "container" would set data for the "content" whenever it initializes the child controllers. I'm using "container" and "content" instead of ContainerViewController and ContentViewController because the solution is not strictly based on the controllers because you might as well replace it wth NSObject + UIView or UIWindow.

Upvotes: 0

dirkgroten
dirkgroten

Reputation: 20672

The only way I can think of is to add delegation so that your tableViewController implements a protocol with one method to return the letter; then you have containerViewController setting its childViewController (the contentViewController) delegate to its parent. And the contentViewController can finally ask its delegate for the letter.

Upvotes: 0

Related Questions