// // 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) { if viewControllers.isEmpty { scrollView.setContentOffset(.init(x: -scrollView.adjustedLeadingContentInset, y: -scrollView.adjustedContentInset.top), animated: false) } else { 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 || viewController.parent !== navigationController 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 } } }