Skip to content

Instantly share code, notes, and snippets.

@bnickel
Created April 12, 2019 19:55
Show Gist options
  • Save bnickel/794547f2fb7857ce042a4091767ad680 to your computer and use it in GitHub Desktop.
Save bnickel/794547f2fb7857ce042a4091767ad680 to your computer and use it in GitHub Desktop.
A three panel view controller used by Stack Exchange.app (currently unmaintained). On a wide iPad, this would allow a collapsed sliver menu, a left navigation panel and a right detail panel to live side-by-side. As width shrunk, the delegate would let you combine the left and right panel, as well as hide the sliver menu completely (accessible by…
//
// SEUIChildManagingViewController.swift
// ThreePanelSplitViewController
//
// Created by Brian Nickel on 6/21/17.
// Copyright © 2017 Brian Nickel. All rights reserved.
//
import UIKit
public enum AppearanceState {
case disappeared, appeared
case appearing(Bool)
case disappearing(Bool)
public var isAnimating: Bool {
switch self {
case .disappeared, .appeared:
return false
case .appearing(let animating):
return animating
case .disappearing(let animating):
return animating
}
}
}
open class SEUIChildManagingViewController: UIViewController {
private(set) public var childViewControllerContainers: [SEUIChildViewControllerContainer] = []
public func add(_ childViewControllerContainer: SEUIChildViewControllerContainer, to superview: UIView) {
childViewControllerContainers.append(childViewControllerContainer)
superview.addSubview(childViewControllerContainer.containerView)
childViewControllerContainer.parentTransitioned(to: appearanceState)
}
private(set) public var appearanceState: AppearanceState = .disappeared {
didSet {
for container in childViewControllerContainers {
container.parentTransitioned(to: appearanceState)
}
}
}
open override var shouldAutomaticallyForwardAppearanceMethods: Bool {
return false
}
open override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
appearanceState = .appearing(animated)
}
open override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
appearanceState = .appeared
}
open override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
appearanceState = .disappearing(animated)
}
open override func viewDidDisappear(_ animated: Bool) {
appearanceState = .disappeared
}
}
//
// SEUIChildViewControllerContainer.swift
// ThreePanelSplitViewController
//
// Created by Brian Nickel on 6/21/17.
// Copyright © 2017 Brian Nickel. All rights reserved.
//
import UIKit
public class SEUIChildViewControllerContainer {
public init(initialVisibility: Bool) {
childState = initialVisibility ? .appeared : .disappeared
containerView.isHidden = !initialVisibility
}
public let containerView = UIView(frame: CGRect(x: 0, y: 0, width: 300, height: 300))
private(set) public var viewController: UIViewController? = nil
private var parentState: AppearanceState = .disappeared
private var childState: AppearanceState = .disappeared
private(set) var state: AppearanceState = .disappeared
public func replace(_ newViewController: UIViewController?, parent: UIViewController) {
guard newViewController != viewController else { return }
if let oldViewController = viewController {
oldViewController.willMove(toParentViewController: nil)
transition(oldViewController, from: state, to: .disappeared)
oldViewController.view.removeFromSuperview()
oldViewController.removeFromParentViewController()
}
state = .disappeared
viewController = newViewController
if let newViewController = newViewController {
parent.addChildViewController(newViewController)
newViewController.view.frame = containerView.bounds
containerView.addSubview(newViewController.view)
newViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
updateState()
}
}
public func startShowing(_ animated: Bool) {
if case .appeared = childState { return }
childState = .appearing(animated)
updateState()
containerView.isHidden = false
}
public func finishShowing() {
childState = .appeared
updateState()
containerView.isHidden = false
}
public func startHiding(_ animated: Bool) {
if case .disappeared = childState { return }
childState = .disappearing(animated)
updateState()
}
public func finishHiding() {
childState = .disappeared
updateState()
containerView.isHidden = true
}
public var isVisibleOrAppearing: Bool {
switch childState {
case .appeared, .appearing:
return true
case .disappeared, .disappearing:
return false
}
}
public func parentTransitioned(to parentState: AppearanceState) {
self.parentState = parentState
updateState()
}
private func updateState() {
guard let viewController = viewController else {
state = .disappeared
// Nothing to animate.
return
}
let oldState = state
switch (parentState, childState) {
case (_, .disappeared), (.disappeared, _):
state = .disappeared
case (.appeared, let x):
state = x
case (let x, .appeared):
state = x
case (.appearing(let a), .appearing(let b)):
state = .appearing(a || b)
case (.disappearing(let a), .disappearing(let b)):
state = .disappearing(a && b)
case (.disappearing(let a), .appearing(let b)):
state = .disappearing(a && b)
case (.appearing(let a), .disappearing(let b)):
state = .disappearing(a && b)
default:
assertionFailure("That's so weird.")
return
}
transition(viewController, from: oldState, to: state)
}
private func transition(_ viewController: UIViewController, from: AppearanceState, to: AppearanceState) {
switch (from, to) {
case (.appearing, .appearing), (.disappearing, .disappearing), (.disappeared, .disappeared), (.appeared, .appeared):
/* Don't record an event if nothing changed. We don't really care about changes from animated <-> unanimated either. */
break
case (_, .appearing(let animated)):
viewController.beginAppearanceTransition(true, animated: animated)
case (_, .disappearing(let animated)):
viewController.beginAppearanceTransition(false, animated: animated)
case (.disappeared, .appeared):
viewController.beginAppearanceTransition(true, animated: false)
viewController.endAppearanceTransition()
case (.appeared, .disappeared):
viewController.beginAppearanceTransition(false, animated: false)
viewController.endAppearanceTransition()
case (.appearing, .appeared):
viewController.endAppearanceTransition()
case (.appearing(let animated), .disappeared):
viewController.beginAppearanceTransition(false, animated: animated)
viewController.endAppearanceTransition()
case (.disappearing, .disappeared):
viewController.endAppearanceTransition()
case (.disappearing(let animated), .appeared):
viewController.beginAppearanceTransition(true, animated: animated)
viewController.endAppearanceTransition()
}
}
}
//
// ThreePanelSplitViewController.swift
// ThreePanelSplitViewController
//
// Created by Brian Nickel on 6/20/17.
// Copyright © 2017 Brian Nickel. All rights reserved.
//
import UIKit
@objc(SEUIThreePanelSplitViewControllerDelegate)
public protocol ThreePanelSplitViewControllerDelegate: class, UIStateRestoring {
func threePanelSplitViewController(_ threePanelSplitViewController: ThreePanelSplitViewController, willExpandTo width: CGFloat)
func threePanelSplitViewController(_ threePanelSplitViewController: ThreePanelSplitViewController, didExpandTo width: CGFloat)
func threePanelSplitViewController(_ threePanelSplitViewController: ThreePanelSplitViewController, didCollapseTo width: CGFloat)
func threePanelSplitViewController(_ threePanelSplitViewController: ThreePanelSplitViewController, willCollapseTo width: CGFloat)
}
@objc(SEUIThreePanelSplitViewController)
open class ThreePanelSplitViewController: SEUIChildManagingViewController {
public var collapsedMenuWidth: CGFloat = 70 { didSet { if isViewLoaded { view.setNeedsLayout() } } }
public var expandedMenuWidth: CGFloat = 300 { didSet { if isViewLoaded { view.setNeedsLayout() } } }
public var masterViewControllerMinimumWidth: CGFloat = 300 { didSet { if isViewLoaded { view.setNeedsLayout() } } }
public var detailViewControllerMinimumWidth: CGFloat = 600 { didSet { if isViewLoaded { view.setNeedsLayout() } } }
public var shouldSplitIfSpaceIsAvailable: Bool = true { didSet { if isViewLoaded { view.setNeedsLayout() } } }
public var statusBarShouldMatchMenuWhenAlwaysVisible: Bool = true { didSet { setNeedsStatusBarAppearanceUpdate() } }
fileprivate var isSeparatingDetails = false
fileprivate(set) public var isMenuAlwaysVisible: Bool = false
fileprivate var overriddenSeparatorWidth: CGFloat? = nil { didSet { if isViewLoaded { view.setNeedsLayout() } } }
public var separatorWidth: CGFloat {
get { return overriddenSeparatorWidth ?? ( 1 / UIScreen.main.scale) }
set { overriddenSeparatorWidth = newValue }
}
public weak var delegate: ThreePanelSplitViewControllerDelegate? {
didSet {
if let delegate = delegate {
let menuWidth = self.menuWidth(for: view.frame.size, isOpen: menuState.isOpen)
if menuState.isOpen {
delegate.threePanelSplitViewController(self, willExpandTo: menuWidth)
delegate.threePanelSplitViewController(self, didExpandTo: menuWidth)
} else {
delegate.threePanelSplitViewController(self, willCollapseTo: menuWidth)
delegate.threePanelSplitViewController(self, didCollapseTo: menuWidth)
}
}
}
}
fileprivate var menu = SEUIChildViewControllerContainer(initialVisibility: false)
fileprivate var master = SEUIChildViewControllerContainer(initialVisibility: true)
fileprivate var detail = SEUIChildViewControllerContainer(initialVisibility: false)
fileprivate var nonMenuView = UIView()
fileprivate var statusBarOverlayView = UIView()
fileprivate struct MenuState { var isOpen: Bool; var openFraction: CGFloat; var panIsOpen: Bool }
fileprivate var menuState = MenuState(isOpen: false, openFraction: 0, panIsOpen: false)
fileprivate var isTransitioningSize = false
fileprivate var isTransitioningMenu = false
fileprivate var menuVisibilityPanGestureRecognizer: UIPanGestureRecognizer!
fileprivate var menuExpandingScreenEdgePanGestureRecognizer: UIScreenEdgePanGestureRecognizer!
fileprivate var menuCollapingTapGestureRecognizer: UITapGestureRecognizer!
fileprivate var loadedPlaceholderViewController: UIViewController?
}
// MARK: - Restoration
extension ThreePanelSplitViewController {
open override func encodeRestorableState(with coder: NSCoder) {
super.encodeRestorableState(with: coder)
coder.encode(Double(collapsedMenuWidth), forKey: "collapsedMenuWidth")
coder.encode(Double(expandedMenuWidth), forKey: "expandedMenuWidth")
coder.encode(Double(masterViewControllerMinimumWidth), forKey: "masterViewControllerMinimumWidth")
coder.encode(Double(detailViewControllerMinimumWidth), forKey: "detailViewControllerMinimumWidth")
coder.encode(shouldSplitIfSpaceIsAvailable, forKey: "shouldSplitIfSpaceIsAvailable")
coder.encode(statusBarShouldMatchMenuWhenAlwaysVisible, forKey: "statusBarShouldMatchMenuWhenAlwaysVisible")
if let overriddenSeparatorWidth = overriddenSeparatorWidth {
coder.encode(Double(overriddenSeparatorWidth), forKey: "overriddenSeparatorWidth")
}
coder.encode(delegate, forKey: "delegate")
coder.encode(masterViewController, forKey: "masterViewController")
coder.encode(detailViewController, forKey: "detailViewController")
coder.encode(menuViewController, forKey: "menuViewController")
coder.encode(menuState.isOpen, forKey: "menuState.isOpen")
coder.encode(!menu.containerView.isHidden, forKey: "menu.appeared")
coder.encode(!detail.containerView.isHidden, forKey: "detail.appeared")
}
open override func decodeRestorableState(with coder: NSCoder) {
super.decodeRestorableState(with: coder)
collapsedMenuWidth = CGFloat(coder.decodeDouble(forKey: "collapsedMenuWidth"))
expandedMenuWidth = CGFloat(coder.decodeDouble(forKey: "expandedMenuWidth"))
masterViewControllerMinimumWidth = CGFloat(coder.decodeDouble(forKey: "masterViewControllerMinimumWidth"))
detailViewControllerMinimumWidth = CGFloat(coder.decodeDouble(forKey: "detailViewControllerMinimumWidth"))
shouldSplitIfSpaceIsAvailable = coder.decodeBool(forKey: "shouldSplitIfSpaceIsAvailable")
if let value = coder.decodeObject(forKey: "overriddenSeparatorWidth") as? Double {
overriddenSeparatorWidth = CGFloat(value)
}
delegate = coder.decodeObject(forKey: "delegate") as? ThreePanelSplitViewControllerDelegate
masterViewController = coder.decodeObject(forKey: "masterViewController") as? UIViewController
detailViewController = coder.decodeObject(forKey: "detailViewController") as? UIViewController
menuViewController = coder.decodeObject(forKey: "menuViewController") as? UIViewController
if coder.decodeBool(forKey: "menuState.isOpen") {
menuState = MenuState(isOpen: true, openFraction: 1, panIsOpen: true)
}
if coder.decodeBool(forKey: "menu.appeared") {
menu.finishShowing()
}
if coder.decodeBool(forKey: "detail.appeared") {
detail.finishShowing()
}
}
open override func applicationFinishedRestoringState() {
super.applicationFinishedRestoringState()
menuExpandCollapseCompleted()
}
}
// MARK: - Public Interface
extension ThreePanelSplitViewController {
open var masterViewController: UIViewController? {
get { return master.viewController }
set {
master.replace(newValue, parent: self)
setNeedsStatusBarAppearanceUpdate()
}
}
open var detailViewController: UIViewController? {
get { return detail.viewController }
set { detail.replace(newValue, parent: self) }
}
open var menuViewController: UIViewController? {
get { return menu.viewController }
set {
menu.replace(newValue, parent: self)
if isViewLoaded { view.setNeedsLayout() }
statusBarOverlayView.backgroundColor = newValue?.view.backgroundColor
setNeedsStatusBarAppearanceUpdate()
}
}
}
// MARK: - Status Bar
extension ThreePanelSplitViewController {
private var statusBarViewController: UIViewController? {
if statusBarShouldMatchMenuWhenAlwaysVisible && isMenuAlwaysVisible, let menuViewController = menuViewController {
return menuViewController
} else {
return masterViewController
}
}
open override var childViewControllerForStatusBarStyle: UIViewController? {
return statusBarViewController
}
open override var childViewControllerForStatusBarHidden: UIViewController? {
return statusBarViewController
}
}
// MARK: - Layout
extension ThreePanelSplitViewController {
open override func viewDidLoad() {
super.viewDidLoad()
nonMenuView.backgroundColor = #colorLiteral(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 1)
add(menu, to: view)
view.addSubview(nonMenuView)
add(master, to: nonMenuView)
add(detail, to: nonMenuView)
view.addSubview(statusBarOverlayView)
statusBarOverlayView.isHidden = true
menuVisibilityPanGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.handlePan(_:)))
menuVisibilityPanGestureRecognizer.delegate = self
view.addGestureRecognizer(menuVisibilityPanGestureRecognizer)
menuExpandingScreenEdgePanGestureRecognizer = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(self.handlePan(_:)))
menuExpandingScreenEdgePanGestureRecognizer.edges = .left
view.addGestureRecognizer(menuExpandingScreenEdgePanGestureRecognizer)
menuCollapingTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.handleTap(_:)))
menuCollapingTapGestureRecognizer.require(toFail: menuVisibilityPanGestureRecognizer)
menuCollapingTapGestureRecognizer.isEnabled = false
nonMenuView.addGestureRecognizer(menuCollapingTapGestureRecognizer)
}
open override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
guard !isTransitioningSize && !isTransitioningMenu else { return }
layoutSubviews()
}
fileprivate func layoutSubviews() {
let old = computedConfiguration()
let new = visualConfiguration(for: view.bounds.size)
moveViewControllersForDetailVisibilityTransition(from: old.detailVisible, to: new.detailVisible)
updateIsMenuAlwaysVisible(size: view.bounds.size)
apply(new, animationPart: false, animated: false)
}
fileprivate func updateIsMenuAlwaysVisible(size: CGSize) {
let isMenuAlwaysVisible = arrangement(forWidth: size.width).showsMenuSliver
guard self.isMenuAlwaysVisible != isMenuAlwaysVisible else { return }
self.isMenuAlwaysVisible = isMenuAlwaysVisible
if statusBarShouldMatchMenuWhenAlwaysVisible {
setNeedsStatusBarAppearanceUpdate()
}
guard !menuState.isOpen, let delegate = self.delegate else { return }
let menuWidth = self.menuWidth(for: size, isOpen: false)
delegate.threePanelSplitViewController(self, willCollapseTo: menuWidth)
delegate.threePanelSplitViewController(self, didCollapseTo: menuWidth)
}
func finishMenuTransition(animated: Bool, completion: ((Bool) -> Void)?) {
let targetFraction: CGFloat = menuState.isOpen ? 1 : 0
let duration = TimeInterval(abs(targetFraction - menuState.openFraction) * 0.25)
guard animated && duration > 0.01 else {
menuState.openFraction = targetFraction
layoutSubviews()
menuExpandCollapseCompleted()
completion?(true)
return
}
menuState.openFraction = targetFraction
let old = computedConfiguration()
let new = visualConfiguration(for: view.bounds.size)
isTransitioningMenu = true
let tintAdjustmentMode: UIViewTintAdjustmentMode = menuState.isOpen ? .dimmed : .automatic
moveViewControllersForDetailVisibilityTransition(from: old.detailVisible, to: new.detailVisible)
updateIsMenuAlwaysVisible(size: view.bounds.size)
UIView.animate(withDuration: duration, animations: { [weak self] in
self?.apply(new, animationPart: true, animated: true)
self?.nonMenuView.tintAdjustmentMode = tintAdjustmentMode
}, completion: { [weak self] finished in
self?.apply(new, animationPart: false, animated: true)
self?.isTransitioningMenu = false
self?.menuExpandCollapseCompleted()
completion?(finished)
})
}
open override func size(forChildContentContainer container: UIContentContainer, withParentContainerSize parentSize: CGSize) -> CGSize {
if master.viewController === container {
return visualConfiguration(for: parentSize).masterFrame.size
} else if menu.viewController === container {
return visualConfiguration(for: parentSize).menuFrame.size
} else if detail.viewController === container {
return visualConfiguration(for: parentSize).detailFrame.size
} else {
return super.size(forChildContentContainer: container, withParentContainerSize: parentSize)
}
}
open override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
let old = computedConfiguration()
let new = visualConfiguration(for: size)
if old.detailVisible == new.detailVisible {
animate(from: old, to: new, finalSize: size, with: coordinator)
} else {
fade(from: old, to: new, finalSize: size, with: coordinator)
}
}
private func fade(from old: VisualConfiguration, to new: VisualConfiguration, finalSize size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
let temporarilyHidingMaster = !old.detailVisible && new.detailVisible
isTransitioningSize = true
let snapshotView = coordinator.isAnimated
? UIDevice.current.isSimulator
? view.snapshotImageView()
: view.resizableSnapshotView(from: view.bounds, afterScreenUpdates: false, withCapInsets: .zero)
: nil
moveViewControllersForDetailVisibilityTransition(from: old.detailVisible, to: new.detailVisible)
updateIsMenuAlwaysVisible(size: size)
apply(new, animationPart: true, animated: coordinator.isAnimated)
view.setNeedsLayout()
view.layoutIfNeeded()
if let snapshotView = snapshotView {
snapshotView.frame = view.bounds
view.addSubview(snapshotView)
}
if temporarilyHidingMaster {
master.finishHiding()
master.startShowing(coordinator.isAnimated)
}
coordinator.animate(alongsideTransition: { context in
snapshotView?.alpha = 0
snapshotView?.frame = self.view.bounds
}, completion: { context in
snapshotView?.removeFromSuperview()
self.apply(context.isCancelled ? old : new, animationPart: false, animated: context.isAnimated)
if context.isCancelled {
self.moveViewControllersForDetailVisibilityTransition(from: new.detailVisible, to: old.detailVisible)
self.updateIsMenuAlwaysVisible(size: self.view.bounds.size)
}
self.master.finishShowing()
self.isTransitioningSize = false
})
}
private func animate(from old: VisualConfiguration, to new: VisualConfiguration, finalSize size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
assert(old.detailVisible == new.detailVisible)
isTransitioningSize = true
updateIsMenuAlwaysVisible(size: size)
coordinator.animate(alongsideTransition: { context in
self.apply(new, animationPart: true, animated: context.isAnimated)
self.view.setNeedsLayout()
self.view.layoutIfNeeded()
}, completion: { context in
self.apply(context.isCancelled ? old : new, animationPart: false, animated: context.isAnimated)
if context.isCancelled {
self.moveViewControllersForDetailVisibilityTransition(from: new.detailVisible, to: old.detailVisible)
self.updateIsMenuAlwaysVisible(size: self.view.bounds.size)
}
self.isTransitioningSize = false
})
}
struct Arrangement {
var showsMenuSliver: Bool
var showsDetail: Bool
}
private struct VisualConfiguration {
var menuFrame: CGRect
var nonMenuFrame: CGRect
var masterFrame: CGRect
var detailFrame: CGRect
var statusBarFrame: CGRect
var menuVisible: Bool
var detailVisible: Bool
var statusBarOverlayVisible: Bool
}
fileprivate func arrangement(forWidth width: CGFloat) -> Arrangement {
if menu.viewController != nil && width >= collapsedMenuWidth + masterViewControllerMinimumWidth + detailViewControllerMinimumWidth {
return Arrangement(showsMenuSliver: true, showsDetail: shouldSplitIfSpaceIsAvailable)
} else if width >= masterViewControllerMinimumWidth + detailViewControllerMinimumWidth {
return Arrangement(showsMenuSliver: false, showsDetail: true)
} else {
return Arrangement(showsMenuSliver: false, showsDetail: false)
}
}
fileprivate func menuWidth(for size: CGSize, isOpen: Bool) -> CGFloat {
if isOpen {
return expandedMenuWidth
} else {
let arrangement = self.arrangement(forWidth: size.width)
return arrangement.showsMenuSliver ? collapsedMenuWidth : 0
}
}
private func visualConfiguration(for size: CGSize) -> VisualConfiguration {
let arrangement = self.arrangement(forWidth: size.width)
let menuFrame = CGRect(x: 0, y: 0, width: expandedMenuWidth, height: size.height)
let menuAlwaysVisible = arrangement.showsMenuSliver
let detailVisible = arrangement.showsDetail
let nonMenuLeftInset = menuWidth(for: size, isOpen: false)
let nonMenuLeftOffset = menuState.openFraction.clamped(to: 0...1) * (menuWidth(for: size, isOpen: true) - nonMenuLeftInset)
let nonMenuFrame = CGRect(x: nonMenuLeftInset + nonMenuLeftOffset, y: 0, width: size.width - nonMenuLeftInset, height: size.height)
let masterFrame: CGRect
let detailFrame: CGRect
if detailVisible {
let detailLeftInset = masterViewControllerMinimumWidth + separatorWidth
masterFrame = CGRect(x: 0, y: 0, width: masterViewControllerMinimumWidth, height: nonMenuFrame.height)
detailFrame = CGRect(x: detailLeftInset, y: 0, width: nonMenuFrame.width - detailLeftInset, height: nonMenuFrame.height)
} else {
masterFrame = CGRect(origin: .zero, size: nonMenuFrame.size)
detailFrame = masterFrame
}
let menuVisible = menuAlwaysVisible || menuState.openFraction > 0
var statusBarInset: CGFloat
if UIApplication.shared.isStatusBarHidden {
statusBarInset = 0
} else {
statusBarInset = UIApplication.shared.statusBarFrame.height
}
let statusBarFrame = CGRect(x: 0, y: 0, width: size.width, height: statusBarInset)
let statusBarOverlayVisible = arrangement.showsMenuSliver && statusBarShouldMatchMenuWhenAlwaysVisible
return VisualConfiguration(menuFrame: menuFrame,
nonMenuFrame: nonMenuFrame,
masterFrame: masterFrame,
detailFrame: detailFrame,
statusBarFrame: statusBarFrame,
menuVisible: menuVisible,
detailVisible: detailVisible,
statusBarOverlayVisible: statusBarOverlayVisible)
}
private func computedConfiguration() -> VisualConfiguration {
return VisualConfiguration(menuFrame: menu.containerView.frame,
nonMenuFrame: nonMenuView.frame,
masterFrame: master.containerView.frame,
detailFrame: detail.containerView.frame,
statusBarFrame: statusBarOverlayView.frame,
menuVisible: menu.isVisibleOrAppearing,
detailVisible: detail.isVisibleOrAppearing,
statusBarOverlayVisible: !statusBarOverlayView.isHidden)
}
private func apply(_ configuration: VisualConfiguration, animationPart: Bool, animated: Bool) {
menu.containerView.frame = configuration.menuFrame
master.containerView.frame = configuration.masterFrame
detail.containerView.frame = configuration.detailFrame
nonMenuView.frame = configuration.nonMenuFrame
statusBarOverlayView.frame = configuration.statusBarFrame
statusBarOverlayView.isHidden = !configuration.statusBarOverlayVisible && !animationPart
statusBarOverlayView.alpha = configuration.statusBarOverlayVisible ? 1 : 0
func transition(_ container: SEUIChildViewControllerContainer, showing: Bool) {
switch (showing, animationPart) {
case (true, true): container.startShowing(animated)
case (true, false): container.finishShowing()
case (false, true): container.startHiding(animated)
case (false, false): container.finishHiding()
}
}
transition(menu, showing: configuration.menuVisible)
transition(detail, showing: configuration.detailVisible)
}
}
// MARK: - Gestures
extension ThreePanelSplitViewController: UIGestureRecognizerDelegate {
public var isMenuExpanded: Bool { get { return menuState.isOpen } }
public func expandMenu(animated: Bool, completion: ((Bool) -> Void)? = nil) {
guard !isMenuExpanded else { return }
menuState.isOpen = true
delegate?.threePanelSplitViewController(self, willExpandTo: menuWidth(for: view.bounds.size, isOpen: true))
finishMenuTransition(animated: animated, completion: completion)
}
public func collapseMenu(animated: Bool, completion: ((Bool) -> Void)? = nil) {
guard isMenuExpanded else { return }
menuState.isOpen = false
delegate?.threePanelSplitViewController(self, willCollapseTo: menuWidth(for: view.bounds.size, isOpen: false))
finishMenuTransition(animated: animated, completion: completion)
}
@objc fileprivate func handlePan(_ sender: UIPanGestureRecognizer) {
let openThreshold: CGFloat = 0.0
let minWidth = menuWidth(for: view.bounds.size, isOpen: false)
let maxWidth = menuWidth(for: view.bounds.size, isOpen: true)
let targetWidth = menuWidth(for: view.bounds.size, isOpen: menuState.isOpen) + sender.translation(in: view).x
let openFraction = ((targetWidth - minWidth) / (maxWidth - minWidth)).clamped(to: 0...1)
var initialState = menuState
var finished = false
switch sender.state {
case .failed, .possible:
return
case .began:
initialState.panIsOpen = initialState.isOpen
fallthrough
case .changed:
menuState.openFraction = openFraction
menuState.panIsOpen = openFraction > openThreshold
case .ended, .cancelled:
menuState.isOpen = menuState.openFraction > 0.5
menuState.panIsOpen = menuState.isOpen
finished = true
}
if menuState.panIsOpen != initialState.panIsOpen, let delegate = delegate {
if menuState.panIsOpen {
delegate.threePanelSplitViewController(self, willExpandTo: maxWidth)
} else {
delegate.threePanelSplitViewController(self, willCollapseTo: minWidth)
}
}
layoutSubviews()
if finished {
finishMenuTransition(animated: true, completion: nil)
}
}
fileprivate func menuExpandCollapseCompleted() {
if let delegate = delegate {
let targetWidth = menuWidth(for: view.bounds.size, isOpen: menuState.isOpen)
if menuState.isOpen {
delegate.threePanelSplitViewController(self, didExpandTo: targetWidth)
} else {
delegate.threePanelSplitViewController(self, didCollapseTo: targetWidth)
}
}
menuCollapingTapGestureRecognizer.isEnabled = menuState.isOpen
master.containerView.isUserInteractionEnabled = !menuState.isOpen
detail.containerView.isUserInteractionEnabled = !menuState.isOpen
nonMenuView.tintAdjustmentMode = menuState.isOpen ? .dimmed : .automatic
}
@objc fileprivate func handleTap(_ sender: UITapGestureRecognizer) {
if menuState.isOpen {
collapseMenu(animated: true)
}
}
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
guard menu.viewController != nil else { return false }
return menuState.isOpen
|| touch.location(in: nonMenuView).x < 0
|| touch.isInAny(masterViewController?.swipeableViewsForPanGesture(for: self))
|| (detail.isVisibleOrAppearing && touch.isInAny(detailViewController?.swipeableViewsForPanGesture(for: self)))
}
}
private extension UITouch {
func isInAny(_ views: [UIView]?) -> Bool {
return views?.first(where: { view in
return !view.isHidden && view.bounds.contains(location(in: view))
}) != nil
}
}
private extension CGFloat {
func clamped(to range: ClosedRange<CGFloat>) -> CGFloat {
return Swift.max(range.lowerBound, Swift.min(self, range.upperBound))
}
}
// MARK: - Adaptive detail controller
extension ThreePanelSplitViewController {
public var placeholderViewController: UIViewController {
get {
if let placeholderViewController = loadedPlaceholderViewController { return placeholderViewController }
let placeholderViewController = loadPlaceholderViewController()
loadedPlaceholderViewController = placeholderViewController
return placeholderViewController
}
set {
loadedPlaceholderViewController = newValue
}
}
open func loadPlaceholderViewController() -> UIViewController {
let viewController = UIViewController()
viewController.view.backgroundColor = .white
return wrapped(viewController)
}
private func wrapped(_ viewController: UIViewController) -> UINavigationController {
return wrapped([viewController])
}
private func wrapped(_ viewControllers: [UIViewController]) -> UINavigationController {
viewControllers.first?.isDetailViewController = true
return loadDetailOrPlaceholderNavigationController(for: viewControllers)
}
open func loadDetailOrPlaceholderNavigationController(for viewControllers: [UIViewController]) -> UINavigationController {
let navigationController = UINavigationController()
navigationController.viewControllers = viewControllers
return navigationController
}
public func addDetailViewControllerIfNeeded() {
guard detailViewController == nil && !isSeparatingDetails else { return }
if let detailViewController = masterViewController?.detailViewController(for: self) {
self.detailViewController = wrapped(detailViewController)
} else {
self.detailViewController = placeholderViewController
}
}
open var canDisposeOfDetailViewController: Bool {
return (detailViewController == nil || detailViewController == loadedPlaceholderViewController) && !isSeparatingDetails
}
fileprivate func moveViewControllersForDetailVisibilityTransition(from: Bool, to: Bool) {
guard from != to else { return }
if to {
handleDetailExpand()
} else {
handleDetailCollapse()
}
}
open override func showDetailViewController(_ vc: UIViewController, sender: Any?) {
vc.isDetailViewController = true
guard arrangement(forWidth: view.bounds.width).showsDetail else {
masterViewController?.targetNavigationController(for: self)?.show(vc, sender: sender)
return
}
if let sender = sender as? UIResponder, let detailViewController = detailViewController as? UINavigationController, detailViewController != loadedPlaceholderViewController {
if sequence(first: sender, next: { $0.next }).first(where: { $0 === detailViewController }) != nil {
detailViewController.show(vc, sender: sender)
return
}
}
detailViewController = wrapped(vc)
}
fileprivate func handleDetailExpand() {
guard let masterViewController = masterViewController else { return }
startSeparatingDetail(from: masterViewController)
}
/**
Similar to the explanation for `finishSeparatingDetail` but more subtle.
If we just pushed a view controller, moving it instantly out will wreck the source and destination
navigation bars.
We're doing the same thing here, just biding time until navigation completes.
*/
open func startSeparatingDetail(from masterViewController: UIViewController) {
isSeparatingDetails = true
if !masterViewController.shouldDelayRemovingDetailViewControllers(for: self) {
isSeparatingDetails = false
separateDetail(from: masterViewController)
} else {
// We're delaying the UI, so show something passable here.
addSeparatedDetail(nil)
DispatchQueue.main.async { [weak self] in
self?.startSeparatingDetail(from: masterViewController)
}
}
}
open func separateDetail(from masterViewController: UIViewController) {
let extractedViewControllers = masterViewController.removeDetailViewControllers(for: self)
if extractedViewControllers.count > 0 {
finishSeparatingDetail(extractedViewControllers)
} else if UIApplication.shared.applicationState != .background, let detailViewController = masterViewController.detailViewController(for: self) {
// When the application transitions to background, the OS may perform rotation events to capture snapshots. In this case, we do not want to insert new detail view controllers. Otherwise, users may return to a screen they were not expecting.
addSeparatedDetail(wrapped(detailViewController))
} else {
addSeparatedDetail(nil)
}
}
private func canAdd(_ viewControllers: [UIViewController], to: UIViewController? = nil) -> Bool {
for viewController in viewControllers where !viewController.canBeAdded(to: to) {
return false
}
return true
}
/**
`UINavigationController.viewControllers = ...` is not always an atomic action.
During size transitions, a flag is set which forces views out of the view hierarchy during the call,
but during `layoutIfNeeded` and `applicationFinishedRestoringState`, the views stick around momentarily.
This leads to validation exception in `_addChildViewController:performHierarchyCheck:notifyWillMove:`
when adding the view to the new view controller.
To avoid this exception, we first check if any of our views are in a parent view controller. If not,
we add right away. If so, we wait one cycle of the main loop and try again. In practice, I've not seen
this take more than once cycle, but I guess it could.
*/
private func finishSeparatingDetail(_ viewControllers: [UIViewController]) {
isSeparatingDetails = true
if canAdd(viewControllers) {
addSeparatedDetail(wrapped(viewControllers))
isSeparatingDetails = false
} else {
DDLogInfo("Delaying separating of view controllers")
// We're delaying the UI, so show something passable here.
addSeparatedDetail(nil)
DispatchQueue.main.async { [weak self] in
self?.finishSeparatingDetail(viewControllers)
}
}
}
private func addSeparatedDetail(_ viewController: UIViewController?) {
if let viewController = viewController {
detailViewController = viewController
} else if detailViewController == nil {
detailViewController = placeholderViewController
}
}
fileprivate func handleDetailCollapse() {
guard let masterViewController = masterViewController, let detailViewController = detailViewController else { return }
collapseDetail(detailViewController, onto: masterViewController)
self.detailViewController = nil
}
open func collapseDetail(_ detailViewController: UIViewController, onto masterViewController: UIViewController) {
guard
detailViewController != loadedPlaceholderViewController,
let masterViewController = masterViewController.targetNavigationController(for: self),
let detailViewController = detailViewController as? UINavigationController
else { return }
let viewControllers = detailViewController.viewControllers
viewControllers.first?.isDetailViewController = true
detailViewController.viewControllers = []
finishCollapsingDetail(viewControllers, into: masterViewController)
}
private func finishCollapsingDetail(_ viewControllers: [UIViewController], into masterViewController: UINavigationController) {
if canAdd(viewControllers, to: masterViewController) {
masterViewController.viewControllers += viewControllers
} else {
DDLogInfo("Delaying collapsing view controllers")
DispatchQueue.main.async { [weak self] in
self?.finishCollapsingDetail(viewControllers, into: masterViewController)
}
}
}
}
private extension UIView {
@nonobjc func snapshotImageView() -> UIImageView? {
UIGraphicsBeginImageContextWithOptions(bounds.size, isOpaque, UIScreen.main.scale)
UIGraphicsBeginImageContext(bounds.size)
guard let context = UIGraphicsGetCurrentContext() else { return nil }
layer.render(in: context)
let viewImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return UIImageView(image: viewImage, highlightedImage: viewImage)
}
}
private extension UIDevice {
var isSimulator: Bool {
return TARGET_IPHONE_SIMULATOR != 0
}
}
extension UIViewController {
public func notifyCanProvideDetailViewController() {
guard self.isNestedWithinMasterViewController,
let threePanelSplitViewController = threePanelSplitViewController,
threePanelSplitViewController.canDisposeOfDetailViewController,
threePanelSplitViewController.arrangement(forWidth: threePanelSplitViewController.view.bounds.width).showsDetail
else { return }
threePanelSplitViewController.detailViewController = nil
threePanelSplitViewController.addDetailViewControllerIfNeeded()
}
fileprivate func canBeAdded(to: UIViewController? = nil) -> Bool {
guard parent == nil && isViewLoaded && view.window != nil else { return true }
guard let parentViewController: UIViewController = view.superview?.nextOfReturnedType() else { return true }
return parentViewController == nil || parentViewController == to
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment