Skip to content

Instantly share code, notes, and snippets.

@DevAndArtist
Last active January 24, 2017 10:48
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save DevAndArtist/ecc8cf410c11829f8deb0e3ec9804394 to your computer and use it in GitHub Desktop.
Save DevAndArtist/ecc8cf410c11829f8deb0e3ec9804394 to your computer and use it in GitHub Desktop.

ContainerViewController

A trully custom container view controller written in Swift 3.0.1. The view of this object is fully resizable and works great with layout constraints.

Todo

  • Mimic UINavigationController without any bar APIs
  • More flexible delegate methods
  • Simple non-interactive transition
  • Interactive transition

Internal helper protocol

import UIKit

protocol MetatypeExtractor {}

extension MetatypeExtractor {
    
    var dynamicMetatype: Self.Type {
    
        return type(of: self)
    }
}

extension NSObject : MetatypeExtractor {}

ContainerViewController.Delegate

@objc public protocol _ContainerViewControllerDelegate : class {
    
    @objc optional func containerViewController(_ containerViewController: ContainerViewController,
                                                willPerform operation: ContainerViewController.Operation,
                                                from oldViewController: UIViewController,
                                                to newViewController: UIViewController,
                                                animated: Bool)
    
    @objc optional func containerViewController(_ containerViewController: ContainerViewController,
                                                didPerform operation: ContainerViewController.Operation,
                                                from oldViewController: UIViewController,
                                                to newViewController: UIViewController,
                                                animated: Bool)
    
    @objc optional func containerViewController(_ containerViewController: ContainerViewController,
                                                stack oldViewControllers: [UIViewController],
                                                willSetTo newViewControllers: [UIViewController],
                                                animated: Bool)
    
    @objc optional func containerViewController(_ containerViewController: ContainerViewController,
                                                stack newViewControllers: [UIViewController],
                                                didSetFrom oldViewControllers: [UIViewController],
                                                animated: Bool)
    
    @objc optional func containerViewController(_ containerViewController: ContainerViewController,
                                                animationControllerFor operation: ContainerViewController.Operation,
                                                from oldViewController: UIViewController,
                                                to newViewController: UIViewController) -> UIViewControllerAnimatedTransitioning?
    
//  TODO
//  @objc optional func containerViewController(_ containerViewController: ContainerViewController,
//                                                interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning?
}

ContainerViewController

public let defaultAnimatedValue = true

open class ContainerViewController : UIViewController {
    
    public typealias Delegate = _ContainerViewControllerDelegate
    
    weak open var delegate: Delegate?

    open internal(set) var viewControllers: [UIViewController]
    
//    open internal(set) var interactivePopGestureRecognizer: UIGestureRecognizer?
    
    var isSuppressingDelegateCallbacks: Bool
    
    let transitionQueue: DispatchQueue
    var lastTransition: DispatchGroup?
    
    public init() {
        
        self.delegate = nil
        self.viewControllers = []
//        self.interactivePopGestureRecognizer = nil
        self.isSuppressingDelegateCallbacks = false
        self.transitionQueue = DispatchQueue(label: "TransitionQueue")
        self.lastTransition = nil
        super.init(nibName: nil, bundle: nil)
    }
    
    public convenience init(rootViewController: UIViewController) {
        
        self.init()
        self.suppressDelegateCallbacks {
            
            self.setViewControllers([rootViewController], animated: false)
        }
    }

    required public init?(coder aDecoder: NSCoder) {

        self.delegate = aDecoder
            .decodeObject(forKey: "delegate") as? Delegate
        self.viewControllers = aDecoder
            .decodeObject(forKey: "viewControllers") as? [UIViewController] ?? []
//        self.interactivePopGestureRecognizer = aDecoder
//            .decodeObject(forKey: "interactivePopGestureRecognizer") as? UIGestureRecognizer
        self.isSuppressingDelegateCallbacks = aDecoder
            .decodeBool(forKey: "isSuppressingDelegateCallbacks")
        self.transitionQueue = aDecoder
            .decodeObject(forKey: "transitionQueue") as? DispatchQueue ?? DispatchQueue(label: "TransitionQueue")
        self.lastTransition = aDecoder
            .decodeObject(forKey: "lastTransition") as? DispatchGroup
        super.init(coder: aDecoder)
    }
    
    open override func encode(with aCoder: NSCoder) {
        
        aCoder.encode(self.delegate, forKey: "delegate")
        aCoder.encode(self.viewControllers, forKey: "viewControllers")
//        aCoder.encode(self.interactivePopGestureRecognizer, forKey: "interactivePopGestureRecognizer")
        aCoder.encode(self.isSuppressingDelegateCallbacks, forKey: "isSuppressingDelegateCallbacks")
        // Mimic `init` phase, which initializes super properties last.
        super.encode(with: aCoder)
    }
}

extension ContainerViewController {
    
    override open func viewDidLoad() {
        
        super.viewDidLoad()
        
        self.view.layer.masksToBounds = true
    }
    
    open override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) {
        
        super.willTransition(to: newCollection, with: coordinator)
        
        if self.lastTransition == nil {
            
            self.lastTransition = DispatchGroup()
        }
        
        let transition = self.lastTransition
        transition?.enter()
        
        coordinator.animate(alongsideTransition: nil) {
            _ in
            transition?.leave()
        }
    }
}

// Internal functions
extension ContainerViewController {
    
    func suppressDelegateCallbacks(_ closure: () -> Void) {
        self.isSuppressingDelegateCallbacks = true
        closure()
        self.isSuppressingDelegateCallbacks = false
    }
    
    func notifyDelegate(_ closure: (Delegate?) -> Void) {
        
        if !self.isSuppressingDelegateCallbacks {
            
            closure(self.delegate)
        }
    }
    
    func perform(_ operation: Operation,
                 from oldViewController: UIViewController?,
                 to newViewController: UIViewController,
                 animated: Bool,
                 completion: @escaping () -> Void) {
        
        self.transitionQueue.async {
            // Wait until the last transition is done
            self.lastTransition?.wait()
            // Prepare a new transition
            let transition = DispatchGroup()
            // Safe the group so the next transition will wait
            self.lastTransition = transition
            // Start the work
            transition.enter()
            // Put the UI work back onto the main queue
            DispatchQueue.main.async {
                
                let animationController: UIViewControllerAnimatedTransitioning
                    
                if let oldViewController = oldViewController {
                    
                    animationController = self.delegate?
                        .containerViewController?(self,
                                                  animationControllerFor: operation,
                                                  from: oldViewController,
                                                  to: newViewController) ?? AnimationController(for: operation)
                } else {
                    
                    animationController = AnimationController(for: operation)
                }
                
                let context = AnimationContext(for: operation,
                                               containerView: self.view,
                                               from: oldViewController,
                                               to: newViewController,
                                               animated: animated)
                {
                    didComplete in
                    animationController.animationEnded?(didComplete)
                    completion()
                    // Finish the last transition
                    transition.leave()
                }
                // Start animation transition
                animationController.animateTransition(using: context)
            }
        }
    }
}

extension ContainerViewController {
    
    open var rootViewController: UIViewController? {
        
        return self.viewControllers.first
    }
    
    open var topViewController: UIViewController? {
        
        return self.viewControllers.last
    }
    
    open var visibleViewController: UIViewController? {
        
        return self.presentedViewController ?? self.topViewController
    }
}

extension ContainerViewController {

    open func push(_ viewController: UIViewController, animated: Bool = defaultAnimatedValue) {
        
        precondition(
            !self.viewControllers.contains(viewController),
            String(format: "\(self.dynamicMetatype) does already contain <\(viewController.dynamicMetatype) %p> on the stack.", viewController)
        )
        
        self.addChildViewController(viewController)
        
        guard let oldViewController = self.topViewController else {
            
            self.viewControllers.append(viewController)
            
            self.perform(.push, from: nil, to: viewController, animated: false) {
                
                viewController.didMove(toParentViewController: self)
            }
            return
        }
        
        self.notifyDelegate {
            
            $0?.containerViewController?(self, willPerform: .push, from: oldViewController, to: viewController, animated: animated)
        }
        // Alter the stack
        self.viewControllers.append(viewController)

        self.perform(.push, from: oldViewController, to: viewController, animated: animated) {
        
            self.notifyDelegate {
                
                $0?.containerViewController?(self, didPerform: .push, from: oldViewController, to: viewController, animated: animated)
            }
            viewController.didMove(toParentViewController: self)
        }
    }
    
    @discardableResult
    open func pop(animated: Bool = defaultAnimatedValue) -> UIViewController? {
        
        guard self.viewControllers.count > 1 else { return nil }
        
        let endIndex = self.viewControllers.endIndex
        let oldViewController = self.viewControllers[endIndex - 1]
        let newViewController = self.viewControllers[endIndex - 2]
        
        oldViewController.willMove(toParentViewController: nil)
        
        self.notifyDelegate {
            
            $0?.containerViewController?(self, willPerform: .pop, from: oldViewController, to: newViewController, animated: animated)
        }
        // Alter the stack
        self.viewControllers.removeLast(1)
        
        self.perform(.pop, from: oldViewController, to: newViewController, animated: animated) { 

            self.notifyDelegate {
                
                $0?.containerViewController?(self, didPerform: .pop, from: oldViewController, to: newViewController, animated: animated)
            }
            oldViewController.removeFromParentViewController()
        }
        return oldViewController
    }
    
    @discardableResult
    open func pop(to viewController: UIViewController, animated: Bool = defaultAnimatedValue) -> [UIViewController]? {
        // Check if the stack contains the view controller and extract its position, otherwise throw an error
        guard let position = self.viewControllers.index(of: viewController) else {
            
            fatalError(String(format: "\(self.dynamicMetatype) does not contain <\(viewController.dynamicMetatype) %p> on the stack.", viewController))
        }
        // Get the top view controller from the stack
        let endIndex = self.viewControllers.endIndex
        let oldViewController = self.viewControllers[endIndex - 1]
        // Don't do anthing if the controllers are the same. For instance the stack contains only the 
        // root view controller and the `popToRootViewController(animated:)` method is called.
        if oldViewController === viewController { return nil }
        // Get the view controllers that we want to drop from the stack.
        let resultArray = Array(self.viewControllers.dropFirst(position + 1))
        // Notify all these controllers in the right order that they will be removed.
        resultArray.reversed()
            .forEach { $0.willMove(toParentViewController: nil) }
        // Notify the delegate of the `pop` transition.
        self.notifyDelegate {
            // Check if the delegate implements the optional method
            $0?.containerViewController?(self, willPerform: .pop, from: oldViewController, to: viewController, animated: animated)
        }
        // Alter the stack
        self.viewControllers = Array(self.viewControllers.dropLast(resultArray.count))
        // Perform the transition
        self.perform(.pop, from: oldViewController, to: viewController, animated: animated) {
            // Notify the delegate that the transition has ended
            self.notifyDelegate {
                // Check if the delegate implements the optional method
                $0?.containerViewController?(self, didPerform: .pop, from: oldViewController, to: viewController, animated: animated)
            }
            // Remove all
            resultArray.reversed()
                .forEach { $0.removeFromParentViewController() }
        }
        return resultArray
    }
    
    @discardableResult
    open func popToRootViewController(animated: Bool = defaultAnimatedValue) -> [UIViewController]? {
        
        if let rootViewController = self.rootViewController {
            
            return self.pop(to: rootViewController, animated: animated)
        }
        return nil
    }
    
    open func setViewControllers(_ viewControllers: [UIViewController], animated: Bool = defaultAnimatedValue) {
        // Ignore an empty new stack
        if let newTopViewController = viewControllers.last {
            // Copy the current stack
            let currentStack = self.viewControllers
            // Get the top view controller from the stack
            let oldViewController = currentStack.last
            // Reverse the current stack for correct ordered notifications
            let filteredOldStack = currentStack.reversed()
                // Remove any view controller that is also inside the new stack, because we don't 
                // need to notify these with any view controller relationship events.
                .filter { !viewControllers.contains($0) }
            // Also filter the new stack to prevent wrong relationship events.
            let filteredNewStack = viewControllers.filter { !currentStack.contains($0) }
            // Prepare only view controllers for the remove events that are not part of the new stack
            filteredOldStack.forEach { $0.willMove(toParentViewController: nil) }
            // Link only new view controllers to self
            filteredNewStack.forEach(self.addChildViewController)
            
            if self.viewControllers.contains(newTopViewController) && self.topViewController === newTopViewController {
                // Notify the delegate that the stack will change
                self.notifyDelegate {
                    // Check if the delegate implements the optional method
                    // Override the animated parameter
                    $0?.containerViewController?(self, stack: currentStack, willSetTo: viewControllers, animated: false)
                }
                // We don't have to change anything here, because the top view controller is already the visible one.
                // Alter the stack
                self.viewControllers = viewControllers
                // Notify the delegate that the stack did changed
                self.notifyDelegate {
                    // Check if the delegate implements the optional method
                    // Override the animated parameter
                    $0?.containerViewController?(self, stack: viewControllers, didSetFrom: currentStack, animated: false)
                }
                // Remove only distinct view controllers
                filteredOldStack.forEach { $0.removeFromParentViewController() }
                // Notify only new linked view controllers
                filteredNewStack.forEach { $0.didMove(toParentViewController: self) }
                
            } else {
                // Check if the transition should perform a pop animation
                let shouldPop = currentStack.contains(newTopViewController) && !(currentStack.last === newTopViewController)
                // Override animated
                let animated = oldViewController == nil ? false : animated
                // Notify the delegate that the stack will change
                self.notifyDelegate {
                    // Check if the delegate implements the optional method
                    $0?.containerViewController?(self, stack: currentStack, willSetTo: viewControllers, animated: animated)
                }
                // Alter the stack
                self.viewControllers = viewControllers
                // Perform the transition
                self.perform(shouldPop ? .pop : .push, from: oldViewController, to: newTopViewController, animated: animated) {
                    // Notify the delegate that the stack will change
                    self.notifyDelegate {
                        // Check if the delegate implements the optional method
                        $0?.containerViewController?(self, stack: viewControllers, didSetFrom: currentStack, animated: animated)
                    }
                    // Remove only distinct view controllers
                    filteredOldStack.forEach { $0.removeFromParentViewController() }
                    // Notify only new linked view controllers
                    filteredNewStack.forEach { $0.didMove(toParentViewController: self) }
                }
            }
        }
    }
}

extension ContainerViewController {

    open override func show(_ viewController: UIViewController, sender: Any?) {
        
        self.push(viewController, animated: UIView.areAnimationsEnabled)
    }
    
    open override func showDetailViewController(_ viewController: UIViewController, sender: Any?) {
        
        self.show(viewController, sender: sender)
    }
}

extension ContainerViewController {
    
    @objc public enum Operation : Int {
        
        case push
        case pop
    }
}

UIViewController extension

extension UIViewController {
    
    open var containerViewController: ContainerViewController? {
        
        var viewController = self.parent
        while let parent = viewController {
            
            if let containerViewController = parent as? ContainerViewController {
                
                return containerViewController
            }
            viewController = parent.parent
        }
        return nil
    }
}

Internal AnimationController

class AnimationController : NSObject, UIViewControllerAnimatedTransitioning {
    
    let operation: ContainerViewController.Operation
    let layoutGuide: UILayoutGuide
    
    init(for operation: ContainerViewController.Operation) {
        
        self.operation = operation
        self.layoutGuide = UILayoutGuide()
        super.init()
    }
    
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        
        return 0.3
    }
    
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        
        let containerView = transitionContext.containerView
        
        containerView.removeLayoutGuide(self.layoutGuide)
        containerView.addLayoutGuide(self.layoutGuide)
        
        NSLayoutConstraint.activate([
            self.layoutGuide.topAnchor.constraint(equalTo: containerView.topAnchor),
            self.layoutGuide.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
            self.layoutGuide.widthAnchor.constraint(equalTo: containerView.widthAnchor, multiplier: 2.0),
            self.layoutGuide.centerXAnchor.constraint(equalTo: containerView.centerXAnchor)
        ])
        
        let fromView = transitionContext.view(forKey: .from)
        let toView = transitionContext.view(forKey: .to)
        
        // Prepare subview order
        var viewArray = [fromView, toView].flatMap { $0 }
        
        if self.operation == .pop { viewArray.reverse() }
        //
        viewArray.forEach {
            
            // That should remove all constraints assigned to `$0`
            $0.removeFromSuperview()
            $0.translatesAutoresizingMaskIntoConstraints = false
            containerView.addSubview($0)
            
            NSLayoutConstraint.activate([
                $0.topAnchor.constraint(equalTo: containerView.topAnchor),
                $0.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
                $0.widthAnchor.constraint(equalTo: containerView.widthAnchor)
            ])
        }
        
        let fromViewConstraint = fromView?.centerXAnchor.constraint(equalTo: self.layoutGuide.centerXAnchor)
        let toViewConstraint: NSLayoutConstraint?
        
        if self.operation == .push {
            
            toViewConstraint = toView?.centerXAnchor.constraint(equalTo: self.layoutGuide.trailingAnchor)
            
            let fromViewEndConstraint = fromView?.leadingAnchor.constraint(equalTo: self.layoutGuide.leadingAnchor)
            let toViewEndConstraint = toView?.centerXAnchor.constraint(equalTo: self.layoutGuide.centerXAnchor)
            
            [fromViewEndConstraint, toViewEndConstraint]
                .flatMap { $0 }
                .forEach {
                    $0.priority = 700
                    $0.isActive = true
            }
            
        } else {
            
            toViewConstraint = toView?.leadingAnchor.constraint(equalTo: self.layoutGuide.leadingAnchor)
            
            let fromViewEndConstraint = fromView?.centerXAnchor.constraint(equalTo: self.layoutGuide.trailingAnchor)
            let toViewEndConstraint = toView?.centerXAnchor.constraint(equalTo: self.layoutGuide.centerXAnchor)
            
            [fromViewEndConstraint, toViewEndConstraint]
                .flatMap { $0 }
                .forEach {
                    $0.priority = 700
                    $0.isActive = true
            }
        }
        
        let constraintsArray = [toViewConstraint, fromViewConstraint].flatMap { $0 }
        
        NSLayoutConstraint.activate(constraintsArray)
        containerView.layoutIfNeeded()
        
        let positionClosure = {
            
            NSLayoutConstraint.deactivate(constraintsArray)
            containerView.layoutIfNeeded()
        }
        
        if transitionContext.isAnimated {
            
            UIView.animate(withDuration: self.transitionDuration(using: transitionContext),
                           delay: 0,
                           options: [.overrideInheritedDuration,
                                     .overrideInheritedCurve,
                                     .curveEaseOut],
                           animations:
            {
                positionClosure()
                    
            }, completion: {
                
                _ in
                fromView?.removeFromSuperview()
                transitionContext.completeTransition(true)
            })
            
        } else {
            
            UIView.performWithoutAnimation {
                
                positionClosure()
                fromView?.removeFromSuperview()
                transitionContext.completeTransition(true)
            }
        }
    }
}

Internal AnimationContext

class AnimationContext : NSObject, UIViewControllerContextTransitioning {
    
    let operation: ContainerViewController.Operation
    let containerView: UIView
    let viewControllers: [UITransitionContextViewControllerKey: UIViewController]
    let isAnimated: Bool
    let completion: (Bool) -> Void

    init(for operation: ContainerViewController.Operation,
         containerView: UIView,
         from fromViewController: UIViewController?,
         to toViewController: UIViewController,
         animated: Bool,
         completion: @escaping (Bool) -> Void) {
        
        var viewControllers: [UITransitionContextViewControllerKey: UIViewController] = [.to: toViewController]
        viewControllers[.from] = fromViewController
        
        self.operation = operation
        self.containerView = containerView
        self.viewControllers = viewControllers
        self.isAnimated = animated
        self.completion = completion
    }

    var presentationStyle: UIModalPresentationStyle { return .custom }
    var targetTransform: CGAffineTransform { return .identity }
    
    func completeTransition(_ didComplete: Bool) {
        
        self.completion(didComplete)
    }
    
    func viewController(forKey key: UITransitionContextViewControllerKey) -> UIViewController? {
        
        return self.viewControllers[key]
    }

    func view(forKey key: UITransitionContextViewKey) -> UIView? {
        
        return self.viewControllers[key == .to ? .to : .from]?.view
    }

    func initialFrame(for vc: UIViewController) -> CGRect {
        
        let frame = self.containerView.bounds
        
        if !self.isAnimated { return frame }
        
        if self.viewControllers[.to] === vc {
            
            switch self.operation {
                
            case .push:
                return frame.offsetBy(dx: frame.width, dy: 0)
                
            case .pop:
                return frame.offsetBy(dx: -(frame.width / 4.0), dy: 0)
            }
        
        } else { return frame }
    }

    func finalFrame(for vc: UIViewController) -> CGRect {
        
        let frame = self.containerView.bounds
        
        if !self.isAnimated { return frame }
        
        if self.viewControllers[.from] === vc {
            
            switch self.operation {
                
            case .pop:
                return frame.offsetBy(dx: frame.width, dy: 0)
                
            case .push:
                return frame.offsetBy(dx: -(frame.width / 4.0), dy: 0)
            }
            
        } else { return frame }
    }
    
    // TODO
    var isInteractive: Bool { return false }
    var transitionWasCancelled: Bool { return false }
    
    // TODO
    func updateInteractiveTransition(_ percentComplete: CGFloat) {}
    func finishInteractiveTransition() {}
    func cancelInteractiveTransition() {}
    func pauseInteractiveTransition() {}
}

MIT License

Copyright (c) 2017 Adrian Zubarev (alias DevAndArtist) adrian.zubarev@devandartist.com

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment