集成 MJRefresh 并重构主 TabBar 为系统 TabView 样式。

新增 SwiftUI 桥接层与单元测试,更新 Tab 图标资源命名和多倍图,同步调整 UI Test 的 Tab/扫码定位逻辑。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 10:22:53 +08:00
parent 79e735f628
commit 8997d1ba1c
140 changed files with 6574 additions and 877 deletions

View File

@ -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 下拉刷新,避免双刷新冲突。

View 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
}
}