集成 MJRefresh 并重构主 TabBar 为系统 TabView 样式。
新增 SwiftUI 桥接层与单元测试,更新 Tab 图标资源命名和多倍图,同步调整 UI Test 的 Tab/扫码定位逻辑。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -91,3 +91,17 @@ 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 下拉刷新,避免双刷新冲突。
|
||||
|
||||
205
suixinkan/Core/Design/MJRefreshSupport.swift
Normal file
205
suixinkan/Core/Design/MJRefreshSupport.swift
Normal file
@ -0,0 +1,205 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user