From 87bc1f5f750ace43c3866970e611ad9435716a60 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 6 Feb 2023 21:26:42 -0500 Subject: [PATCH] Rewrite search results VC using UICollectionView --- .../Explore/ExploreViewController.swift | 11 + .../Explore/TrendsViewController.swift | 2 +- .../Search/SearchResultsViewController.swift | 362 ++++++++++++------ 3 files changed, 264 insertions(+), 111 deletions(-) diff --git a/Tusker/Screens/Explore/ExploreViewController.swift b/Tusker/Screens/Explore/ExploreViewController.swift index 1d23eb31..2441b181 100644 --- a/Tusker/Screens/Explore/ExploreViewController.swift +++ b/Tusker/Screens/Explore/ExploreViewController.swift @@ -101,12 +101,23 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + // UISearchController exists outside of the normal VC hierarchy, + // so we manually propagate this down to the results controller + // so that it can deselect on appear + if searchController.isActive { + resultsController.viewWillAppear(animated) + } + clearSelectionOnAppear(animated: animated) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + if searchController.isActive { + resultsController.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 diff --git a/Tusker/Screens/Explore/TrendsViewController.swift b/Tusker/Screens/Explore/TrendsViewController.swift index fc976107..df524702 100644 --- a/Tusker/Screens/Explore/TrendsViewController.swift +++ b/Tusker/Screens/Explore/TrendsViewController.swift @@ -98,7 +98,7 @@ class TrendsViewController: UIViewController, CollectionViewController { private func createDataSource() -> UICollectionViewDiffableDataSource { let sectionHeaderCell = UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader) { [unowned self] (headerView, collectionView, indexPath) in - let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section] + let section = self.dataSource.sectionIdentifier(for: indexPath.section)! var config = UIListContentConfiguration.groupedHeader() config.text = section.title headerView.contentConfiguration = config diff --git a/Tusker/Screens/Search/SearchResultsViewController.swift b/Tusker/Screens/Search/SearchResultsViewController.swift index 04bc76b2..f757a3ad 100644 --- a/Tusker/Screens/Search/SearchResultsViewController.swift +++ b/Tusker/Screens/Search/SearchResultsViewController.swift @@ -9,6 +9,7 @@ import UIKit import Combine import Pachyderm +import WebURLFoundationExtras fileprivate let accountCell = "accountCell" fileprivate let statusCell = "statusCell" @@ -26,17 +27,15 @@ extension SearchResultsViewControllerDelegate { func selectedSearchResult(status statusID: String) {} } -class SearchResultsViewController: EnhancedTableViewController { +class SearchResultsViewController: UIViewController, CollectionViewController { weak var mastodonController: MastodonController! weak var exploreNavigationController: UINavigationController? weak var delegate: SearchResultsViewControllerDelegate? - var dataSource: UITableViewDiffableDataSource! - - private var activityIndicator: UIActivityIndicatorView! - private var errorLabel: UILabel! + var collectionView: UICollectionView! { view as? UICollectionView } + private var dataSource: UICollectionViewDiffableDataSource! /// Types of results to search for. var scope: Scope @@ -50,9 +49,7 @@ class SearchResultsViewController: EnhancedTableViewController { self.mastodonController = mastodonController self.scope = scope - super.init(style: .grouped) - - dragEnabled = true + super.init(nibName: nil, bundle: nil) title = NSLocalizedString("Search", comment: "search screen title") } @@ -61,49 +58,88 @@ class SearchResultsViewController: EnhancedTableViewController { fatalError("init(coder:) has not been implemented") } + override func loadView() { + let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in + var config = UICollectionLayoutListConfiguration(appearance: .grouped) + config.backgroundColor = .appGroupedBackground + config.headerMode = .supplementary + switch self.dataSource.sectionIdentifier(for: sectionIndex) { + case .loadingIndicator: + config.showsSeparators = false + config.headerMode = .none + case .statuses: + config.leadingSwipeActionsConfigurationProvider = { [unowned self] in + (self.collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions() + } + config.trailingSwipeActionsConfigurationProvider = { [unowned self] in + (self.collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions() + } + config.separatorConfiguration.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets + config.separatorConfiguration.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets + default: + break + } + let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) + if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { + section.contentInsetsReference = .readableContent + } + return section + } + view = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.delegate = self + collectionView.dragDelegate = self + collectionView.allowsFocus = true + collectionView.backgroundColor = .appGroupedBackground + + dataSource = createDataSource() + } + override func viewDidLoad() { super.viewDidLoad() - errorLabel = UILabel() - errorLabel.translatesAutoresizingMaskIntoConstraints = false - errorLabel.font = .preferredFont(forTextStyle: .callout) - errorLabel.textColor = .secondaryLabel - errorLabel.numberOfLines = 0 - errorLabel.textAlignment = .center - errorLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - tableView.addSubview(errorLabel) - NSLayoutConstraint.activate([ - errorLabel.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), - errorLabel.centerXAnchor.constraint(equalTo: tableView.centerXAnchor), - errorLabel.leadingAnchor.constraint(equalToSystemSpacingAfter: tableView.leadingAnchor, multiplier: 1), - tableView.trailingAnchor.constraint(equalToSystemSpacingAfter: errorLabel.trailingAnchor, multiplier: 1), - ]) + _ = searchSubject + .debounce(for: .milliseconds(500), scheduler: RunLoop.main) + .map { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { $0 != self.currentQuery } + .sink(receiveValue: performSearch(query:)) - tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: accountCell) - tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: statusCell) - tableView.register(UINib(nibName: "HashtagTableViewCell", bundle: .main), forCellReuseIdentifier: hashtagCell) + userActivity = UserActivityManager.searchActivity() - tableView.allowsFocus = true - tableView.backgroundColor = .appGroupedBackground - - dataSource = DataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in - let cell: UITableViewCell - switch item { - case let .account(id): - let accountCell = tableView.dequeueReusableCell(withIdentifier: accountCell, for: indexPath) as! AccountTableViewCell - accountCell.delegate = self - accountCell.updateUI(accountID: id) - cell = accountCell - case let .hashtag(tag): - let hashtagCell = tableView.dequeueReusableCell(withIdentifier: hashtagCell, for: indexPath) as! HashtagTableViewCell - hashtagCell.delegate = self - hashtagCell.updateUI(hashtag: tag) - cell = hashtagCell - case let .status(id, state): - let statusCell = tableView.dequeueReusableCell(withIdentifier: statusCell, for: indexPath) as! TimelineStatusTableViewCell - statusCell.delegate = self - statusCell.updateUI(statusID: id, state: state) - cell = statusCell + NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) + } + + private func createDataSource() -> UICollectionViewDiffableDataSource { + let sectionHeader = UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader) { [unowned self] supplementaryView, elementKind, indexPath in + let section = self.dataSource.sectionIdentifier(for: indexPath.section)! + var config = UIListContentConfiguration.groupedHeader() + config.text = section.displayName + supplementaryView.contentConfiguration = config + } + let loadingCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in + cell.indicator.startAnimating() + } + let accountCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in + cell.delegate = self + cell.updateUI(accountID: itemIdentifier) + } + let hashtagCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in + cell.updateUI(hashtag: itemIdentifier) + } + let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, itemIdentifier in + cell.delegate = self + cell.updateUI(statusID: itemIdentifier.0, state: itemIdentifier.1, filterResult: .allow, precomputedContent: nil) + } + let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in + let cell: UICollectionViewCell + switch itemIdentifier { + case .loadingIndicator: + return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ()) + case .account(let accountID): + cell = collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: accountID) + case .hashtag(let hashtag): + cell = collectionView.dequeueConfiguredReusableCell(using: hashtagCell, for: indexPath, item: hashtag) + case .status(let id, let state): + cell = collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state)) } cell.configurationUpdateHandler = { cell, state in var config = UIBackgroundConfiguration.listGroupedCell().updated(for: state) @@ -115,26 +151,21 @@ class SearchResultsViewController: EnhancedTableViewController { cell.backgroundConfiguration = config } return cell - }) + } + dataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in + if elementKind == UICollectionView.elementKindSectionHeader { + return collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeader, for: indexPath) + } else { + return nil + } + } + return dataSource + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) - activityIndicator = UIActivityIndicatorView(style: .large) - activityIndicator.translatesAutoresizingMaskIntoConstraints = false - activityIndicator.isHidden = true - view.addSubview(activityIndicator) - NSLayoutConstraint.activate([ - activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), - activityIndicator.topAnchor.constraint(equalTo: view.topAnchor, constant: 8) - ]) - - _ = searchSubject - .debounce(for: .milliseconds(500), scheduler: RunLoop.main) - .map { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { $0 != self.currentQuery } - .sink(receiveValue: performSearch(query:)) - - userActivity = UserActivityManager.searchActivity() - - NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) + clearSelectionOnAppear(animated: animated) } override func targetViewController(forAction action: Selector, sender: Any?) -> UIViewController? { @@ -161,25 +192,19 @@ class SearchResultsViewController: EnhancedTableViewController { } self.currentQuery = query - activityIndicator.isHidden = false - activityIndicator.startAnimating() - errorLabel.isHidden = true + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.loadingIndicator]) + snapshot.appendItems([.loadingIndicator]) + dataSource.apply(snapshot) let request = Client.search(query: query, types: scope.resultTypes, resolve: true, limit: 10, following: following) mastodonController.run(request) { (response) in switch response { case let .success(results, _): guard self.currentQuery == query else { return } - DispatchQueue.main.async { - self.activityIndicator.isHidden = true - self.activityIndicator.stopAnimating() - } self.showSearchResults(results) case let .failure(error): DispatchQueue.main.async { - self.activityIndicator.isHidden = true - self.activityIndicator.stopAnimating() - self.showSearchError(error) } } @@ -207,7 +232,6 @@ class SearchResultsViewController: EnhancedTableViewController { } }, completion: { DispatchQueue.main.async { - self.errorLabel.isHidden = true self.dataSource.apply(snapshot) } }) @@ -217,8 +241,11 @@ class SearchResultsViewController: EnhancedTableViewController { let snapshot = NSDiffableDataSourceSnapshot() dataSource.apply(snapshot) - errorLabel.isHidden = false - errorLabel.text = error.localizedDescription + let config = ToastConfiguration(from: error, with: "Error Searching", in: self) { [unowned self] toast in + toast.dismissToast(animated: true) + self.performSearch(query: self.currentQuery) + } + showToast(configuration: config, animated: true) } @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { @@ -242,26 +269,6 @@ class SearchResultsViewController: EnhancedTableViewController { } } - - // MARK: - Table view delegate - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if let delegate = delegate { - switch dataSource.itemIdentifier(for: indexPath) { - case nil: - return - case let .account(id): - delegate.selectedSearchResult(account: id) - case let .hashtag(hashtag): - delegate.selectedSearchResult(hashtag: hashtag) - case let .status(id, _): - delegate.selectedSearchResult(status: id) - } - } else { - super.tableView(tableView, didSelectRowAt: indexPath) - } - } - } extension SearchResultsViewController { @@ -301,12 +308,15 @@ extension SearchResultsViewController { extension SearchResultsViewController { enum Section: CaseIterable { + case loadingIndicator case accounts case hashtags case statuses - var displayName: String { + var displayName: String? { switch self { + case .loadingIndicator: + return nil case .accounts: return NSLocalizedString("People", comment: "accounts search results section") case .hashtags: @@ -317,12 +327,15 @@ extension SearchResultsViewController { } } enum Item: Hashable { + case loadingIndicator case account(String) case hashtag(Hashtag) case status(String, CollapseState) func hash(into hasher: inout Hasher) { switch self { + case .loadingIndicator: + hasher.combine("loadingIndicator") case let .account(id): hasher.combine("account") hasher.combine(id) @@ -334,16 +347,121 @@ extension SearchResultsViewController { hasher.combine(id) } } + + static func ==(lhs: Item, rhs: Item) -> Bool { + switch (lhs, rhs) { + case (.loadingIndicator, .loadingIndicator): + return true + case (.account(let a), .account(let b)): + return a == b + case (.hashtag(let a), .hashtag(let b)): + return a.name == b.name + case (.status(let a, _), .status(let b, _)): + return a == b + default: + return false + } + } + } +} + +extension SearchResultsViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { + switch dataSource.itemIdentifier(for: indexPath) { + case .loadingIndicator: + return false + default: + return true + } } - class DataSource: UITableViewDiffableDataSource { - override func tableView(_ tableView: UITableView, titleForHeaderInSection sectionIndex: Int) -> String? { - let currentSnapshot = snapshot() - for section in Section.allCases where currentSnapshot.indexOfSection(section) == sectionIndex { - return section.displayName + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + switch dataSource.itemIdentifier(for: indexPath) { + case nil, .loadingIndicator: + return + case let .account(id): + if let delegate { + delegate.selectedSearchResult(account: id) + } else { + selected(account: id) } + case let .hashtag(hashtag): + if let delegate { + delegate.selectedSearchResult(hashtag: hashtag) + } else { + selected(tag: hashtag) + } + case let .status(id, state): + if let delegate { + delegate.selectedSearchResult(status: id) + } else { + selected(status: id, state: state.copy()) + } + } + } + + func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + guard let item = dataSource.itemIdentifier(for: indexPath), + let cell = collectionView.cellForItem(at: indexPath) else { return nil } + switch item { + case .loadingIndicator: + return nil + case .account(let id): + return UIContextMenuConfiguration { + ProfileViewController(accountID: id, mastodonController: self.mastodonController) + } actionProvider: { _ in + UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell))) + } + case .hashtag(let tag): + return UIContextMenuConfiguration { + HashtagTimelineViewController(for: tag, mastodonController: self.mastodonController) + } actionProvider: { _ in + UIMenu(children: self.actionsForHashtag(tag, source: .view(cell))) + } + case .status(_, _): + return (cell as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration() + } + } + + func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self) + } +} + +extension SearchResultsViewController: UICollectionViewDragDelegate { + func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { + guard let accountInfo = mastodonController.accountInfo, + let item = dataSource.itemIdentifier(for: indexPath) else { + return [] + } + let url: URL + let activity: NSUserActivity + switch item { + case .loadingIndicator: + return [] + case .account(let id): + guard let account = mastodonController.persistentContainer.account(for: id) else { + return [] + } + url = account.url + activity = UserActivityManager.showProfileActivity(id: id, accountID: accountInfo.id) + case .hashtag(let tag): + url = URL(tag.url)! + activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: tag.name), accountID: accountInfo.id)! + case .status(let id, _): + guard let status = mastodonController.persistentContainer.status(for: id), + status.url != nil else { + return [] + } + url = status.url! + activity = UserActivityManager.showConversationActivity(mainStatusID: id, accountID: accountInfo.id) + } + activity.displaysAuxiliaryScene = true + let provider = NSItemProvider(object: url as NSURL) + provider.registerObject(activity, visibility: .all) + return [UIDragItem(itemProvider: provider)] } } @@ -360,8 +478,25 @@ extension SearchResultsViewController: UISearchBarDelegate { } func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) { - self.scope = Scope.allCases[selectedScope] - performSearch(query: searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines)) + let newQuery = searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines) + let newScope = Scope.allCases[selectedScope] + if self.scope == .all && currentQuery == newQuery { + self.scope = newScope + var snapshot = dataSource.snapshot() + if snapshot.sectionIdentifiers.contains(.accounts) && scope != .people { + snapshot.deleteSections([.accounts]) + } + if snapshot.sectionIdentifiers.contains(.hashtags) && scope != .hashtags { + snapshot.deleteSections([.hashtags]) + } + if snapshot.sectionIdentifiers.contains(.statuses) && scope != .posts { + snapshot.deleteSections([.statuses]) + } + dataSource.apply(snapshot) + } else { + self.scope = newScope + performSearch(query: newQuery) + } } } @@ -375,9 +510,16 @@ extension SearchResultsViewController: ToastableViewController { extension SearchResultsViewController: MenuActionProvider { } -extension SearchResultsViewController: StatusTableViewCellDelegate { - func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) { - tableView.beginUpdates() - tableView.endUpdates() +extension SearchResultsViewController: StatusCollectionViewCellDelegate { + func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) { + if let indexPath = collectionView.indexPath(for: cell) { + var snapshot = dataSource.snapshot() + snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!]) + dataSource.apply(snapshot, animatingDifferences: animated, completion: completion) + } + } + + func statusCellShowFiltered(_ cell: StatusCollectionViewCell) { + // not yet supported } }