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",
|
"hashtags": "Hashtags",
|
||||||
"posts": "Posts"
|
"posts": "Posts"
|
||||||
},
|
},
|
||||||
|
"empty_state": {
|
||||||
|
"no_results": "No results"
|
||||||
|
},
|
||||||
"recent_search": "Recent searches",
|
"recent_search": "Recent searches",
|
||||||
"clear": "Clear"
|
"clear": "Clear"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue