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.
- Mimic
UINavigationController
without any bar APIs - More flexible delegate methods
- Simple non-interactive transition
- Interactive transition
import UIKit
protocol MetatypeExtractor {}
extension MetatypeExtractor {
var dynamicMetatype: Self.Type {
return type(of: self)
}
}
extension NSObject : MetatypeExtractor {}
@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?
}
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
}
}
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
}
}
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)
}
}
}
}
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() {}
}
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.