移除 MJRefresh 依赖,恢复 SwiftUI 系统下拉刷新。

同时收紧各 ViewModel 内部 isLoading 可见性,避免不必要的视图刷新。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 10:55:45 +08:00
parent 8997d1ba1c
commit 8e34a45d50
93 changed files with 661 additions and 6261 deletions

View File

@ -14,7 +14,6 @@ struct suixinkanApp: App {
init() {
AppUITestLaunchState.resetIfNeeded()
MJRefreshSupport.configure()
}
var body: some Scene {

View File

@ -91,17 +91,3 @@ Toast UI 是顶部全宽横幅,背景使用不透明主色并延伸到顶部
- 上传进度只用于当前页面展示。
网络图片统一使用 `RemoteImage` / `RemoteAvatarImage`,内部由 Kingfisher 负责下载和缓存。业务页面不要再直接使用 `AsyncImage` 加载网络图片。
### MJRefresh
项目通过 CocoaPods 集成 `MJRefresh`,并在 `Core/Design/MJRefreshSupport.swift` 提供 SwiftUI 桥接能力。App 启动时会调用 `MJRefreshSupport.configure()` 设置默认中文文案。
SwiftUI 列表如需使用 MJRefresh 风格的下拉刷新或上拉加载,可在 `ScrollView` / `List` 上使用:
- `.mjRefresh(onRefresh:onLoadMore:hasMore:)`
- `.mjRefreshHeader { ... }`
- `.mjRefreshFooter(hasMore:action:)`
桥接层会自动定位底层 `UIScrollView` 并绑定 header/footer。页面仍应把真正的数据加载逻辑放在 ViewModel 中modifier 只负责触发刷新和结束 MJRefresh 动画。
若页面已经使用 SwiftUI 原生 `.refreshable`,不要重复叠加 MJRefresh 下拉刷新,避免双刷新冲突。

View File

@ -1,205 +0,0 @@
//
// MJRefreshSupport.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import MJRefresh
import SwiftUI
import UIKit
/// MJRefresh
enum MJRefreshSupport {
/// MJRefresh
static func configure() {
MJRefreshConfig.default.languageCode = "zh-Hans"
}
}
/// MJRefresh
struct MJRefreshBindingConfiguration {
var onRefresh: (() async -> Void)?
var onLoadMore: (() async -> Void)?
var hasMore = false
///
var isEmpty: Bool {
onRefresh == nil && onLoadMore == nil
}
}
extension View {
/// SwiftUI `ScrollView` / `List` MJRefresh
func mjRefresh(
onRefresh: (() async -> Void)? = nil,
onLoadMore: (() async -> Void)? = nil,
hasMore: Bool = false
) -> some View {
modifier(
MJRefreshModifier(
configuration: MJRefreshBindingConfiguration(
onRefresh: onRefresh,
onLoadMore: onLoadMore,
hasMore: hasMore
)
)
)
}
/// SwiftUI MJRefresh
func mjRefreshHeader(_ action: @escaping () async -> Void) -> some View {
mjRefresh(onRefresh: action)
}
/// SwiftUI MJRefresh
func mjRefreshFooter(hasMore: Bool, action: @escaping () async -> Void) -> some View {
mjRefresh(onLoadMore: action, hasMore: hasMore)
}
}
/// SwiftUI MJRefresh `UIScrollView`
private struct MJRefreshModifier: ViewModifier {
let configuration: MJRefreshBindingConfiguration
func body(content: Content) -> some View {
content.background {
MJRefreshScrollAnchor(configuration: configuration)
.frame(width: 0, height: 0)
}
}
}
/// SwiftUI MJRefresh
private struct MJRefreshScrollAnchor: UIViewRepresentable {
let configuration: MJRefreshBindingConfiguration
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> UIView {
let view = UIView(frame: .zero)
view.isUserInteractionEnabled = false
view.backgroundColor = .clear
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
context.coordinator.scheduleApply(configuration: configuration, from: uiView)
}
static func dismantleUIView(_ uiView: UIView, coordinator: Coordinator) {
coordinator.unbind(from: uiView)
}
/// MJRefresh header/footer
final class Coordinator {
private weak var scrollView: UIScrollView?
private var configuration = MJRefreshBindingConfiguration()
private var pendingApply: DispatchWorkItem?
/// runloop SwiftUI
func scheduleApply(configuration: MJRefreshBindingConfiguration, from anchorView: UIView) {
pendingApply?.cancel()
let workItem = DispatchWorkItem { [weak self, weak anchorView] in
guard let self, let anchorView else { return }
guard let scrollView = anchorView.enclosingScrollView else { return }
self.apply(configuration: configuration, to: scrollView)
}
pendingApply = workItem
DispatchQueue.main.async(execute: workItem)
}
///
private func apply(configuration: MJRefreshBindingConfiguration, to scrollView: UIScrollView) {
self.scrollView = scrollView
self.configuration = configuration
if configuration.isEmpty {
scrollView.mj_header = nil
scrollView.mj_footer = nil
return
}
if let onRefresh = configuration.onRefresh {
if scrollView.mj_header == nil {
scrollView.mj_header = MJRefreshNormalHeader { [weak self] in
self?.performHeaderRefresh(onRefresh)
}
}
} else {
scrollView.mj_header = nil
}
if let onLoadMore = configuration.onLoadMore {
if scrollView.mj_footer == nil {
scrollView.mj_footer = MJRefreshAutoNormalFooter { [weak self] in
self?.performFooterLoadMore(onLoadMore)
}
}
updateFooterState(hasMore: configuration.hasMore)
} else {
scrollView.mj_footer = nil
}
}
/// MJRefresh
private func performHeaderRefresh(_ action: @escaping () async -> Void) {
Task { @MainActor in
await action()
await scrollView?.mj_header?.endRefreshing()
}
}
/// MJRefresh
private func performFooterLoadMore(_ action: @escaping () async -> Void) {
Task { @MainActor in
await action()
guard let scrollView else { return }
if configuration.hasMore {
await scrollView.mj_footer?.endRefreshing()
} else {
await scrollView.mj_footer?.endRefreshingWithNoMoreData()
}
}
}
/// footer
private func updateFooterState(hasMore: Bool) {
guard let footer = scrollView?.mj_footer else { return }
if hasMore {
footer.resetNoMoreData()
footer.isHidden = false
} else {
Task { @MainActor in
await footer.endRefreshingWithNoMoreData()
}
}
}
/// MJRefresh
func unbind(from anchorView: UIView) {
pendingApply?.cancel()
pendingApply = nil
guard let scrollView = anchorView.enclosingScrollView, scrollView === self.scrollView else { return }
scrollView.mj_header = nil
scrollView.mj_footer = nil
self.scrollView = nil
}
}
}
extension UIView {
/// `UIScrollView` SwiftUI MJRefresh
var enclosingScrollView: UIScrollView? {
var current: UIView? = self
while let view = current {
if let scrollView = view as? UIScrollView {
return scrollView
}
current = view.superview
}
return nil
}
}

View File

@ -63,7 +63,7 @@ final class CloudStorageViewModel: ObservableObject {
@Published var searchText = ""
@Published var selectedFilter: CloudDriveFilter = .all
@Published var selectedSort: CloudDriveSort = .updatedDesc
@Published var isLoading = false
private var isLoading = false
@Published var isLoadingMore = false
@Published var isMutating = false
@Published var errorMessage: String?
@ -267,9 +267,9 @@ final class MediaLibraryViewModel: ObservableObject {
@Published var page = 1
@Published var keyword = ""
@Published var selectedAuditFilter: MediaLibraryAuditFilter = .all
@Published var isLoading = false
private var isLoading = false
@Published var isLoadingMore = false
@Published var isLoadingDetail = false
private var isLoadingDetail = false
@Published var errorMessage: String?
private let pageSize = 10
@ -614,7 +614,7 @@ final class AlbumListViewModel: ObservableObject {
@Published var searchText = ""
@Published var startTime = ""
@Published var endTime = ""
@Published var isLoading = false
private var isLoading = false
@Published var isLoadingMore = false
@Published var isMutating = false
@Published var errorMessage: String?
@ -718,7 +718,7 @@ final class AlbumDetailViewModel: ObservableObject {
@Published var selectedTab: AlbumFileTab = .image
@Published var total = 0
@Published var page = 1
@Published var isLoading = false
private var isLoading = false
@Published var isLoadingMore = false
@Published var isMutating = false
@Published var errorMessage: String?
@ -862,7 +862,7 @@ final class AlbumTrailerViewModel: ObservableObject {
@Published var selectedFolderId: Int?
@Published var localFiles: [AlbumLocalUploadFile] = []
@Published var uploadProgress = 0
@Published var isLoadingFolders = false
private var isLoadingFolders = false
@Published var isSubmitting = false
@Published var errorMessage: String?
@Published var didUploadSuccessfully = false

View File

@ -135,7 +135,7 @@ final class LocationReportHistoryViewModel: ObservableObject {
@Published var endDate: Date?
@Published var items: [LocationReportHistoryItem] = []
@Published var errorMessage: String?
@Published var isLoading = false
private var isLoading = false
@Published var isLoadingMore = false
@Published var total = 0

View File

@ -20,8 +20,8 @@ final class PaymentCollectionViewModel: ObservableObject {
@Published var remarkText = ""
@Published var currentPayUrl = ""
@Published var qrImage: UIImage?
@Published var isLoading = false
@Published var isPolling = false
private var isLoading = false
private var isPolling = false
@Published var errorMessage: String?
@Published var status: PaymentCollectionStatus = .idle
@ -193,7 +193,7 @@ private extension String {
/// ViewModel
final class PaymentCollectionRecordViewModel: ObservableObject {
@Published var groups: [PaymentCollectionRecordGroup] = []
@Published var isLoading = false
private var isLoading = false
@Published var errorMessage: String?
///

View File

@ -13,7 +13,7 @@ import Combine
final class ProfileViewModel: ObservableObject {
@Published var userInfo: UserInfoResponse?
@Published var realNameInfo: RealNameInfo?
@Published var isLoading = false
private var isLoading = false
@Published var isSaving = false
@Published var isEditingProfile = false
@Published var editingNickname = ""

View File

@ -15,7 +15,7 @@ final class PunchPointListViewModel: ObservableObject {
@Published var items: [PunchPointItem] = []
@Published var selectedDetail: PunchPointItem?
@Published var errorMessage: String?
@Published var isLoading = false
private var isLoading = false
@Published var isLoadingMore = false
@Published var total = 0

View File

@ -179,7 +179,7 @@ enum ScenicSettlementAmountValidation: Equatable {
final class ScenicSettlementReviewViewModel: ObservableObject {
@Published var scenicApplications: [ScenicApplicationPendingResponse] = []
@Published var roleApplications: [RoleApplyPendingResponse] = []
@Published var isLoading = false
private var isLoading = false
@Published var message: String?
@Published var loadFailedAll = false
@Published var scenicLoadFailed = false

View File

@ -39,7 +39,7 @@ final class TaskCloudFileSelectionViewModel: ObservableObject {
@Published var searchText = ""
@Published var selectedFilter: TaskCloudFileFilter = .all
@Published var selectedFiles: [CloudDriveFile] = []
@Published var isLoading = false
private var isLoading = false
@Published var isLoadingMore = false
@Published var errorMessage: String?

View File

@ -51,7 +51,7 @@ final class TaskCreateViewModel: ObservableObject {
@Published var availableOrders: [AvailableOrderResponse] = []
@Published var selectedCloudFiles: [TaskCloudSelectionItem] = []
@Published var selectedLocalFiles: [TaskLocalUploadItem] = []
@Published var isLoadingOrders = false
private(set) var isLoadingOrders = false
@Published var isSubmitting = false
@Published var errorMessage: String?
@Published var didSubmitSuccessfully = false

View File

@ -38,7 +38,7 @@ enum TaskStatusFilter: Int, CaseIterable, Identifiable {
/// ViewModel
@MainActor
final class TaskManagementViewModel: ObservableObject {
@Published var isLoading = false
private var isLoading = false
@Published var isLoadingMore = false
@Published var tasks: [PhotographerTaskItem] = []
@Published var total = 0
@ -146,7 +146,7 @@ final class TaskManagementViewModel: ObservableObject {
/// ViewModel
@MainActor
final class TaskDetailViewModel: ObservableObject {
@Published var isLoading = false
private var isLoading = false
@Published var detail: TaskDetailResponse?
@Published var errorMessage: String?

View File

@ -229,7 +229,7 @@ final class WithdrawApplyViewModel: ObservableObject {
@Published var info: WithdrawInfoResponse?
@Published var amountText = ""
@Published var smsCode = ""
@Published var isLoading = false
private var isLoading = false
@Published var isSubmitting = false
@Published var smsCountdown = 0
@Published var errorMessage: String?
@ -309,7 +309,7 @@ final class WithdrawalSettingsViewModel: ObservableObject {
@Published var frontImageURL = ""
@Published var backImageURL = ""
@Published var uploadProgress = 0
@Published var isLoading = false
private var isLoading = false
@Published var isSubmitting = false
@Published var smsCountdown = 0
@Published var errorMessage: String?
@ -442,7 +442,7 @@ final class PointsRedemptionViewModel: ObservableObject {
@Published var overview = PointOverviewResponse()
@Published var records: [PointWithdrawItem] = []
@Published var pointsText = ""
@Published var isLoading = false
private var isLoading = false
@Published var isSubmitting = false
@Published var errorMessage: String?