diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 2960d230..da1f0785 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -92,6 +92,7 @@ D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62D2421217AA7E1005076CC /* UserActivityManager.swift */; }; D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62D2423217ABF3F005076CC /* NSUserActivity+Extensions.swift */; }; D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62D2425217ABF63005076CC /* UserActivityType.swift */; }; + D62D67C52A97D8CD00167EE2 /* MultiColumnNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62D67C42A97D8CD00167EE2 /* MultiColumnNavigationController.swift */; }; D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62E9984279CA23900C26176 /* URLSession+Development.swift */; }; D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */; }; D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */; }; @@ -488,6 +489,7 @@ D62D2421217AA7E1005076CC /* UserActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityManager.swift; sourceTree = ""; }; D62D2423217ABF3F005076CC /* NSUserActivity+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSUserActivity+Extensions.swift"; sourceTree = ""; }; D62D2425217ABF63005076CC /* UserActivityType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityType.swift; sourceTree = ""; }; + D62D67C42A97D8CD00167EE2 /* MultiColumnNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiColumnNavigationController.swift; sourceTree = ""; }; D62E9984279CA23900C26176 /* URLSession+Development.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSession+Development.swift"; sourceTree = ""; }; D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMetaIndicatorsView.swift; sourceTree = ""; }; D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringHelperTests.swift; sourceTree = ""; }; @@ -1409,6 +1411,7 @@ D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */, D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */, D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */, + D62D67C42A97D8CD00167EE2 /* MultiColumnNavigationController.swift */, D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */, D693DE5823FE24300061E07D /* InteractivePushTransition.swift */, D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */, @@ -2160,6 +2163,7 @@ D6B81F442560390300F6E31D /* MenuController.swift in Sources */, D691771129A2B76A0054D7EF /* MainActor+Unsafe.swift in Sources */, D65B4B542971F71D00DABDFB /* EditedReport.swift in Sources */, + D62D67C52A97D8CD00167EE2 /* MultiColumnNavigationController.swift in Sources */, D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */, D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */, D68A76F129539116001DA1B3 /* FlipView.swift in Sources */, diff --git a/Tusker/Screens/Main/MainSplitViewController.swift b/Tusker/Screens/Main/MainSplitViewController.swift index e5ad98f9..d342ce0b 100644 --- a/Tusker/Screens/Main/MainSplitViewController.swift +++ b/Tusker/Screens/Main/MainSplitViewController.swift @@ -20,8 +20,8 @@ class MainSplitViewController: UISplitViewController { private var tabBarViewController: MainTabBarViewController! - private var secondaryNavController: SplitNavigationController! { - viewController(for: .secondary) as? SplitNavigationController + private var secondaryNavController: NavigationControllerProtocol! { + viewController(for: .secondary) as? NavigationControllerProtocol } private var sidebarVisibile: Bool { @@ -59,9 +59,12 @@ class MainSplitViewController: UISplitViewController { } else { hide(.primary) } + + let multiColumnNav = MultiColumnNavigationController() + setViewController(multiColumnNav, for: .secondary) - let splitNav = SplitNavigationController() - setViewController(splitNav, for: .secondary) +// 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 { @@ -573,8 +576,9 @@ extension MainSplitViewController: TuskerRootViewController { return tabBarViewController.handleStatusBarTapped(xPosition: xPosition) } else { let pointInSecondary = secondaryNavController.view.convert(CGPoint(x: xPosition, y: 0), from: view) - if secondaryNavController.view.bounds.contains(pointInSecondary) { - return secondaryNavController.handleStatusBarTapped(xPosition: pointInSecondary.x) + if secondaryNavController.view.bounds.contains(pointInSecondary), + let statusBarTappable = secondaryNavController as? StatusBarTappableViewController { + return statusBarTappable.handleStatusBarTapped(xPosition: pointInSecondary.x) } else { return .continue } diff --git a/Tusker/Screens/Main/TuskerRootViewController.swift b/Tusker/Screens/Main/TuskerRootViewController.swift index 38c9171b..bd6b7bd5 100644 --- a/Tusker/Screens/Main/TuskerRootViewController.swift +++ b/Tusker/Screens/Main/TuskerRootViewController.swift @@ -83,8 +83,10 @@ enum TuskerRoute { // case myProfile //} // -protocol NavigationControllerProtocol { +protocol NavigationControllerProtocol: UIViewController { + var viewControllers: [UIViewController] { get set } var topViewController: UIViewController? { get } + @discardableResult func popToRootViewController(animated: Bool) -> [UIViewController]? func pushViewController(_ vc: UIViewController, animated: Bool) } diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index 9c7bcf11..9d5e6023 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -110,9 +110,9 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro // just setting layout.configuration.contentInsetsReference doesn't work with UICollectionViewCompositionalLayout.list let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) - if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { - section.contentInsetsReference = .readableContent - } +// if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { +// section.contentInsetsReference = .readableContent +// } return section } collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) diff --git a/Tusker/Screens/Utilities/MultiColumnNavigationController.swift b/Tusker/Screens/Utilities/MultiColumnNavigationController.swift new file mode 100644 index 00000000..2f2858ef --- /dev/null +++ b/Tusker/Screens/Utilities/MultiColumnNavigationController.swift @@ -0,0 +1,325 @@ +// +// MultiColumnNavigationController.swift +// Tusker +// +// Created by Shadowfacts on 8/24/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit + +class MultiColumnNavigationController: UIViewController { + + private var isManuallyUpdating = false + var viewControllers: [UIViewController] = [] { + didSet { + guard isViewLoaded, + !isManuallyUpdating else { + return + } + updateViews() + scrollToEnd(animated: false) + } + } + + private var scrollView = UIScrollView() + private var stackView = UIStackView() + + init() { + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .appSecondaryBackground + + scrollView.contentInsetAdjustmentBehavior = .always + scrollView.contentInset = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) + scrollView.alwaysBounceHorizontal = true + scrollView.translatesAutoresizingMaskIntoConstraints = false + + stackView.axis = .horizontal + stackView.spacing = 8 + stackView.alignment = .fill + stackView.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(stackView) + + view.addSubview(scrollView) + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + stackView.heightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.heightAnchor), + ]) + + updateViews() + } + + private func updateViews() { + var i = 0 + while i < viewControllers.count { + let needsCloseButton = i > 0 + if i <= stackView.arrangedSubviews.count - 1 { + let existing = stackView.arrangedSubviews[i] as! ColumnView + existing.setContent(viewControllers[i], needsCloseButton: needsCloseButton) + } else { + let new = ColumnView(owner: self, contentViewController: viewControllers[i], needsCloseButton: needsCloseButton) + stackView.addArrangedSubview(new) + } + i += 1 + } + + while i < stackView.arrangedSubviews.count { + let toRemove = stackView.arrangedSubviews[i] as! ColumnView + toRemove.willRemoveColumn() + stackView.removeArrangedSubview(toRemove) + } + } + + override func show(_ vc: UIViewController, sender: Any?) { + if let sender = sender as? UIViewController { + var index: Int? = nil + var current: UIViewController? = sender + while let c = current { + index = viewControllers.firstIndex(of: c) + if index != nil { + break + } else { + current = c.parent + } + } + if let index { + replaceViewControllers([vc], after: index, animated: true) + } else { + pushViewController(vc, animated: true) + } + } else { + pushViewController(vc, animated: true) + } + + SplitNavigationController.clearSelectedRow(sender: sender) + } + + func replaceViewControllers(_ vcs: [UIViewController], after afterIndex: Int, animated: Bool) { + if afterIndex == viewControllers.count - 1 && vcs.count == 1 { + pushViewController(vcs[0], animated: animated) + } else { + viewControllers = Array(viewControllers[...afterIndex]) + vcs + scrollToEnd(animated: animated) + } + } + + private func scrollToEnd(animated: Bool) { + scrollColumnToEnd(columnIndex: viewControllers.count - 1, animated: animated) + } + + private func scrollColumnToEnd(columnIndex: Int, animated: Bool) { + scrollView.layoutIfNeeded() + let column = stackView.arrangedSubviews[columnIndex] + let columnFrame = column.convert(column.bounds, to: scrollView) + let offset: CGFloat + if columnFrame.maxX < scrollView.bounds.width - scrollView.adjustedTrailingContentInset { + offset = -scrollView.adjustedLeadingContentInset + } else { + offset = columnFrame.minX + scrollView.adjustedLeadingContentInset - (scrollView.bounds.width - columnFrame.width) + } + scrollView.setContentOffset(CGPoint(x: offset, y: -scrollView.adjustedContentInset.top), animated: animated) + } + + fileprivate func closeColumn(_ vc: UIViewController) { + let index = viewControllers.firstIndex(of: vc)! + guard index > 0 else { + // Can't close the last column + return + } + isManuallyUpdating = true + defer { isManuallyUpdating = false } + viewControllers.removeSubrange(index...) + animateChanges { + for column in self.stackView.arrangedSubviews[index...] { + column.layer.opacity = 0 + } + self.scrollColumnToEnd(columnIndex: index - 1, animated: false) + } completion: { + self.updateViews() + } + + } + + private func animateChanges(_ animations: @escaping () -> Void, completion: (() -> Void)? = nil) { + let animator = UIViewPropertyAnimator(duration: 0.3, timingParameters: UISpringTimingParameters()) + animator.addAnimations(animations) + animator.addCompletion { _ in + completion?() + } + animator.startAnimation() + } +} + +extension MultiColumnNavigationController: NavigationControllerProtocol { + var topViewController: UIViewController? { + viewControllers.last + } + + func popToRootViewController(animated: Bool) -> [UIViewController]? { + let removed = Array(viewControllers.dropFirst()) + viewControllers = [viewControllers.first!] + return removed + } + + func pushViewController(_ vc: UIViewController, animated: Bool) { + isManuallyUpdating = true + defer { isManuallyUpdating = false } + viewControllers.append(vc) + updateViews() + scrollToEnd(animated: animated) + if animated { + let column = stackView.arrangedSubviews.last! + column.layer.opacity = 0 + animateChanges { + column.layer.opacity = 1 + } + } + } +} + +private class ColumnView: UIView { + private unowned let owner: MultiColumnNavigationController + private let contentView = UIView() + private let navigationController: ColumnNavigationController + private var contentViewController: UIViewController! + + init(owner: MultiColumnNavigationController, contentViewController: UIViewController, needsCloseButton: Bool) { + self.owner = owner + self.navigationController = ColumnNavigationController(owner: owner) + super.init(frame: .zero) + + contentView.translatesAutoresizingMaskIntoConstraints = false + addSubview(contentView) + + layer.shadowOpacity = 0.2 + layer.shadowRadius = 8 + layer.shadowOffset = .zero + contentView.layer.masksToBounds = false + contentView.layer.cornerRadius = 12.5 + contentView.layer.cornerCurve = .continuous + contentView.layer.masksToBounds = true + + NSLayoutConstraint.activate([ + contentView.leadingAnchor.constraint(equalTo: leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: trailingAnchor), + contentView.topAnchor.constraint(equalTo: topAnchor), + contentView.bottomAnchor.constraint(equalTo: bottomAnchor), + + widthAnchor.constraint(equalToConstant: 400), + ]) + + setContent(contentViewController, needsCloseButton: needsCloseButton) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setContent(_ viewController: UIViewController, needsCloseButton: Bool) { + guard viewController !== contentViewController else { + return + } + + contentViewController?.removeViewAndController() + + if navigationController.parent != owner { + navigationController.removeViewAndController() + + owner.addChild(navigationController) + navigationController.didMove(toParent: owner) + navigationController.view.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(navigationController.view) + NSLayoutConstraint.activate([ + navigationController.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + navigationController.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + navigationController.view.topAnchor.constraint(equalTo: contentView.topAnchor), + navigationController.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + } + + contentViewController = viewController + navigationController.setViewControllers([viewController], animated: false) + + if needsCloseButton { + installCloseBarButton(navigationItem: viewController.navigationItem) + } + } + + func willRemoveColumn() { + navigationController.removeViewAndController() + navigationController.setViewControllers([], animated: false) + removeCloseBarButton(navigationItem: contentViewController.navigationItem) + } + + private func installCloseBarButton(navigationItem: UINavigationItem) { + let item = UIBarButtonItem(image: UIImage(systemName: "xmark"), style: .done, target: self, action: #selector(closeNavigationColumn)) + item.accessibilityLabel = "Close Column" + if navigationItem.leftBarButtonItems != nil { + navigationItem.leftBarButtonItems!.insert(item, at: 0) + } else { + navigationItem.leftBarButtonItems = [item] + } + } + + private func removeCloseBarButton(navigationItem: UINavigationItem) { + navigationItem.leftBarButtonItems = (navigationItem.leftBarButtonItems ?? []).filter { + $0.action != #selector(closeNavigationColumn) + } + } + + @objc private func closeNavigationColumn() { + owner.closeColumn(contentViewController) + } +} + +private class ColumnNavigationController: UINavigationController { + unowned let owner: MultiColumnNavigationController + + init(owner: MultiColumnNavigationController) { + self.owner = owner + super.init(nibName: nil, bundle: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func show(_ vc: UIViewController, sender: Any?) { + owner.show(vc, sender: sender) + } +} + +private extension UIScrollView { + var adjustedLeadingContentInset: CGFloat { + if traitCollection.layoutDirection == .leftToRight { + adjustedContentInset.left + } else { + adjustedContentInset.right + } + } + + var adjustedTrailingContentInset: CGFloat { + if traitCollection.layoutDirection == .leftToRight { + adjustedContentInset.right + } else { + adjustedContentInset.left + } + } +} diff --git a/Tusker/Screens/Utilities/SplitNavigationController.swift b/Tusker/Screens/Utilities/SplitNavigationController.swift index 1953bf31..a0c09b91 100644 --- a/Tusker/Screens/Utilities/SplitNavigationController.swift +++ b/Tusker/Screens/Utilities/SplitNavigationController.swift @@ -49,19 +49,7 @@ class SplitNavigationController: UIViewController { 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 if let sender = sender as? UIViewController, - let collectionView = (sender as? CollectionViewController)?.collectionView ?? sender.view as? UICollectionView { - // the collection view's animation speed is weirdly fast, so we do it slower - UIView.animate(withDuration: 0.5, delay: 0) { - collectionView.indexPathsForSelectedItems?.forEach { collectionView.deselectItem(at: $0, animated: false) } - } - } + SplitNavigationController.clearSelectedRow(sender: sender) } else { self.rootNav.pushViewController(vc, animated: true) } @@ -118,6 +106,21 @@ class SplitNavigationController: UIViewController { fatalError("init(coder:) has not been implemented") } + static func clearSelectedRow(sender: Any?) { + // 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 if let sender = sender as? UIViewController, + let collectionView = (sender as? CollectionViewController)?.collectionView ?? sender.view as? UICollectionView { + // the collection view's animation speed is weirdly fast, so we do it slower + UIView.animate(withDuration: 0.5, delay: 0) { + collectionView.indexPathsForSelectedItems?.forEach { collectionView.deselectItem(at: $0, animated: false) } + } + } + } + override func viewDidLoad() { super.viewDidLoad()