feat: 修复MainView Tab切换问题并优化MeView逻辑

- 新增MainView Tab切换问题分析文档,详细描述问题原因及解决方案。
- 优化BottomTabView的绑定逻辑,简化状态管理,确保Tab切换时状态正确更新。
- 在MeView中实现用户信息加载逻辑调整,确保动态列表仅在首次进入时加载,并添加错误处理视图。
- 创建EmptyStateView组件,提供统一的空状态展示和重试功能。
- 增强调试信息输出,便于后续问题排查和用户体验提升。
This commit is contained in:
edwinQQQ
2025-08-05 15:51:07 +08:00
parent 99a53d7274
commit 4ff92c8c4d
16 changed files with 481 additions and 322 deletions

View File

@@ -0,0 +1,82 @@
# MainView Tab切换问题修复
## 问题描述
点击me tab时页面没有切换到MeView而是停留在FeedListView并显示"no moments yet"但触发了2次MeFeature onAppear事件。
## 问题分析
### 1. 根本原因MainFeature被重新初始化
从debug日志发现
```
📱 MainContentView selectedTab: other
🏗️ MainFeature 初始化 ← MainFeature被重新创建
📱 MainContentView selectedTab: feed
```
**问题**AppRootView中每次渲染都重新创建MainFeature的store导致状态丢失。
### 2. Tab枚举不匹配问题
- **MainFeature.Tab**: `feed(0), other(1)`
- **BottomTabView.Tab**: `feed(0), me(1)`
虽然rawValue相同但类型不同导致类型转换问题。
### 3. MainView中的绑定逻辑问题
```swift
// 原来的错误代码
BottomTabView(selectedTab: Binding(
get: { Tab(rawValue: store.selectedTab.rawValue) ?? .feed }, // Tab类型不匹配
set: { newTab in
store.send(.selectTab(MainFeature.Tab(rawValue: newTab.rawValue) ?? .feed))
}
))
```
### 4. MainContentView缺少状态追踪
MainContentView没有使用`WithPerceptionTracking`,可能导致状态更新时视图不刷新。
## 解决方案
### 1. 简化BottomTabView绑定逻辑
- 添加详细的调试信息追踪Tab转换过程
- 避免复杂的switch语句使用三元运算符
- 确保绑定逻辑的清晰性和可追踪性
### 2. 优化MainFeature的selectTab处理
- 添加重复设置检查,避免重复状态变化
- 增加详细的调试信息
- 确保状态变化的唯一性
### 3. 添加状态一致性检查
- 在MainView加载时检查selectedTab状态
- 在MainContentView中验证状态一致性
- 添加详细的调试信息追踪状态变化
### 4. 优化AppRootView的store管理
- 修复store创建和缓存的逻辑
- 确保store的稳定性
- 添加store生命周期调试信息
### 5. 添加全面的调试信息
- BottomTabView的get/set操作追踪
- MainFeature的selectTab处理追踪
- MainView和MainContentView的状态检查
- AppRootView的store管理追踪
## 修复状态
- ✅ 简化BottomTabView绑定逻辑
- ✅ 优化MainFeature的selectTab处理
- ✅ 添加状态一致性检查
- ✅ 优化AppRootView的store管理
- ✅ 添加全面的调试信息
- ✅ 更新问题分析文档
## 测试要点
1. 点击feed tab时正确显示FeedListView
2. 点击me tab时正确显示MeView
3. Tab切换时状态正确更新
4. 调试信息正确输出
5. 不再出现重复的onAppear事件

View File

@@ -0,0 +1,53 @@
# MeView逻辑调整计划
## 需求分析
1. **用户信息获取逻辑**:每次显示都重新获取用户信息
2. **动态列表获取逻辑**:只在首次进入时获取动态列表
3. **错误处理逻辑**动态列表API失败时显示错误视图组件
4. **下拉刷新**:用户可以下拉刷新获取最新数据
## 实现方案
### 1. 创建EmptyStateView组件
- 位置:`Views/Components/EmptyStateView.swift`
- 功能:显示"暂无数据"文案和"重试"按钮
- 高度100与列表视图对齐
- 接受重试回调函数
### 2. 修改MeFeature.State
- 添加 `isUserInfoFirstLoad: Bool = true`
- 添加 `showErrorView: Bool = false`
- 添加 `momentsFirstLoadFailed: Bool = false`
### 3. 修改MeFeature.Action
- 添加 `loadUserInfo`:专门用于获取用户信息
- 添加 `retryMoments`:用于重试动态列表加载
### 4. 修改MeFeature.reducer逻辑
- `onAppear`:每次显示都获取用户信息,只在首次进入时获取动态列表
- `refresh`:同时获取用户信息和动态列表(下拉刷新)
- `retryMoments`:重新加载动态列表第一页
- `momentsResponse`:处理错误状态,第一页失败时显示错误视图
### 5. 修改MeView
- 根据 `showErrorView` 状态显示错误视图或动态列表
- 保持下拉刷新功能
- 添加调试信息
## 实现状态
- ✅ 创建EmptyStateView组件
- ✅ 修改MeFeature.State
- ✅ 修改MeFeature.Action
- ✅ 修改MeFeature.reducer逻辑
- ✅ 修改MeView显示逻辑
## 测试要点
1. 每次进入页面都获取最新用户信息
2. 动态列表只在首次进入时加载
3. 动态列表API失败时显示错误视图
4. 点击重试按钮重新加载动态列表
5. 下拉刷新功能正常工作
6. 用户信息加载失败时的错误处理

View File

@@ -50,8 +50,6 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
4C4C8FBE2DE5AF9200384527 /* yanaAPITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = yanaAPITests;
sourceTree = "<group>";
};
@@ -258,10 +256,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-resources.sh\"\n";
@@ -275,10 +277,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-frameworks.sh\"\n";

View File

@@ -42,8 +42,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections",
"state" : {
"revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0",
"version" : "1.2.0"
"revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341",
"version" : "1.2.1"
}
},
{
@@ -51,8 +51,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-composable-architecture",
"state" : {
"revision" : "6574de2396319a58e86e2178577268cb4aeccc30",
"version" : "1.20.2"
"revision" : "4c47829a080789cf20d82c64d8c27291352391d4",
"version" : "1.21.1"
}
},
{
@@ -78,8 +78,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-dependencies",
"state" : {
"revision" : "4c90d6b2b9bf0911af87b103bb40f41771891596",
"version" : "1.9.2"
"revision" : "eefcdaa88d2e2fd82e3405dfb6eb45872011a0b5",
"version" : "1.9.3"
}
},
{
@@ -96,8 +96,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-navigation",
"state" : {
"revision" : "db6bc9dbfed001f21e6728fd36413d9342c235b4",
"version" : "2.3.0"
"revision" : "4e89284c1966538109dc783497405bc680e9bc96",
"version" : "2.4.0"
}
},
{
@@ -105,8 +105,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-perception",
"state" : {
"revision" : "d924c62a70fca5f43872f286dbd7cef0957f1c01",
"version" : "1.6.0"
"revision" : "328a0b49e2690135c4c2660661f0ed83f16853e3",
"version" : "2.0.4"
}
},
{
@@ -114,8 +114,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-sharing",
"state" : {
"revision" : "75e846ee3159dc75b3a29bfc24b6ce5a557ddca9",
"version" : "2.5.2"
"revision" : "5d87dda90ed048f216826efbad404110141161bb",
"version" : "2.6.0"
}
},
{
@@ -132,8 +132,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state" : {
"revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4",
"version" : "1.5.2"
"revision" : "23e3442166b5122f73f9e3e622cd1e4bafeab3b7",
"version" : "1.6.0"
}
}
],

View File

@@ -21,7 +21,7 @@ struct ConfigView: View {
} else if let configData = store.configData {
ConfigDataView(configData: configData, lastUpdated: store.lastUpdated)
} else {
EmptyStateView()
// EmptyStateView()
}
}
@@ -161,20 +161,20 @@ struct SettingsSection: View {
}
// MARK: - Empty State View
struct EmptyStateView: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "arrow.down.circle")
.font(.system(size: 40))
.foregroundColor(.blue)
Text(LocalizedString("config.click_to_load", comment: ""))
.font(.body)
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
}
.frame(maxHeight: .infinity)
}
}
//struct EmptyStateView: View {
// var body: some View {
// VStack(spacing: 16) {
// Image(systemName: "arrow.down.circle")
// .font(.system(size: 40))
// .foregroundColor(.blue)
// Text(LocalizedString("config.click_to_load", comment: ""))
// .font(.body)
// .multilineTextAlignment(.center)
// .foregroundColor(.secondary)
// }
// .frame(maxHeight: .infinity)
// }
//}
// MARK: - Action Buttons View
struct ActionButtonsView: View {
@@ -229,10 +229,10 @@ struct InfoRow: View {
}
// MARK: - Preview
#Preview {
ConfigView(
store: Store(initialState: ConfigFeature.State()) {
ConfigFeature()
}
)
}
//#Preview {
// ConfigView(
// store: Store(initialState: ConfigFeature.State()) {
// ConfigFeature()
// }
// )
//}

View File

@@ -50,6 +50,7 @@ struct FeedListFeature {
// CreateFeed
case createFeedPublishSuccess
// Action
case checkAuthAndLoad
}
func reduce(into state: inout State, action: Action) -> Effect<Action> {
@@ -57,7 +58,35 @@ struct FeedListFeature {
case .onAppear:
guard state.isFirstLoad else { return .none }
state.isFirstLoad = false
return .send(.fetchFeeds)
debugInfoSync("📱 FeedListFeature onAppear")
//
return .send(.checkAuthAndLoad)
case .checkAuthAndLoad:
//
return .run { send in
//
let accountModel = await UserInfoManager.getAccountModel()
if accountModel?.uid != nil {
debugInfoSync("✅ FeedListFeature: 认证信息已准备好,开始获取动态")
await send(.fetchFeeds)
} else {
debugInfoSync("⏳ FeedListFeature: 认证信息未准备好,等待...")
//
for attempt in 1...3 {
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5
let retryAccountModel = await UserInfoManager.getAccountModel()
if retryAccountModel?.uid != nil {
debugInfoSync("✅ FeedListFeature: 第\(attempt)次重试成功,认证信息已保存,开始获取动态")
await send(.fetchFeeds)
return
} else {
debugInfoSync("⏳ FeedListFeature: 第\(attempt)次重试,认证信息仍未准备好")
}
}
debugInfoSync("❌ FeedListFeature: 多次重试后认证信息仍未准备好")
}
}
case .reload:
//
state.isLoading = true

View File

@@ -26,6 +26,19 @@ struct MainFeature {
debugInfoSync("🏗️ MainFeature 初始化")
debugInfoSync(" accountModel.uid: \(accountModel?.uid ?? "nil")")
debugInfoSync(" 转换后的uid: \(uid)")
// accountModelKeychain
if accountModel == nil {
debugInfoSync(" 🔍 尝试从Keychain获取AccountModel")
Task {
if let savedAccountModel = await UserInfoManager.getAccountModel() {
debugInfoSync(" ✅ 从Keychain获取到AccountModel: \(savedAccountModel.uid ?? "nil")")
} else {
debugInfoSync(" ⚠️ 从Keychain未获取到AccountModel")
}
}
}
var meState = MeFeature.State(displayUID: uid > 0 ? uid : nil)
if uid > 0 {
meState.uid = uid // uiddisplayUID
@@ -73,15 +86,34 @@ struct MainFeature {
await send(.accountModelLoaded(accountModel))
}
case .selectTab(let tab):
debugInfoSync("🎯 MainFeature selectTab: \(tab)")
debugInfoSync(" 当前selectedTab: \(state.selectedTab)")
debugInfoSync(" 新selectedTab: \(tab)")
// tab
guard state.selectedTab != tab else {
debugInfoSync(" ⚠️ 重复设置相同tab忽略")
return .none
}
state.selectedTab = tab
state.navigationPath = []
if tab == .other, let uidStr = state.accountModel?.uid, let uid = Int(uidStr), uid > 0 {
if state.me.displayUID != uid {
state.me.displayUID = uid
state.me.uid = uid // uid
state.me.isFirstLoad = true
debugInfoSync(" ✅ selectedTab已更新为: \(state.selectedTab)")
// MeViewuid
if tab == .other {
if let uidStr = state.accountModel?.uid, let uid = Int(uidStr), uid > 0 {
if state.me.displayUID != uid {
state.me.displayUID = uid
state.me.uid = uid // uid
state.me.isFirstLoad = true
debugInfoSync(" 🔄 更新MeFeature状态uid: \(uid)")
}
debugInfoSync(" 📱 切换到MeView触发数据加载")
return .send(.me(.onAppear))
} else {
debugInfoSync(" ⚠️ 切换到MeView但uid无效等待AccountModel加载")
}
return .send(.me(.onAppear))
}
return .none
case .feedList(.testButtonTapped):
@@ -97,14 +129,31 @@ struct MainFeature {
return .none
case let .accountModelLoaded(accountModel):
state.accountModel = accountModel
// MeView uid
if state.selectedTab == .other, let uidStr = accountModel?.uid, let uid = Int(uidStr), uid > 0 {
debugInfoSync("📦 MainFeature: AccountModel已加载")
debugInfoSync(" uid: \(accountModel?.uid ?? "nil")")
// MeFeature
if let uidStr = accountModel?.uid, let uid = Int(uidStr), uid > 0 {
if state.me.displayUID != uid {
state.me.displayUID = uid
state.me.uid = uid // uid
state.me.isFirstLoad = true
debugInfoSync(" 🔄 更新MeFeature状态uid: \(uid)")
}
return .send(.me(.onAppear))
// MeView
if state.selectedTab == .other {
debugInfoSync(" 📱 当前在MeView触发数据加载")
return .send(.me(.onAppear))
}
// FeedView
if state.selectedTab == .feed {
debugInfoSync(" 📱 当前在FeedView触发数据加载")
return .send(.feedList(.checkAuthAndLoad))
}
} else {
debugInfoSync(" ⚠️ AccountModel中uid无效")
}
return .none
case .me(.settingButtonTapped):

View File

@@ -7,6 +7,7 @@ struct MeFeature {
@ObservableState
struct State: Equatable {
var isFirstLoad: Bool = true
var isUserInfoFirstLoad: Bool = true
var userInfo: UserInfo?
var isLoadingUserInfo: Bool = false
var userInfoError: String?
@@ -24,6 +25,9 @@ struct MeFeature {
// DetailView
var showDetail: Bool = false
var selectedMoment: MomentsInfo?
//
var showErrorView: Bool = false
var momentsFirstLoadFailed: Bool = false
init(displayUID: Int? = nil) {
self.displayUID = displayUID
@@ -48,6 +52,8 @@ struct MeFeature {
case onAppear
case refresh
case loadMore
case loadUserInfo
case retryMoments
case userInfoResponse(Result<UserInfo, APIError>)
case momentsResponse(Result<MyMomentsResponse, APIError>)
//
@@ -60,25 +66,54 @@ struct MeFeature {
func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .onAppear:
guard state.isFirstLoad else { return .none }
debugInfoSync("📱 MeFeature onAppear")
debugInfoSync("\n📱 MeFeature onAppear")
debugInfoSync(" isFirstLoad: \(state.isFirstLoad)")
debugInfoSync(" isUserInfoFirstLoad: \(state.isUserInfoFirstLoad)")
debugInfoSync(" effectiveUID: \(state.effectiveUID)")
state.isFirstLoad = false
return .send(.refresh)
//
let userInfoEffect = fetchUserInfo(uid: state.effectiveUID)
//
if state.isFirstLoad {
state.isFirstLoad = false
return .merge(
userInfoEffect,
fetchMoments(uid: state.effectiveUID, page: 1, pageSize: state.pageSize)
)
} else {
return userInfoEffect
}
case .refresh:
guard state.effectiveUID > 0 else { return .none }
debugInfoSync("🔄 MeFeature refresh")
debugInfoSync("\n🔄 MeFeature refresh")
debugInfoSync(" effectiveUID: \(state.effectiveUID)")
state.isRefreshing = true
state.page = 1
state.hasMore = true
state.userInfoError = nil //
state.momentsError = nil //
state.showErrorView = false //
return .merge(
fetchUserInfo(uid: state.effectiveUID),
fetchMoments(uid: state.effectiveUID, page: 1, pageSize: state.pageSize)
)
case .loadUserInfo:
guard state.effectiveUID > 0 else { return .none }
debugInfoSync("\n👤 MeFeature loadUserInfo")
debugInfoSync(" effectiveUID: \(state.effectiveUID)")
return fetchUserInfo(uid: state.effectiveUID)
case .retryMoments:
guard state.effectiveUID > 0 else { return .none }
debugInfoSync("\n🔄 MeFeature retryMoments")
debugInfoSync(" effectiveUID: \(state.effectiveUID)")
state.showErrorView = false //
state.momentsFirstLoadFailed = false
state.isLoadingMoments = true
state.page = 1
state.hasMore = true
state.momentsError = nil
return fetchMoments(uid: state.effectiveUID, page: 1, pageSize: state.pageSize)
case .loadMore:
guard state.effectiveUID > 0, state.hasMore, !state.isLoadingMore else { return .none }
state.isLoadingMore = true
@@ -151,6 +186,8 @@ struct MeFeature {
state.hasMore = newMoments.count == state.pageSize
if state.hasMore { state.page += 1 }
state.momentsError = nil
state.showErrorView = false //
state.momentsFirstLoadFailed = false
debugInfoSync("✅ 我的动态加载成功")
debugInfoSync(" 加载数量: \(newMoments.count)")
@@ -158,6 +195,11 @@ struct MeFeature {
debugInfoSync(" 是否有更多: \(state.hasMore)")
case let .failure(error):
state.momentsError = error.localizedDescription
//
if state.page == 1 {
state.showErrorView = true
state.momentsFirstLoadFailed = true
}
debugErrorSync("❌ 我的动态加载失败: \(error.localizedDescription)")
}
return .none

View File

@@ -1,63 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23727" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23721"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="s0d-6b-0kx">
<objects>
<viewController id="Y6W-OH-hqX" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="5EZ-qb-Rvc">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="bg" translatesAutoresizingMaskIntoConstraints="NO" id="Mom-Je-A43">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
</imageView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="logo" translatesAutoresizingMaskIntoConstraints="NO" id="jVZ-ey-zjS">
<rect key="frame" x="146.66666666666666" y="200" width="100" height="100"/>
<constraints>
<constraint firstAttribute="width" constant="100" id="61A-Xv-MlD"/>
<constraint firstAttribute="height" constant="100" id="NWJ-mJ-K2O"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="E-Parti" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="GrU-nK-tAY">
<rect key="frame" x="138" y="332" width="117" height="48"/>
<fontDescription key="fontDescription" type="system" pointSize="40"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<viewLayoutGuide key="safeArea" id="vDu-zF-Fre"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstItem="jVZ-ey-zjS" firstAttribute="centerX" secondItem="Mom-Je-A43" secondAttribute="centerX" id="Atv-LB-aNW"/>
<constraint firstItem="Mom-Je-A43" firstAttribute="leading" secondItem="vDu-zF-Fre" secondAttribute="leading" id="fQ2-yj-nze"/>
<constraint firstItem="Mom-Je-A43" firstAttribute="top" secondItem="5EZ-qb-Rvc" secondAttribute="top" id="gVA-6N-OG4"/>
<constraint firstItem="GrU-nK-tAY" firstAttribute="centerY" secondItem="Mom-Je-A43" secondAttribute="centerY" constant="-70" id="j81-qa-9vS"/>
<constraint firstAttribute="bottom" secondItem="Mom-Je-A43" secondAttribute="bottom" id="lBn-me-aJu"/>
<constraint firstItem="Mom-Je-A43" firstAttribute="trailing" secondItem="vDu-zF-Fre" secondAttribute="trailing" id="lka-KN-fEy"/>
<constraint firstItem="jVZ-ey-zjS" firstAttribute="top" secondItem="Mom-Je-A43" secondAttribute="top" constant="200" id="nCq-oK-mTB"/>
<constraint firstItem="GrU-nK-tAY" firstAttribute="centerX" secondItem="Mom-Je-A43" secondAttribute="centerX" id="sZE-bZ-0Xj"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-208.3969465648855" y="-13.380281690140846"/>
</scene>
</scenes>
<resources>
<image name="bg" width="375" height="812"/>
<image name="logo" width="100" height="100"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>

View File

@@ -3,93 +3,62 @@ import SwiftUI
// MARK: - API Loading Effect View
/// API
///
///
/// - Loading 88x8860% alpha
/// - 2
/// -
/// -
struct APILoadingEffectView: View {
@ObservedObject private var loadingManager = APILoadingManager.shared
var body: some View {
ZStack {
// 🚨 ForEach
if let firstItem = getFirstDisplayItem() {
SingleLoadingView(item: firstItem)
.onAppear {
debugInfoSync("🔍 Loading item appeared: \(firstItem.id)")
}
.onDisappear {
debugInfoSync("🔍 Loading item disappeared: \(firstItem.id)")
}
LoadingItemView(item: firstItem)
}
}
.allowsHitTesting(false) //
.ignoresSafeArea(.all) //
.onReceive(loadingManager.$loadingItems) { items in
debugInfoSync("🔍 Loading items updated: \(items.count) items")
}
.allowsHitTesting(false)
.ignoresSafeArea(.all)
}
///
private func getFirstDisplayItem() -> APILoadingItem? {
guard Thread.isMainThread else {
debugWarnSync("⚠️ getFirstDisplayItem called from background thread")
return nil
}
guard Thread.isMainThread else { return nil }
return loadingManager.loadingItems.first { $0.shouldDisplay }
}
}
// MARK: - Single Loading View
// MARK: - Loading Item View
/// -
private struct SingleLoadingView: View {
private struct LoadingItemView: View {
let item: APILoadingItem
var body: some View {
Group {
switch item.state {
case .loading:
SimpleLoadingView()
case .error(let message):
if item.shouldShowError {
SimpleErrorView(message: message)
}
case .success:
EmptyView() //
switch item.state {
case .loading:
LoadingSpinnerView()
case .error(let message):
if item.shouldShowError {
ErrorMessageView(message: message)
} else {
EmptyView()
}
case .success:
EmptyView()
}
// 🚨
}
}
// MARK: - Simple Loading View
// MARK: - Loading Spinner View
/// Loading
private struct SimpleLoadingView: View {
private struct LoadingSpinnerView: View {
var body: some View {
VStack {
Spacer()
HStack {
Spacer()
// +
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color.black.opacity(0.6))
.frame(width: 88, height: 88)
// 使 ProgressView
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(1.2)
}
Spacer()
}
Spacer()
@@ -97,10 +66,9 @@ private struct SimpleLoadingView: View {
}
}
// MARK: - Simple Error View
// MARK: - Error Message View
///
private struct SimpleErrorView: View {
private struct ErrorMessageView: View {
let message: String
var body: some View {
@@ -108,13 +76,10 @@ private struct SimpleErrorView: View {
Spacer()
HStack {
Spacer()
//
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.white)
.font(.title2)
Text(message)
.foregroundColor(.white)
.font(.system(size: 14))
@@ -127,101 +92,9 @@ private struct SimpleErrorView: View {
.fill(Color.black.opacity(0.6))
)
.frame(maxWidth: 250)
Spacer()
}
Spacer()
}
}
}
// MARK: - Preview
//#if DEBUG
//struct APILoadingEffectView_Previews: PreviewProvider {
// static var previews: some View {
// ZStack {
// //
// Rectangle()
// .fill(Color.blue.opacity(0.3))
// .ignoresSafeArea()
//
// VStack(spacing: 20) {
// Text("")
// .font(.title)
//
// Button("") {
// debugInfoSync("")
// }
// .padding()
// .background(Color.blue)
// .foregroundColor(.white)
// .cornerRadius(8)
// }
//
// // Loading Effect View
// APILoadingEffectView()
// }
// .previewDisplayName("API Loading Effect")
// .onAppear {
// //
// Task {
// let manager = APILoadingManager.shared
//
// // loading
// let id1 = manager.startLoading()
//
// // 2
// DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
// Task {
// manager.setError(id1, errorMessage: "")
// }
// }
// }
// }
// }
//}
//
//// MARK: - Preview Helpers
//
/////
//private struct PreviewStateModifier: ViewModifier {
// let showLoading: Bool
// let showError: Bool
// let errorMessage: String
//
// func body(content: Content) -> some View {
// content
// .onAppear {
// Task {
// let manager = APILoadingManager.shared
//
// if showLoading {
// let _ = manager.startLoading()
// }
//
// if showError {
// let id = manager.startLoading()
// try? await Task.sleep(nanoseconds: 1_000_000_000) // 1
// manager.setError(id, errorMessage: errorMessage)
// }
// }
// }
// }
//}
//
//extension View {
// ///
// func previewLoadingState(
// showLoading: Bool = false,
// showError: Bool = false,
// errorMessage: String = ""
// ) -> some View {
// self.modifier(PreviewStateModifier(
// showLoading: showLoading,
// showError: showError,
// errorMessage: errorMessage
// ))
// }
//}
//#endif
}

View File

@@ -3,27 +3,49 @@ import ComposableArchitecture
struct AppRootView: View {
@State private var isLoggedIn = false
@State private var mainStore: StoreOf<MainFeature>?
var body: some View {
if isLoggedIn {
MainView(
store: Store(
initialState: MainFeature.State()
) {
MainFeature()
Group {
if isLoggedIn {
if let mainStore = mainStore {
MainView(store: mainStore)
.onAppear {
debugInfoSync("🔄 AppRootView: 使用已存在的MainStore")
}
} else {
// MainStore
let store = createMainStore()
let _ = debugInfoSync("🆕 AppRootView: 创建MainStore")
let _ = { mainStore = store }()
MainView(store: store)
.onAppear {
debugInfoSync("💾 AppRootView: MainStore已保存")
}
}
)
} else {
LoginView(
store: Store(
initialState: LoginFeature.State()
) {
LoginFeature()
},
onLoginSuccess: {
isLoggedIn = true
}
)
} else {
LoginView(
store: Store(
initialState: LoginFeature.State()
) {
LoginFeature()
},
onLoginSuccess: {
debugInfoSync("🔐 AppRootView: 登录成功准备创建MainStore")
isLoggedIn = true
}
)
}
}
}
private func createMainStore() -> StoreOf<MainFeature> {
debugInfoSync("🏗️ AppRootView: 创建新的MainStore实例")
return Store(
initialState: MainFeature.State()
) {
MainFeature()
}
}
}

View File

@@ -0,0 +1,38 @@
import SwiftUI
struct EmptyStateView: View {
let onRetry: () -> Void
var body: some View {
VStack(spacing: 12) {
Image(systemName: "doc.text")
.font(.system(size: 32))
.foregroundColor(.white.opacity(0.5))
Text("暂无数据")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white.opacity(0.7))
Button("重试") {
onRetry()
}
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
.padding(.horizontal, 24)
.padding(.vertical, 8)
.background(Color.blue.opacity(0.8))
.cornerRadius(8)
}
.frame(height: 100)
.frame(maxWidth: .infinity)
}
}
#Preview {
ZStack {
Color.black
EmptyStateView {
print("重试按钮被点击")
}
}
}

View File

@@ -40,7 +40,7 @@ public struct ImagePickerWithPreviewView: View {
}
} else {
//
EmptyView()
CustomEmptyView(onRetry: {})
.onChange(of: viewStore.inner.isLoading) { _, isLoading in
if isLoading && loadingId == nil {
loadingId = APILoadingManager.shared.startLoading()

View File

@@ -64,12 +64,11 @@ struct ErrorView: View {
}
// MARK: - EmptyView
struct EmptyView: View {
struct CustomEmptyView: View {
let onRetry: () -> Void
var body: some View {
Text(LocalizedString("feedList.empty", comment: "暂无动态"))
.font(.system(size: 16))
.foregroundColor(.white.opacity(0.7))
.padding(.top, 20)
EmptyStateView(onRetry: onRetry)
}
}
@@ -185,7 +184,9 @@ struct FeedListContentView: View {
} else if let error = store.error {
ErrorView(error: error)
} else if store.moments.isEmpty {
EmptyView()
CustomEmptyView(onRetry: {
store.send(.reload)
})
} else {
MomentsListView(
moments: store.moments,

View File

@@ -41,6 +41,8 @@ struct InternalMainView: View {
}
}
.onAppear {
debugInfoSync("🚀 MainView onAppear")
debugInfoSync(" 当前selectedTab: \(store.selectedTab)")
store.send(.onAppear)
}
}
@@ -85,6 +87,9 @@ struct InternalMainView: View {
store: store,
selectedTab: store.selectedTab
)
.onChange(of: store.selectedTab) { _, newTab in
debugInfoSync("🔄 MainView selectedTab changed: \(newTab)")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.bottom, 80) //
@@ -92,9 +97,17 @@ struct InternalMainView: View {
VStack {
Spacer()
BottomTabView(selectedTab: Binding(
get: { Tab(rawValue: store.selectedTab.rawValue) ?? .feed },
get: {
// MainFeature.TabBottomTabView.Tab
let currentTab = store.selectedTab == .feed ? Tab.feed : Tab.me
debugInfoSync("🔍 BottomTabView get: MainFeature.Tab.\(store.selectedTab) → BottomTabView.Tab.\(currentTab)")
return currentTab
},
set: { newTab in
store.send(.selectTab(MainFeature.Tab(rawValue: newTab.rawValue) ?? .feed))
// BottomTabView.TabMainFeature.Tab
let mainTab: MainFeature.Tab = newTab == .feed ? .feed : .other
debugInfoSync("🔍 BottomTabView set: BottomTabView.Tab.\(newTab) → MainFeature.Tab.\(mainTab)")
store.send(.selectTab(mainTab))
}
))
}
@@ -113,22 +126,26 @@ struct MainContentView: View {
let store: StoreOf<MainFeature>
let selectedTab: MainFeature.Tab
var body: some View {
Group {
if selectedTab == .feed {
FeedListView(store: store.scope(
state: \.feedList,
action: \.feedList
))
} else if selectedTab == .other {
MeView(
store: store.scope(
state: \.me,
action: \.me
),
showCloseButton: false // MainView
)
} else {
EmptyView()
WithPerceptionTracking {
let _ = debugInfoSync("📱 MainContentView selectedTab: \(selectedTab)")
let _ = debugInfoSync(" 与store.selectedTab一致: \(selectedTab == store.selectedTab)")
Group {
if selectedTab == .feed {
FeedListView(store: store.scope(
state: \.feedList,
action: \.feedList
))
} else if selectedTab == .other {
MeView(
store: store.scope(
state: \.me,
action: \.me
),
showCloseButton: false // MainView
)
} else {
CustomEmptyView(onRetry: {})
}
}
}
}

View File

@@ -84,6 +84,7 @@ struct MeView: View {
debugInfoSync(" 动态数量: \(store.moments.count)")
debugInfoSync(" 用户信息错误: \(store.userInfoError ?? "nil")")
debugInfoSync(" 动态错误: \(store.momentsError ?? "nil")")
debugInfoSync(" 显示错误视图: \(store.showErrorView)")
store.send(.onAppear)
}
//
@@ -97,19 +98,19 @@ struct MeView: View {
get: { store.showDetail },
set: { _ in store.send(.detailDismissed) }
)) {
if let selectedMoment = store.selectedMoment {
let detailStore = Store(
initialState: DetailFeature.State(moment: selectedMoment)
) {
DetailFeature()
}
DetailView(store: detailStore)
.onChange(of: detailStore.shouldDismiss) { _, shouldDismiss in
if shouldDismiss {
store.send(.detailDismissed)
}
if let selectedMoment = store.selectedMoment {
let detailStore = Store(
initialState: DetailFeature.State(moment: selectedMoment)
) {
DetailFeature()
}
DetailView(store: detailStore)
.onChange(of: detailStore.shouldDismiss) { _, shouldDismiss in
if shouldDismiss {
store.send(.detailDismissed)
}
}
}
}
}
@@ -165,12 +166,12 @@ struct MeView: View {
.padding(.top, 8)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = store.userInfoError ?? store.momentsError {
} else if let error = store.userInfoError {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 32))
.foregroundColor(.orange)
Text("加载失败")
Text("用户信息加载失败")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
Text(error)
@@ -179,7 +180,7 @@ struct MeView: View {
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
Button("重试") {
store.send(.refresh)
store.send(.loadUserInfo)
}
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
@@ -189,6 +190,12 @@ struct MeView: View {
.cornerRadius(8)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if store.showErrorView {
//
EmptyStateView {
store.send(.retryMoments)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if store.moments.isEmpty {
VStack(spacing: 12) {
Image(systemName: "doc.text")
@@ -213,6 +220,9 @@ struct MeView: View {
Text("调试: momentsError = \(store.momentsError ?? "nil")")
.font(.system(size: 12))
.foregroundColor(.yellow)
Text("调试: showErrorView = \(store.showErrorView)")
.font(.system(size: 12))
.foregroundColor(.yellow)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {