forked from zelo72/mastodon-ios
feat: add not result hint for search. fix: user timeline loader spinner not stop issue
This commit is contained in:
parent
bf351f8abb
commit
acc24b7ef5
|
@ -456,6 +456,9 @@
|
|||
"hashtags": "Hashtags",
|
||||
"posts": "Posts"
|
||||
},
|
||||
"empty_state": {
|
||||
"no_results": "No results"
|
||||
},
|
||||
"recent_search": "Recent searches",
|
||||
"clear": "Clear"
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@ enum Item {
|
|||
case publicMiddleLoader(statusID: String)
|
||||
case topLoader
|
||||
case bottomLoader
|
||||
case emptyBottomLoader
|
||||
|
||||
case emptyStateHeader(attribute: EmptyStateHeaderAttribute)
|
||||
|
||||
|
@ -98,6 +99,7 @@ extension Item {
|
|||
super.init(isSeparatorLineHidden: isSeparatorLineHidden)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Item: Equatable {
|
||||
|
@ -123,6 +125,8 @@ extension Item: Equatable {
|
|||
return true
|
||||
case (.bottomLoader, .bottomLoader):
|
||||
return true
|
||||
case (.emptyBottomLoader, .emptyBottomLoader):
|
||||
return true
|
||||
case (.emptyStateHeader(let attributeLeft), .emptyStateHeader(let attributeRight)):
|
||||
return attributeLeft == attributeRight
|
||||
case (.reportStatus(let objectIDLeft, _), .reportStatus(let objectIDRight, _)):
|
||||
|
@ -158,6 +162,8 @@ extension Item: Hashable {
|
|||
hasher.combine(String(describing: Item.topLoader.self))
|
||||
case .bottomLoader:
|
||||
hasher.combine(String(describing: Item.bottomLoader.self))
|
||||
case .emptyBottomLoader:
|
||||
hasher.combine(String(describing: Item.emptyBottomLoader.self))
|
||||
case .emptyStateHeader(let attribute):
|
||||
hasher.combine(attribute)
|
||||
case .reportStatus(let objectID, _):
|
||||
|
@ -184,6 +190,7 @@ extension Item {
|
|||
.publicMiddleLoader,
|
||||
.topLoader,
|
||||
.bottomLoader,
|
||||
.emptyBottomLoader,
|
||||
.emptyStateHeader:
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -17,7 +17,27 @@ enum SearchResultItem {
|
|||
case hashtagObjectID(hashtagObjectID: NSManagedObjectID)
|
||||
case status(statusObjectID: NSManagedObjectID, attribute: Item.StatusAttribute)
|
||||
|
||||
case bottomLoader
|
||||
case bottomLoader(attribute: BottomLoaderAttribute)
|
||||
}
|
||||
|
||||
extension SearchResultItem {
|
||||
class BottomLoaderAttribute: Hashable {
|
||||
let id = UUID()
|
||||
|
||||
var isNoResult: Bool
|
||||
|
||||
init(isEmptyResult: Bool) {
|
||||
self.isNoResult = isEmptyResult
|
||||
}
|
||||
|
||||
static func == (lhs: SearchResultItem.BottomLoaderAttribute, rhs: SearchResultItem.BottomLoaderAttribute) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchResultItem: Equatable {
|
||||
|
@ -33,8 +53,8 @@ extension SearchResultItem: Equatable {
|
|||
return idLeft == idRight
|
||||
case (.status(let idLeft, _), .status(let idRight, _)):
|
||||
return idLeft == idRight
|
||||
case (.bottomLoader, .bottomLoader):
|
||||
return true
|
||||
case (.bottomLoader(let attributeLeft), .bottomLoader(let attributeRight)):
|
||||
return attributeLeft == attributeRight
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
@ -56,8 +76,8 @@ extension SearchResultItem: Hashable {
|
|||
hasher.combine(id)
|
||||
case .status(let id, _):
|
||||
hasher.combine(id)
|
||||
case .bottomLoader:
|
||||
hasher.combine(String(describing: SearchResultItem.bottomLoader.self))
|
||||
case .bottomLoader(let attribute):
|
||||
hasher.combine(attribute)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -61,9 +61,17 @@ extension SearchResultSection {
|
|||
}
|
||||
cell.delegate = statusTableViewCellDelegate
|
||||
return cell
|
||||
case .bottomLoader:
|
||||
case .bottomLoader(let attribute):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell
|
||||
if attribute.isNoResult {
|
||||
cell.stopAnimating()
|
||||
cell.loadMoreLabel.text = L10n.Scene.Search.Searching.EmptyState.noResults
|
||||
cell.loadMoreLabel.textColor = Asset.Colors.Label.secondary.color
|
||||
cell.loadMoreLabel.isHidden = false
|
||||
} else {
|
||||
cell.startAnimating()
|
||||
cell.loadMoreLabel.isHidden = true
|
||||
}
|
||||
return cell
|
||||
default:
|
||||
fatalError()
|
||||
|
|
|
@ -207,6 +207,12 @@ extension StatusSection {
|
|||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
|
||||
cell.startAnimating()
|
||||
return cell
|
||||
case .emptyBottomLoader:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
|
||||
cell.stopAnimating()
|
||||
cell.loadMoreLabel.text = " "
|
||||
cell.loadMoreLabel.isHidden = false
|
||||
return cell
|
||||
case .emptyStateHeader(let attribute):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineHeaderTableViewCell.self), for: indexPath) as! TimelineHeaderTableViewCell
|
||||
StatusSection.configureEmptyStateHeader(cell: cell, attribute: attribute)
|
||||
|
|
|
@ -823,6 +823,10 @@ internal enum L10n {
|
|||
internal static let clear = L10n.tr("Localizable", "Scene.Search.Searching.Clear")
|
||||
/// Recent searches
|
||||
internal static let recentSearch = L10n.tr("Localizable", "Scene.Search.Searching.RecentSearch")
|
||||
internal enum EmptyState {
|
||||
/// No results
|
||||
internal static let noResults = L10n.tr("Localizable", "Scene.Search.Searching.EmptyState.NoResults")
|
||||
}
|
||||
internal enum Segment {
|
||||
/// All
|
||||
internal static let all = L10n.tr("Localizable", "Scene.Search.Searching.Segment.All")
|
||||
|
|
|
@ -277,6 +277,7 @@ tap the link to confirm your account.";
|
|||
"Scene.Search.SearchBar.Cancel" = "Cancel";
|
||||
"Scene.Search.SearchBar.Placeholder" = "Search hashtags and users";
|
||||
"Scene.Search.Searching.Clear" = "Clear";
|
||||
"Scene.Search.Searching.EmptyState.NoResults" = "No results";
|
||||
"Scene.Search.Searching.RecentSearch" = "Recent searches";
|
||||
"Scene.Search.Searching.Segment.All" = "All";
|
||||
"Scene.Search.Searching.Segment.Hashtags" = "Hashtags";
|
||||
|
|
|
@ -277,6 +277,7 @@ tap the link to confirm your account.";
|
|||
"Scene.Search.SearchBar.Cancel" = "Cancel";
|
||||
"Scene.Search.SearchBar.Placeholder" = "Search hashtags and users";
|
||||
"Scene.Search.Searching.Clear" = "Clear";
|
||||
"Scene.Search.Searching.EmptyState.NoResults" = "No results";
|
||||
"Scene.Search.Searching.RecentSearch" = "Recent searches";
|
||||
"Scene.Search.Searching.Segment.All" = "All";
|
||||
"Scene.Search.Searching.Segment.Hashtags" = "Hashtags";
|
||||
|
|
|
@ -179,8 +179,8 @@ extension UserTimelineViewModel.State {
|
|||
super.didEnter(from: previousState)
|
||||
guard let viewModel = viewModel, let _ = stateMachine else { return }
|
||||
|
||||
// trigger data source update
|
||||
viewModel.statusFetchedResultsController.objectIDs.value = viewModel.statusFetchedResultsController.objectIDs.value
|
||||
// trigger data source update. otherwise, spinner always display
|
||||
viewModel.isSuspended.value = viewModel.isSuspended.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ final class UserTimelineViewModel {
|
|||
let isBlockedBy = CurrentValueSubject<Bool, Never>(false)
|
||||
let isSuspended = CurrentValueSubject<Bool, Never>(false)
|
||||
let userDisplayName = CurrentValueSubject<String?, Never>(nil) // for suspended prompt label
|
||||
var dataSourceDidUpdate = PassthroughSubject<Void, Never>()
|
||||
|
||||
// output
|
||||
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
|
||||
|
@ -77,9 +78,13 @@ final class UserTimelineViewModel {
|
|||
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
|
||||
snapshot.appendSections([.main])
|
||||
|
||||
var animatingDifferences = true
|
||||
defer {
|
||||
// not animate when empty items fix loader first appear layout issue
|
||||
diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty)
|
||||
diffableDataSource.apply(snapshot, animatingDifferences: animatingDifferences) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.dataSourceDidUpdate.send()
|
||||
}
|
||||
}
|
||||
|
||||
let name = self.userDisplayName.value
|
||||
|
@ -125,7 +130,8 @@ final class UserTimelineViewModel {
|
|||
case is State.Reloading, is State.Loading, is State.Idle, is State.Fail:
|
||||
snapshot.appendItems([.bottomLoader], toSection: .main)
|
||||
case is State.NoMore:
|
||||
break
|
||||
snapshot.appendItems([.emptyBottomLoader], toSection: .main)
|
||||
animatingDifferences = false
|
||||
// TODO: handle other states
|
||||
default:
|
||||
break
|
||||
|
|
|
@ -205,6 +205,8 @@ extension SearchResultViewController: UITableViewDelegate {
|
|||
coordinator.present(scene: .hashtagTimeline(viewModel: hashtagViewModel), from: self, transition: .show)
|
||||
case .status:
|
||||
aspectTableView(tableView, didSelectRowAt: indexPath)
|
||||
case .bottomLoader:
|
||||
break
|
||||
default:
|
||||
assertionFailure()
|
||||
}
|
||||
|
|
|
@ -61,16 +61,18 @@ extension SearchResultViewModel.State {
|
|||
return
|
||||
}
|
||||
|
||||
if previousState is Initial {
|
||||
// trigger bottom loader display
|
||||
viewModel.items.value = viewModel.items.value
|
||||
}
|
||||
|
||||
let domain = activeMastodonAuthenticationBox.domain
|
||||
|
||||
let searchText = viewModel.searchText.value
|
||||
let searchType = viewModel.searchScope.searchType
|
||||
|
||||
if previousState is NoMore && previousSearchText == searchText {
|
||||
// same searchText from NoMore. should silent refresh
|
||||
} else {
|
||||
// trigger bottom loader display
|
||||
viewModel.items.value = viewModel.items.value
|
||||
}
|
||||
|
||||
guard !searchText.isEmpty else {
|
||||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
|
@ -79,6 +81,8 @@ extension SearchResultViewModel.State {
|
|||
if searchText != previousSearchText {
|
||||
previousSearchText = searchText
|
||||
offset = nil
|
||||
} else {
|
||||
offset = viewModel.items.value.count
|
||||
}
|
||||
|
||||
// not set offset for all case
|
||||
|
@ -150,7 +154,7 @@ extension SearchResultViewModel.State {
|
|||
newStatusIDs.append(status.id)
|
||||
}
|
||||
|
||||
if viewModel.searchScope == .all {
|
||||
if viewModel.searchScope == .all || newItems.isEmpty {
|
||||
stateMachine.enter(NoMore.self)
|
||||
} else {
|
||||
stateMachine.enter(Idle.self)
|
||||
|
|
|
@ -92,10 +92,16 @@ final class SearchResultViewModel {
|
|||
|
||||
if let currentState = self.stateMachine.currentState {
|
||||
switch currentState {
|
||||
case is State.Loading, is State.Fail, is State.Fail:
|
||||
snapshot.appendItems([.bottomLoader], toSection: .main)
|
||||
case is State.NoMore:
|
||||
case is State.Loading, is State.Fail, is State.Idle:
|
||||
let attribute = SearchResultItem.BottomLoaderAttribute(isEmptyResult: false)
|
||||
snapshot.appendItems([.bottomLoader(attribute: attribute)], toSection: .main)
|
||||
case is State.Fail:
|
||||
break
|
||||
case is State.NoMore:
|
||||
if snapshot.itemIdentifiers.isEmpty {
|
||||
let attribute = SearchResultItem.BottomLoaderAttribute(isEmptyResult: true)
|
||||
snapshot.appendItems([.bottomLoader(attribute: attribute)], toSection: .main)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
|
|
@ -9,11 +9,18 @@ import UIKit
|
|||
import Combine
|
||||
|
||||
final class TimelineBottomLoaderTableViewCell: TimelineLoaderTableViewCell {
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
loadMoreLabel.isHidden = true
|
||||
loadMoreButton.isHidden = true
|
||||
}
|
||||
|
||||
override func _init() {
|
||||
super._init()
|
||||
|
||||
activityIndicatorView.isHidden = false
|
||||
|
||||
startAnimating()
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue