Created
April 12, 2019 19:55
-
-
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…
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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 | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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() | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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