diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumEntryViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumEntryViewModel.swift index dbb7c2c..f77858e 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumEntryViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumEntryViewModel.swift @@ -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 } diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift index e576bf5..8e41390 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift @@ -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! 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() 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) diff --git a/suixinkanTests/TravelAlbumViewModelTests.swift b/suixinkanTests/TravelAlbumViewModelTests.swift index 5d4677b..f40efb7 100644 --- a/suixinkanTests/TravelAlbumViewModelTests.swift +++ b/suixinkanTests/TravelAlbumViewModelTests.swift @@ -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() + var listError: Error? var createResponse = TravelAlbumCreateResponse(id: 0) var infoResponse = TravelAlbum() var materialListResponses: [TravelAlbumListResponse] = [] @@ -1281,7 +1295,8 @@ final class TravelAlbumMockAPI: TravelAlbumServing { } func list(page: Int, pageSize: Int) async throws -> TravelAlbumListResponse { - listResponse + if let listError { throw listError } + return listResponse } func info(id: Int) async throws -> TravelAlbum {