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

View File

@@ -22,6 +22,10 @@ struct DetailFeature {
// DetailView
var shouldDismiss = false
//
var showUserProfile = false
var targetUserId: Int = 0
init(moment: MomentsInfo) {
self.moment = moment
}
@@ -41,6 +45,10 @@ struct DetailFeature {
// IDactions
case loadCurrentUserId
case currentUserIdLoaded(String?)
// actions
case showUserProfile(Int)
case hideUserProfile
}
var body: some ReducerOf<Self> {
@@ -190,6 +198,15 @@ struct DetailFeature {
debugInfoSync("🔍 DetailFeature: 请求关闭DetailView")
state.shouldDismiss = true
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 {
var selectedTab: Tab = .feed
var feedList: FeedListFeature.State = .init()
var me: MeFeature.State = .init()
var me: MeFeature.State
var accountModel: AccountModel? = nil
// State
var navigationPath: [Destination] = []
@@ -20,8 +20,10 @@ struct MainFeature {
//
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.navigationPath = []
if tab == .other, let uidStr = state.accountModel?.uid, let uid = Int(uidStr), uid > 0 {
if state.me.uid != uid {
state.me.uid = uid
state.me.isFirstLoad = true //
if state.me.displayUID != uid {
state.me.displayUID = uid
state.me.isFirstLoad = true
}
return .send(.me(.onAppear))
}
@@ -86,8 +88,8 @@ struct MainFeature {
state.accountModel = accountModel
// MeView uid
if state.selectedTab == .other, let uidStr = accountModel?.uid, let uid = Int(uidStr), uid > 0 {
if state.me.uid != uid {
state.me.uid = uid
if state.me.displayUID != uid {
state.me.displayUID = uid
state.me.isFirstLoad = true
}
return .send(.me(.onAppear))

View File

@@ -19,12 +19,24 @@ struct MeFeature {
var page: Int = 1
var pageSize: Int = 20
var uid: Int = 0
// IDnil
var displayUID: Int?
// DetailView
var showDetail: Bool = false
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
return .send(.refresh)
case .refresh:
guard state.uid > 0 else { return .none }
guard state.effectiveUID > 0 else { return .none }
state.isRefreshing = true
state.page = 1
state.hasMore = true
return .merge(
fetchUserInfo(uid: state.uid),
fetchMoments(uid: state.uid, page: 1, pageSize: state.pageSize)
fetchUserInfo(uid: state.effectiveUID),
fetchMoments(uid: state.effectiveUID, page: 1, pageSize: state.pageSize)
)
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
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):
state.isLoadingUserInfo = false
state.isRefreshing = false
@@ -106,15 +118,11 @@ struct MeFeature {
private func fetchUserInfo(uid: Int) -> Effect<Action> {
.run { send in
// do {
if let userInfo = await UserInfoManager.fetchUserInfoFromServer(uid: String(uid), apiService: apiService) {
await send(.userInfoResponse(.success(userInfo)))
} else {
await send(.userInfoResponse(.failure(.noData)))
}
// } catch {
// await send(.userInfoResponse(.failure(error as? APIError ?? .unknown(error.localizedDescription))))
// }
if let userInfo = await UserInfoManager.fetchUserInfoFromServer(uid: String(uid), apiService: apiService) {
await send(.userInfoResponse(.success(userInfo)))
} else {
await send(.userInfoResponse(.failure(.noData)))
}
}
}

View File

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

View File

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

View File

@@ -51,6 +51,12 @@ struct DetailView: View {
store.send(.likeDynamic(dynamicId, uid, likedUid, worldId))
},
onCardTap: nil, //
onAvatarTap: {
//
if !isCurrentUserDynamic {
store.send(.showUserProfile(store.moment.uid))
}
},
isDetailMode: true, //
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,
onLikeTap: onLikeTap,
onCardTap: onTap,
onAvatarTap: nil, // FeedListView
isDetailMode: false,
isLikeLoading: isLikeLoading
)

View File

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

View File

@@ -6,6 +6,15 @@ struct MeView: View {
//
@State private var previewItem: PreviewItem? = nil
@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 {
WithPerceptionTracking {
@@ -22,16 +31,35 @@ struct MeView: View {
//
VStack {
HStack {
Spacer()
Button(action: {
store.send(.settingButtonTapped)
}) {
Image(systemName: "gearshape")
.font(.system(size: 33, weight: .medium))
.foregroundColor(.white)
// present
if showCloseButton {
Button(action: {
dismiss()
}) {
Image(systemName: "xmark")
.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()
}
@@ -111,7 +139,7 @@ struct MeView: View {
.foregroundColor(.white)
// ID
UserIDDisplay(uid: store.userInfo?.uid ?? 0)
UserIDDisplay(uid: store.userInfo?.uid ?? 0, isDisplayCopy: true)
}
.padding(.horizontal, 32)
}
@@ -184,7 +212,8 @@ struct MeView: View {
},
onCardTap: {
store.send(.showDetail(moment))
}
},
onAvatarTap: nil // MeView
)
.padding(.horizontal, 12)
}