All of us sometimes need to display overlay views in our iOS apps: informational or error messages, confirmation screens and so on. Usually I use child view controllers for that, and I have to write the boilerplate code of adding and presenting overlays all the time. It’s definitely a field for optimization!

Let’s start with creating UI for the overlay using storyboards. As you know, it’s a good idea to have a separate view controller for the overlay, so it’s not mixed up with the parent view controller’s logic and UI. For demonstration purposes I created simple MessageViewController:

How to create overlay view controller using protocols in Swift

Presenting it on the parent view controller requires us to implement something like this method:

func addReusableViewController() {
    guard let vc = storyboard?.instantiateViewController(withIdentifier: "MessageViewController") as? MessageViewController else { return }
    vc.willMove(toParentViewController: self)
    addChildViewController(vc)
    containerView.addSubview(vc.view)
    vc.didMove(toParentViewController: self)
}

Pollutes our parent view controller too much. After thinking about this I’ve decided to move the presentation logic to a protocol and its extension. In this case I could easily extend my MessageViewController by this protocol, without any subclassing. This gives me a flexibility, so necessary if I want to evolve my project further.

OverlayViewController protocol

So basically the protocol is quite simple:

protocol OverlayViewController: class {
    var overlaySize: CGSize? { get }
    func presentOverlay(from parentViewController: UIViewController)
    func dismissOverlay()
}

In Swift, protocols functions can’t have bodies, but we can extend our protocol and provide implementation. In Swift this is called protocol’s default implementation and here how it looks like:

extension OverlayViewController where Self: UIViewController {
    var overlaySize: CGSize? {
        return nil
    }
    /// Just a random number. We use this to access blackOverlayView later after we've added it.
    private var blackOverlayViewTag: Int {
        return 392895
    }

    /// Presents the current view controller as an overlay on a given parent view controller.
    ///
    /// - Parameter parentViewController: The parent view controller.
    func presentOverlay(from parentViewController: UIViewController) {
        // Dim out background.
        let parentBounds = parentViewController.view.bounds
        let blackOverlayView = UIView()
        blackOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.2)
        blackOverlayView.frame = parentBounds
        blackOverlayView.alpha = 0.0
        blackOverlayView.isUserInteractionEnabled = true
        blackOverlayView.tag = blackOverlayViewTag
        parentViewController.view.addSubview(blackOverlayView)

        let containerView = UIView()
        if let overlaySize = overlaySize {
            // The user has provided the overlaySize.
            let x = (parentBounds.width - overlaySize.width) * 0.5
            let y = (parentBounds.height - overlaySize.height) * 0.5
            containerView.frame = CGRect(x: x, y: y, width: overlaySize.width, height: overlaySize.height)
        } else {
            // No overlaySize provided. By default we have small paddings at every edge.
            containerView.frame = parentBounds.insetBy(dx: parentBounds.width*0.05,
                                                       dy: parentBounds.height*0.05)
        }

        // Adding a shadow.
        containerView.layer.shadowColor = UIColor.black.cgColor
        containerView.layer.shadowRadius = 10.0
        containerView.layer.shadowOpacity = 0.4
        containerView.layer.shadowOffset = CGSize.zero

        parentViewController.view.addSubview(containerView)

        // Round corners.
        view.layer.cornerRadius = 8.0
        view.clipsToBounds = false

        // Adding to the parent view controller.
        parentViewController.addChildViewController(self)
        containerView.addSubview(self.view)
        // Fit into the container view.
        constraintViewEqual(view1: containerView, view2: self.view)
        self.didMove(toParentViewController: parentViewController)

        // Fade the overlay view in.
        containerView.alpha = 0.0
        containerView.transform = CGAffineTransform(scaleX: 0.85, y: 0.85)
        UIView.animate(withDuration: 0.3) {
            containerView.alpha = 1.0
            containerView.transform = .identity
            blackOverlayView.alpha = 1.0
        }
    }

    /// Removes the current view controller from the parent view controller with animation.
    func dismissOverlay() {
        guard let containerView = view.superview else { return }
        let blackOverlayView = containerView.superview?.viewWithTag(blackOverlayViewTag)
        UIView.animate(withDuration: 0.3, animations: {
            blackOverlayView?.alpha = 0.0
            containerView.alpha = 0.0
            containerView.transform = CGAffineTransform(scaleX: 0.85, y: 0.85)
        }) { _ in
            self.removeFromParentViewController()
            containerView.removeFromSuperview()
            blackOverlayView?.removeFromSuperview()
        }
    }

    /// Sticks child view (view1) to the parent view (view2) using constraints.
    private func constraintViewEqual(view1: UIView, view2: UIView) {
        view2.translatesAutoresizingMaskIntoConstraints = false
        let constraint1 = NSLayoutConstraint(item: view1, attribute: .top, relatedBy: .equal, toItem: view2, attribute: .top, multiplier: 1.0, constant: 0.0)
        let constraint2 = NSLayoutConstraint(item: view1, attribute: .trailing, relatedBy: .equal, toItem: view2, attribute: .trailing, multiplier: 1.0, constant: 0.0)
        let constraint3 = NSLayoutConstraint(item: view1, attribute: .bottom, relatedBy: .equal, toItem: view2, attribute: .bottom, multiplier: 1.0, constant: 0.0)
        let constraint4 = NSLayoutConstraint(item: view1, attribute: .leading, relatedBy: .equal, toItem: view2, attribute: .leading, multiplier: 1.0, constant: 0.0)
        view1.addConstraints([constraint1, constraint2, constraint3, constraint4])
    }
}

As you can see, the protocol encapsulates the logic of presenting/dismissing itself on/from the given parent view controller. It also adds a shadow and rounded corners to the overlay.

Now defining an overlay is as simple as:

class MessageViewController: UIViewController, OverlayViewController {
    let overlaySize: CGSize? = CGSize(width: UIScreen.main.bounds.width * 0.8, height: 160.0)

    @IBAction func closeButtonPressed() {
        dismissOverlay()
    }
}

OverlayHost protocol

However, we still need to instantiate the overlay view controller from storyboard and tell it to present itself. One of options is to have this method in each view controller, which wants to present the overlay:

func presentOverlay() {
    let identifier = String(describing: MessageViewController.self)
    guard let overlay = storyboard?.instantiateViewController(withIdentifier: identifier) as? OverlayViewController else { return }
    overlay.presentOverlay(from: self)
}

We can move this method into a base class to preserve DRY principle. However, it’s easy to come up with the huge base class having many methods and pretty hard to refactor and maintain. And, after all, there is also SOLID principle we should follow.

The similar case was covered in my previous article about Reusing a view controller between screens. My suggestion is to encapsulate the logic into a protocol with extension again:

protocol OverlayHost {
    func showOverlay<T: OverlayViewController>(type: T.Type, fromStoryboardWithName storyboardName: String) -> T?
    func showOverlay<T: OverlayViewController>(identifier: String, fromStoryboardWithName storyboardName: String) -> T?
}

extension OverlayHost where Self: UIViewController {
    @discardableResult
    func showOverlay<T: OverlayViewController>(type: T.Type, fromStoryboardWithName storyboardName: String) -> T? {
        let identifier = String(describing: T.self)
        return showOverlay(identifier: identifier, fromStoryboardWithName: storyboardName)
    }

    @discardableResult
    func showOverlay<T: OverlayViewController>(identifier: String, fromStoryboardWithName storyboardName: String) -> T? {
        let storyboard = UIStoryboard(name: storyboardName, bundle: nil)
        guard let overlay = storyboard.instantiateViewController(withIdentifier: identifier) as? T else { return nil }
        overlay.presentOverlay(from: self)
        return overlay
    }
}

The first method in the protocol would show the overlay view controller, which is located in the given storyboard. This is a generic method, and the type must conform to OverlayViewController protocol we’ve defined before.

The second method is more concrete, and can be used if the view controller’s identifier differs from its class name (which is uncommon). Both methods are generic to constrain the type we’re passing to OverlayViewController descendant.

With this protocol the parent view controller would look like this:

class ViewController: UIViewController, OverlayHost {
    @IBAction func showOverlayButtonPressed() {
        showOverlay(type: MessageViewController.self, fromStoryboardWithName: "Main")
    }
}

To summarize, with OverlayViewController all what you need is to:

  1. Define parent and overlay view controllers on storyboard.
  2. Make parent view controller conformable to OverlayHost protocol.
  3. Make overlay view controller conformable to OverlayViewController protocol.
  4. Call showOverlay(_:) method on parent view controller.
  5. Call dismissOverlay() when the user pressed Dismiss button on overlay view controller.

That’s it!

Github repo: https://github.com/agordeev/OverlayViewController