diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 9e4a97545..165b9af31 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -20,10 +20,13 @@ 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D33725E6401400AAD544 /* PickServerCell.swift */; }; 18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; }; 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; }; + 2D0B7A0B261D5A5600B44727 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0B7A0A261D5A5600B44727 /* Array.swift */; }; 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.swift */; }; 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; }; 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; }; 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198648261C0B8500F0B013 /* SearchResultSection.swift */; }; + 2D19864F261C372A00F0B013 /* SearchBottomLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D19864E261C372A00F0B013 /* SearchBottomLoader.swift */; }; + 2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */; }; 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */; }; 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7F25F5F45E00143C56 /* UIImage.swift */; }; 2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; }; @@ -97,7 +100,7 @@ 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; }; 2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; }; 2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */; }; - 2DE0FAC12615F04D00CDF649 /* RecomendHashTagSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC02615F04D00CDF649 /* RecomendHashTagSection.swift */; }; + 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */; }; 2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */; }; 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */; }; 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF123A625C3B0210020F248 /* ActiveLabel.swift */; }; @@ -368,10 +371,13 @@ 0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSearchCell.swift; sourceTree = ""; }; 0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.swift; sourceTree = ""; }; 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = ""; }; + 2D0B7A0A261D5A5600B44727 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; 2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = ""; }; 2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; 2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; }; 2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = ""; }; + 2D19864E261C372A00F0B013 /* SearchBottomLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBottomLoader.swift; sourceTree = ""; }; + 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewModel+LoadOldestState.swift"; sourceTree = ""; }; 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = ""; }; 2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; 2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = ""; }; @@ -442,7 +448,7 @@ 2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; 2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendCollectionHeader.swift; sourceTree = ""; }; - 2DE0FAC02615F04D00CDF649 /* RecomendHashTagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecomendHashTagSection.swift; sourceTree = ""; }; + 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendHashTagSection.swift; sourceTree = ""; }; 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendAccountsCollectionViewCell.swift; sourceTree = ""; }; 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendAccountSection.swift; sourceTree = ""; }; 2DF123A625C3B0210020F248 /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLabel.swift; sourceTree = ""; }; @@ -949,7 +955,7 @@ DB4481C525EE2ADA00BEFB67 /* PollSection.swift */, DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */, DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */, - 2DE0FAC02615F04D00CDF649 /* RecomendHashTagSection.swift */, + 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */, 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */, 2D198648261C0B8500F0B013 /* SearchResultSection.swift */, DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, @@ -1039,6 +1045,7 @@ isa = PBXGroup; children = ( 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */, + 2D19864E261C372A00F0B013 /* SearchBottomLoader.swift */, ); path = TableViewCell; sourceTree = ""; @@ -1470,6 +1477,7 @@ 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */, DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */, DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, + 2D0B7A0A261D5A5600B44727 /* Array.swift */, DB68A06225E905E000CFDF14 /* UIApplication.swift */, DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */, 2D42FF8E25C8228A004A627A /* UIButton.swift */, @@ -1532,6 +1540,7 @@ 2D34D9CA261489930081BFC0 /* SearchViewController+Recomend.swift */, 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */, 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */, + 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */, 2D34D9E026149C550081BFC0 /* CollectionViewCell */, ); path = Search; @@ -2097,6 +2106,7 @@ 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */, DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, + 2D19864F261C372A00F0B013 /* SearchBottomLoader.swift in Sources */, DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */, @@ -2135,7 +2145,7 @@ DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */, 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, - 2DE0FAC12615F04D00CDF649 /* RecomendHashTagSection.swift in Sources */, + 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */, DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */, @@ -2179,6 +2189,7 @@ 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, + 2D0B7A0B261D5A5600B44727 /* Array.swift in Sources */, DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, @@ -2245,6 +2256,7 @@ DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */, 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */, 2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */, + 2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */, 2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */, 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */, 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/SearchResultItem.swift b/Mastodon/Diffiable/Item/SearchResultItem.swift index a0b5fe253..1156a05fa 100644 --- a/Mastodon/Diffiable/Item/SearchResultItem.swift +++ b/Mastodon/Diffiable/Item/SearchResultItem.swift @@ -12,6 +12,8 @@ enum SearchResultItem { case hashTag(tag: Mastodon.Entity.Tag) case account(account: Mastodon.Entity.Account) + + case bottomLoader } extension SearchResultItem: Equatable { @@ -19,8 +21,10 @@ extension SearchResultItem: Equatable { switch (lhs, rhs) { case (.hashTag(let tagLeft), .hashTag(let tagRight)): return tagLeft == tagRight - case (.account(let accountLeft), account(let accountRight)): + case (.account(let accountLeft), .account(let accountRight)): return accountLeft == accountRight + case (.bottomLoader, .bottomLoader): + return true default: return false } @@ -34,6 +38,8 @@ extension SearchResultItem: Hashable { hasher.combine(account) case .hashTag(let tag): hasher.combine(tag) + case .bottomLoader: + hasher.combine(String(describing: SearchResultItem.bottomLoader.self)) } } } diff --git a/Mastodon/Diffiable/Section/RecomendHashTagSection.swift b/Mastodon/Diffiable/Section/RecommendHashTagSection.swift similarity index 74% rename from Mastodon/Diffiable/Section/RecomendHashTagSection.swift rename to Mastodon/Diffiable/Section/RecommendHashTagSection.swift index 2f78e73b9..502086910 100644 --- a/Mastodon/Diffiable/Section/RecomendHashTagSection.swift +++ b/Mastodon/Diffiable/Section/RecommendHashTagSection.swift @@ -1,5 +1,5 @@ // -// RecomendHashTagSection.swift +// RecommendHashTagSection.swift // Mastodon // // Created by sxiaojian on 2021/4/1. @@ -9,14 +9,14 @@ import Foundation import MastodonSDK import UIKit -enum RecomendHashTagSection: Equatable, Hashable { +enum RecommendHashTagSection: Equatable, Hashable { case main } -extension RecomendHashTagSection { +extension RecommendHashTagSection { static func collectionViewDiffableDataSource( for collectionView: UICollectionView - ) -> UICollectionViewDiffableDataSource { + ) -> UICollectionViewDiffableDataSource { UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, tag -> UICollectionViewCell? in let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self), for: indexPath) as! SearchRecommendTagsCollectionViewCell cell.config(with: tag) diff --git a/Mastodon/Diffiable/Section/SearchResultSection.swift b/Mastodon/Diffiable/Section/SearchResultSection.swift index 9c481a53a..91e443bdc 100644 --- a/Mastodon/Diffiable/Section/SearchResultSection.swift +++ b/Mastodon/Diffiable/Section/SearchResultSection.swift @@ -12,6 +12,7 @@ import UIKit enum SearchResultSection: Equatable, Hashable { case account case hashTag + case bottomLoader } extension SearchResultSection { @@ -19,14 +20,20 @@ extension SearchResultSection { for tableView: UITableView ) -> UITableViewDiffableDataSource { UITableViewDiffableDataSource(tableView: tableView) { (tableView, indexPath, result) -> UITableViewCell? in - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell switch result { case .account(let account): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell cell.config(with: account) + return cell case .hashTag(let tag): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell cell.config(with: tag) + return cell + case .bottomLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchBottomLoader.self)) as! SearchBottomLoader + cell.startAnimating() + return cell } - return cell } } } diff --git a/Mastodon/Extension/Array.swift b/Mastodon/Extension/Array.swift new file mode 100644 index 000000000..91c2e3d66 --- /dev/null +++ b/Mastodon/Extension/Array.swift @@ -0,0 +1,20 @@ +// +// Array.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/7. +// + +import Foundation + +public extension Array where Element: Equatable { + + func removeDuplicate() -> Array { + return self.enumerated().filter { (index,value) -> Bool in + return self.firstIndex(of: value) == index + }.map { (_, value) in + value + } + } +} + diff --git a/Mastodon/Scene/Search/SearchViewController+Recomend.swift b/Mastodon/Scene/Search/SearchViewController+Recomend.swift index ff6ba7d17..f2e916ab7 100644 --- a/Mastodon/Scene/Search/SearchViewController+Recomend.swift +++ b/Mastodon/Scene/Search/SearchViewController+Recomend.swift @@ -25,22 +25,6 @@ extension SearchViewController { hashTagCollectionView.constrain([ hashTagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 130) ]) - - viewModel.requestRecommendHashTags() - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self = self else { return } - if !self.viewModel.recommendHashTags.isEmpty { - let dataSource = RecomendHashTagSection.collectionViewDiffableDataSource(for: self.hashTagCollectionView) - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(self.viewModel.recommendHashTags, toSection: .main) - dataSource.apply(snapshot, animatingDifferences: false, completion: nil) - self.hashTagDiffableDataSource = dataSource - } - } receiveValue: { _ in - } - .store(in: &disposeBag) } func setupAccountsCollectionView() { @@ -57,22 +41,6 @@ extension SearchViewController { accountsCollectionView.constrain([ accountsCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 202) ]) - - viewModel.requestRecommendAccounts() - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self = self else { return } - if !self.viewModel.recommendAccounts.isEmpty { - let dataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: self.accountsCollectionView) - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(self.viewModel.recommendAccounts, toSection: .main) - dataSource.apply(snapshot, animatingDifferences: false, completion: nil) - self.accountDiffableDataSource = dataSource - } - } receiveValue: { _ in - } - .store(in: &disposeBag) } override func viewDidLayoutSubviews() { diff --git a/Mastodon/Scene/Search/SearchViewController+Searching.swift b/Mastodon/Scene/Search/SearchViewController+Searching.swift index b46714832..51281f30c 100644 --- a/Mastodon/Scene/Search/SearchViewController+Searching.swift +++ b/Mastodon/Scene/Search/SearchViewController+Searching.swift @@ -6,12 +6,14 @@ // import Foundation +import MastodonSDK import UIKit extension SearchViewController { func setupSearchingTableView() { searchingTableView.delegate = self searchingTableView.register(SearchingTableViewCell.self, forCellReuseIdentifier: String(describing: SearchingTableViewCell.self)) + searchingTableView.register(SearchBottomLoader.self, forCellReuseIdentifier: String(describing: SearchBottomLoader.self)) view.addSubview(searchingTableView) searchingTableView.constrain([ searchingTableView.frameLayoutGuide.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), @@ -20,35 +22,11 @@ extension SearchViewController { searchingTableView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), searchingTableView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), ]) - + searchingTableView.tableFooterView = UIView() viewModel.isSearching .receive(on: DispatchQueue.main) - .sink {[weak self] isSearching in + .sink { [weak self] isSearching in self?.searchingTableView.isHidden = !isSearching - if !isSearching { - self?.searchResultDiffableDataSource = nil - } - } - .store(in: &disposeBag) - - viewModel.searchResult - .receive(on: DispatchQueue.main) - .sink { [weak self] searchResult in - guard let self = self else { return } - let dataSource = SearchResultSection.tableViewDiffableDataSource(for: self.searchingTableView) - var snapshot = NSDiffableDataSourceSnapshot() - if let accounts = searchResult?.accounts { - snapshot.appendSections([.account]) - let items = accounts.compactMap { SearchResultItem.account(account: $0) } - snapshot.appendItems(items, toSection: .account) - } - if let tags = searchResult?.hashtags { - snapshot.appendSections([.hashTag]) - let items = tags.compactMap { SearchResultItem.hashTag(tag: $0) } - snapshot.appendItems(items, toSection: .hashTag) - } - dataSource.apply(snapshot, animatingDifferences: false, completion: nil) - self.searchResultDiffableDataSource = dataSource } .store(in: &disposeBag) } @@ -60,8 +38,10 @@ extension SearchViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { 66 } + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 66 } + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {} } diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index a0a25fef6..0b26cdb40 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -6,6 +6,7 @@ // import Combine +import GameplayKit import MastodonSDK import UIKit @@ -25,7 +26,7 @@ final class SearchViewController: UIViewController, NeedsDependency { searchBar.setImage(micImage, for: .bookmark, state: .normal) searchBar.showsBookmarkButton = true searchBar.showsScopeBar = false - searchBar.scopeButtonTitles = [L10n.Scene.Search.Searching.Segment.all, L10n.Scene.Search.Searching.Segment.people,L10n.Scene.Search.Searching.Segment.hashtags] + searchBar.scopeButtonTitles = [L10n.Scene.Search.Searching.Segment.all, L10n.Scene.Search.Searching.Segment.people, L10n.Scene.Search.Searching.Segment.hashtags] return searchBar }() @@ -59,9 +60,6 @@ final class SearchViewController: UIViewController, NeedsDependency { view.translatesAutoresizingMaskIntoConstraints = false return view }() - - var hashTagDiffableDataSource: UICollectionViewDiffableDataSource? - var accountDiffableDataSource: UICollectionViewDiffableDataSource? let accountsCollectionView: UICollectionView = { let flowLayout = UICollectionViewFlowLayout() @@ -83,7 +81,6 @@ final class SearchViewController: UIViewController, NeedsDependency { tableView.backgroundColor = .white return tableView }() - var searchResultDiffableDataSource: UITableViewDiffableDataSource? } extension SearchViewController { @@ -97,6 +94,7 @@ extension SearchViewController { setupHashTagCollectionView() setupAccountsCollectionView() setupSearchingTableView() + setupDataSource() } func setupScrollView() { @@ -118,6 +116,20 @@ extension SearchViewController { scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: stackView.bottomAnchor), ]) } + + func setupDataSource() { + viewModel.hashTagDiffableDataSource = RecommendHashTagSection.collectionViewDiffableDataSource(for: hashTagCollectionView) + viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: accountsCollectionView) + viewModel.searchResultDiffableDataSource = SearchResultSection.tableViewDiffableDataSource(for: searchingTableView) + } +} + +extension SearchViewController: UIScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if scrollView == searchingTableView { + handleScrollViewDidScroll(scrollView) + } + } } extension SearchViewController: UISearchBarDelegate { @@ -150,16 +162,24 @@ extension SearchViewController: UISearchBarDelegate { case 0: viewModel.searchScope.value = "" case 1: - viewModel.searchScope.value = "accounts" + viewModel.searchScope.value = Mastodon.API.Search.Scope.accounts.rawValue case 2: - viewModel.searchScope.value = "hashtags" + viewModel.searchScope.value = Mastodon.API.Search.Scope.hashTags.rawValue default: break } } + func searchBarBookmarkButtonClicked(_ searchBar: UISearchBar) {} } +extension SearchViewController: LoadMoreConfigurableTableViewContainer { + typealias BottomLoaderTableViewCell = SearchBottomLoader + typealias LoadingState = SearchViewModel.LoadOldestState.Loading + var loadMoreConfigurableTableView: UITableView { searchingTableView } + var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadoldestStateMachine } +} + #if canImport(SwiftUI) && DEBUG import SwiftUI diff --git a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift new file mode 100644 index 000000000..fb57b6d34 --- /dev/null +++ b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift @@ -0,0 +1,141 @@ +// +// SearchViewModel+LoadOldestState.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/6. +// + +import Foundation +import GameplayKit +import MastodonSDK +import os.log + +extension SearchViewModel { + class LoadOldestState: GKState { + weak var viewModel: SearchViewModel? + + init(viewModel: SearchViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", (#file as NSString).lastPathComponent, #line, #function, debugDescription, previousState.debugDescription) + viewModel?.loadOldestStateMachinePublisher.send(self) + } + } +} + +extension SearchViewModel.LoadOldestState { + class Initial: SearchViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard let viewModel = viewModel else { return false } + guard viewModel.searchResult.value != nil else { return false } + return stateClass == Loading.self + } + } + + class Loading: SearchViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + stateMachine.enter(Fail.self) + return + } + guard let oldSearchResult = viewModel.searchResult.value else { + stateMachine.enter(Fail.self) + return + } + var offset = 0 + switch viewModel.searchScope.value { + case Mastodon.API.Search.Scope.accounts.rawValue: + offset = oldSearchResult.accounts.count + case Mastodon.API.Search.Scope.hashTags.rawValue: + offset = oldSearchResult.hashtags.count + default: + return + } + let query = Mastodon.API.Search.Query(accountID: nil, + maxID: nil, + minID: nil, + type: viewModel.searchScope.value, + excludeUnreviewed: nil, + q: viewModel.searchText.value, + resolve: nil, + limit: nil, + offset: offset, + following: nil) + viewModel.context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { completion in + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: load oldest search failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + case .finished: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + } receiveValue: { result in + switch viewModel.searchScope.value { + case Mastodon.API.Search.Scope.accounts.rawValue: + if result.value.accounts.isEmpty { + stateMachine.enter(NoMore.self) + } else { + var newAccounts = [Mastodon.Entity.Account]() + newAccounts.append(contentsOf: oldSearchResult.accounts) + newAccounts.append(contentsOf: result.value.accounts) + viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: newAccounts.removeDuplicate(), statuses: oldSearchResult.statuses, hashtags: oldSearchResult.hashtags) + stateMachine.enter(Idle.self) + } + case Mastodon.API.Search.Scope.hashTags.rawValue: + if result.value.hashtags.isEmpty { + stateMachine.enter(NoMore.self) + } else { + var newTags = [Mastodon.Entity.Tag]() + newTags.append(contentsOf: oldSearchResult.hashtags) + newTags.append(contentsOf: result.value.hashtags) + viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: oldSearchResult.accounts, statuses: oldSearchResult.statuses, hashtags: newTags.removeDuplicate()) + stateMachine.enter(Idle.self) + } + default: + return + } + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: SearchViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + stateClass == Loading.self || stateClass == Idle.self + } + } + + class Idle: SearchViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + stateClass == Loading.self + } + } + + class NoMore: SearchViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // reset state if needs + stateClass == Idle.self + } + + override func didEnter(from previousState: GKState?) { + guard let viewModel = viewModel else { return } + guard let diffableDataSource = viewModel.searchResultDiffableDataSource else { + assertionFailure() + return + } + var snapshot = diffableDataSource.snapshot() + snapshot.deleteItems([.bottomLoader]) + diffableDataSource.apply(snapshot) + } + } +} diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 03f689a1b..4fbdab5ba 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -7,6 +7,7 @@ import Combine import Foundation +import GameplayKit import MastodonSDK import OSLog import UIKit @@ -28,6 +29,26 @@ final class SearchViewModel { var recommendHashTags = [Mastodon.Entity.Tag]() var recommendAccounts = [Mastodon.Entity.Account]() + var hashTagDiffableDataSource: UICollectionViewDiffableDataSource? + var accountDiffableDataSource: UICollectionViewDiffableDataSource? + var searchResultDiffableDataSource: UITableViewDiffableDataSource? + + // bottom loader + private(set) lazy var loadoldestStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + LoadOldestState.Initial(viewModel: self), + LoadOldestState.Loading(viewModel: self), + LoadOldestState.Fail(viewModel: self), + LoadOldestState.Idle(viewModel: self), + LoadOldestState.NoMore(viewModel: self), + ]) + stateMachine.enter(LoadOldestState.Initial.self) + return stateMachine + }() + + lazy var loadOldestStateMachinePublisher = CurrentValueSubject(nil) + init(context: AppContext) { self.context = context guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { @@ -60,10 +81,66 @@ final class SearchViewModel { isSearching .sink { [weak self] isSearching in if !isSearching { - self?.searchResult.value == nil + self?.searchResult.value = nil } } .store(in: &disposeBag) + + requestRecommendHashTags() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + if !self.recommendHashTags.isEmpty { + guard let dataSource = self.hashTagDiffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(self.recommendHashTags, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + } receiveValue: { _ in + } + .store(in: &disposeBag) + + requestRecommendAccounts() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + if !self.recommendAccounts.isEmpty { + guard let dataSource = self.accountDiffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(self.recommendAccounts, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + } receiveValue: { _ in + } + .store(in: &disposeBag) + + searchResult + .receive(on: DispatchQueue.main) + .sink { [weak self] searchResult in + guard let self = self else { return } + guard let dataSource = self.searchResultDiffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + if let accounts = searchResult?.accounts { + snapshot.appendSections([.account]) + let items = accounts.compactMap { SearchResultItem.account(account: $0) } + snapshot.appendItems(items, toSection: .account) + if self.searchScope.value == Mastodon.API.Search.Scope.accounts.rawValue { + snapshot.appendItems([.bottomLoader], toSection: .account) + } + } + if let tags = searchResult?.hashtags { + snapshot.appendSections([.hashTag]) + let items = tags.compactMap { SearchResultItem.hashTag(tag: $0) } + snapshot.appendItems(items, toSection: .hashTag) + if self.searchScope.value == Mastodon.API.Search.Scope.hashTags.rawValue { + snapshot.appendItems([.bottomLoader], toSection: .hashTag) + } + } + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + .store(in: &disposeBag) } func requestRecommendHashTags() -> Future { diff --git a/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift b/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift new file mode 100644 index 000000000..dcd4c4971 --- /dev/null +++ b/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift @@ -0,0 +1,47 @@ +// +// SearchBottomLoader.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/6. +// + +import Foundation +import UIKit + +final class SearchBottomLoader: UITableViewCell { + let activityIndicatorView: UIActivityIndicatorView = { + let activityIndicatorView = UIActivityIndicatorView(style: .medium) + activityIndicatorView.tintColor = Asset.Colors.Label.primary.color + activityIndicatorView.hidesWhenStopped = true + return activityIndicatorView + }() + + override func prepareForReuse() { + super.prepareForReuse() + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + func startAnimating() { + activityIndicatorView.startAnimating() + } + + func stopAnimating() { + activityIndicatorView.stopAnimating() + } + + func _init() { + selectionStyle = .none + backgroundColor = Asset.Colors.lightWhite.color + contentView.addSubview(activityIndicatorView) + activityIndicatorView.constrainToCenter() + } +} diff --git a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift index ceb35678a..379d720ea 100644 --- a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift +++ b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift @@ -6,8 +6,8 @@ // import Foundation -import UIKit import MastodonSDK +import UIKit final class SearchingTableViewCell: UITableViewCell { let _imageView: UIImageView = { @@ -50,7 +50,7 @@ final class SearchingTableViewCell: UITableViewCell { extension SearchingTableViewCell { private func configure() { - self.selectionStyle = .none + selectionStyle = .none contentView.addSubview(_imageView) _imageView.pin(toSize: CGSize(width: 42, height: 42)) _imageView.constrain([ @@ -65,28 +65,28 @@ extension SearchingTableViewCell { _subTitleLabel.pin(top: 34, left: 75, bottom: nil, right: 0) } - func config(with account:Mastodon.Entity.Account) { - self._imageView.af.setImage( + func config(with account: Mastodon.Entity.Account) { + _imageView.af.setImage( withURL: URL(string: account.avatar)!, placeholderImage: UIImage.placeholder(color: .systemFill), imageTransition: .crossDissolve(0.2) ) - self._titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName - self._subTitleLabel.text = account.acct + _titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName + _subTitleLabel.text = account.acct } - func config(with tag:Mastodon.Entity.Tag) { + func config(with tag: Mastodon.Entity.Tag) { let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate) - self._imageView.image = image - self._titleLabel.text = "# " + tag.name + _imageView.image = image + _titleLabel.text = "# " + tag.name guard let historys = tag.history else { - self._subTitleLabel.text = "" + _subTitleLabel.text = "" return } - let recentHistory = historys[0...2] - let peopleAreTalking = recentHistory.compactMap({ Int($0.accounts) }).reduce(0, +) + let recentHistory = historys[0 ... 2] + let peopleAreTalking = recentHistory.compactMap { Int($0.accounts) }.reduce(0, +) let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) - self._subTitleLabel.text = string + _subTitleLabel.text = string } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift index 8f266437f..465c133f2 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift @@ -49,8 +49,8 @@ extension Mastodon.API.Search { } } -extension Mastodon.API.Search { - public struct Query: Codable, GetQuery { +public extension Mastodon.API.Search { + struct Query: Codable, GetQuery { public init(accountID: Mastodon.Entity.Account.ID?, maxID: Mastodon.Entity.Status.ID?, minID: Mastodon.Entity.Status.ID?, type: String?, excludeUnreviewed: Bool?, q: String, resolve: Bool?, limit: Int?, offset: Int?, following: Bool?) { self.accountID = accountID self.maxID = maxID @@ -93,3 +93,19 @@ extension Mastodon.API.Search { } } } + +public extension Mastodon.API.Search { + enum Scope: String { + case accounts + case hashTags + + public var rawValue: String { + switch self { + case .accounts: + return "accounts" + case .hashTags: + return "hashtags" + } + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift index f10339664..44446d0d9 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift @@ -8,6 +8,12 @@ import Foundation extension Mastodon.Entity { public struct SearchResult: Codable { + public init(accounts: [Mastodon.Entity.Account], statuses: [Mastodon.Entity.Status], hashtags: [Mastodon.Entity.Tag]) { + self.accounts = accounts + self.statuses = statuses + self.hashtags = hashtags + } + public let accounts: [Mastodon.Entity.Account] public let statuses: [Mastodon.Entity.Status] public let hashtags: [Mastodon.Entity.Tag]