feat: 实现DetailView头像点击功能并优化MeView

- 在DetailView中添加头像点击功能,支持展示非当前用户的主页。
- 更新OptimizedDynamicCardView以支持头像点击回调。
- 修改DetailFeature以管理用户主页显示状态。
- 在MeView中添加关闭按钮支持,优化用户体验。
- 确保其他页面的兼容性,未影响现有功能。
This commit is contained in:
edwinQQQ
2025-08-01 16:12:24 +08:00
parent 57ba103996
commit fa544139c1
11 changed files with 214 additions and 51 deletions

View File

@@ -0,0 +1,68 @@
# DetailView头像点击功能实现
## 需求分析
在DetailView中点击OptimizedDynamicCardView的头像时如果是非当前用户的动态则present一个MeView并传入该动态的uid作为displayUID。
## 实施计划
### 修改文件
1. **OptimizedDynamicCardView.swift**:添加头像点击回调参数
2. **DetailFeature.swift**:添加显示用户主页的状态管理
3. **DetailView.swift**添加MeView的present逻辑
4. **MeView.swift**更新OptimizedDynamicCardView调用添加关闭按钮支持
5. **FeedListView.swift**更新OptimizedDynamicCardView调用
6. **MainView.swift**更新MeView调用
### 核心功能设计
1. **OptimizedDynamicCardView**
- 添加`onAvatarTap: (() -> Void)?`参数
- 在头像上添加点击手势
- 移除头像的`allowsHitTesting(false)`
2. **DetailFeature**
- 添加`showUserProfile: Bool`状态
- 添加`targetUserId: Int`状态
- 添加`showUserProfile(Int)``hideUserProfile` Action
3. **DetailView**
- 在OptimizedDynamicCardView中添加头像点击回调
- 判断是否为当前用户动态
- 使用sheet替代fullScreenCover支持下拉关闭
- 添加presentationDetents和presentationDragIndicator
4. **MeView**
- 添加`showCloseButton: Bool`参数
- 在present时显示关闭按钮
- 在MainView中不显示关闭按钮
### 实施步骤
1. ✅ 修改OptimizedDynamicCardView添加头像点击回调
2. ✅ 修改DetailFeature添加用户主页状态管理
3. ✅ 修改DetailView添加MeView present逻辑
4. ✅ 更新其他使用OptimizedDynamicCardView的地方
5. ✅ 改进present方式使用sheet替代fullScreenCover
6. ✅ 添加MeView关闭按钮支持
### 功能特点
- **智能判断**:只有点击非当前用户的头像才会显示用户主页
- **复用MeView**利用之前实现的displayUID功能
- **用户体验**使用sheet支持下拉关闭更符合iOS设计规范
- **关闭按钮**在present时提供明确的关闭方式
- **向后兼容**其他页面的OptimizedDynamicCardView不受影响
## 完成状态
- [x] OptimizedDynamicCardView头像点击功能
- [x] DetailFeature状态管理
- [x] DetailView MeView present逻辑
- [x] 其他页面兼容性更新
- [x] 改进present方式sheet替代fullScreenCover
- [x] MeView关闭按钮支持
## 测试要点
1. 在DetailView中点击当前用户头像不触发任何操作
2. 在DetailView中点击其他用户头像正确显示该用户的主页
3. 用户主页支持下拉关闭
4. 用户主页显示关闭按钮,点击可关闭
5. MainView中的MeView不显示关闭按钮
6. 其他页面的OptimizedDynamicCardView正常工作
7. MeView正确显示指定用户的信息

View File

@@ -17,10 +17,11 @@
### 核心组件设计 ### 核心组件设计
1. **UserIDDisplay组件** 1. **UserIDDisplay组件**
- 参数uid (Int), fontSize (CGFloat), textColor (Color) - 参数uid (Int), fontSize (CGFloat), textColor (Color), isDisplayCopy (Bool)
- 功能:显示"ID: xxx"右侧复制图标点击复制ID - 功能:显示"ID: xxx"可选的复制图标点击复制ID
- 样式:数字不使用逗号分割 - 样式:数字不使用逗号分割
- 反馈:点击后显示"已复制"提示 - 反馈:点击后显示"已复制"提示
- 配置isDisplayCopy控制是否显示复制图标和启用复制功能
2. **头像样式调整** 2. **头像样式调整**
- 尺寸130x130 - 尺寸130x130
@@ -44,11 +45,12 @@
- [x] OptimizedDynamicCardView组件更新 - [x] OptimizedDynamicCardView组件更新
- [x] 复制功能实现 - [x] 复制功能实现
- [x] 视觉反馈实现 - [x] 视觉反馈实现
- [x] 复制图标显示控制功能
## 测试要点 ## 测试要点
1. 头像尺寸和边框显示正确 1. 头像尺寸和边框显示正确
2. ID显示格式正确无逗号分割 2. ID显示格式正确无逗号分割
3. 复制图标显示正确 3. 复制图标显示控制正确MeView显示其他页面不显示
4. 点击复制功能正常 4. 点击复制功能正常
5. 复制成功反馈显示 5. 复制成功反馈显示
6. 组件在不同场景下复用正常 6. 组件在不同场景下复用正常

View File

@@ -22,6 +22,10 @@ struct DetailFeature {
// DetailView // DetailView
var shouldDismiss = false var shouldDismiss = false
//
var showUserProfile = false
var targetUserId: Int = 0
init(moment: MomentsInfo) { init(moment: MomentsInfo) {
self.moment = moment self.moment = moment
} }
@@ -41,6 +45,10 @@ struct DetailFeature {
// IDactions // IDactions
case loadCurrentUserId case loadCurrentUserId
case currentUserIdLoaded(String?) case currentUserIdLoaded(String?)
// actions
case showUserProfile(Int)
case hideUserProfile
} }
var body: some ReducerOf<Self> { var body: some ReducerOf<Self> {
@@ -190,6 +198,15 @@ struct DetailFeature {
debugInfoSync("🔍 DetailFeature: 请求关闭DetailView") debugInfoSync("🔍 DetailFeature: 请求关闭DetailView")
state.shouldDismiss = true state.shouldDismiss = true
return .none return .none
case let .showUserProfile(userId):
state.targetUserId = userId
state.showUserProfile = true
return .none
case .hideUserProfile:
state.showUserProfile = false
return .none
} }
} }
} }

View File

@@ -12,7 +12,7 @@ struct MainFeature {
struct State: Equatable { struct State: Equatable {
var selectedTab: Tab = .feed var selectedTab: Tab = .feed
var feedList: FeedListFeature.State = .init() var feedList: FeedListFeature.State = .init()
var me: MeFeature.State = .init() var me: MeFeature.State
var accountModel: AccountModel? = nil var accountModel: AccountModel? = nil
// State // State
var navigationPath: [Destination] = [] var navigationPath: [Destination] = []
@@ -20,8 +20,10 @@ struct MainFeature {
// //
var isLoggedOut: Bool = false var isLoggedOut: Bool = false
init() { init(accountModel: AccountModel? = nil) {
// self.accountModel = accountModel
let uid = accountModel?.uid.flatMap { Int($0) } ?? 0
self.me = MeFeature.State(displayUID: uid > 0 ? uid : nil)
} }
} }
@@ -64,9 +66,9 @@ struct MainFeature {
state.selectedTab = tab state.selectedTab = tab
state.navigationPath = [] state.navigationPath = []
if tab == .other, let uidStr = state.accountModel?.uid, let uid = Int(uidStr), uid > 0 { if tab == .other, let uidStr = state.accountModel?.uid, let uid = Int(uidStr), uid > 0 {
if state.me.uid != uid { if state.me.displayUID != uid {
state.me.uid = uid state.me.displayUID = uid
state.me.isFirstLoad = true // state.me.isFirstLoad = true
} }
return .send(.me(.onAppear)) return .send(.me(.onAppear))
} }
@@ -86,8 +88,8 @@ struct MainFeature {
state.accountModel = accountModel state.accountModel = accountModel
// MeView uid // MeView uid
if state.selectedTab == .other, let uidStr = accountModel?.uid, let uid = Int(uidStr), uid > 0 { if state.selectedTab == .other, let uidStr = accountModel?.uid, let uid = Int(uidStr), uid > 0 {
if state.me.uid != uid { if state.me.displayUID != uid {
state.me.uid = uid state.me.displayUID = uid
state.me.isFirstLoad = true state.me.isFirstLoad = true
} }
return .send(.me(.onAppear)) return .send(.me(.onAppear))

View File

@@ -19,12 +19,24 @@ struct MeFeature {
var page: Int = 1 var page: Int = 1
var pageSize: Int = 20 var pageSize: Int = 20
var uid: Int = 0 var uid: Int = 0
// IDnil
var displayUID: Int?
// DetailView // DetailView
var showDetail: Bool = false var showDetail: Bool = false
var selectedMoment: MomentsInfo? var selectedMoment: MomentsInfo?
init() { init(displayUID: Int? = nil) {
// self.displayUID = displayUID
}
// ID
var effectiveUID: Int {
return displayUID ?? uid
}
//
var isDisplayingOtherUser: Bool {
return displayUID != nil && displayUID != uid
} }
} }
@@ -48,18 +60,18 @@ struct MeFeature {
state.isFirstLoad = false state.isFirstLoad = false
return .send(.refresh) return .send(.refresh)
case .refresh: case .refresh:
guard state.uid > 0 else { return .none } guard state.effectiveUID > 0 else { return .none }
state.isRefreshing = true state.isRefreshing = true
state.page = 1 state.page = 1
state.hasMore = true state.hasMore = true
return .merge( return .merge(
fetchUserInfo(uid: state.uid), fetchUserInfo(uid: state.effectiveUID),
fetchMoments(uid: state.uid, page: 1, pageSize: state.pageSize) fetchMoments(uid: state.effectiveUID, page: 1, pageSize: state.pageSize)
) )
case .loadMore: case .loadMore:
guard state.uid > 0, state.hasMore, !state.isLoadingMore else { return .none } guard state.effectiveUID > 0, state.hasMore, !state.isLoadingMore else { return .none }
state.isLoadingMore = true state.isLoadingMore = true
return fetchMoments(uid: state.uid, page: state.page + 1, pageSize: state.pageSize) return fetchMoments(uid: state.effectiveUID, page: state.page + 1, pageSize: state.pageSize)
case let .userInfoResponse(result): case let .userInfoResponse(result):
state.isLoadingUserInfo = false state.isLoadingUserInfo = false
state.isRefreshing = false state.isRefreshing = false
@@ -106,15 +118,11 @@ struct MeFeature {
private func fetchUserInfo(uid: Int) -> Effect<Action> { private func fetchUserInfo(uid: Int) -> Effect<Action> {
.run { send in .run { send in
// do { if let userInfo = await UserInfoManager.fetchUserInfoFromServer(uid: String(uid), apiService: apiService) {
if let userInfo = await UserInfoManager.fetchUserInfoFromServer(uid: String(uid), apiService: apiService) { await send(.userInfoResponse(.success(userInfo)))
await send(.userInfoResponse(.success(userInfo))) } else {
} else { await send(.userInfoResponse(.failure(.noData)))
await send(.userInfoResponse(.failure(.noData))) }
}
// } catch {
// await send(.userInfoResponse(.failure(error as? APIError ?? .unknown(error.localizedDescription))))
// }
} }
} }

View File

@@ -13,18 +13,21 @@ struct OptimizedDynamicCardView: View {
let onLikeTap: (_ dynamicId: Int, _ uid: Int, _ likedUid: Int, _ worldId: Int) -> Void let onLikeTap: (_ dynamicId: Int, _ uid: Int, _ likedUid: Int, _ worldId: Int) -> Void
// //
let onCardTap: (() -> Void)? let onCardTap: (() -> Void)?
//
let onAvatarTap: (() -> Void)?
// //
let isDetailMode: Bool let isDetailMode: Bool
// loading // loading
let isLikeLoading: Bool let isLikeLoading: Bool
init(moment: MomentsInfo, allMoments: [MomentsInfo], currentIndex: Int, onImageTap: @escaping (_ images: [String], _ index: Int) -> Void, onLikeTap: @escaping (_ dynamicId: Int, _ uid: Int, _ likedUid: Int, _ worldId: Int) -> Void, onCardTap: (() -> Void)? = nil, isDetailMode: Bool = false, isLikeLoading: Bool = false) { init(moment: MomentsInfo, allMoments: [MomentsInfo], currentIndex: Int, onImageTap: @escaping (_ images: [String], _ index: Int) -> Void, onLikeTap: @escaping (_ dynamicId: Int, _ uid: Int, _ likedUid: Int, _ worldId: Int) -> Void, onCardTap: (() -> Void)? = nil, onAvatarTap: (() -> Void)? = nil, isDetailMode: Bool = false, isLikeLoading: Bool = false) {
self.moment = moment self.moment = moment
self.allMoments = allMoments self.allMoments = allMoments
self.currentIndex = currentIndex self.currentIndex = currentIndex
self.onImageTap = onImageTap self.onImageTap = onImageTap
self.onLikeTap = onLikeTap self.onLikeTap = onLikeTap
self.onCardTap = onCardTap self.onCardTap = onCardTap
self.onAvatarTap = onAvatarTap
self.isDetailMode = isDetailMode self.isDetailMode = isDetailMode
self.isLikeLoading = isLikeLoading self.isLikeLoading = isLikeLoading
} }
@@ -62,7 +65,11 @@ struct OptimizedDynamicCardView: View {
} }
.frame(width: 40, height: 40) .frame(width: 40, height: 40)
.clipShape(Circle()) .clipShape(Circle())
.allowsHitTesting(false) // .onTapGesture {
if let onAvatarTap = onAvatarTap {
onAvatarTap()
}
}
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(moment.nick) Text(moment.nick)

View File

@@ -4,13 +4,15 @@ struct UserIDDisplay: View {
let uid: Int let uid: Int
let fontSize: CGFloat let fontSize: CGFloat
let textColor: Color let textColor: Color
let isDisplayCopy: Bool
@State private var showCopiedFeedback: Bool = false @State private var showCopiedFeedback: Bool = false
init(uid: Int, fontSize: CGFloat = 14, textColor: Color = .white.opacity(0.7)) { init(uid: Int, fontSize: CGFloat = 14, textColor: Color = .white.opacity(0.7), isDisplayCopy: Bool = false) {
self.uid = uid self.uid = uid
self.fontSize = fontSize self.fontSize = fontSize
self.textColor = textColor self.textColor = textColor
self.isDisplayCopy = isDisplayCopy
} }
var body: some View { var body: some View {
@@ -19,17 +21,21 @@ struct UserIDDisplay: View {
.font(.system(size: fontSize)) .font(.system(size: fontSize))
.foregroundColor(textColor) .foregroundColor(textColor)
Image("icon_copy") if isDisplayCopy {
.resizable() Image("icon_copy")
.frame(width: 14, height: 14) .resizable()
.foregroundColor(textColor) .frame(width: 14, height: 14)
.foregroundColor(textColor)
}
} }
.onTapGesture { .onTapGesture {
copyToClipboard() if isDisplayCopy {
copyToClipboard()
}
} }
.overlay( .overlay(
Group { Group {
if showCopiedFeedback { if isDisplayCopy && showCopiedFeedback {
Text("已复制") Text("已复制")
.font(.system(size: 12)) .font(.system(size: 12))
.foregroundColor(.white) .foregroundColor(.white)
@@ -61,8 +67,8 @@ struct UserIDDisplay: View {
#Preview { #Preview {
VStack(spacing: 20) { VStack(spacing: 20) {
UserIDDisplay(uid: 123456789) UserIDDisplay(uid: 123456789, isDisplayCopy: true)
UserIDDisplay(uid: 987654321, fontSize: 16, textColor: .black) UserIDDisplay(uid: 987654321, fontSize: 16, textColor: .black, isDisplayCopy: false)
} }
.padding() .padding()
} }

View File

@@ -51,6 +51,12 @@ struct DetailView: View {
store.send(.likeDynamic(dynamicId, uid, likedUid, worldId)) store.send(.likeDynamic(dynamicId, uid, likedUid, worldId))
}, },
onCardTap: nil, // onCardTap: nil, //
onAvatarTap: {
//
if !isCurrentUserDynamic {
store.send(.showUserProfile(store.moment.uid))
}
},
isDetailMode: true, // isDetailMode: true, //
isLikeLoading: store.isLikeLoading isLikeLoading: store.isLikeLoading
) )
@@ -91,7 +97,23 @@ struct DetailView: View {
} }
) )
} }
} }
.sheet(isPresented: Binding(
get: { store.showUserProfile },
set: { _ in store.send(.hideUserProfile) }
)) {
WithPerceptionTracking {
let meStore = Store(
initialState: MeFeature.State(displayUID: store.targetUserId)
) {
MeFeature()
}
MeView(store: meStore, showCloseButton: true)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
}
} }
// //

View File

@@ -96,6 +96,7 @@ struct MomentCardView: View {
onImageTap: onImageTap, onImageTap: onImageTap,
onLikeTap: onLikeTap, onLikeTap: onLikeTap,
onCardTap: onTap, onCardTap: onTap,
onAvatarTap: nil, // FeedListView
isDetailMode: false, isDetailMode: false,
isLikeLoading: isLikeLoading isLikeLoading: isLikeLoading
) )

View File

@@ -124,7 +124,8 @@ struct MainContentView: View {
store: store.scope( store: store.scope(
state: \.me, state: \.me,
action: \.me action: \.me
) ),
showCloseButton: false // MainView
) )
} else { } else {
EmptyView() EmptyView()

View File

@@ -6,6 +6,15 @@ struct MeView: View {
// //
@State private var previewItem: PreviewItem? = nil @State private var previewItem: PreviewItem? = nil
@State private var previewCurrentIndex: Int = 0 @State private var previewCurrentIndex: Int = 0
//
let showCloseButton: Bool
// dismiss
@Environment(\.dismiss) private var dismiss
init(store: StoreOf<MeFeature>, showCloseButton: Bool = false) {
self.store = store
self.showCloseButton = showCloseButton
}
var body: some View { var body: some View {
WithPerceptionTracking { WithPerceptionTracking {
@@ -22,16 +31,35 @@ struct MeView: View {
// //
VStack { VStack {
HStack { HStack {
Spacer() // present
Button(action: { if showCloseButton {
store.send(.settingButtonTapped) Button(action: {
}) { dismiss()
Image(systemName: "gearshape") }) {
.font(.system(size: 33, weight: .medium)) Image(systemName: "xmark")
.foregroundColor(.white) .font(.system(size: 20, weight: .medium))
.foregroundColor(.white)
.frame(width: 44, height: 44)
.background(Color.black.opacity(0.3))
.clipShape(Circle())
}
.padding(.leading, 16)
.padding(.top, 8)
}
Spacer()
if !store.isDisplayingOtherUser {
Button(action: {
store.send(.settingButtonTapped)
}) {
Image(systemName: "gearshape")
.font(.system(size: 33, weight: .medium))
.foregroundColor(.white)
}
.padding(.trailing, 16)
.padding(.top, 8)
} }
.padding(.trailing, 16)
.padding(.top, 8)
} }
Spacer() Spacer()
} }
@@ -111,7 +139,7 @@ struct MeView: View {
.foregroundColor(.white) .foregroundColor(.white)
// ID // ID
UserIDDisplay(uid: store.userInfo?.uid ?? 0) UserIDDisplay(uid: store.userInfo?.uid ?? 0, isDisplayCopy: true)
} }
.padding(.horizontal, 32) .padding(.horizontal, 32)
} }
@@ -184,7 +212,8 @@ struct MeView: View {
}, },
onCardTap: { onCardTap: {
store.send(.showDetail(moment)) store.send(.showDetail(moment))
} },
onAvatarTap: nil // MeView
) )
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }