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
|
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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user