Tusker/Tusker/Screens/Main/MainSplitViewController.swift

573 lines
26 KiB
Swift

//
// MainSplitViewController.swift
// Tusker
//
// Created by Shadowfacts on 6/23/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
class MainSplitViewController: UISplitViewController {
weak var mastodonController: MastodonController!
private var sidebar: MainSidebarViewController!
private var fastAccountSwitcher: FastAccountSwitcherViewController?
// Keep track of navigation stacks per-item so that we can only ever use a single navigation controller
private var navigationStacks: [MainSidebarViewController.Item: [UIViewController]] = [:]
private var tabBarViewController: MainTabBarViewController!
private var secondaryNavController: SplitNavigationController! {
viewController(for: .secondary) as? SplitNavigationController
}
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
super.init(style: .doubleColumn)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
preferredDisplayMode = .oneBesideSecondary
preferredSplitBehavior = .tile
delegate = self
sidebar = MainSidebarViewController(mastodonController: mastodonController)
sidebar.sidebarDelegate = self
setViewController(sidebar, for: .primary)
primaryBackgroundStyle = .sidebar
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 {
select(item: .tab(.timelines))
}
if UIDevice.current.userInterfaceIdiom != .mac {
let switcher = FastAccountSwitcherViewController()
fastAccountSwitcher = switcher
switcher.itemOrientation = .iconsLeading
switcher.view.translatesAutoresizingMaskIntoConstraints = false
switcher.delegate = self
// accessing .view unconditionally loads the view, which we don't want to happen
// because the sidebar view not being loaded is how we know not to transfer nav state
// in splitViewControllerDidCollapse on devices where the sidebar is never shown
sidebar.onViewDidLoad = { [unowned self] in
self.sidebar.view.addGestureRecognizer(switcher.createSwitcherGesture())
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(sidebarTapped))
tapRecognizer.cancelsTouchesInView = false
self.sidebar.view.addGestureRecognizer(tapRecognizer)
}
}
tabBarViewController = MainTabBarViewController(mastodonController: mastodonController)
setViewController(tabBarViewController, for: .compact)
addKeyCommand(MenuController.composeCommand)
MenuController.sidebarItemKeyCommands.forEach(addKeyCommand(_:))
}
func select(item: MainSidebarViewController.Item) {
secondaryNavController.viewControllers = getOrCreateNavigationStack(item: item)
}
func navigationStackFor(item: MainSidebarViewController.Item) -> [UIViewController]? {
if sidebar.selectedItem == item {
return secondaryNavController.viewControllers
} else {
return navigationStacks[item]
}
}
func getOrCreateNavigationStack(item: MainSidebarViewController.Item) -> [UIViewController] {
if let existing = navigationStacks[item], existing.count > 0 {
return existing
} else {
let new = [item.createRootViewController(mastodonController)!]
navigationStacks[item] = new
return new
}
}
@objc func handleSidebarItemCommand(_ sender: AnyObject) {
// workaround for crash when sender is not a UICommand, see #253 and FB11804009
guard let command = sender as? UICommand else {
return
}
let item: MainSidebarViewController.Item
if let index = command.propertyList as? Int {
item = .tab(MainTabBarViewController.Tab(rawValue: index)!)
} else if let str = command.propertyList as? String {
if str == "search" {
item = .explore
} else if str == "bookmarks" {
item = .bookmarks
} else {
fatalError()
}
} else {
fatalError()
}
sidebar.select(item: item, animated: false)
select(item: item)
}
@objc private func sidebarTapped() {
fastAccountSwitcher?.hide()
}
@objc func handleComposeKeyCommand() {
compose(editing: nil)
}
}
extension MainSplitViewController: UISplitViewControllerDelegate {
/// Transfer the navigation stack for a sidebar item to a destination navgiation controller.
/// - Parameter dropFirst: Remove the first view controller from the item's navigation stack before transferring.
/// - Parameter append: Append the item's navigation stack to the destination nav controller's instead of replacing it.
private func transferNavigationStack(from item: MainSidebarViewController.Item, to destination: UINavigationController, dropFirst: Bool = false, append: Bool = false) {
var itemNavStack: [UIViewController]
if item == sidebar.selectedItem {
itemNavStack = secondaryNavController.viewControllers
} else {
itemNavStack = navigationStacks[item] ?? []
navigationStacks.removeValue(forKey: item)
}
if itemNavStack.isEmpty {
itemNavStack = [item.createRootViewController(mastodonController)!]
}
if dropFirst {
itemNavStack.remove(at: 0)
}
if append {
destination.viewControllers += itemNavStack
} else {
destination.viewControllers = itemNavStack
}
}
func splitViewControllerDidCollapse(_ svc: UISplitViewController) {
// on iPhones, the sidebar VC is never loaded, but since this method is still called, we can't do anything
guard sidebar.isViewLoaded else { return }
// Transfer the nav stacks for all the sidebar items that map 1 <-> 1 with tabs
for tab in [MainTabBarViewController.Tab.timelines, .notifications, .myProfile] {
let tabNav = tabBarViewController.viewController(for: tab) as! UINavigationController
if tabNav.isViewLoaded {
transferNavigationStack(from: .tab(tab), to: tabNav)
}
}
// Since several sidebar items map to the single Explore tab, we only transfer the
// navigation stack of the most-recently used one.
let mostRecentExploreItem: (MainSidebarViewController.Item, Date)? =
sidebar.exploreTabItems.compactMap {
if let timestamp = sidebar.itemLastSelectedTimestamps[$0] {
return ($0, timestamp)
} else {
return nil
}
}.min {
$0.1 > $1.1
}
if let mostRecentExploreItem = mostRecentExploreItem?.0,
mostRecentExploreItem != .explore {
let exploreNav = tabBarViewController.viewController(for: .explore) as! UINavigationController
// Pop back to root, so we're appending to the Explore VC instead of some other VC
exploreNav.popToRootViewController(animated: false)
// Append so we don't replace the Explore VC
transferNavigationStack(from: mostRecentExploreItem, to: exploreNav, append: true)
}
// Switch the tab bar to focus the same item as the sidebar has selected
switch sidebar.selectedItem {
case nil:
break
case let .tab(tab):
// sidebar items that map 1 <-> 1 can be transferred directly
tabBarViewController.select(tab: tab)
case .explore:
// Search sidebar item maps to the Explore tab with the search controller/results visible
// The nav stack can't be copied directly, since the split VC uses a different SearchViewController
// so that explore items aren't shown multiple times.
let exploreNav = tabBarViewController.viewController(for: .explore) as! UINavigationController
let explore: ExploreViewController
if let existing = exploreNav.viewControllers.first as? ExploreViewController {
explore = existing
exploreNav.popToRootViewController(animated: false)
} else {
// If the Explore tab hasn't been loaded before, it's root view controller won't be loaded yet, so create and add it manually.
explore = ExploreViewController(mastodonController: mastodonController)
exploreNav.viewControllers = [explore]
}
// Make sure viewDidLoad is called so that the searchController/resultsController have been initialized
explore.loadViewIfNeeded()
let search = secondaryNavController.viewControllers.first as! InlineTrendsViewController
// Copy the search query from the search VC to the Explore VC's search controller.
let query = search.searchController.searchBar.text ?? ""
explore.searchController.searchBar.text = query
// Instruct the explore controller to show its search controller immediately upon its first appearance.
// explore.searchController.isActive can't be set directly, see FB7814561
explore.searchControllerStatusOnAppearance = !query.isEmpty
// Copy the results from the search VC's results controller to avoid the delay introduced by an extra network request
explore.resultsController.loadResults(from: search.resultsController)
// Transfer the navigation stack, dropping the search VC, to keep anything the user has opened
transferNavigationStack(from: .explore, to: exploreNav, dropFirst: true, append: true)
tabBarViewController.select(tab: .explore)
case .bookmarks, .favorites, .list(_), .savedHashtag(_), .savedInstance(_):
tabBarViewController.select(tab: .explore)
// Make sure the Explore VC doesn't show its search bar when it appears, in case the user was previously
// in compact mode and performing a search.
let exploreNav = tabBarViewController.viewController(for: .explore) as! UINavigationController
let explore = exploreNav.viewControllers.first as! ExploreViewController
explore.searchControllerStatusOnAppearance = false
case .listsHeader, .addList, .savedHashtagsHeader, .addSavedHashtag, .savedInstancesHeader, .addSavedInstance:
// These items are not selectable in the sidebar collection view, so this code is unreachable.
fatalError("unexpected selected sidebar item: \(sidebar.selectedItem!)")
}
}
/// Transfer a navigation stack from a navigation controller belonging to the tab bar VC to a sidebar item.
/// - Parameter skipFirst:The number of view controllers that should be skipped from the source navigation controller.
/// - Parameter prepend: An optional view controller to prepend to the beginning of the navigation stack being moved.
private func transferNavigationStack(from navController: UINavigationController, to item: MainSidebarViewController.Item, skipFirst: Int = 0, prepend: UIViewController? = nil) {
let viewControllersToMove = navController.viewControllers.dropFirst(skipFirst)
navController.viewControllers.removeLast(navController.viewControllers.count - skipFirst)
if let prepend = prepend {
navigationStacks[item] = [prepend] + viewControllersToMove
} else {
navigationStacks[item] = Array(viewControllersToMove)
}
}
func splitViewControllerDidExpand(_ svc: UISplitViewController) {
// For each sidebar item, transfer the existing navigation stasck from the tab bar controller to ourself.
var exploreItem: MainSidebarViewController.Item?
for tab in MainTabBarViewController.Tab.allCases {
guard let tabNavController = tabBarViewController.viewController(for: tab) as? UINavigationController,
tabNavController.isViewLoaded else { continue }
let tabNavigationStack = tabNavController.viewControllers
switch tab {
case .timelines, .notifications, .myProfile:
// Items that map 1 <-> 1 to tabs can be transferred directly.
let item = MainSidebarViewController.Item.tab(tab)
transferNavigationStack(from: tabNavController, to: item)
case .explore:
// The Explore tab is more complicated since it encapsulates a bunch of screens which have top-level sidebar items.
var skipFirst = 1
var toPrepend: UIViewController? = nil
// If the tab navigation stack has only one item or the search controller is active, it corresponds to the Search item
// For other items, the 2nd VC in the nav stack determines which sidebar item they map to.
// Search screen has special considerations, all others can be transferred directly.
if tabNavigationStack.count == 1 || ((tabNavigationStack.first as? ExploreViewController)?.searchController?.isActive ?? false) {
exploreItem = .explore
// reuse the existing VC, if there is one
let searchVC = getOrCreateNavigationStack(item: .explore).first! as! InlineTrendsViewController
// load the view so that the search controller is accessible
searchVC.loadViewIfNeeded()
let explore = tabNavigationStack.first as! ExploreViewController
if let exploreSearchControler = explore.searchController,
let query = exploreSearchControler.searchBar.text {
// Transfer query to search VC
searchVC.searchController.searchBar.text = query
// If there is a query, make the search VC activate itself upon appearing
searchVC.searchControllerStatusOnAppearance = !query.isEmpty
// Transfer the results from the explore VC, to avoid an extra network request
searchVC.resultsController.loadResults(from: explore.resultsController)
}
// Insert the new search VC at the beginning of the new search nav stack
toPrepend = searchVC
} else {
switch tabNavigationStack[1] {
case is BookmarksViewController:
exploreItem = .bookmarks
case is FavoritesViewController:
exploreItem = .favorites
case let listVC as ListTimelineViewController:
exploreItem = .list(listVC.list)
case let hashtagVC as HashtagTimelineViewController:
exploreItem = .savedHashtag(hashtagVC.hashtag)
case let instanceVC as InstanceTimelineViewController:
exploreItem = .savedInstance(instanceVC.instanceURL)
case is TrendingStatusesViewController, is TrendingHashtagsViewController, is TrendingLinksViewController:
exploreItem = .explore
// these three VCs are part of the root SearchViewController, so we don't need to transfer them
skipFirst = 2
default:
// transfer the navigation stack prepending, the existing explore VC
// if there was other stuff on the explore stack, it will get discarded
toPrepend = getOrCreateNavigationStack(item: .explore).first!
exploreItem = .explore
}
}
transferNavigationStack(from: tabNavController, to: exploreItem!, skipFirst: skipFirst, prepend: toPrepend)
case .compose:
// The compose tab can't be activated, this is unreachable.
fatalError("unreachable")
}
}
// Transfer the selected tab from the tab bar VC to the sidebar
switch tabBarViewController.selectedTab {
case .timelines, .notifications, .myProfile:
// These tabs map 1 <-> 1 with sidebar items
let item = MainSidebarViewController.Item.tab(tabBarViewController.selectedTab)
sidebar.select(item: item, animated: false)
select(item: item)
case .explore:
// If the explore tab is active, the sidebar item is determined above when transferring the explore VC's nav stack
sidebar.select(item: exploreItem!, animated: false)
select(item: exploreItem!)
default:
return
}
}
}
extension MainSplitViewController: MainSidebarViewControllerDelegate {
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController) {
compose(editing: nil)
}
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) {
if let previous = sidebar.previouslySelectedItem {
navigationStacks[previous] = secondaryNavController.viewControllers
}
select(item: item)
}
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController) {
if let previous = sidebar.previouslySelectedItem {
navigationStacks[previous] = secondaryNavController.viewControllers
}
secondaryNavController.viewControllers = [viewController]
}
}
fileprivate extension MainSidebarViewController.Item {
func createRootViewController(_ mastodonController: MastodonController) -> UIViewController? {
switch self {
case let .tab(tab):
return tab.createViewController(mastodonController)
case .explore:
return InlineTrendsViewController(mastodonController: mastodonController)
case .bookmarks:
return BookmarksViewController(mastodonController: mastodonController)
case .favorites:
return FavoritesViewController(mastodonController: mastodonController)
case let .list(list):
return ListTimelineViewController(for: list, mastodonController: mastodonController)
case let .savedHashtag(hashtag):
return HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController)
case let .savedInstance(url):
return InstanceTimelineViewController(for: url, parentMastodonController: mastodonController)
case .listsHeader, .addList, .savedHashtagsHeader, .addSavedHashtag, .savedInstancesHeader, .addSavedInstance:
return nil
}
}
}
extension MainSplitViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension MainSplitViewController: StateRestorableViewController {
func stateRestorationActivity() -> NSUserActivity? {
if traitCollection.horizontalSizeClass == .compact {
return tabBarViewController.stateRestorationActivity()
} else if let currentItem = sidebar.selectedItem,
let navStack = navigationStackFor(item: currentItem),
let top = navStack.last as? StateRestorableViewController {
return top.stateRestorationActivity()
} else {
stateRestorationLogger.fault("MainSplitViewController: Unable to create state restoration activity")
return nil
}
}
}
extension MainSplitViewController: TuskerRootViewController {
func select(route: TuskerRoute, animated: Bool) {
guard traitCollection.horizontalSizeClass != .compact else {
tabBarViewController?.select(route: route, animated: animated)
return
}
guard presentedViewController == nil else {
dismiss(animated: animated) {
self.select(route: route, animated: animated)
}
return
}
let item: MainSidebarViewController.Item
switch route {
case .timelines:
item = .tab(.timelines)
case .notifications:
item = .tab(.notifications)
case .myProfile:
item = .tab(.myProfile)
case .explore:
item = .explore
case .bookmarks:
item = .bookmarks
case .list(id: let id):
if let list = mastodonController.getCachedList(id: id) {
item = .list(list)
} else {
return
}
}
sidebar.select(item: item, animated: false)
select(item: item)
}
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
if traitCollection.horizontalSizeClass == .compact {
return tabBarViewController?.getTabController(tab: tab)
} else {
if tab == .compose {
return nil
} else if case .tab(tab) = sidebar.selectedItem {
return secondaryNavController
} else {
return nil
}
}
}
func getNavigationDelegate() -> TuskerNavigationDelegate? {
if traitCollection.horizontalSizeClass == .compact {
return tabBarViewController.getNavigationDelegate()
} else {
return self
}
}
func getNavigationController() -> NavigationControllerProtocol {
if traitCollection.horizontalSizeClass == .compact {
return tabBarViewController.getNavigationController()
} else {
return secondaryNavController
}
}
func performSearch(query: String) {
guard traitCollection.horizontalSizeClass != .compact else {
// ensure the tab bar VC is loaded
loadViewIfNeeded()
tabBarViewController.performSearch(query: query)
return
}
if sidebar.selectedItem != .explore {
select(item: .explore)
}
guard let searchViewController = secondaryNavController.viewControllers.first as? InlineTrendsViewController else {
return
}
secondaryNavController.popToRootViewController(animated: false)
if searchViewController.isViewLoaded {
DispatchQueue.main.async {
searchViewController.searchController.isActive = true
}
} else {
searchViewController.searchControllerStatusOnAppearance = true
searchViewController.loadViewIfNeeded()
}
searchViewController.searchController.searchBar.text = query
searchViewController.resultsController.performSearch(query: query)
}
func presentPreferences(completion: (() -> Void)?) {
present(PreferencesNavigationController(mastodonController: mastodonController), animated: true, completion: completion)
}
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
guard presentedViewController == nil else {
return .stop
}
if traitCollection.horizontalSizeClass == .compact {
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)
} else {
return .continue
}
}
}
}
extension MainSplitViewController: BackgroundableViewController {
func sceneDidEnterBackground() {
if traitCollection.horizontalSizeClass == .compact {
tabBarViewController.sceneDidEnterBackground()
} else {
// todo: should this do the same for the sidebar VC as well?
if let contentVC = viewController(for: .secondary) as? BackgroundableViewController {
contentVC.sceneDidEnterBackground()
}
}
}
}
extension MainSplitViewController: FastAccountSwitcherViewControllerDelegate {
func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController) {
view.addSubview(fastAccountSwitcher.view)
let currentAccount = fastAccountSwitcher.accountViews.first(where: \.isCurrent)!
let myProfileCell = sidebar.myProfileCell()!
NSLayoutConstraint.activate([
currentAccount.centerYAnchor.constraint(equalTo: myProfileCell.centerYAnchor),
fastAccountSwitcher.view.leadingAnchor.constraint(equalTo: sidebar.view.trailingAnchor),
fastAccountSwitcher.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
fastAccountSwitcher.view.topAnchor.constraint(equalTo: view.topAnchor),
fastAccountSwitcher.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
func fastAccountSwitcher(_ fastAccountSwitcher: FastAccountSwitcherViewController, triggerZoneContains point: CGPoint) -> Bool {
guard !isCollapsed,
let cell = sidebar.myProfileCell() else {
return false
}
let cellRect = cell.convert(cell.bounds, to: sidebar.view)
return cellRect.contains(point)
}
}