Files
e-party-iOS/yana/Views/FeedView.swift
edwinQQQ 4bbb4f8434 feat: 添加CreateFeed功能及相关视图组件
- 新增CreateFeedView和CreateFeedFeature,支持用户发布图文动态。
- 在FeedView中集成CreateFeedView,允许用户通过加号按钮访问发布界面。
- 实现图片选择和文本输入功能,支持最多9张图片的上传。
- 添加发布API请求模型,处理动态发布逻辑。
- 更新FeedFeature以管理CreateFeedView的显示状态,确保用户体验流畅。
- 完善UI结构分析与执行计划文档,明确开发步骤和技术要点。
2025-07-16 15:53:32 +08:00

591 lines
22 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import ComposableArchitecture
struct FeedView: View {
let store: StoreOf<FeedFeature>
var body: some View {
WithPerceptionTracking {
GeometryReader { geometry in
ScrollView {
VStack(spacing: 20) {
// -
HStack {
Spacer()
//
Text("Enjoy your Life Time")
.font(.system(size: 22, weight: .semibold))
.foregroundColor(.white)
Spacer()
//
Button(action: {
store.send(.showCreateFeed)
}) {
Image("add icon")
.frame(width: 36, height: 36)
}
}
.padding(.horizontal, 20)
//
Image(systemName: "heart.fill")
.font(.system(size: 60))
.foregroundColor(.red)
.padding(.top, 40)
//
Text("The disease is like a cruel ruler,\nand time is our most precious treasure.\nEvery moment we live is a victory\nagainst the inevitable.")
.font(.system(size: 16))
.multilineTextAlignment(.center)
.foregroundColor(.white.opacity(0.9))
.padding(.horizontal, 30)
.padding(.top, 20)
// - 使store
WithPerceptionTracking {
LazyVStack(spacing: 16) {
if store.moments.isEmpty {
//
VStack(spacing: 12) {
Image(systemName: "heart.text.square")
.font(.system(size: 40))
.foregroundColor(.white.opacity(0.6))
Text("暂无动态内容")
.font(.system(size: 16))
.foregroundColor(.white.opacity(0.8))
if let error = store.error {
Text("错误: \(error)")
.font(.system(size: 12))
.foregroundColor(.red.opacity(0.8))
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
}
}
.padding(.top, 40)
} else {
//
ForEach(Array(store.moments.enumerated()), id: \.element.dynamicId) { index, moment in
OptimizedDynamicCardView(
moment: moment,
allMoments: store.moments,
currentIndex: index
)
}
}
}
}
.padding(.horizontal, 16)
.padding(.top, 30)
// - 使store
if store.isLoading {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
Text("加载中...")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
.padding(.top, 20)
}
//
Color.clear.frame(height: geometry.safeAreaInsets.bottom + 100)
}
}
.refreshable {
store.send(.loadLatestMoments)
}
.onAppear {
store.send(.onAppear)
}
.sheet(isPresented: .init(
get: { store.isShowingCreateFeed },
set: { _ in store.send(.dismissCreateFeed) }
)) {
CreateFeedView(
store: Store(initialState: CreateFeedFeature.State()) {
CreateFeedFeature()
}
)
.onDisappear {
// CreateFeedView
//
}
}
}
}
}
}
// MARK: -
struct OptimizedDynamicCardView: View {
let moment: MomentsInfo
let allMoments: [MomentsInfo]
let currentIndex: Int
var body: some View {
VStack(alignment: .leading, spacing: 12) {
//
HStack {
// 使
CachedAsyncImage(url: moment.avatar) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Circle()
.fill(Color.gray.opacity(0.3))
.overlay(
Text(String(moment.nick.prefix(1)))
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
)
}
.frame(width: 40, height: 40)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(moment.nick)
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
Text(formatTime(moment.publishTime))
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.6))
}
Spacer()
// VIP
if let vipInfo = moment.userVipInfoVO, let vipLevel = vipInfo.vipLevel {
Text("VIP\(vipLevel)")
.font(.system(size: 10, weight: .bold))
.foregroundColor(.yellow)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.yellow.opacity(0.2))
.cornerRadius(4)
}
}
//
if !moment.content.isEmpty {
Text(moment.content)
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.9))
.multilineTextAlignment(.leading)
}
//
if let images = moment.dynamicResList, !images.isEmpty {
OptimizedImageGrid(images: images)
}
//
HStack(spacing: 20) {
Button(action: {}) {
HStack(spacing: 4) {
Image(systemName: "message")
.font(.system(size: 16))
Text("\(moment.commentCount)")
.font(.system(size: 14))
}
.foregroundColor(.white.opacity(0.8))
}
Button(action: {}) {
HStack(spacing: 4) {
Image(systemName: moment.isLike ? "heart.fill" : "heart")
.font(.system(size: 16))
Text("\(moment.likeCount)")
.font(.system(size: 14))
}
.foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
}
Spacer()
}
.padding(.top, 8)
}
.padding(16)
.background(
Color.white.opacity(0.1)
.cornerRadius(12)
)
.onAppear {
//
preloadNearbyImages()
}
}
private func formatTime(_ timestamp: Int) -> String {
let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
let now = Date()
let interval = now.timeIntervalSince(date)
if interval < 60 {
return "刚刚"
} else if interval < 3600 {
return "\(Int(interval / 60))分钟前"
} else if interval < 86400 {
return "\(Int(interval / 3600))小时前"
} else {
formatter.dateFormat = "MM-dd HH:mm"
return formatter.string(from: date)
}
}
private func preloadNearbyImages() {
var urlsToPreload: [String] = []
// 2
let preloadRange = max(0, currentIndex - 2)...min(allMoments.count - 1, currentIndex + 2)
for index in preloadRange {
let moment = allMoments[index]
//
urlsToPreload.append(moment.avatar)
//
if let images = moment.dynamicResList {
urlsToPreload.append(contentsOf: images.map { $0.resUrl })
}
}
//
ImageCacheManager.shared.preloadImages(urls: urlsToPreload)
}
}
// MARK: -
struct OptimizedImageGrid: View {
let images: [MomentsPicture]
var body: some View {
GeometryReader { geometry in
let availableWidth = geometry.size.width
let spacing: CGFloat = 8
switch images.count {
case 1:
//
let imageSize: CGFloat = min(availableWidth * 0.6, 200)
HStack {
Spacer()
SquareImageView(image: images[0], size: imageSize)
Spacer()
}
case 2:
//
let imageSize: CGFloat = (availableWidth - spacing) / 2
HStack(spacing: spacing) {
SquareImageView(image: images[0], size: imageSize)
SquareImageView(image: images[1], size: imageSize)
}
case 3:
//
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
HStack(spacing: spacing) {
ForEach(images.prefix(3), id: \.id) { image in
SquareImageView(image: image, size: imageSize)
}
}
default:
// 9
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
let columns = Array(repeating: GridItem(.fixed(imageSize), spacing: spacing), count: 3)
WithPerceptionTracking {
LazyVGrid(columns: columns, spacing: spacing) {
ForEach(images.prefix(9), id: \.id) { image in
SquareImageView(image: image, size: imageSize)
}
}
}
}
}
.frame(height: calculateGridHeight())
}
private func calculateGridHeight() -> CGFloat {
switch images.count {
case 1:
return 200 //
case 2:
return 120 //
case 3:
return 100 //
case 4...6:
return 216 // 2 ( * 2 + )
default:
return 340 // 3 ( * 3 + + )
}
}
}
// MARK: -
struct SquareImageView: View {
let image: MomentsPicture
let size: CGFloat
var body: some View {
CachedAsyncImage(url: image.resUrl) { imageView in
imageView
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.3))
.overlay(
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6)))
.scaleEffect(0.8)
)
}
.frame(width: size, height: size)
.clipped()
.cornerRadius(8)
}
}
// MARK: -
struct RealDynamicCardView: View {
let moment: MomentsInfo
var body: some View {
VStack(alignment: .leading, spacing: 12) {
//
HStack {
AsyncImage(url: URL(string: moment.avatar)) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Circle()
.fill(Color.gray.opacity(0.3))
.overlay(
Text(String(moment.nick.prefix(1)))
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
)
}
.frame(width: 40, height: 40)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(moment.nick)
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
Text(formatTime(moment.publishTime))
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.6))
}
Spacer()
// VIP
if let vipInfo = moment.userVipInfoVO, let vipLevel = vipInfo.vipLevel {
Text("VIP\(vipLevel)")
.font(.system(size: 10, weight: .bold))
.foregroundColor(.yellow)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.yellow.opacity(0.2))
.cornerRadius(4)
}
}
//
if !moment.content.isEmpty {
Text(moment.content)
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.9))
.multilineTextAlignment(.leading)
}
//
if let images = moment.dynamicResList, !images.isEmpty {
WithPerceptionTracking {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: min(images.count, 3)), spacing: 8) {
ForEach(images.prefix(9), id: \.id) { image in
AsyncImage(url: URL(string: image.resUrl)) { imageView in
imageView
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.3))
.overlay(
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6)))
)
}
.frame(height: 100)
.clipped()
.cornerRadius(8)
}
}
}
}
//
HStack(spacing: 20) {
Button(action: {}) {
HStack(spacing: 4) {
Image(systemName: "message")
.font(.system(size: 16))
Text("\(moment.commentCount)")
.font(.system(size: 14))
}
.foregroundColor(.white.opacity(0.8))
}
Button(action: {}) {
HStack(spacing: 4) {
Image(systemName: moment.isLike ? "heart.fill" : "heart")
.font(.system(size: 16))
Text("\(moment.likeCount)")
.font(.system(size: 14))
}
.foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
}
Spacer()
}
.padding(.top, 8)
}
.padding(16)
.background(
Color.white.opacity(0.1)
.cornerRadius(12)
)
}
private func formatTime(_ timestamp: Int) -> String {
let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
let now = Date()
let interval = now.timeIntervalSince(date)
if interval < 60 {
return "刚刚"
} else if interval < 3600 {
return "\(Int(interval / 60))分钟前"
} else if interval < 86400 {
return "\(Int(interval / 3600))小时前"
} else {
formatter.dateFormat = "MM-dd HH:mm"
return formatter.string(from: date)
}
}
}
// MARK: -
struct DynamicCardView: View {
let index: Int
var body: some View {
VStack(alignment: .leading, spacing: 12) {
//
HStack {
Circle()
.fill(Color.gray.opacity(0.3))
.frame(width: 40, height: 40)
.overlay(
Text("U\(index + 1)")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
)
VStack(alignment: .leading, spacing: 2) {
Text("用户\(index + 1)")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
Text("2小时前")
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.6))
}
Spacer()
}
//
Text("今天是美好的一天,分享一些生活中的小确幸。希望大家都能珍惜每一个当下的时刻。")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.9))
.multilineTextAlignment(.leading)
//
WithPerceptionTracking {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), spacing: 8) {
ForEach(0..<3) { imageIndex in
Rectangle()
.fill(Color.gray.opacity(0.3))
.aspectRatio(1, contentMode: .fit)
.overlay(
Image(systemName: "photo")
.foregroundColor(.white.opacity(0.6))
)
}
}
}
//
HStack(spacing: 20) {
Button(action: {}) {
HStack(spacing: 4) {
Image(systemName: "message")
.font(.system(size: 16))
Text("354")
.font(.system(size: 14))
}
.foregroundColor(.white.opacity(0.8))
}
Button(action: {}) {
HStack(spacing: 4) {
Image(systemName: "heart")
.font(.system(size: 16))
Text("354")
.font(.system(size: 14))
}
.foregroundColor(.white.opacity(0.8))
}
Spacer()
}
.padding(.top, 8)
}
.padding(16)
.background(
Color.white.opacity(0.1)
.cornerRadius(12)
)
}
}
#Preview {
FeedView(
store: Store(initialState: FeedFeature.State()) {
FeedFeature()
}
)
}