移除 MJRefresh 依赖,恢复 SwiftUI 系统下拉刷新。
同时收紧各 ViewModel 内部 isLoading 可见性,避免不必要的视图刷新。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -14,7 +14,6 @@ struct suixinkanApp: App {
|
||||
|
||||
init() {
|
||||
AppUITestLaunchState.resetIfNeeded()
|
||||
MJRefreshSupport.configure()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
|
||||
@ -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 下拉刷新,避免双刷新冲突。
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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?
|
||||
|
||||
/// 加载当前景区收款记录,无景区时不请求接口。
|
||||
|
||||
@ -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 = ""
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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?
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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?
|
||||
|
||||
|
||||
@ -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?
|
||||
|
||||
|
||||
Reference in New Issue
Block a user