// // MainSplitViewController.swift // Tusker // // Created by Shadowfacts on 6/23/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import UIKit import Combine import TuskerPreferences class MainSplitViewController: UISplitViewController { private let 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 navigationMode: WidescreenNavigationMode! private var secondaryNavController: NavigationControllerProtocol! { viewController(for: .secondary) as? NavigationControllerProtocol } private var cancellables = Set() private var sidebarVisibile: Bool { get { (UserDefaults.standard.object(forKey: "MainSplitViewControllerSidebarVisible") as? Bool) ?? true } set { UserDefaults.standard.set(newValue, forKey: "MainSplitViewControllerSidebarVisible") } } 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 if sidebarVisibile { show(.primary) } else { hide(.primary) } let nav: UIViewController let visionIdiom = UIUserInterfaceIdiom(rawValue: 6) if [visionIdiom, .pad, .mac].contains(UIDevice.current.userInterfaceIdiom) { navigationMode = Preferences.shared.widescreenNavigationMode switch navigationMode! { case .stack: nav = EnhancedNavigationViewController() case .splitScreen: nav = SplitNavigationController() case .multiColumn: nav = MultiColumnNavigationController() } } else { navigationMode = .stack nav = EnhancedNavigationViewController() } setViewController(nav, 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(_:)) Preferences.shared.$widescreenNavigationMode .sink { [unowned self] in self.updateNavigationMode($0) } .store(in: &cancellables) } private func updateNavigationMode(_ mode: WidescreenNavigationMode) { let visionIdiom = UIUserInterfaceIdiom(rawValue: 6) guard [visionIdiom, .pad, .mac].contains(UIDevice.current.userInterfaceIdiom), mode != navigationMode else { return } navigationMode = mode let viewControllers = secondaryNavController.viewControllers secondaryNavController.viewControllers = [] // Setting viewControllers = [] doesn't remove the VC views from their superviews immediately, // so do that ourselves so we can re-parent the VCs to the new nav controller. for viewController in viewControllers { viewController.viewIfLoaded?.removeFromSuperview() } let newNav: NavigationControllerProtocol switch mode { case .stack: newNav = EnhancedNavigationViewController() case .splitScreen: newNav = SplitNavigationController() case .multiColumn: newNav = MultiColumnNavigationController() } newNav.viewControllers = viewControllers self.setViewController(newNav, for: .secondary) } 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 } } override func show(_ vc: UIViewController, sender: Any?) { if traitCollection.horizontalSizeClass == .regular { secondaryNavController.show(vc, sender: sender) } else { super.show(vc, sender: sender) } } @objc func handleSidebarCommandTimelines() { sidebar.select(item: .tab(.timelines), animated: false) select(item: .tab(.timelines)) } @objc func handleSidebarCommandNotifications() { sidebar.select(item: .tab(.notifications), animated: false) select(item: .tab(.notifications)) } @objc func handleSidebarCommandExplore() { sidebar.select(item: .tab(.explore), animated: false) select(item: .tab(.explore)) } @objc func handleSidebarCommandBookmarks() { sidebar.select(item: .bookmarks, animated: false) select(item: .bookmarks) } @objc func handleSidebarCommandMyProfile() { sidebar.select(item: .tab(.myProfile), animated: false) select(item: .tab(.myProfile)) } @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 secondaryNavController.viewControllers = [] // Sometimes removing a VC from the viewControllers array doesn't immediately remove it's view from the hierarchy for vc in itemNavStack { vc.viewIfLoaded?.removeFromSuperview() } } 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, dismissPresented: false) 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 // make sure there's a root ExploreViewController 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 if search.searchController?.isActive == true { // 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) } else { // if there is more than just the InlineTrendsVC, and the search VC is not active, // then the user selected something from the trends screen if secondaryNavController.viewControllers.count >= 2 { // make sure there's a corresponding trends VC in the collapsed nav that they can go back to exploreNav.pushViewController(TrendsViewController(mastodonController: mastodonController), animated: false) } } // 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, dismissPresented: false) case .bookmarks, .favorites, .list(_), .savedHashtag(_), .savedInstance(_): tabBarViewController.select(tab: .explore, dismissPresented: false) // 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) // Sometimes removing a VC from the viewControllers array doesn't immediately remove it's view from the hierarchy for vc in viewControllersToMove { vc.viewIfLoaded?.removeFromSuperview() } 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 where sidebar.hasItem(.savedHashtag(hashtagVC.hashtagName)): exploreItem = .savedHashtag(hashtagVC.hashtagName) case let instanceVC as InstanceTimelineViewController: exploreItem = .savedInstance(instanceVC.instanceURL) case is TrendsViewController: exploreItem = .explore // skip transferring the ExploreViewController and TrendsViewController skipFirst = 2 // prepend the InlineTrendsViewController toPrepend = getOrCreateNavigationStack(item: .explore).first! 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 } } func splitViewController(_ svc: UISplitViewController, willHide column: UISplitViewController.Column) { if column == .primary { sidebarVisibile = false } } func splitViewController(_ svc: UISplitViewController, willShow column: UISplitViewController.Column) { if column == .primary { sidebarVisibile = true } } } 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] } func sidebar(_ sidebarViewController: MainSidebarViewController, scrollToTopFor item: MainSidebarViewController.Item) { (secondaryNavController as? TabBarScrollableViewController)?.tabBarScrollToTop() } } fileprivate extension MainSidebarViewController.Item { @MainActor 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(name): return HashtagTimelineViewController(forNamed: name, 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)?) -> PreferencesNavigationController? { let vc = PreferencesNavigationController(mastodonController: mastodonController) present(vc, animated: true, completion: completion) return vc } func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { guard presentedViewController == nil else { return .continue } 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), let statusBarTappable = secondaryNavController as? StatusBarTappableViewController { return statusBarTappable.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) } } extension MainSplitViewController: AccountSwitchableViewController { var isFastAccountSwitcherActive: Bool { if isCollapsed { return tabBarViewController.isFastAccountSwitcherActive } else if let fastAccountSwitcher { return !fastAccountSwitcher.view.isHidden } else { return false } } }