feat: add not result hint for search. fix: user timeline loader spinner not stop issue

This commit is contained in:
CMK 2021-07-15 17:26:04 +08:00
parent bf351f8abb
commit acc24b7ef5
14 changed files with 97 additions and 22 deletions

View File

@ -456,6 +456,9 @@
"hashtags": "Hashtags", "hashtags": "Hashtags",
"posts": "Posts" "posts": "Posts"
}, },
"empty_state": {
"no_results": "No results"
},
"recent_search": "Recent searches", "recent_search": "Recent searches",
"clear": "Clear" "clear": "Clear"
} }

View File

@ -31,6 +31,7 @@ enum Item {
case publicMiddleLoader(statusID: String) case publicMiddleLoader(statusID: String)
case topLoader case topLoader
case bottomLoader case bottomLoader
case emptyBottomLoader
case emptyStateHeader(attribute: EmptyStateHeaderAttribute) case emptyStateHeader(attribute: EmptyStateHeaderAttribute)
@ -98,6 +99,7 @@ extension Item {
super.init(isSeparatorLineHidden: isSeparatorLineHidden) super.init(isSeparatorLineHidden: isSeparatorLineHidden)
} }
} }
} }
extension Item: Equatable { extension Item: Equatable {
@ -123,6 +125,8 @@ extension Item: Equatable {
return true return true
case (.bottomLoader, .bottomLoader): case (.bottomLoader, .bottomLoader):
return true return true
case (.emptyBottomLoader, .emptyBottomLoader):
return true
case (.emptyStateHeader(let attributeLeft), .emptyStateHeader(let attributeRight)): case (.emptyStateHeader(let attributeLeft), .emptyStateHeader(let attributeRight)):
return attributeLeft == attributeRight return attributeLeft == attributeRight
case (.reportStatus(let objectIDLeft, _), .reportStatus(let objectIDRight, _)): case (.reportStatus(let objectIDLeft, _), .reportStatus(let objectIDRight, _)):
@ -158,6 +162,8 @@ extension Item: Hashable {
hasher.combine(String(describing: Item.topLoader.self)) hasher.combine(String(describing: Item.topLoader.self))
case .bottomLoader: case .bottomLoader:
hasher.combine(String(describing: Item.bottomLoader.self)) hasher.combine(String(describing: Item.bottomLoader.self))
case .emptyBottomLoader:
hasher.combine(String(describing: Item.emptyBottomLoader.self))
case .emptyStateHeader(let attribute): case .emptyStateHeader(let attribute):
hasher.combine(attribute) hasher.combine(attribute)
case .reportStatus(let objectID, _): case .reportStatus(let objectID, _):
@ -184,6 +190,7 @@ extension Item {
.publicMiddleLoader, .publicMiddleLoader,
.topLoader, .topLoader,
.bottomLoader, .bottomLoader,
.emptyBottomLoader,
.emptyStateHeader: .emptyStateHeader:
return nil return nil
} }

View File

@ -17,7 +17,27 @@ enum SearchResultItem {
case hashtagObjectID(hashtagObjectID: NSManagedObjectID) case hashtagObjectID(hashtagObjectID: NSManagedObjectID)
case status(statusObjectID: NSManagedObjectID, attribute: Item.StatusAttribute) 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 { extension SearchResultItem: Equatable {
@ -33,8 +53,8 @@ extension SearchResultItem: Equatable {
return idLeft == idRight return idLeft == idRight
case (.status(let idLeft, _), .status(let idRight, _)): case (.status(let idLeft, _), .status(let idRight, _)):
return idLeft == idRight return idLeft == idRight
case (.bottomLoader, .bottomLoader): case (.bottomLoader(let attributeLeft), .bottomLoader(let attributeRight)):
return true return attributeLeft == attributeRight
default: default:
return false return false
} }
@ -56,8 +76,8 @@ extension SearchResultItem: Hashable {
hasher.combine(id) hasher.combine(id)
case .status(let id, _): case .status(let id, _):
hasher.combine(id) hasher.combine(id)
case .bottomLoader: case .bottomLoader(let attribute):
hasher.combine(String(describing: SearchResultItem.bottomLoader.self)) hasher.combine(attribute)
} }
} }
} }

View File

@ -61,9 +61,17 @@ extension SearchResultSection {
} }
cell.delegate = statusTableViewCellDelegate cell.delegate = statusTableViewCellDelegate
return cell return cell
case .bottomLoader: case .bottomLoader(let attribute):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell
cell.startAnimating() 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 return cell
default: default:
fatalError() fatalError()

View File

@ -207,6 +207,12 @@ extension StatusSection {
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
cell.startAnimating() cell.startAnimating()
return cell 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): case .emptyStateHeader(let attribute):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineHeaderTableViewCell.self), for: indexPath) as! TimelineHeaderTableViewCell let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineHeaderTableViewCell.self), for: indexPath) as! TimelineHeaderTableViewCell
StatusSection.configureEmptyStateHeader(cell: cell, attribute: attribute) StatusSection.configureEmptyStateHeader(cell: cell, attribute: attribute)

View File

@ -823,6 +823,10 @@ internal enum L10n {
internal static let clear = L10n.tr("Localizable", "Scene.Search.Searching.Clear") internal static let clear = L10n.tr("Localizable", "Scene.Search.Searching.Clear")
/// Recent searches /// Recent searches
internal static let recentSearch = L10n.tr("Localizable", "Scene.Search.Searching.RecentSearch") 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 { internal enum Segment {
/// All /// All
internal static let all = L10n.tr("Localizable", "Scene.Search.Searching.Segment.All") internal static let all = L10n.tr("Localizable", "Scene.Search.Searching.Segment.All")

View File

@ -277,6 +277,7 @@ tap the link to confirm your account.";
"Scene.Search.SearchBar.Cancel" = "Cancel"; "Scene.Search.SearchBar.Cancel" = "Cancel";
"Scene.Search.SearchBar.Placeholder" = "Search hashtags and users"; "Scene.Search.SearchBar.Placeholder" = "Search hashtags and users";
"Scene.Search.Searching.Clear" = "Clear"; "Scene.Search.Searching.Clear" = "Clear";
"Scene.Search.Searching.EmptyState.NoResults" = "No results";
"Scene.Search.Searching.RecentSearch" = "Recent searches"; "Scene.Search.Searching.RecentSearch" = "Recent searches";
"Scene.Search.Searching.Segment.All" = "All"; "Scene.Search.Searching.Segment.All" = "All";
"Scene.Search.Searching.Segment.Hashtags" = "Hashtags"; "Scene.Search.Searching.Segment.Hashtags" = "Hashtags";

View File

@ -277,6 +277,7 @@ tap the link to confirm your account.";
"Scene.Search.SearchBar.Cancel" = "Cancel"; "Scene.Search.SearchBar.Cancel" = "Cancel";
"Scene.Search.SearchBar.Placeholder" = "Search hashtags and users"; "Scene.Search.SearchBar.Placeholder" = "Search hashtags and users";
"Scene.Search.Searching.Clear" = "Clear"; "Scene.Search.Searching.Clear" = "Clear";
"Scene.Search.Searching.EmptyState.NoResults" = "No results";
"Scene.Search.Searching.RecentSearch" = "Recent searches"; "Scene.Search.Searching.RecentSearch" = "Recent searches";
"Scene.Search.Searching.Segment.All" = "All"; "Scene.Search.Searching.Segment.All" = "All";
"Scene.Search.Searching.Segment.Hashtags" = "Hashtags"; "Scene.Search.Searching.Segment.Hashtags" = "Hashtags";

View File

@ -179,8 +179,8 @@ extension UserTimelineViewModel.State {
super.didEnter(from: previousState) super.didEnter(from: previousState)
guard let viewModel = viewModel, let _ = stateMachine else { return } guard let viewModel = viewModel, let _ = stateMachine else { return }
// trigger data source update // trigger data source update. otherwise, spinner always display
viewModel.statusFetchedResultsController.objectIDs.value = viewModel.statusFetchedResultsController.objectIDs.value viewModel.isSuspended.value = viewModel.isSuspended.value
} }
} }
} }

View File

@ -29,6 +29,7 @@ final class UserTimelineViewModel {
let isBlockedBy = CurrentValueSubject<Bool, Never>(false) let isBlockedBy = CurrentValueSubject<Bool, Never>(false)
let isSuspended = CurrentValueSubject<Bool, Never>(false) let isSuspended = CurrentValueSubject<Bool, Never>(false)
let userDisplayName = CurrentValueSubject<String?, Never>(nil) // for suspended prompt label let userDisplayName = CurrentValueSubject<String?, Never>(nil) // for suspended prompt label
var dataSourceDidUpdate = PassthroughSubject<Void, Never>()
// output // output
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
@ -77,9 +78,13 @@ final class UserTimelineViewModel {
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>() var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
snapshot.appendSections([.main]) snapshot.appendSections([.main])
var animatingDifferences = true
defer { defer {
// not animate when empty items fix loader first appear layout issue // 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 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: case is State.Reloading, is State.Loading, is State.Idle, is State.Fail:
snapshot.appendItems([.bottomLoader], toSection: .main) snapshot.appendItems([.bottomLoader], toSection: .main)
case is State.NoMore: case is State.NoMore:
break snapshot.appendItems([.emptyBottomLoader], toSection: .main)
animatingDifferences = false
// TODO: handle other states // TODO: handle other states
default: default:
break break

View File

@ -205,6 +205,8 @@ extension SearchResultViewController: UITableViewDelegate {
coordinator.present(scene: .hashtagTimeline(viewModel: hashtagViewModel), from: self, transition: .show) coordinator.present(scene: .hashtagTimeline(viewModel: hashtagViewModel), from: self, transition: .show)
case .status: case .status:
aspectTableView(tableView, didSelectRowAt: indexPath) aspectTableView(tableView, didSelectRowAt: indexPath)
case .bottomLoader:
break
default: default:
assertionFailure() assertionFailure()
} }

View File

@ -61,16 +61,18 @@ extension SearchResultViewModel.State {
return return
} }
if previousState is Initial {
// trigger bottom loader display
viewModel.items.value = viewModel.items.value
}
let domain = activeMastodonAuthenticationBox.domain let domain = activeMastodonAuthenticationBox.domain
let searchText = viewModel.searchText.value let searchText = viewModel.searchText.value
let searchType = viewModel.searchScope.searchType 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 { guard !searchText.isEmpty else {
stateMachine.enter(Fail.self) stateMachine.enter(Fail.self)
return return
@ -79,6 +81,8 @@ extension SearchResultViewModel.State {
if searchText != previousSearchText { if searchText != previousSearchText {
previousSearchText = searchText previousSearchText = searchText
offset = nil offset = nil
} else {
offset = viewModel.items.value.count
} }
// not set offset for all case // not set offset for all case
@ -150,7 +154,7 @@ extension SearchResultViewModel.State {
newStatusIDs.append(status.id) newStatusIDs.append(status.id)
} }
if viewModel.searchScope == .all { if viewModel.searchScope == .all || newItems.isEmpty {
stateMachine.enter(NoMore.self) stateMachine.enter(NoMore.self)
} else { } else {
stateMachine.enter(Idle.self) stateMachine.enter(Idle.self)

View File

@ -92,10 +92,16 @@ final class SearchResultViewModel {
if let currentState = self.stateMachine.currentState { if let currentState = self.stateMachine.currentState {
switch currentState { switch currentState {
case is State.Loading, is State.Fail, is State.Fail: case is State.Loading, is State.Fail, is State.Idle:
snapshot.appendItems([.bottomLoader], toSection: .main) let attribute = SearchResultItem.BottomLoaderAttribute(isEmptyResult: false)
case is State.NoMore: snapshot.appendItems([.bottomLoader(attribute: attribute)], toSection: .main)
case is State.Fail:
break break
case is State.NoMore:
if snapshot.itemIdentifiers.isEmpty {
let attribute = SearchResultItem.BottomLoaderAttribute(isEmptyResult: true)
snapshot.appendItems([.bottomLoader(attribute: attribute)], toSection: .main)
}
default: default:
break break
} }

View File

@ -9,11 +9,18 @@ import UIKit
import Combine import Combine
final class TimelineBottomLoaderTableViewCell: TimelineLoaderTableViewCell { final class TimelineBottomLoaderTableViewCell: TimelineLoaderTableViewCell {
override func prepareForReuse() {
super.prepareForReuse()
loadMoreLabel.isHidden = true
loadMoreButton.isHidden = true
}
override func _init() { override func _init() {
super._init() super._init()
activityIndicatorView.isHidden = false activityIndicatorView.isHidden = false
startAnimating() startAnimating()
} }
} }