2

I want to open a view controller over an existing view controller via button click without using storyboards. How do I do this? Here is what I mean:

Let's say we have three view controllers I can scroll between:

"zeroVC", "oneVC", and "twoVC"

When I press a button on "twoVC" I want to now scroll between:

"zeroVC", "oneVC", and "threeVC"

I tried looking all through stack overflow but they all use storyboards.

newswiftcoder
  • 111
  • 1
  • 11
  • 1
    What do you mean by “replacing”? – Jay Lee Aug 27 '19 at 02:19
  • In android programming you can replace views using fragments. In ios I want to click a button on the screen and open a new different screen over this screen. – newswiftcoder Aug 27 '19 at 03:31
  • 1
    I think this is what you are looking for: https://stackoverflow.com/questions/30475235/ios-programmatically-set-a-uicontainerviews-embedded-uiviewcontroller – Jay Lee Aug 27 '19 at 03:32
  • 1
    Yes, you can. In the above link it talks about programmatically embedding a container view controller. Using that, you can create a view controller with a scroll view, make a content view within the scroll view, and put three container view controllers in the scroll view with auto layout constraints. – Jay Lee Aug 27 '19 at 03:43
  • I tried Rivera's answer from your link and get the error "Value of type 'UIView?' has no member 'addSubview' ". thank you for the help Jay, I am new to swift – newswiftcoder Aug 27 '19 at 03:58
  • I can provide a MWE sample code after work. In the meantime, you can try other solutions and others might help you out. – Jay Lee Aug 27 '19 at 04:09
  • That would be incredible! I will keep programming and reading. Thank you – newswiftcoder Aug 27 '19 at 04:10

1 Answers1

2

Let's assume we have four view controllers: RedViewController, GreenViewController, BlueViewController, and the one to contain them all, ContainerViewController.

Although you mentioned a scrolling view controller with three children within, we'll make it a two screen setup to keep it simple. The following approach is scalable, so you would easily adopt it with an arbitrary number of view controllers.

Our RedViewController is 7 lines long:

class RedViewController: UIViewController {
  override func loadView() {
    let view = UIView()
    view.backgroundColor = .red
    self.view = view
  }
}

Before we move on to GreenViewController and BlueViewController, we will define protocol SwapViewControllerDelegate:

protocol SwapViewControllerDelegate: AnyObject {
  func swap()
}

GreenViewController and BlueViewController will have a delegate that conforms to this protocol, which will handle the swapping. We will make ContainerViewController conform to this protocol.

Note that SwapViewControllerDelegate has the AnyObject in its inheritance list to make it a class-only protocol–we can thus make the delegate weak, to avoid memory retain cycle.

The following is GreenViewController:

class GreenViewController: UIViewController {
  weak var delegate: SwapViewControllerDelegate?

  override func loadView() {
    let view = UIView()
    view.backgroundColor = .green
    self.view = view
  }

  override func viewDidLoad() {
    super.viewDidLoad()

    let button = UIButton()
    button.setTitle("Swap Me!", for: .normal)
    button.setTitleColor(.black, for: .normal)
    button.titleLabel?.font = .boldSystemFont(ofSize: 50)
    button.addTarget(
      self,
      action: #selector(swapButtonWasTouched),
      for: .touchUpInside)

    view.addSubview(button)

    // Put button at the center of the view
    button.translatesAutoresizingMaskIntoConstraints = false
    button.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
    button.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
  }

  @objc private func swapButtonWasTouched(_ sender: UIButton) {
    delegate?.swap()
  }
}

It has weak var delegate: SwapViewControllerDelegate? which will handle the swap when the button added in viewDidLoad is touched, triggering the swapButtonWasTouched method.

BlueViewController is implemented likewise:

class BlueViewController: UIViewController {
  weak var delegate: SwapViewControllerDelegate?

  override func loadView() {
    let view = UIView()
    view.backgroundColor = .blue
    self.view = view
  }


  override func viewDidLoad() {
    super.viewDidLoad()

    let button = UIButton()
    button.setTitle("Swap Me!", for: .normal)
    button.setTitleColor(.white, for: .normal)
    button.titleLabel?.font = .boldSystemFont(ofSize: 50)
    button.addTarget(
      self,
      action: #selector(swapButtonWasTouched),
      for: .touchUpInside)

    view.addSubview(button)

    // Put button at the center of the view
    button.translatesAutoresizingMaskIntoConstraints = false
    button.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
    button.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
  }

  @objc private func swapButtonWasTouched(_ sender: UIButton) {
    delegate?.swap()
  }
}

The only difference is the view's backgroundColor and the button's titleColor.

Finally, we'll take a look at ContainerViewController. ContainerViewController has four properties:

class ContainerViewController: UIViewController {

  let redVC = RedViewController()
  let greenVC = GreenViewController()
  let blueVC = BlueViewController()

  private lazy var scrollView: UIScrollView = {
    let scrollView = UIScrollView()
    scrollView.translatesAutoresizingMaskIntoConstraints = false
    scrollView.bounces = false
    scrollView.isPagingEnabled = true
    return scrollView
  }()
...
}

scrollView is the view that will contain child view controllers, redVC, greenVC, and blueVC. We will use autolayout, so don't forget to mark translatesAutoresizingMaskIntoConstraints as false.

Now, setup autolayout constraints of the scrollView:

class ContainerViewController: UIViewController {
...
  private func setupScrollView() {
    view.addSubview(scrollView)
    let views = ["scrollView": scrollView]
    [
      NSLayoutConstraint.constraints(
        withVisualFormat: "H:|[scrollView]|",
        metrics: nil,
        views: views),
      NSLayoutConstraint.constraints(
        withVisualFormat: "V:|[scrollView]|",
        metrics: nil,
        views: views),
    ]
      .forEach { NSLayoutConstraint.activate($0) }
  }
...
}

I used VFL, but you can manually set autolayou constraints as we did for the button above. Using autolayout, we don't have to set contentSize of the scrollView ourselves. For more information about using autolayout with UIScrollView, see Technical Note TN2154: UIScrollView And Autolayout.

Now the most important setupChildViewControllers():

class ContainerViewController: UIViewController {
...
  private func setupChildViewControllers() {
    [redVC, greenVC, blueVC].forEach { addChild($0) }

    let views = [
      "redVC": redVC.view!,
      "greenVC": greenVC.view!,
      "blueVC": blueVC.view!,
    ]
    views.values.forEach {
      scrollView.addSubview($0)
      $0.translatesAutoresizingMaskIntoConstraints = false
      $0.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true
      $0.heightAnchor.constraint(equalTo: view.heightAnchor).isActive = true
    }

    [
      NSLayoutConstraint.constraints(
        withVisualFormat: "H:|[redVC][greenVC]|",
        options: .alignAllTop,
        metrics: nil,
        views: views),
      NSLayoutConstraint.constraints(
        withVisualFormat: "H:|[redVC][blueVC]|",
        options: .alignAllTop,
        metrics: nil,
        views: views),
      NSLayoutConstraint.constraints(
        withVisualFormat: "V:|[redVC(==greenVC,==blueVC)]|",
        metrics: nil,
        views: views),
    ]
      .forEach { NSLayoutConstraint.activate($0) }

    [redVC, greenVC, blueVC].forEach { $0.didMove(toParent: self) }

    greenVC.view.isHidden = true

    greenVC.delegate = self
    blueVC.delegate = self
  }
...
}

We first add each of [redVC, greenVC, blueVC] as child view controllers of ContainerViewController. Then add the view's of child view controllers to scrollView. Set widthAnchor and heightAnchor of the child view controllers to be view.widthAnchor and view.heightAnchor, in order to make them fullscreen. Moreover, this will also work when the screen rotates.

Using views dictionary, we use VFL to set autolayout constraints. We will put greenVC.view on the right of redVC.view: H:|[redVC][greenVC]|, and similarly for the blueVC.view: H:|[redVC][blueVC]|. To fix the vertical position of greenVC.view and blueVC.view, add .alignAllTop option to the constraints. Then apply vertical layout for redVC.view, and set the height of the greenVC.view and blueVC.view: "V:|[redVC(==greenVC,==blueVC)]|. The vertical position is set, as we used .alignAllTop while setting the horizontal constraints.

We should call didMove(toParent:) methods on the child view controllers after we add then as child view controllers. (If you are wondering about what didMove(toParent:) and addChild(_:) methods do, apparently they do very little; see What does addChildViewController actually do? and didMoveToParentViewController and willMoveToParentViewController.)

Finally, hide greenVC.view, and set greenVC.delegate and blueVC.delegate to self. Then of course, we need ContainerViewController to conform to SwapViewControllerDelegate:

extension ContainerViewController: SwapViewControllerDelegate {
  func swap() {
    greenVC.view.isHidden.toggle()
    blueVC.view.isHidden.toggle()
  }
}

That's it! The entire project is uploaded here.

I recommend reading Implementing a Container View Controller, which is well-documented by Apple. (It is written in Objective-C, but it is actually straightforward to translate into Swift)

Jay Lee
  • 1,684
  • 1
  • 15
  • 27
  • I will work through your example and read up on visual format language, child view controllers, container view controllers, and the rest of the links. Your time and effort is deeply appreciated, man! – newswiftcoder Aug 27 '19 at 21:17
  • 1
    I got it to work with three children!! You don't know how happy I am. Thank you for teaching me. – newswiftcoder Aug 28 '19 at 06:55
  • 1
    Glad to hear that it helped! :) – Jay Lee Aug 28 '19 at 06:59
  • Final question: how do I set the starting view controller? When I open the app it opens the left child. Is there a way to open the middle child instead? – newswiftcoder Aug 28 '19 at 06:59
  • 1
    You can set the `contentOffset` property of the `scrollView` in the container view controller. – Jay Lee Aug 28 '19 at 07:02
  • 1
    You are welcome to upvote the question if it helped! – Jay Lee Aug 28 '19 at 07:03
  • Awesome!! and of course! you are the reason it worked out. – newswiftcoder Aug 28 '19 at 07:07
  • Hey bro I tried this but I think I got the width wrong. Is it what you meant? scrollView.contentOffset = CGPoint(x: scrollView.frame.size.width*2, y: 0) – newswiftcoder Aug 29 '19 at 00:51
  • 1
    That's probably because you are setting the `contentOffset` before the frame of the `scrollView` got set. Try `UIScreen.main.bounds.width * 2` instead of `scrollView.frame.size.width*2`. – Jay Lee Aug 29 '19 at 00:54
  • 1
    Note that this won't work in Playground, as `UIScreen.main.bounds.width` will get you the width of the screen of your Mac. – Jay Lee Aug 29 '19 at 01:00
  • You are absolutely correct, it worked! Thank you Jay!! – newswiftcoder Aug 29 '19 at 11:35