From 5b70c713b2829a39332c7e33f01f55566e6f32fb Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Wed, 6 Jul 2022 17:47:40 -0400 Subject: [PATCH] Two column navigation on iPad --- Tusker.xcodeproj/project.pbxproj | 4 + .../Main/MainSplitViewController.swift | 14 +- .../Main/MainTabBarViewController.swift | 2 +- .../Utilities/SplitNavigationController.swift | 262 ++++++++++++++++++ .../Utilities/UIViewController+Children.swift | 23 +- Tusker/ViewTags.swift | 1 + 6 files changed, 290 insertions(+), 16 deletions(-) create mode 100644 Tusker/Screens/Utilities/SplitNavigationController.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 63f98ec9..68fee5f2 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -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 = ""; }; D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinkCardCollectionViewCell.swift; sourceTree = ""; }; D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TrendingLinkCardCollectionViewCell.xib; sourceTree = ""; }; + D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitNavigationController.swift; sourceTree = ""; }; D6E9CDA7281A427800BBC98E /* PostService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostService.swift; sourceTree = ""; }; D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusableTextField.swift; sourceTree = ""; }; D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Scenes.swift"; sourceTree = ""; }; @@ -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 */, diff --git a/Tusker/Screens/Main/MainSplitViewController.swift b/Tusker/Screens/Main/MainSplitViewController.swift index 18f90fa9..0461e8dd 100644 --- a/Tusker/Screens/Main/MainSplitViewController.swift +++ b/Tusker/Screens/Main/MainSplitViewController.swift @@ -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 { diff --git a/Tusker/Screens/Main/MainTabBarViewController.swift b/Tusker/Screens/Main/MainTabBarViewController.swift index 318b39d3..02a75f9f 100644 --- a/Tusker/Screens/Main/MainTabBarViewController.swift +++ b/Tusker/Screens/Main/MainTabBarViewController.swift @@ -142,7 +142,7 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { return vc } else { let nav = EnhancedNavigationViewController(rootViewController: vc) - nav.useBrowserStyleNavigation = true +// nav.useBrowserStyleNavigation = true return nav } } diff --git a/Tusker/Screens/Utilities/SplitNavigationController.swift b/Tusker/Screens/Utilities/SplitNavigationController.swift new file mode 100644 index 00000000..4c6c4a64 --- /dev/null +++ b/Tusker/Screens/Utilities/SplitNavigationController.swift @@ -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() + } + +} diff --git a/Tusker/Screens/Utilities/UIViewController+Children.swift b/Tusker/Screens/Utilities/UIViewController+Children.swift index 4221a950..6f79415f 100644 --- a/Tusker/Screens/Utilities/UIViewController+Children.swift +++ b/Tusker/Screens/Utilities/UIViewController+Children.swift @@ -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 { diff --git a/Tusker/ViewTags.swift b/Tusker/ViewTags.swift index e5c54735..953af036 100644 --- a/Tusker/ViewTags.swift +++ b/Tusker/ViewTags.swift @@ -16,4 +16,5 @@ struct ViewTags { static let navBackBarButton = 42003 static let navForwardBarButton = 42004 static let navEmptyTitleView = 42005 + static let splitNavCloseSecondaryButton = 42006 }