diff --git a/Tusker/Screens/Explore/ExploreViewController.swift b/Tusker/Screens/Explore/ExploreViewController.swift index 00a8aace..4fdff7ab 100644 --- a/Tusker/Screens/Explore/ExploreViewController.swift +++ b/Tusker/Screens/Explore/ExploreViewController.swift @@ -19,6 +19,8 @@ class ExploreViewController: EnhancedTableViewController { var resultsController: SearchResultsViewController! var searchController: UISearchController! + var searchControllerStatusOnAppearance: Bool? = nil + init(mastodonController: MastodonController) { self.mastodonController = mastodonController @@ -109,6 +111,18 @@ class ExploreViewController: EnhancedTableViewController { reloadLists() } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + // this is a workaround for the issue that setting isActive on a search controller that is not visible + // does not cause it to automatically become active once it becomes visible + // see FB7814561 + if let active = searchControllerStatusOnAppearance { + searchController.isActive = active + searchControllerStatusOnAppearance = nil + } + } + func reloadLists() { let request = Client.getLists() mastodonController.run(request) { (response) in diff --git a/Tusker/Screens/Main/MainSidebarViewController.swift b/Tusker/Screens/Main/MainSidebarViewController.swift index 412f15f0..5294637d 100644 --- a/Tusker/Screens/Main/MainSidebarViewController.swift +++ b/Tusker/Screens/Main/MainSidebarViewController.swift @@ -18,12 +18,45 @@ protocol MainSidebarViewControllerDelegate: class { @available(iOS 14.0, *) class MainSidebarViewController: UIViewController { - weak var mastodonController: MastodonController! + private weak var mastodonController: MastodonController! weak var sidebarDelegate: MainSidebarViewControllerDelegate? - var collectionView: UICollectionView! - var dataSource: UICollectionViewDiffableDataSource! + private var collectionView: UICollectionView! + private var dataSource: UICollectionViewDiffableDataSource! + + var allItems: [Item] { + [ + .tab(.timelines), + .tab(.notifications), + .tab(.myProfile), + ] + exploreTabItems + } + + var exploreTabItems: [Item] { + var items: [Item] = [.search, .bookmarks] + let snapshot = dataSource.snapshot() + for case let .list(list) in snapshot.itemIdentifiers(inSection: .lists) { + items.append(.list(list)) + } + for case let .savedHashtag(hashtag) in snapshot.itemIdentifiers(inSection: .savedHashtags) { + items.append(.savedHashtag(hashtag)) + } + for case let .savedInstance(instance) in snapshot.itemIdentifiers(inSection: .savedInstances) { + items.append(.savedInstance(instance)) + } + return items + } + + private(set) var previouslySelectedItem: Item? + var selectedItem: Item? { + guard let indexPath = collectionView.indexPathsForSelectedItems?.first else { + return nil + } + return dataSource.itemIdentifier(for: indexPath) + } + + private(set) var itemLastSelectedTimestamps = [Item: Date]() init(mastodonController: MastodonController) { self.mastodonController = mastodonController @@ -53,15 +86,16 @@ class MainSidebarViewController: UIViewController { applyInitialSnapshot() - select(.tab(.timelines), animated: false) + select(item: .tab(.timelines), animated: false) NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedHashtags), name: .savedHashtagsChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil) } - func select(_ item: Item, animated: Bool) { + func select(item: Item, animated: Bool) { guard let indexPath = dataSource.indexPath(for: item) else { return } collectionView.selectItem(at: indexPath, animated: animated, scrollPosition: .top) + itemLastSelectedTimestamps[item] = Date() } private func createDataSource() -> UICollectionViewDiffableDataSource { @@ -301,32 +335,35 @@ fileprivate extension MainTabBarViewController.Tab { @available(iOS 14.0, *) extension MainSidebarViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { - guard let item = dataSource.itemIdentifier(for: indexPath) else { - return false - } - switch item { - case .tab(.compose): - sidebarDelegate?.sidebarRequestPresentCompose(self) - return false - case .addList: - showAddList() - return false - case .addSavedHashtag: - showAddSavedHashtag() - return false - case .addSavedInstance: - showAddSavedInstance() - return false - default: - return true - } + previouslySelectedItem = selectedItem + return true } + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let item = dataSource.itemIdentifier(for: indexPath) else { collectionView.deselectItem(at: indexPath, animated: true) return } - sidebarDelegate?.sidebar(self, didSelectItem: item) + itemLastSelectedTimestamps[item] = Date() + if [MainSidebarViewController.Item.tab(.compose), .addList, .addSavedHashtag, .addSavedInstance].contains(item) { + if let previous = previouslySelectedItem, let indexPath = dataSource.indexPath(for: previous) { + collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredVertically) + } + switch item { + case .tab(.compose): + sidebarDelegate?.sidebarRequestPresentCompose(self) + case .addList: + showAddList() + case .addSavedHashtag: + showAddSavedHashtag() + case .addSavedInstance: + showAddSavedInstance() + default: + fatalError("unreachable") + } + } else { + sidebarDelegate?.sidebar(self, didSelectItem: item) + } } } diff --git a/Tusker/Screens/Main/MainSplitViewController.swift b/Tusker/Screens/Main/MainSplitViewController.swift index 086af8b4..8a4644f1 100644 --- a/Tusker/Screens/Main/MainSplitViewController.swift +++ b/Tusker/Screens/Main/MainSplitViewController.swift @@ -15,7 +15,10 @@ class MainSplitViewController: UISplitViewController { private var sidebar: MainSidebarViewController! - private var detailViewControllers: [MainSidebarViewController.Item: UIViewController] = [:] + // 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! init(mastodonController: MastodonController) { self.mastodonController = mastodonController @@ -34,36 +37,234 @@ class MainSplitViewController: UISplitViewController { preferredSplitBehavior = .tile presentsWithGesture = false showsSecondaryOnlyButton = false + delegate = self sidebar = MainSidebarViewController(mastodonController: mastodonController) sidebar.sidebarDelegate = self setViewController(sidebar, for: .primary) + + setViewController(EnhancedNavigationViewController(), for: .secondary) select(item: .tab(.timelines)) - setViewController(MainTabBarViewController(mastodonController: mastodonController), for: .compact) + + tabBarViewController = MainTabBarViewController(mastodonController: mastodonController) + setViewController(tabBarViewController, for: .compact) } func select(item: MainSidebarViewController.Item) { - let itemController = getOrCreateDetailViewController(item: item) - setViewController(itemController, for: .secondary) + let nav = viewController(for: .secondary) as! UINavigationController + nav.viewControllers = getOrCreateNavigationStack(item: item) } - func getOrCreateDetailViewController(item: MainSidebarViewController.Item) -> UIViewController? { - if let existing = detailViewControllers[item] { + func getOrCreateNavigationStack(item: MainSidebarViewController.Item) -> [UIViewController] { + if let existing = navigationStacks[item], existing.count > 0 { return existing } else { - guard let new = item.createRootViewController(mastodonController) else { return nil } - let nav = EnhancedNavigationViewController(rootViewController: new) - - // Prevents the navigation bar from going transparent when switching sidebar sections. - nav.navigationBar.scrollEdgeAppearance = nav.navigationBar.standardAppearance - - detailViewControllers[item] = nav - return nav + let new = [item.createRootViewController(mastodonController)!] + navigationStacks[item] = new + return new } } } +@available(iOS 14.0, *) +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 { + let detailNav = viewController(for: .secondary) as! UINavigationController + itemNavStack = detailNav.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) { + // 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 + 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 != .search { + 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 let .tab(tab): + // sidebar items that map 1 <-> 1 can be transferred directly + tabBarViewController.select(tab: tab) + + case .search: + // 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 nav = viewController(for: .secondary) as! UINavigationController + let search = nav.viewControllers.first as! SearchViewController + // 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: .search, to: exploreNav, dropFirst: true, append: true) + + tabBarViewController.select(tab: .explore) + + case .bookmarks, .list(_), .savedHashtag(_), .savedInstance(_): + tabBarViewController.select(tab: .explore) + // Make sure the Explore VC doesn't show it's 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("unreachable") + } + } + + /// 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 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 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 = .search + let searchVC = SearchViewController(mastodonController: mastodonController) + 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 if tabNavigationStack[1] is BookmarksTableViewController { + exploreItem = .bookmarks + } else if let listVC = tabNavigationStack[1] as? ListTimelineViewController { + exploreItem = .list(listVC.list) + } else if let hashtagVC = tabNavigationStack[1] as? HashtagTimelineViewController { + exploreItem = .savedHashtag(hashtagVC.hashtag) + } else if let instanceVC = tabNavigationStack[1] as? InstanceTimelineViewController { + exploreItem = .savedInstance(instanceVC.instanceURL) + } + transferNavigationStack(from: tabNavController, to: exploreItem!, skipFirst: 1, 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 + } + } +} + @available(iOS 14.0, *) extension MainSplitViewController: MainSidebarViewControllerDelegate { func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController) { @@ -71,6 +272,10 @@ extension MainSplitViewController: MainSidebarViewControllerDelegate { } func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) { + let nav = viewController(for: .secondary) as! UINavigationController + if let previous = sidebar.previouslySelectedItem { + navigationStacks[previous] = nav.viewControllers + } select(item: item) } } diff --git a/Tusker/Screens/Main/MainTabBarViewController.swift b/Tusker/Screens/Main/MainTabBarViewController.swift index f402cb71..8494b9d2 100644 --- a/Tusker/Screens/Main/MainTabBarViewController.swift +++ b/Tusker/Screens/Main/MainTabBarViewController.swift @@ -13,6 +13,10 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { weak var mastodonController: MastodonController! private var composePlaceholder: UIViewController! + + var selectedTab: Tab { + return Tab(rawValue: selectedIndex)! + } override var supportedInterfaceOrientations: UIInterfaceOrientationMask { if UIDevice.current.userInterfaceIdiom == .phone { @@ -65,6 +69,14 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { } return true } + + func setViewController(_ viewController: UIViewController, forTab tab: Tab) { + viewControllers![tab.rawValue] = viewController + } + + func viewController(for tab: Tab) -> UIViewController { + return viewControllers![tab.rawValue] + } } extension MainTabBarViewController { @@ -91,8 +103,6 @@ extension MainTabBarViewController { } } - - func getTabController(tab: Tab) -> UIViewController? { if tab == .compose { return nil diff --git a/Tusker/Screens/Profile/MyProfileTableViewController.swift b/Tusker/Screens/Profile/MyProfileTableViewController.swift index 8e8cdf44..4776b0ab 100644 --- a/Tusker/Screens/Profile/MyProfileTableViewController.swift +++ b/Tusker/Screens/Profile/MyProfileTableViewController.swift @@ -17,7 +17,6 @@ class MyProfileTableViewController: ProfileTableViewController { title = "My Profile" tabBarItem.image = UIImage(systemName: "person.fill") - mastodonController.getOwnAccount { (account) in self.accountID = account.id @@ -41,14 +40,6 @@ class MyProfileTableViewController: ProfileTableViewController { fatalError("init(coder:) has not been implemented") } - override func viewDidLoad() { - super.viewDidLoad() - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - } - @objc func preferencesPressed() { present(PreferencesNavigationController(mastodonController: mastodonController), animated: true) } diff --git a/Tusker/Screens/Profile/ProfileTableViewController.swift b/Tusker/Screens/Profile/ProfileTableViewController.swift index b8d84b8f..50e509a0 100644 --- a/Tusker/Screens/Profile/ProfileTableViewController.swift +++ b/Tusker/Screens/Profile/ProfileTableViewController.swift @@ -31,10 +31,6 @@ class ProfileTableViewController: EnhancedTableViewController { self.accountID = accountID super.init(style: .plain) - - self.refreshControl = UIRefreshControl() - refreshControl!.addTarget(self, action: #selector(refreshStatuses(_:)), for: .valueChanged) - navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composePressed(_:))) } required init?(coder aDecoder: NSCoder) { @@ -51,6 +47,10 @@ class ProfileTableViewController: EnhancedTableViewController { override func viewDidLoad() { super.viewDidLoad() + + self.refreshControl = UIRefreshControl() + refreshControl!.addTarget(self, action: #selector(refreshStatuses(_:)), for: .valueChanged) + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composePressed(_:))) tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 140 diff --git a/Tusker/Screens/Search/SearchResultsViewController.swift b/Tusker/Screens/Search/SearchResultsViewController.swift index b6337bc0..6919ad88 100644 --- a/Tusker/Screens/Search/SearchResultsViewController.swift +++ b/Tusker/Screens/Search/SearchResultsViewController.swift @@ -109,6 +109,15 @@ class SearchResultsViewController: EnhancedTableViewController { return super.targetViewController(forAction: action, sender: sender) } + func loadResults(from source: SearchResultsViewController) { + currentQuery = source.currentQuery + if let sourceDataSource = source.dataSource { + dataSource.apply(sourceDataSource.snapshot()) + } + // todo: check if the search needs to be performed before searching +// performSearch(query: currentQuery) + } + func performSearch(query: String?) { guard let query = query, !query.isEmpty else { self.dataSource.apply(NSDiffableDataSourceSnapshot()) @@ -212,6 +221,20 @@ extension SearchResultsViewController { case account(String) case hashtag(Hashtag) case status(String, StatusState) + + func hash(into hasher: inout Hasher) { + switch self { + case let .account(id): + hasher.combine("account") + hasher.combine(id) + case let .hashtag(hashtag): + hasher.combine("hashtag") + hasher.combine(hashtag.url) + case let .status(id, _): + hasher.combine("status") + hasher.combine(id) + } + } } class DataSource: UITableViewDiffableDataSource { diff --git a/Tusker/Screens/Search/SearchViewController.swift b/Tusker/Screens/Search/SearchViewController.swift index b8f560b0..71153eef 100644 --- a/Tusker/Screens/Search/SearchViewController.swift +++ b/Tusker/Screens/Search/SearchViewController.swift @@ -15,6 +15,8 @@ class SearchViewController: UIViewController { var resultsController: SearchResultsViewController! var searchController: UISearchController! + var searchControllerStatusOnAppearance: Bool? = nil + init(mastodonController: MastodonController) { self.mastodonController = mastodonController @@ -42,5 +44,16 @@ class SearchViewController: UIViewController { navigationItem.hidesSearchBarWhenScrolling = false } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + // this is a workaround for the issue that setting isActive on a search controller that is not visible + // does not cause it to automatically become active once it becomes visible + // see FB7814561 + if let active = searchControllerStatusOnAppearance { + searchController.isActive = active + searchControllerStatusOnAppearance = nil + } + } }