feat(album): add pull-to-refresh to album entry

This commit is contained in:
2026-08-17 15:19:21 +08:00
parent 76aa7bb4aa
commit b127495e52
3 changed files with 48 additions and 7 deletions
@@ -52,7 +52,10 @@ final class TravelAlbumEntryViewModel {
} }
/// 重新拉取相册列表。 /// 重新拉取相册列表。
func loadAlbums(api: any TravelAlbumServing) async { func loadAlbums(
api: any TravelAlbumServing,
preservingContentOnFailure: Bool = false
) async {
isLoading = true isLoading = true
notifyStateChange() notifyStateChange()
defer { defer {
@@ -67,12 +70,20 @@ final class TravelAlbumEntryViewModel {
} catch is CancellationError { } catch is CancellationError {
return return
} catch { } catch {
albums = [] if !preservingContentOnFailure {
albumTotal = 0 albums = []
albumTotal = 0
}
onShowMessage?(error.localizedDescription) 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 { func openCreateSheet(api: any TravelAlbumServing) async {
guard !isCreating else { return } guard !isCreating else { return }
@@ -36,6 +36,7 @@ final class TravelAlbumEntryViewController: BaseViewController {
private let heroCard = TravelAlbumHeroCard() private let heroCard = TravelAlbumHeroCard()
private let titleLabel = UILabel() private let titleLabel = UILabel()
private let tableView = UITableView(frame: .zero, style: .plain) private let tableView = UITableView(frame: .zero, style: .plain)
private let refreshControl = UIRefreshControl()
private let emptyView = TravelAlbumEmptyView() private let emptyView = TravelAlbumEmptyView()
private var dataSource: UITableViewDiffableDataSource<Int, TravelAlbum>! private var dataSource: UITableViewDiffableDataSource<Int, TravelAlbum>!
private var refreshState = TravelAlbumReturnRefreshState() private var refreshState = TravelAlbumReturnRefreshState()
@@ -65,6 +66,8 @@ final class TravelAlbumEntryViewController: BaseViewController {
tableView.separatorStyle = .none tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 148 tableView.estimatedRowHeight = 148
tableView.alwaysBounceVertical = true
tableView.refreshControl = refreshControl
tableView.delegate = self tableView.delegate = self
tableView.register(TravelAlbumTaskCell.self, forCellReuseIdentifier: TravelAlbumTaskCell.reuseIdentifier) tableView.register(TravelAlbumTaskCell.self, forCellReuseIdentifier: TravelAlbumTaskCell.reuseIdentifier)
@@ -84,6 +87,7 @@ final class TravelAlbumEntryViewController: BaseViewController {
view.addSubview(titleLabel) view.addSubview(titleLabel)
view.addSubview(tableView) view.addSubview(tableView)
view.addSubview(emptyView) view.addSubview(emptyView)
emptyView.isUserInteractionEnabled = false
} }
override func setupConstraints() { override func setupConstraints() {
@@ -108,6 +112,7 @@ final class TravelAlbumEntryViewController: BaseViewController {
override func bindActions() { override func bindActions() {
heroCard.addTarget(self, action: #selector(createTapped), for: .touchUpInside) heroCard.addTarget(self, action: #selector(createTapped), for: .touchUpInside)
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
viewModel.onStateChange = { [weak self] in viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() } Task { @MainActor in self?.applyViewModel() }
} }
@@ -138,8 +143,8 @@ final class TravelAlbumEntryViewController: BaseViewController {
heroCard.isLoading = viewModel.isCreating heroCard.isLoading = viewModel.isCreating
titleLabel.text = "我的任务(\(viewModel.albumTotal))" titleLabel.text = "我的任务(\(viewModel.albumTotal))"
titleLabel.isHidden = viewModel.albums.isEmpty titleLabel.isHidden = viewModel.albums.isEmpty
tableView.isHidden = viewModel.albums.isEmpty emptyView.isHidden = !viewModel.albums.isEmpty
emptyView.isHidden = !viewModel.albums.isEmpty || viewModel.isLoading || (viewModel.isLoading && !refreshControl.isRefreshing)
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbum>() var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbum>()
snapshot.appendSections([0]) snapshot.appendSections([0])
snapshot.appendItems(viewModel.albums) snapshot.appendItems(viewModel.albums)
@@ -147,13 +152,23 @@ final class TravelAlbumEntryViewController: BaseViewController {
snapshot, snapshot,
animatingDifferences: !refreshState.suppressesSnapshotAnimations animatingDifferences: !refreshState.suppressesSnapshotAnimations
) )
if viewModel.isLoading && viewModel.albums.isEmpty { if viewModel.isLoading && viewModel.albums.isEmpty && !refreshControl.isRefreshing {
showLoading() showLoading()
} else { } else {
hideLoading() hideLoading()
} }
} }
@objc private func refreshPulled() {
Task {
await viewModel.refreshAlbums(api: api)
await MainActor.run {
self.refreshControl.endRefreshing()
self.applyViewModel()
}
}
}
@objc private func createTapped() { @objc private func createTapped() {
Task { Task {
await viewModel.openCreateSheet(api: api) await viewModel.openCreateSheet(api: api)
+16 -1
View File
@@ -123,6 +123,19 @@ final class TravelAlbumEntryViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.albums.first?.name, "A") 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 { func testPreShootRequiresSinglePrice() async {
let api = TravelAlbumMockAPI() let api = TravelAlbumMockAPI()
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 }) let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
@@ -1226,6 +1239,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
var availableOrdersResponse: [TravelAlbumAvailableOrder] = [] var availableOrdersResponse: [TravelAlbumAvailableOrder] = []
var listResponse = TravelAlbumListResponse<TravelAlbum>() var listResponse = TravelAlbumListResponse<TravelAlbum>()
var listError: Error?
var createResponse = TravelAlbumCreateResponse(id: 0) var createResponse = TravelAlbumCreateResponse(id: 0)
var infoResponse = TravelAlbum() var infoResponse = TravelAlbum()
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = [] var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
@@ -1281,7 +1295,8 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
} }
func list(page: Int, pageSize: Int) async throws -> TravelAlbumListResponse<TravelAlbum> { 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 { func info(id: Int) async throws -> TravelAlbum {