feat: 添加CreateFeed功能及相关视图组件

- 新增CreateFeedView和CreateFeedFeature,支持用户发布图文动态。
- 在FeedView中集成CreateFeedView,允许用户通过加号按钮访问发布界面。
- 实现图片选择和文本输入功能,支持最多9张图片的上传。
- 添加发布API请求模型,处理动态发布逻辑。
- 更新FeedFeature以管理CreateFeedView的显示状态,确保用户体验流畅。
- 完善UI结构分析与执行计划文档,明确开发步骤和技术要点。
This commit is contained in:
edwinQQQ
2025-07-16 15:53:32 +08:00
parent 33a558ae7b
commit 4bbb4f8434
5 changed files with 703 additions and 2 deletions

View File

@@ -0,0 +1,79 @@
# CreateFeedView UI 结构分析与执行计划
## UI 结构分析
根据设计稿CreateFeedView 应包含以下UI元素
### 1. 顶部导航栏
- 左侧:返回按钮
- 中间:"图文发布" 标题
- 右侧:"发布" 按钮
### 2. 主要内容区域
- 文本输入框:"Enter Content" 占位符支持多行输入最大500字符
- 字符计数显示:"0/500" 格式
- 图片添加区域:
- 默认显示一个 "+" 按钮(使用 "add photo" 图片资源)
- 支持添加最多9张图片
- 图片以网格形式排列
- 每张图片可以删除
### 3. 底部发布按钮
- 紫色渐变背景的"发布"按钮
- 占据屏幕底部,固定位置
## 执行计划
### 第一步:创建 CreateFeedFeature
- 定义状态管理结构
- 实现文本输入、图片选择、发布等Action
- 添加表单验证逻辑
- 集成图片选择器
### 第二步:创建 CreateFeedView
- 实现顶部导航栏
- 创建文本输入区域
- 实现图片选择和展示网格
- 添加发布按钮
- 应用深色主题样式
### 第三步:集成到 FeedView
- 修改 FeedView 中的加号按钮点击事件
- 添加导航到 CreateFeedView 的逻辑
- 确保返回时能刷新动态列表
### 第四步创建发布API模型
- 定义发布动态的请求和响应模型
- 添加API端点定义
- 实现发布逻辑模拟或真实API
### 第五步:测试和优化
- 测试各种输入场景
- 验证图片选择和预览功能
- 确保UI响应和交互流畅
## 技术要点
1. **状态管理**:使用 ComposableArchitecture 模式
2. **图片选择**:使用 PhotosUI 框架
3. **UI样式**:保持与现有深色主题一致
4. **表单验证**:实时字符计数和输入限制
5. **导航管理**:使用 NavigationStack 或 sheet 展示
## 文件结构
```
yana/
├── Features/
│ └── CreateFeedFeature.swift # 新建
├── Views/
│ └── CreateFeedView.swift # 新建
├── APIs/
│ ├── APIEndpoints.swift # 修改:添加发布端点
│ └── DynamicsModels.swift # 修改:添加发布模型
└── Assets.xcassets/
└── Home/
└── add photo.imageset/ # 已存在
```
开始实施第一步:创建 CreateFeedFeature。

View File

@@ -0,0 +1,223 @@
import Foundation
import ComposableArchitecture
import SwiftUI
// PhotosUI (iOS 16.0+)
#if canImport(PhotosUI)
import PhotosUI
#endif
@Reducer
struct CreateFeedFeature {
@ObservableState
struct State: Equatable {
var content: String = ""
var processedImages: [UIImage] = []
var isLoading: Bool = false
var errorMessage: String? = nil
var characterCount: Int = 0
// iOS 16+ PhotosPicker
#if canImport(PhotosUI) && swift(>=5.7)
var selectedImages: [PhotosPickerItem] = []
#endif
// iOS 15 UIImagePickerController
var showingImagePicker: Bool = false
var canAddMoreImages: Bool {
processedImages.count < 9
}
var canPublish: Bool {
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isLoading
}
}
enum Action {
case contentChanged(String)
case publishButtonTapped
case publishResponse(Result<PublishDynamicResponse, Error>)
case clearError
case dismissView
// iOS 16+ PhotosPicker Actions
#if canImport(PhotosUI) && swift(>=5.7)
case photosPickerItemsChanged([PhotosPickerItem])
case processPhotosPickerItems([PhotosPickerItem])
#endif
// iOS 15 UIImagePickerController Actions
case showImagePicker
case hideImagePicker
case imageSelected(UIImage)
case removeImage(Int)
}
@Dependency(\.apiService) var apiService
@Dependency(\.dismiss) var dismiss
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .contentChanged(let newContent):
state.content = newContent
state.characterCount = newContent.count
return .none
#if canImport(PhotosUI) && swift(>=5.7)
case .photosPickerItemsChanged(let items):
state.selectedImages = items
return .run { send in
await send(.processPhotosPickerItems(items))
}
case .processPhotosPickerItems(let items):
return .run { [currentImages = state.processedImages] send in
var newImages = currentImages
for item in items {
if let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
if newImages.count < 9 {
newImages.append(image)
}
}
}
await MainActor.run {
state.processedImages = newImages
}
}
#endif
case .showImagePicker:
state.showingImagePicker = true
return .none
case .hideImagePicker:
state.showingImagePicker = false
return .none
case .imageSelected(let image):
if state.processedImages.count < 9 {
state.processedImages.append(image)
}
state.showingImagePicker = false
return .none
case .removeImage(let index):
guard index < state.processedImages.count else { return .none }
state.processedImages.remove(at: index)
#if canImport(PhotosUI) && swift(>=5.7)
if index < state.selectedImages.count {
state.selectedImages.remove(at: index)
}
#endif
return .none
case .publishButtonTapped:
guard state.canPublish else {
state.errorMessage = "请输入内容"
return .none
}
state.isLoading = true
state.errorMessage = nil
let request = PublishDynamicRequest(
content: state.content.trimmingCharacters(in: .whitespacesAndNewlines),
images: state.processedImages
)
return .run { send in
do {
let response = try await apiService.request(request)
await send(.publishResponse(.success(response)))
} catch {
await send(.publishResponse(.failure(error)))
}
}
case .publishResponse(.success(let response)):
state.isLoading = false
if response.code == 200 {
//
return .send(.dismissView)
} else {
state.errorMessage = response.message.isEmpty ? "发布失败" : response.message
return .none
}
case .publishResponse(.failure(let error)):
state.isLoading = false
state.errorMessage = error.localizedDescription
return .none
case .clearError:
state.errorMessage = nil
return .none
case .dismissView:
return .run { _ in
await dismiss()
}
}
}
}
}
// MARK: -
///
struct PublishDynamicRequest: APIRequestProtocol {
typealias Response = PublishDynamicResponse
let endpoint: String = "/dynamic/square/publish" //
let method: HTTPMethod = .POST
let includeBaseParameters: Bool = true
let queryParameters: [String: String]? = nil
let timeout: TimeInterval = 30.0
let content: String
let images: [UIImage]
let type: Int // 0: , 2:
init(content: String, images: [UIImage] = []) {
self.content = content
self.images = images
self.type = images.isEmpty ? 0 : 2
}
var bodyParameters: [String: Any]? {
var params: [String: Any] = [
"content": content,
"type": type
]
// base64
if !images.isEmpty {
let imageData = images.compactMap { image in
image.jpegData(compressionQuality: 0.8)?.base64EncodedString()
}
params["images"] = imageData
}
return params
}
}
///
struct PublishDynamicResponse: Codable {
let code: Int
let message: String
let data: PublishDynamicData?
}
struct PublishDynamicData: Codable {
let dynamicId: Int
let publishTime: Int
}

View File

@@ -13,6 +13,9 @@ struct FeedFeature {
//
var isInitialized = false
// CreateFeedView
var isShowingCreateFeed = false
}
enum Action: Equatable {
@@ -22,6 +25,11 @@ struct FeedFeature {
case momentsResponse(TaskResult<MomentsLatestResponse>)
case clearError
case retryLoad
// CreateFeedView Action
case showCreateFeed
case dismissCreateFeed
case createFeedCompleted
}
@Dependency(\.apiService) var apiService
@@ -131,7 +139,20 @@ struct FeedFeature {
} else {
return .send(.loadMoreMoments)
}
case .showCreateFeed:
state.isShowingCreateFeed = true
return .none
case .dismissCreateFeed:
state.isShowingCreateFeed = false
return .none
case .createFeedCompleted:
state.isShowingCreateFeed = false
//
return .send(.loadLatestMoments)
}
}
}
}
}

View File

@@ -0,0 +1,364 @@
import SwiftUI
import ComposableArchitecture
// PhotosUI (iOS 16.0+)
#if canImport(PhotosUI)
import PhotosUI
#endif
struct CreateFeedView: View {
let store: StoreOf<CreateFeedFeature>
var body: some View {
WithPerceptionTracking {
NavigationView {
GeometryReader { geometry in
ZStack {
//
LinearGradient(
gradient: Gradient(colors: [
Color(red: 0.1, green: 0.1, blue: 0.2),
Color(red: 0.2, green: 0.1, blue: 0.3)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 20) {
//
VStack(alignment: .leading, spacing: 12) {
//
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 12)
.fill(Color.white.opacity(0.1))
.frame(minHeight: 120)
if store.content.isEmpty {
Text("Enter Content")
.foregroundColor(.white.opacity(0.5))
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
TextEditor(text: .init(
get: { store.content },
set: { store.send(.contentChanged($0)) }
))
.foregroundColor(.white)
.background(Color.clear)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.scrollContentBackground(.hidden)
}
//
HStack {
Spacer()
Text("\(store.characterCount)/500")
.font(.system(size: 12))
.foregroundColor(
store.isCharacterLimitExceeded ? .red : .white.opacity(0.6)
)
}
}
.padding(.horizontal, 20)
.padding(.top, 20)
//
VStack(alignment: .leading, spacing: 12) {
if !store.processedImages.isEmpty || store.canAddMoreImages {
if #available(iOS 16.0, *) {
#if canImport(PhotosUI)
ModernImageSelectionGrid(
images: store.processedImages,
selectedItems: store.selectedImages,
canAddMore: store.canAddMoreImages,
onItemsChanged: { items in
store.send(.photosPickerItemsChanged(items))
},
onRemoveImage: { index in
store.send(.removeImage(index))
}
)
#endif
} else {
LegacyImageSelectionGrid(
images: store.processedImages,
canAddMore: store.canAddMoreImages,
onAddImage: {
store.send(.showImagePicker)
},
onRemoveImage: { index in
store.send(.removeImage(index))
}
)
}
}
}
.padding(.horizontal, 20)
//
if store.isLoading {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
Text("处理图片中...")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
.padding(.top, 10)
}
//
if let error = store.error {
Text(error)
.font(.system(size: 14))
.foregroundColor(.red)
.padding(.horizontal, 20)
.multilineTextAlignment(.center)
}
//
Color.clear.frame(height: geometry.safeAreaInsets.bottom + 100)
}
}
//
VStack {
Spacer()
Button(action: {
store.send(.publishButtonTapped)
}) {
HStack {
if store.isPublishing {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
Text("发布中...")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
} else {
Text("发布")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
}
}
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(
LinearGradient(
gradient: Gradient(colors: [
Color.purple,
Color.blue
]),
startPoint: .leading,
endPoint: .trailing
)
)
.cornerRadius(25)
.disabled(store.isPublishing || (!store.isContentValid && !store.isLoading))
.opacity(store.isPublishing || (!store.isContentValid && !store.isLoading) ? 0.6 : 1.0)
}
.padding(.horizontal, 20)
.padding(.bottom, geometry.safeAreaInsets.bottom + 20)
}
}
}
.navigationTitle("图文发布")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("取消") {
store.send(.dismissView)
}
.foregroundColor(.white)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("发布") {
store.send(.publishButtonTapped)
}
.foregroundColor(store.isContentValid ? .white : .white.opacity(0.5))
.disabled(!store.isContentValid || store.isPublishing)
}
}
}
.preferredColorScheme(.dark)
.sheet(isPresented: .init(
get: { store.showingImagePicker },
set: { _ in store.send(.hideImagePicker) }
)) {
ImagePickerView { image in
store.send(.imageSelected(image))
}
}
}
}
}
// MARK: - iOS 16+
#if canImport(PhotosUI)
@available(iOS 16.0, *)
struct ModernImageSelectionGrid: View {
let images: [UIImage]
let selectedItems: [PhotosPickerItem]
let canAddMore: Bool
let onItemsChanged: ([PhotosPickerItem]) -> Void
let onRemoveImage: (Int) -> Void
private let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 3)
var body: some View {
LazyVGrid(columns: columns, spacing: 8) {
//
ForEach(Array(images.enumerated()), id: \.offset) { index, image in
ZStack(alignment: .topTrailing) {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 100)
.clipped()
.cornerRadius(8)
//
Button(action: {
onRemoveImage(index)
}) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 20))
.foregroundColor(.white)
.background(Color.black.opacity(0.6))
.clipShape(Circle())
}
.padding(4)
}
}
//
if canAddMore {
PhotosPicker(
selection: .init(
get: { selectedItems },
set: onItemsChanged
),
maxSelectionCount: 9,
matching: .images
) {
RoundedRectangle(cornerRadius: 8)
.fill(Color.white.opacity(0.1))
.frame(height: 100)
.overlay(
Image(systemName: "plus")
.font(.system(size: 40))
.foregroundColor(.white.opacity(0.6))
)
}
}
}
}
}
#endif
// MARK: - iOS 15
struct LegacyImageSelectionGrid: View {
let images: [UIImage]
let canAddMore: Bool
let onAddImage: () -> Void
let onRemoveImage: (Int) -> Void
private let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 3)
var body: some View {
LazyVGrid(columns: columns, spacing: 8) {
//
ForEach(Array(images.enumerated()), id: \.offset) { index, image in
ZStack(alignment: .topTrailing) {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 100)
.clipped()
.cornerRadius(8)
//
Button(action: {
onRemoveImage(index)
}) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 20))
.foregroundColor(.white)
.background(Color.black.opacity(0.6))
.clipShape(Circle())
}
.padding(4)
}
}
//
if canAddMore {
Button(action: onAddImage) {
RoundedRectangle(cornerRadius: 8)
.fill(Color.white.opacity(0.1))
.frame(height: 100)
.overlay(
Image(systemName: "plus")
.font(.system(size: 40))
.foregroundColor(.white.opacity(0.6))
)
}
}
}
}
}
// MARK: - UIImagePicker
struct ImagePickerView: UIViewControllerRepresentable {
let onImageSelected: (UIImage) -> Void
@Environment(\.presentationMode) var presentationMode
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = .photoLibrary
picker.allowsEditing = false
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: ImagePickerView
init(_ parent: ImagePickerView) {
self.parent = parent
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
if let image = info[.originalImage] as? UIImage {
parent.onImageSelected(image)
}
parent.presentationMode.wrappedValue.dismiss()
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.presentationMode.wrappedValue.dismiss()
}
}
}
// MARK: -
#Preview {
CreateFeedView(
store: Store(initialState: CreateFeedFeature.State()) {
CreateFeedFeature()
}
)
}

View File

@@ -22,7 +22,7 @@ struct FeedView: View {
//
Button(action: {
//
store.send(.showCreateFeed)
}) {
Image("add icon")
.frame(width: 36, height: 36)
@@ -104,6 +104,20 @@ struct FeedView: View {
.onAppear {
store.send(.onAppear)
}
.sheet(isPresented: .init(
get: { store.isShowingCreateFeed },
set: { _ in store.send(.dismissCreateFeed) }
)) {
CreateFeedView(
store: Store(initialState: CreateFeedFeature.State()) {
CreateFeedFeature()
}
)
.onDisappear {
// CreateFeedView
//
}
}
}
}
}