feat(album): add pull-to-refresh to album entry
This commit is contained in:
@@ -52,7 +52,10 @@ final class TravelAlbumEntryViewModel {
|
||||
}
|
||||
|
||||
/// 重新拉取相册列表。
|
||||
func loadAlbums(api: any TravelAlbumServing) async {
|
||||
func loadAlbums(
|
||||
api: any TravelAlbumServing,
|
||||
preservingContentOnFailure: Bool = false
|
||||
) async {
|
||||
isLoading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
@@ -67,12 +70,20 @@ final class TravelAlbumEntryViewModel {
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
albums = []
|
||||
albumTotal = 0
|
||||
if !preservingContentOnFailure {
|
||||
albums = []
|
||||
albumTotal = 0
|
||||
}
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户下拉刷新相册列表;失败时保留当前内容,避免页面瞬间清空。
|
||||
func refreshAlbums(api: any TravelAlbumServing) async {
|
||||
guard !isLoading else { return }
|
||||
await loadAlbums(api: api, preservingContentOnFailure: true)
|
||||
}
|
||||
|
||||
/// 打开创建相册弹窗。
|
||||
func openCreateSheet(api: any TravelAlbumServing) async {
|
||||
guard !isCreating else { return }
|
||||
|
||||
@@ -36,6 +36,7 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
||||
private let heroCard = TravelAlbumHeroCard()
|
||||
private let titleLabel = UILabel()
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let refreshControl = UIRefreshControl()
|
||||
private let emptyView = TravelAlbumEmptyView()
|
||||
private var dataSource: UITableViewDiffableDataSource<Int, TravelAlbum>!
|
||||
private var refreshState = TravelAlbumReturnRefreshState()
|
||||
@@ -65,6 +66,8 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
||||
tableView.separatorStyle = .none
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 148
|
||||
tableView.alwaysBounceVertical = true
|
||||
tableView.refreshControl = refreshControl
|
||||
tableView.delegate = self
|
||||
tableView.register(TravelAlbumTaskCell.self, forCellReuseIdentifier: TravelAlbumTaskCell.reuseIdentifier)
|
||||
|
||||
@@ -84,6 +87,7 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
||||
view.addSubview(titleLabel)
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(emptyView)
|
||||
emptyView.isUserInteractionEnabled = false
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
@@ -108,6 +112,7 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
||||
|
||||
override func bindActions() {
|
||||
heroCard.addTarget(self, action: #selector(createTapped), for: .touchUpInside)
|
||||
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
@@ -138,8 +143,8 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
||||
heroCard.isLoading = viewModel.isCreating
|
||||
titleLabel.text = "我的任务(\(viewModel.albumTotal))"
|
||||
titleLabel.isHidden = viewModel.albums.isEmpty
|
||||
tableView.isHidden = viewModel.albums.isEmpty
|
||||
emptyView.isHidden = !viewModel.albums.isEmpty || viewModel.isLoading
|
||||
emptyView.isHidden = !viewModel.albums.isEmpty
|
||||
|| (viewModel.isLoading && !refreshControl.isRefreshing)
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbum>()
|
||||
snapshot.appendSections([0])
|
||||
snapshot.appendItems(viewModel.albums)
|
||||
@@ -147,13 +152,23 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
||||
snapshot,
|
||||
animatingDifferences: !refreshState.suppressesSnapshotAnimations
|
||||
)
|
||||
if viewModel.isLoading && viewModel.albums.isEmpty {
|
||||
if viewModel.isLoading && viewModel.albums.isEmpty && !refreshControl.isRefreshing {
|
||||
showLoading()
|
||||
} else {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func refreshPulled() {
|
||||
Task {
|
||||
await viewModel.refreshAlbums(api: api)
|
||||
await MainActor.run {
|
||||
self.refreshControl.endRefreshing()
|
||||
self.applyViewModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func createTapped() {
|
||||
Task {
|
||||
await viewModel.openCreateSheet(api: api)
|
||||
|
||||
@@ -123,6 +123,19 @@ final class TravelAlbumEntryViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(viewModel.albums.first?.name, "A")
|
||||
}
|
||||
|
||||
func testPullRefreshFailureKeepsExistingAlbums() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.listResponse = TravelAlbumListResponse(total: 1, list: [TravelAlbum(id: 1, name: "A")])
|
||||
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
|
||||
await viewModel.loadAlbums(api: api)
|
||||
api.listError = APIError.serverCode(500, "刷新失败")
|
||||
|
||||
await viewModel.refreshAlbums(api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.albumTotal, 1)
|
||||
XCTAssertEqual(viewModel.albums.map(\.id), [1])
|
||||
}
|
||||
|
||||
func testPreShootRequiresSinglePrice() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
|
||||
@@ -1226,6 +1239,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
||||
|
||||
var availableOrdersResponse: [TravelAlbumAvailableOrder] = []
|
||||
var listResponse = TravelAlbumListResponse<TravelAlbum>()
|
||||
var listError: Error?
|
||||
var createResponse = TravelAlbumCreateResponse(id: 0)
|
||||
var infoResponse = TravelAlbum()
|
||||
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
|
||||
@@ -1281,7 +1295,8 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
||||
}
|
||||
|
||||
func list(page: Int, pageSize: Int) async throws -> TravelAlbumListResponse<TravelAlbum> {
|
||||
listResponse
|
||||
if let listError { throw listError }
|
||||
return listResponse
|
||||
}
|
||||
|
||||
func info(id: Int) async throws -> TravelAlbum {
|
||||
|
||||
Reference in New Issue
Block a user