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",
"posts": "Posts"
},
"empty_state": {
"no_results": "No results"
},
"recent_search": "Recent searches",
"clear": "Clear"
}

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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()

View File

@ -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)

View File

@ -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")

View File

@ -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";

View File

@ -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";

View File

@ -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
}
}
}

View File

@ -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

View File

@ -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()
}

View File

@ -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)

View File

@ -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
}

View File

@ -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()
}
}