Tusker/Tusker/Screens/Utilities/MultiColumnNavigationContro...

326 lines
12 KiB
Swift

//
// 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
}
}
}