From 6cf6db6a8d7596468eec4774a1010ed5dd426e53 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Wed, 24 Jun 2020 16:40:45 -0400 Subject: [PATCH] Add sidebar on iPadOS 14 --- Pachyderm/Model/List.swift | 10 +- Tusker.xcodeproj/project.pbxproj | 16 + Tusker/SceneDelegate.swift | 9 +- .../Explore/ExploreViewController.swift | 4 +- .../Main/MainSidebarViewController.swift | 344 ++++++++++++++++++ .../Main/MainSplitViewController.swift | 116 ++++++ .../Main/MainTabBarViewController.swift | 67 ++-- .../Main/TuskerRootViewController.swift | 14 + .../Search/SearchResultsViewController.swift | 2 +- .../Screens/Search/SearchViewController.swift | 46 +++ 10 files changed, 600 insertions(+), 28 deletions(-) create mode 100644 Tusker/Screens/Main/MainSidebarViewController.swift create mode 100644 Tusker/Screens/Main/MainSplitViewController.swift create mode 100644 Tusker/Screens/Main/TuskerRootViewController.swift create mode 100644 Tusker/Screens/Search/SearchViewController.swift diff --git a/Pachyderm/Model/List.swift b/Pachyderm/Model/List.swift index 0d141088..4cc33bc9 100644 --- a/Pachyderm/Model/List.swift +++ b/Pachyderm/Model/List.swift @@ -8,7 +8,7 @@ import Foundation -public class List: Decodable { +public class List: Decodable, Equatable, Hashable { public let id: String public let title: String @@ -16,6 +16,14 @@ public class List: Decodable { return .list(id: id) } + public static func ==(lhs: List, rhs: List) -> Bool { + return lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> { var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts") request.range = range diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 6fb7026d..638c0bf6 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -174,6 +174,8 @@ D681E4D7246E32290053414F /* StatusActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D6246E32290053414F /* StatusActivityItemSource.swift */; }; D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */; }; D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */; }; + D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */; }; + D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525C24A3E8F00054355A /* SearchViewController.swift */; }; D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */; }; D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */; }; D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5823FE24300061E07D /* InteractivePushTransition.swift */; }; @@ -249,6 +251,8 @@ D6E6F26521604242006A8599 /* CharacterCounterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E6F26421604242006A8599 /* CharacterCounterTests.swift */; }; D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; }; D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */; }; + D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; }; + D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; }; D6F1F84D2193B56E00F5FE67 /* Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F1F84C2193B56E00F5FE67 /* Cache.swift */; }; D6F2E965249E8BFD005846BB /* CrashReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* CrashReporterViewController.swift */; }; D6F2E966249E8BFD005846BB /* CrashReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* CrashReporterViewController.xib */; }; @@ -481,6 +485,8 @@ D681E4D6246E32290053414F /* StatusActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActivityItemSource.swift; sourceTree = ""; }; D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountActivityItemSource.swift; sourceTree = ""; }; D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeDrawingViewController.swift; sourceTree = ""; }; + D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = ""; }; + D68E525C24A3E8F00054355A /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = ""; }; D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPageViewController.swift; sourceTree = ""; }; D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedNavigationViewController.swift; sourceTree = ""; }; D693DE5823FE24300061E07D /* InteractivePushTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractivePushTransition.swift; sourceTree = ""; }; @@ -558,6 +564,8 @@ D6E6F26421604242006A8599 /* CharacterCounterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCounterTests.swift; sourceTree = ""; }; D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Scenes.swift"; sourceTree = ""; }; D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UISceneSession+MastodonController.swift"; sourceTree = ""; }; + D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSplitViewController.swift; sourceTree = ""; }; + D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = ""; }; D6F1F84C2193B56E00F5FE67 /* Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cache.swift; sourceTree = ""; }; D6F2E963249E8BFD005846BB /* CrashReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashReporterViewController.swift; sourceTree = ""; }; D6F2E964249E8BFD005846BB /* CrashReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CrashReporterViewController.xib; sourceTree = ""; }; @@ -896,7 +904,10 @@ D641C782213DD7F0004B4513 /* Main */ = { isa = PBXGroup; children = ( + D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */, 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */, + D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */, + D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */, ); path = Main; sourceTree = ""; @@ -1190,6 +1201,7 @@ D6BC9DD8232D8BCA002CA326 /* Search */ = { isa = PBXGroup; children = ( + D68E525C24A3E8F00054355A /* SearchViewController.swift */, D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */, ); path = Search; @@ -1703,6 +1715,7 @@ D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */, D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */, 04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */, + D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */, D6AEBB412321642700E5038B /* SendMesasgeActivity.swift in Sources */, D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */, D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */, @@ -1734,6 +1747,7 @@ D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */, D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */, D60309B52419D4F100A465FF /* ComposeAttachmentsViewController.swift in Sources */, + D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */, D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */, D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */, D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */, @@ -1773,6 +1787,7 @@ D6AEBB4523216AF800E5038B /* FollowAccountActivity.swift in Sources */, D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */, D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */, + D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */, D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */, D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */, 0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */, @@ -1834,6 +1849,7 @@ D64D8CA92463B494006B0BAA /* CachedDictionary.swift in Sources */, D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */, D6F1F84D2193B56E00F5FE67 /* Cache.swift in Sources */, + D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */, 0427037C22B316B9000D31B6 /* SilentActionPrefs.swift in Sources */, D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */, D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */, diff --git a/Tusker/SceneDelegate.swift b/Tusker/SceneDelegate.swift index 9626e0ee..27eccb6d 100644 --- a/Tusker/SceneDelegate.swift +++ b/Tusker/SceneDelegate.swift @@ -157,8 +157,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { mastodonController.getOwnAccount() mastodonController.getOwnInstance() - let tabBarController = MainTabBarViewController(mastodonController: mastodonController) - window!.rootViewController = tabBarController + let rootController: UIViewController + if #available(iOS 14.0, *) { + rootController = MainSplitViewController(mastodonController: mastodonController) + } else { + rootController = MainTabBarViewController(mastodonController: mastodonController) + } + window!.rootViewController = rootController } func showOnboardingUI() { diff --git a/Tusker/Screens/Explore/ExploreViewController.swift b/Tusker/Screens/Explore/ExploreViewController.swift index e8fde0c4..00a8aace 100644 --- a/Tusker/Screens/Explore/ExploreViewController.swift +++ b/Tusker/Screens/Explore/ExploreViewController.swift @@ -90,9 +90,7 @@ class ExploreViewController: EnhancedTableViewController { snapshot.appendItems(SavedDataManager.shared.sortedHashtags(for: account).map { .savedHashtag($0) } + [.addSavedHashtag], toSection: .savedHashtags) snapshot.appendItems(SavedDataManager.shared.savedInstances(for: account).map { .savedInstance($0) } + [.findInstance], toSection: .savedInstances) // the initial, static items should not be displayed with an animation - UIView.performWithoutAnimation { - dataSource.apply(snapshot) - } + dataSource.apply(snapshot, animatingDifferences: false) resultsController = SearchResultsViewController(mastodonController: mastodonController) resultsController.exploreNavigationController = self.navigationController! diff --git a/Tusker/Screens/Main/MainSidebarViewController.swift b/Tusker/Screens/Main/MainSidebarViewController.swift new file mode 100644 index 00000000..412f15f0 --- /dev/null +++ b/Tusker/Screens/Main/MainSidebarViewController.swift @@ -0,0 +1,344 @@ +// +// MainSidebarViewController.swift +// Tusker +// +// Created by Shadowfacts on 6/24/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +@available(iOS 14.0, *) +protocol MainSidebarViewControllerDelegate: class { + func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController) + func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) +} + +@available(iOS 14.0, *) +class MainSidebarViewController: UIViewController { + + weak var mastodonController: MastodonController! + + weak var sidebarDelegate: MainSidebarViewControllerDelegate? + + var collectionView: UICollectionView! + var dataSource: UICollectionViewDiffableDataSource! + + init(mastodonController: MastodonController) { + self.mastodonController = mastodonController + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + navigationItem.title = "Tusker" + navigationItem.largeTitleDisplayMode = .always + navigationController!.navigationBar.prefersLargeTitles = true + + let layout = UICollectionViewCompositionalLayout.list(using: .init(appearance: .sidebar)) + collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) + collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + collectionView.backgroundColor = .systemGroupedBackground + collectionView.delegate = self + view.addSubview(collectionView) + + dataSource = createDataSource() + + applyInitialSnapshot() + + select(.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) { + guard let indexPath = dataSource.indexPath(for: item) else { return } + collectionView.selectItem(at: indexPath, animated: animated, scrollPosition: .top) + } + + private func createDataSource() -> UICollectionViewDiffableDataSource { + let listCell = UICollectionView.CellRegistration { (cell, indexPath, item) in + var config = cell.defaultContentConfiguration() + config.text = item.title + if let imageName = item.imageName { + config.image = UIImage(systemName: imageName) + } + cell.contentConfiguration = config + } + + let outlineHeaderCell = UICollectionView.CellRegistration { (cell, indexPath, item) in + var config = cell.defaultContentConfiguration() + config.attributedText = NSAttributedString(string: item.title, attributes: [ + .font: UIFont.boldSystemFont(ofSize: 21) + ]) + cell.contentConfiguration = config + cell.accessories = [.outlineDisclosure(options: .init(style: .header))] + } + + return UICollectionViewDiffableDataSource(collectionView: collectionView, cellProvider: { (collectionView, indexPath, item) -> UICollectionViewCell? in + if item.hasChildren { + return collectionView.dequeueConfiguredReusableCell(using: outlineHeaderCell, for: indexPath, item: item) + } else { + return collectionView.dequeueConfiguredReusableCell(using: listCell, for: indexPath, item: item) + } + }) + } + + private func applyInitialSnapshot() { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections(Section.allCases) + snapshot.appendItems([ + .tab(.timelines), + .tab(.notifications), + .search, + .bookmarks, + .tab(.myProfile) + ], toSection: .tabs) + snapshot.appendItems([ + .tab(.compose) + ], toSection: .compose) + dataSource.apply(snapshot, animatingDifferences: false) + + reloadLists() + reloadSavedHashtags() + reloadSavedInstances() + } + + private func reloadLists() { + let request = Client.getLists() + mastodonController.run(request) { [weak self] (response) in + guard let self = self, case let .success(lists, _) = response else { return } + + var exploreSnapshot = NSDiffableDataSourceSectionSnapshot() + exploreSnapshot.append([.listsHeader]) + exploreSnapshot.expand([.listsHeader]) + exploreSnapshot.append(lists.map { .list($0) }, to: .listsHeader) + exploreSnapshot.append([.addList], to: .listsHeader) + DispatchQueue.main.async { + self.dataSource.apply(exploreSnapshot, to: .lists) + } + } + } + + @objc private func reloadSavedHashtags() { + var hashtagsSnapshot = NSDiffableDataSourceSectionSnapshot() + hashtagsSnapshot.append([.savedHashtagsHeader]) + hashtagsSnapshot.expand([.savedHashtagsHeader]) + let sortedHashtags = SavedDataManager.shared.sortedHashtags(for: mastodonController.accountInfo!) + hashtagsSnapshot.append(sortedHashtags.map { .savedHashtag($0) }, to: .savedHashtagsHeader) + hashtagsSnapshot.append([.addSavedHashtag], to: .savedHashtagsHeader) + self.dataSource.apply(hashtagsSnapshot, to: .savedHashtags, animatingDifferences: false) + } + + @objc private func reloadSavedInstances() { + var instancesSnapshot = NSDiffableDataSourceSectionSnapshot() + instancesSnapshot.append([.savedInstancesHeader]) + instancesSnapshot.expand([.savedInstancesHeader]) + let sortedInstances = SavedDataManager.shared.savedInstances(for: mastodonController.accountInfo!) + instancesSnapshot.append(sortedInstances.map { .savedInstance($0) }, to: .savedInstancesHeader) + instancesSnapshot.append([.addSavedInstance], to: .savedInstancesHeader) + self.dataSource.apply(instancesSnapshot, to: .savedInstances, animatingDifferences: false) + } + + // todo: deduplicate with ExploreViewController + private func showAddList() { + let alert = UIAlertController(title: "New List", message: "Choose a title for your new list", preferredStyle: .alert) + alert.addTextField(configurationHandler: nil) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) + alert.addAction(UIAlertAction(title: "Create List", style: .default, handler: { (_) in + guard let title = alert.textFields?.first?.text else { + fatalError() + } + + let request = Client.createList(title: title) + self.mastodonController.run(request) { (response) in + guard case let .success(list, _) = response else { fatalError() } + + self.reloadLists() + + DispatchQueue.main.async { + self.sidebarDelegate?.sidebar(self, didSelectItem: .list(list)) + } + } + })) + present(alert, animated: true) + } + + // todo: deduplicate with ExploreViewController + private func showAddSavedHashtag() { + let navController = EnhancedNavigationViewController(rootViewController: AddSavedHashtagViewController(mastodonController: mastodonController)) + present(navController, animated: true) + } + + // todo: deduplicate with ExploreViewController + private func showAddSavedInstance() { + let findController = FindInstanceViewController(parentMastodonController: mastodonController) + findController.instanceTimelineDelegate = self + let navController = EnhancedNavigationViewController(rootViewController: findController) + present(navController, animated: true) + } + +} + +@available(iOS 14.0, *) +extension MainSidebarViewController { + enum Section: Int, Hashable, CaseIterable { + case tabs + case compose + case lists + case savedHashtags + case savedInstances + } + enum Item: Hashable { + case tab(MainTabBarViewController.Tab) + case search, bookmarks + case listsHeader, list(List), addList + case savedHashtagsHeader, savedHashtag(Hashtag), addSavedHashtag + case savedInstancesHeader, savedInstance(URL), addSavedInstance + + var title: String { + switch self { + case let .tab(tab): + return tab.title + case .search: + return "Search" + case .bookmarks: + return "Bookmarks" + case .listsHeader: + return "Lists" + case let .list(list): + return list.title + case .addList: + return "New List..." + case .savedHashtagsHeader: + return "Saved Hashtags" + case let .savedHashtag(hashtag): + return hashtag.name + case .addSavedHashtag: + return "Save Hashtag..." + case .savedInstancesHeader: + return "Saved Instances" + case let .savedInstance(url): + return url.host! + case .addSavedInstance: + return "Find An Instance..." + } + } + + var imageName: String? { + switch self { + case let .tab(tab): + return tab.imageName + case .search: + return "magnifyingglass" + case .bookmarks: + return "bookmark" + case .list(_): + return "list.bullet" + case .savedHashtag(_): + return "number" + case .savedInstance(_): + return "globe" + case .listsHeader, .savedHashtagsHeader, .savedInstancesHeader: + return nil + case .addList, .addSavedHashtag, .addSavedInstance: + return "plus" + } + } + + var hasChildren: Bool { + switch self { + case .listsHeader, .savedHashtagsHeader, .savedInstancesHeader: + return true + default: + return false + } + } + } +} + +fileprivate extension MainTabBarViewController.Tab { + var title: String { + switch self { + case .timelines: + return "Home" + case .notifications: + return "Notifications" + case .compose: + return "Compose" + case .explore: + return "Explore" + case .myProfile: + return "My Profile" + } + } + + var imageName: String? { + switch self { + case .timelines: + return "house" + case .notifications: + return "bell" + case .compose: + return "pencil" + case .explore: + return "magnifyingglass" + case .myProfile: + // todo: use user avatar image + return "person" + } + } +} + +@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 + } + } + 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) + } +} + +@available(iOS 14.0, *) +extension MainSidebarViewController: InstanceTimelineViewControllerDelegate { + func didSaveInstance(url: URL) { + dismiss(animated: true) { + self.sidebarDelegate?.sidebar(self, didSelectItem: .savedInstance(url)) + } + } + + func didUnsaveInstance(url: URL) { + dismiss(animated: true) + } +} diff --git a/Tusker/Screens/Main/MainSplitViewController.swift b/Tusker/Screens/Main/MainSplitViewController.swift new file mode 100644 index 00000000..086af8b4 --- /dev/null +++ b/Tusker/Screens/Main/MainSplitViewController.swift @@ -0,0 +1,116 @@ +// +// MainSplitViewController.swift +// Tusker +// +// Created by Shadowfacts on 6/23/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import UIKit + +@available(iOS 14.0, *) +class MainSplitViewController: UISplitViewController { + + weak var mastodonController: MastodonController! + + private var sidebar: MainSidebarViewController! + + private var detailViewControllers: [MainSidebarViewController.Item: UIViewController] = [:] + + 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 + presentsWithGesture = false + showsSecondaryOnlyButton = false + + sidebar = MainSidebarViewController(mastodonController: mastodonController) + sidebar.sidebarDelegate = self + setViewController(sidebar, for: .primary) + select(item: .tab(.timelines)) + setViewController(MainTabBarViewController(mastodonController: mastodonController), for: .compact) + } + + func select(item: MainSidebarViewController.Item) { + let itemController = getOrCreateDetailViewController(item: item) + setViewController(itemController, for: .secondary) + } + + func getOrCreateDetailViewController(item: MainSidebarViewController.Item) -> UIViewController? { + if let existing = detailViewControllers[item] { + 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 + } + } + +} + +@available(iOS 14.0, *) +extension MainSplitViewController: MainSidebarViewControllerDelegate { + func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController) { + presentCompose() + } + + func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) { + select(item: item) + } +} + +@available(iOS 14.0, *) +fileprivate extension MainSidebarViewController.Item { + func createRootViewController(_ mastodonController: MastodonController) -> UIViewController? { + switch self { + case let .tab(tab): + return tab.createViewController(mastodonController) + case .search: + return SearchViewController(mastodonController: mastodonController) + case .bookmarks: + return BookmarksTableViewController(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) + default: + return nil + } + } +} + +@available(iOS 14.0, *) +extension MainSplitViewController: TuskerRootViewController { + func presentCompose() { + let compose = ComposeViewController(mastodonController: mastodonController) + let navigationController = EnhancedNavigationViewController(rootViewController: compose) + navigationController.presentationController?.delegate = compose + present(navigationController, animated: true) + } + + func select(tab: MainTabBarViewController.Tab) { + if tab == .compose { + presentCompose() + } else { + select(item: .tab(tab)) + } + } +} diff --git a/Tusker/Screens/Main/MainTabBarViewController.swift b/Tusker/Screens/Main/MainTabBarViewController.swift index 0636d7bc..f402cb71 100644 --- a/Tusker/Screens/Main/MainTabBarViewController.swift +++ b/Tusker/Screens/Main/MainTabBarViewController.swift @@ -11,6 +11,8 @@ import UIKit class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { weak var mastodonController: MastodonController! + + private var composePlaceholder: UIViewController! override var supportedInterfaceOrientations: UIInterfaceOrientationMask { if UIDevice.current.userInterfaceIdiom == .phone { @@ -35,12 +37,16 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { self.delegate = self + composePlaceholder = UIViewController() + composePlaceholder.title = "Compose" + composePlaceholder.tabBarItem.image = UIImage(systemName: "pencil") + viewControllers = [ - embedInNavigationController(TimelinesPageViewController(mastodonController: mastodonController)), - embedInNavigationController(NotificationsPageViewController(mastodonController: mastodonController)), - ComposeViewController(mastodonController: mastodonController), - embedInNavigationController(ExploreViewController(mastodonController: mastodonController)), - embedInNavigationController(MyProfileTableViewController(mastodonController: mastodonController)), + embedInNavigationController(Tab.timelines.createViewController(mastodonController)), + embedInNavigationController(Tab.notifications.createViewController(mastodonController)), + composePlaceholder, + embedInNavigationController(Tab.explore.createViewController(mastodonController)), + embedInNavigationController(Tab.myProfile.createViewController(mastodonController)), ] } @@ -53,38 +59,40 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { } func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { - if viewController is ComposeViewController { + if viewController == composePlaceholder { presentCompose() return false } return true } - - func presentCompose() { - let compose = ComposeViewController(mastodonController: mastodonController) - let navigationController = embedInNavigationController(compose) - navigationController.presentationController?.delegate = compose - present(navigationController, animated: true) - } } extension MainTabBarViewController { - enum Tab: Int { + enum Tab: Int, Hashable, CaseIterable { case timelines case notifications case compose case explore case myProfile - } - - func select(tab: Tab) { - if tab == .compose { - presentCompose() - } else { - selectedIndex = tab.rawValue + + func createViewController(_ mastodonController: MastodonController) -> UIViewController { + switch self { + case .timelines: + return TimelinesPageViewController(mastodonController: mastodonController) + case .notifications: + return NotificationsPageViewController(mastodonController: mastodonController) + case .compose: + return ComposeViewController(mastodonController: mastodonController) + case .explore: + return ExploreViewController(mastodonController: mastodonController) + case .myProfile: + return MyProfileTableViewController(mastodonController: mastodonController) + } } } + + func getTabController(tab: Tab) -> UIViewController? { if tab == .compose { return nil @@ -93,3 +101,20 @@ extension MainTabBarViewController { } } } + +extension MainTabBarViewController: TuskerRootViewController { + func presentCompose() { + let compose = ComposeViewController(mastodonController: mastodonController) + let navigationController = embedInNavigationController(compose) + navigationController.presentationController?.delegate = compose + present(navigationController, animated: true) + } + + func select(tab: Tab) { + if tab == .compose { + presentCompose() + } else { + selectedIndex = tab.rawValue + } + } +} diff --git a/Tusker/Screens/Main/TuskerRootViewController.swift b/Tusker/Screens/Main/TuskerRootViewController.swift new file mode 100644 index 00000000..1eb795be --- /dev/null +++ b/Tusker/Screens/Main/TuskerRootViewController.swift @@ -0,0 +1,14 @@ +// +// TuskerRootViewController.swift +// Tusker +// +// Created by Shadowfacts on 6/24/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import UIKit + +protocol TuskerRootViewController: UIViewController { + func presentCompose() + func select(tab: MainTabBarViewController.Tab) +} diff --git a/Tusker/Screens/Search/SearchResultsViewController.swift b/Tusker/Screens/Search/SearchResultsViewController.swift index 4e51c686..b6337bc0 100644 --- a/Tusker/Screens/Search/SearchResultsViewController.swift +++ b/Tusker/Screens/Search/SearchResultsViewController.swift @@ -28,7 +28,7 @@ extension SearchResultsViewControllerDelegate { class SearchResultsViewController: EnhancedTableViewController { - let mastodonController: MastodonController! + weak var mastodonController: MastodonController! weak var exploreNavigationController: UINavigationController? weak var delegate: SearchResultsViewControllerDelegate? diff --git a/Tusker/Screens/Search/SearchViewController.swift b/Tusker/Screens/Search/SearchViewController.swift new file mode 100644 index 00000000..b8f560b0 --- /dev/null +++ b/Tusker/Screens/Search/SearchViewController.swift @@ -0,0 +1,46 @@ +// +// SearchViewController.swift +// Tusker +// +// Created by Shadowfacts on 6/24/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import UIKit + +class SearchViewController: UIViewController { + + weak var mastodonController: MastodonController! + + var resultsController: SearchResultsViewController! + var searchController: UISearchController! + + init(mastodonController: MastodonController) { + self.mastodonController = mastodonController + + super.init(nibName: nil, bundle: nil) + + title = NSLocalizedString("Search", comment: "search tab title") + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + resultsController = SearchResultsViewController(mastodonController: mastodonController) + resultsController.exploreNavigationController = self.navigationController + searchController = UISearchController(searchResultsController: resultsController) + searchController.searchResultsUpdater = resultsController + searchController.searchBar.autocapitalizationType = .none + searchController.searchBar.delegate = resultsController + definesPresentationContext = true + + navigationItem.searchController = searchController + navigationItem.hidesSearchBarWhenScrolling = false + } + + +}