Two column navigation on iPad

This commit is contained in:
Shadowfacts 2022-07-06 17:47:40 -04:00
parent efb96eddf3
commit 5b70c713b2
6 changed files with 290 additions and 16 deletions

View File

@ -291,6 +291,7 @@
D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E77D08286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift */; };
D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */; };
D6E77D0D286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */; };
D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */; };
D6E9CDA8281A427800BBC98E /* PostService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E9CDA7281A427800BBC98E /* PostService.swift */; };
D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */; };
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; };
@ -645,6 +646,7 @@
D6E77D08286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagCollectionViewCell.swift; sourceTree = "<group>"; };
D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinkCardCollectionViewCell.swift; sourceTree = "<group>"; };
D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TrendingLinkCardCollectionViewCell.xib; sourceTree = "<group>"; };
D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitNavigationController.swift; sourceTree = "<group>"; };
D6E9CDA7281A427800BBC98E /* PostService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostService.swift; sourceTree = "<group>"; };
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusableTextField.swift; sourceTree = "<group>"; };
D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Scenes.swift"; sourceTree = "<group>"; };
@ -1298,6 +1300,7 @@
D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */,
D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */,
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */,
D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */,
D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */,
D693DE5823FE24300061E07D /* InteractivePushTransition.swift */,
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */,
@ -1804,6 +1807,7 @@
D662AEF2263A4BE10082A153 /* ComposePollView.swift in Sources */,
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */,
D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */,
D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */,
D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */,
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */,
D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */,

View File

@ -20,8 +20,11 @@ class MainSplitViewController: UISplitViewController {
private var tabBarViewController: MainTabBarViewController!
private var secondaryNavController: UINavigationController! {
viewController(for: .secondary) as? UINavigationController
// private var secondaryNavController: UINavigationController! {
// viewController(for: .secondary) as? UINavigationController
// }
private var secondaryNavController: SplitNavigationController! {
viewController(for: .secondary) as? SplitNavigationController
}
init(mastodonController: MastodonController) {
@ -46,9 +49,10 @@ class MainSplitViewController: UISplitViewController {
setViewController(sidebar, for: .primary)
primaryBackgroundStyle = .sidebar
let secondaryNav = EnhancedNavigationViewController()
secondaryNav.useBrowserStyleNavigation = true
setViewController(secondaryNav, for: .secondary)
// let secondaryNav = EnhancedNavigationViewController()
// secondaryNav.useBrowserStyleNavigation = true
let splitNav = SplitNavigationController()
setViewController(splitNav, for: .secondary)
// don't unnecesarily construct a content VC unless the we're in actually split mode
// when we change from compact -> split for the first time, the VC will be transferred anyways
if traitCollection.horizontalSizeClass != .compact {

View File

@ -142,7 +142,7 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
return vc
} else {
let nav = EnhancedNavigationViewController(rootViewController: vc)
nav.useBrowserStyleNavigation = true
// nav.useBrowserStyleNavigation = true
return nav
}
}

View File

@ -0,0 +1,262 @@
//
// SplitNavigationController.swift
// Tusker
//
// Created by Shadowfacts on 7/1/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
class SplitNavigationController: UIViewController {
private let rootNav = SplitRootNavigationController()
private let secondaryNav = SplitSecondaryNavigationController()
private let separatorView = UIView()
private var constraints: [NSLayoutConstraint] = []
var viewControllers: [UIViewController] {
get {
return rootNav.viewControllers + secondaryNav.viewControllers
}
set {
if newValue.isEmpty {
rootNav.viewControllers = []
secondaryNav.viewControllers = []
} else if canShowSecondaryNav {
var newValue = newValue
rootNav.viewControllers = [newValue.removeFirst()]
secondaryNav.viewControllers = newValue
} else {
rootNav.viewControllers = newValue
secondaryNav.viewControllers = []
}
updateSecondaryNavVisibility()
}
}
/// This property is only valid after the view has been laid out.
private var canShowSecondaryNav: Bool {
// minimum of 360pt for each column
// this allows split navigation on all ipads in portrait w/ sidebar hidden and in landscape (regardless of sidebar)
(viewIfLoaded?.bounds.width ?? 0) >= 720
}
init(rootViewController: UIViewController? = nil) {
super.init(nibName: nil, bundle: nil)
rootNav.showImpl = { [unowned self] vc, sender in
if self.canShowSecondaryNav {
self.setSecondaryViewControllers([vc], animated: true)
// the split nav shouldn't really be reaching down into the inner VCs like this,
// but I can't think of a cleaner way
if let tableVC = sender as? UITableViewController,
let selectedIndexPath = tableVC.tableView.indexPathForSelectedRow {
tableVC.tableView.deselectRow(at: selectedIndexPath, animated: true)
}
} else {
self.rootNav.pushViewController(vc, animated: true)
}
}
secondaryNav.closeSecondaryImpl = { [unowned self] in
self.popToRootViewController(animated: true)
}
if let rootViewController {
rootNav.viewControllers = [rootViewController]
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
embedChild(rootNav, layout: false)
embedChild(secondaryNav, layout: false)
rootNav.view.translatesAutoresizingMaskIntoConstraints = false
secondaryNav.view.translatesAutoresizingMaskIntoConstraints = false
separatorView.backgroundColor = .separator
separatorView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(separatorView)
NSLayoutConstraint.activate([
rootNav.view.topAnchor.constraint(equalTo: view.topAnchor),
rootNav.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
rootNav.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
separatorView.topAnchor.constraint(equalTo: view.topAnchor),
separatorView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
separatorView.leadingAnchor.constraint(equalTo: rootNav.view.trailingAnchor),
separatorView.widthAnchor.constraint(equalToConstant: 0.5),
secondaryNav.view.topAnchor.constraint(equalTo: view.topAnchor),
secondaryNav.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
secondaryNav.view.leadingAnchor.constraint(equalTo: separatorView.trailingAnchor),
])
updateSecondaryNavVisibility()
}
override func show(_ vc: UIViewController, sender: Any?) {
if !canShowSecondaryNav {
rootNav.pushViewController(vc, animated: true)
} else if rootNav.viewControllers.isEmpty {
rootNav.pushViewController(vc, animated: false)
} else {
secondaryNav.pushViewController(vc, animated: true)
}
updateSecondaryNavVisibility()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
if !isLayingOutForAnimation {
updateSecondaryNavVisibility()
}
}
private func updateSecondaryNavVisibility() {
guard isViewLoaded else {
return
}
if canShowSecondaryNav {
if rootNav.viewControllers.count > 1 {
var vcs = rootNav.viewControllers
let root = vcs.removeFirst()
rootNav.viewControllers = [root]
// this shouldn't be necessary since the vcs are removed from their parent vc by setting rootNav.viewControllers
// but it doesn't remove the views from their superview (until the next runloop iteration?)
// so we need to do that ourselves before we can set them on the secondary nav (otherwise it raises an exception)
vcs.forEach { $0.removeViewAndController() }
secondaryNav.viewControllers = vcs
}
} else {
if !secondaryNav.viewControllers.isEmpty {
let firstSecondary = secondaryNav.viewControllers.first!
// remove the left bar button item so that the builtin Back item shows
if firstSecondary.navigationItem.leftBarButtonItem?.tag == ViewTags.splitNavCloseSecondaryButton {
firstSecondary.navigationItem.leftBarButtonItem = nil
}
rootNav.viewControllers.append(contentsOf: secondaryNav.viewControllers)
secondaryNav.viewControllers = []
}
}
setSecondaryVisible(canShowSecondaryNav && !secondaryNav.viewControllers.isEmpty)
}
private func setSecondaryVisible(_ visible: Bool) {
guard isViewLoaded else {
return
}
NSLayoutConstraint.deactivate(constraints)
if visible {
constraints = [
rootNav.view.trailingAnchor.constraint(equalTo: view.centerXAnchor),
secondaryNav.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
]
} else {
constraints = [
rootNav.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
secondaryNav.view.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.5),
]
}
NSLayoutConstraint.activate(constraints)
}
private func setSecondaryViewControllers(_ vcs: [UIViewController], animated: Bool) {
if animated {
if vcs.isEmpty {
popToRootViewController(animated: true)
} else {
let wasVisible = !secondaryNav.viewControllers.isEmpty
secondaryNav.viewControllers = vcs
secondaryNav.view.frame = CGRect(x: view.bounds.width, y: 0, width: view.bounds.width / 2, height: view.bounds.height)
secondaryNav.view.layoutIfNeeded()
if !wasVisible {
let animator = UIViewPropertyAnimator(duration: 0.35, curve: .easeInOut) {
self.updateSecondaryNavVisibility()
self.view.layoutIfNeeded()
}
animator.startAnimation()
}
}
} else {
secondaryNav.viewControllers = vcs
updateSecondaryNavVisibility()
}
}
private var isLayingOutForAnimation = false
func popToRootViewController(animated: Bool) {
if animated {
// we don't update secondaryNav.viewControllers until after the animation is completed
// otherwise the secondary nav's contents disappear immediately, rather than sliding off-screen
let animator = UIViewPropertyAnimator(duration: 0.35, curve: .easeInOut) {
self.isLayingOutForAnimation = true
self.setSecondaryVisible(false)
self.view.layoutIfNeeded()
}
animator.addCompletion { _ in
self.secondaryNav.viewControllers = []
self.isLayingOutForAnimation = false
// self.updateSecondaryNavVisibility()
}
animator.startAnimation()
} else {
self.secondaryNav.viewControllers = []
self.updateSecondaryNavVisibility()
}
}
}
private class SplitRootNavigationController: UINavigationController {
fileprivate var showImpl: ((UIViewController, Any?) -> Void)!
override func show(_ vc: UIViewController, sender: Any?) {
showImpl(vc, sender)
}
}
private class SplitSecondaryNavigationController: EnhancedNavigationViewController {
fileprivate var closeSecondaryImpl: (() -> Void)!
override var viewControllers: [UIViewController] {
didSet {
if let first = viewControllers.first {
configureSecondarySplitCloseButton(for: first)
}
}
}
private func configureSecondarySplitCloseButton(for viewController: UIViewController) {
guard viewController.navigationItem.leftBarButtonItem?.tag != ViewTags.splitNavCloseSecondaryButton else {
return
}
let item = UIBarButtonItem(title: "Close", style: .done, target: self, action: #selector(closeSecondary))
item.tag = ViewTags.splitNavCloseSecondaryButton
viewController.navigationItem.leftBarButtonItem = item
}
@objc private func closeSecondary() {
closeSecondaryImpl()
}
}

View File

@ -10,7 +10,7 @@ import UIKit
// Based on MVCTodo by Dave DeLong: https://github.com/davedelong/MVCTodo/blob/841649dd6aa31bacda3ad7ef9a9a836f66281e50/MVCTodo/Extensions/UIViewController.swift
extension UIViewController {
func embedChild(_ newChild: UIViewController, in container: UIView? = nil) {
func embedChild(_ newChild: UIViewController, in container: UIView? = nil, layout: Bool = true) {
// if the view controller is already a child of something else, remove it
if let oldParent = newChild.parent, oldParent != self {
newChild.beginAppearanceTransition(false, animated: false)
@ -36,7 +36,7 @@ extension UIViewController {
newChild.beginAppearanceTransition(true, animated: false)
addChild(newChild)
newChild.didMove(toParent: self)
targetContainer.embedSubview(newChild.view)
targetContainer.embedSubview(newChild.view, layout: layout)
newChild.endAppearanceTransition()
} else {
// the view controller is already a child
@ -45,7 +45,7 @@ extension UIViewController {
// we don't do the appearance transition stuff here,
// because the vc is already a child, so *presumably*
// that transition stuff has already appened
targetContainer.embedSubview(newChild.view)
targetContainer.embedSubview(newChild.view, layout: layout)
}
}
@ -57,22 +57,25 @@ extension UIViewController {
// Based on MVCTodo by Dave DeLong: https://github.com/davedelong/MVCTodo/blob/841649dd6aa31bacda3ad7ef9a9a836f66281e50/MVCTodo/Extensions/UIView.swift
extension UIView {
func embedSubview(_ subview: UIView) {
func embedSubview(_ subview: UIView, layout: Bool = true) {
if subview.superview == self { return }
if subview.superview != nil {
subview.removeFromSuperview()
}
subview.frame = bounds
addSubview(subview)
NSLayoutConstraint.activate([
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
subview.trailingAnchor.constraint(equalTo: trailingAnchor),
subview.topAnchor.constraint(equalTo: topAnchor),
subview.bottomAnchor.constraint(equalTo: bottomAnchor)
if layout {
subview.frame = bounds
NSLayoutConstraint.activate([
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
subview.trailingAnchor.constraint(equalTo: trailingAnchor),
subview.topAnchor.constraint(equalTo: topAnchor),
subview.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
}
func isContainedWithin(_ other: UIView) -> Bool {

View File

@ -16,4 +16,5 @@ struct ViewTags {
static let navBackBarButton = 42003
static let navForwardBarButton = 42004
static let navEmptyTitleView = 42005
static let splitNavCloseSecondaryButton = 42006
}