feat: 新增图片选择与预览功能

- 在ImagePickerWithPreview组件中实现相机和相册选择功能,提升用户体验。
- 新增ImagePreviewView以支持图片预览,增强交互性。
- 更新MainView以移除冗余调试日志,优化代码整洁性。
- 在swift-assistant-style.mdc中添加项目基础信息,确保开发环境一致性。
This commit is contained in:
edwinQQQ
2025-07-25 17:08:05 +08:00
parent 79fc03b52a
commit 2cfdf110af
6 changed files with 415 additions and 4 deletions

View File

@@ -5,6 +5,8 @@ alwaysApply: true
---
# CONTEXT
This project based on iOS 16.0+ & SwiftUI & TCA 1.20.2
I wish to receive advice using the latest tools and seek step-by-step guidance to fully understand the implementation process.
## OBJECTIVE

View File

@@ -0,0 +1,38 @@
import SwiftUI
import UIKit
import PhotosUI
public struct CameraPicker: UIViewControllerRepresentable {
public var onImagePicked: (UIImage?) -> Void
public init(onImagePicked: @escaping (UIImage?) -> Void) {
self.onImagePicked = onImagePicked
}
public func makeCoordinator() -> Coordinator {
Coordinator(onImagePicked: onImagePicked)
}
public func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.delegate = context.coordinator
picker.allowsEditing = false
return picker
}
public func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
public class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
let onImagePicked: (UIImage?) -> Void
init(onImagePicked: @escaping (UIImage?) -> Void) {
self.onImagePicked = onImagePicked
}
public func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
let image = info[.originalImage] as? UIImage
onImagePicked(image)
picker.dismiss(animated: true)
}
public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
onImagePicked(nil)
picker.dismiss(animated: true)
}
}
}
//

View File

@@ -0,0 +1,135 @@
import Foundation
import ComposableArchitecture
import SwiftUI
import PhotosUI
public enum ImagePickerSelectionMode: Equatable {
case single
case multiple(max: Int)
}
public struct ImagePickerWithPreviewState: Equatable {
public var selectionMode: ImagePickerSelectionMode = .single
public var showActionSheet: Bool = false
public var showPhotoPicker: Bool = false
public var showCamera: Bool = false
public var showPreview: Bool = false
public var isLoading: Bool = false
public var errorMessage: String?
public var selectedPhotoItems: [PhotosPickerItem] = []
public var selectedImages: [UIImage] = []
public var cameraImage: UIImage? = nil
public var previewIndex: Int = 0 //
public init(selectionMode: ImagePickerSelectionMode = .single) {
self.selectionMode = selectionMode
}
}
public enum ImagePickerWithPreviewAction: Equatable {
case showActionSheet(Bool)
case selectSource(ImageSource)
case photoPickerItemsChanged([PhotosPickerItem])
case cameraImagePicked(UIImage?)
case previewConfirm
case previewCancel
case uploadStart
case uploadSuccess
case uploadFailure(String)
case setLoading(Bool)
case setError(String?)
case setPreviewIndex(Int)
case setShowCamera(Bool)
case setShowPhotoPicker(Bool)
}
public enum ImageSource: Equatable {
case camera
case photoLibrary
}
public struct ImagePickerWithPreviewReducer: Reducer {
public init() {}
public struct State: Equatable {
public var inner: ImagePickerWithPreviewState
public init(inner: ImagePickerWithPreviewState) { self.inner = inner }
}
public enum Action: Equatable {
case inner(ImagePickerWithPreviewAction)
}
public var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .inner(let action):
switch action {
case .showActionSheet(let show):
state.inner.showActionSheet = show
return .none
case .selectSource(let source):
state.inner.showActionSheet = false
switch source {
case .camera:
state.inner.showCamera = true
state.inner.showPhotoPicker = false
case .photoLibrary:
state.inner.showPhotoPicker = true
state.inner.showCamera = false
}
return .none
case .photoPickerItemsChanged(let items):
state.inner.selectedPhotoItems = items
//
state.inner.showPreview = !items.isEmpty
state.inner.previewIndex = 0
return .none
case .cameraImagePicked(let image):
state.inner.cameraImage = image
state.inner.selectedImages = image.map { [$0] } ?? []
state.inner.showPreview = image != nil
state.inner.previewIndex = 0
return .none
case .previewConfirm:
state.inner.showPreview = false
state.inner.isLoading = true
state.inner.errorMessage = nil
return .none // Effect
case .previewCancel:
state.inner.showPreview = false
state.inner.selectedPhotoItems = []
state.inner.selectedImages = []
state.inner.cameraImage = nil
return .none
case .uploadStart:
state.inner.isLoading = true
state.inner.errorMessage = nil
return .none
case .uploadSuccess:
state.inner.isLoading = false
state.inner.selectedPhotoItems = []
state.inner.selectedImages = []
state.inner.cameraImage = nil
return .none
case .uploadFailure(let msg):
state.inner.isLoading = false
state.inner.errorMessage = msg
return .none
case .setLoading(let loading):
state.inner.isLoading = loading
return .none
case .setError(let msg):
state.inner.errorMessage = msg
return .none
case .setPreviewIndex(let idx):
state.inner.previewIndex = idx
return .none
case .setShowCamera(let show):
state.inner.showCamera = show
return .none
case .setShowPhotoPicker(let show):
state.inner.showPhotoPicker = show
return .none
}
}
}
}
}

View File

@@ -0,0 +1,184 @@
import SwiftUI
import ComposableArchitecture
import PhotosUI
public struct ImagePickerWithPreviewView: View {
let store: StoreOf<ImagePickerWithPreviewReducer>
let onUpload: ([UIImage]) -> Void
@State private var loadedImages: [UIImage] = []
@State private var isLoadingImages: Bool = false
public init(store: StoreOf<ImagePickerWithPreviewReducer>, onUpload: @escaping ([UIImage]) -> Void) {
self.store = store
self.onUpload = onUpload
}
public var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
ZStack {
Color.clear
LoadingView(isLoading: viewStore.inner.isLoading || isLoadingImages)
}
.modifier(ActionSheetModifier(viewStore: viewStore))
.modifier(CameraSheetModifier(viewStore: viewStore))
.modifier(PhotosPickerModifier(viewStore: viewStore, loadedImages: $loadedImages, isLoadingImages: $isLoadingImages))
.modifier(PreviewCoverModifier(viewStore: viewStore, loadedImages: loadedImages, onUpload: onUpload))
.modifier(ErrorToastModifier(viewStore: viewStore))
}
}
}
private struct LoadingView: View {
let isLoading: Bool
var body: some View {
if isLoading {
Color.black.opacity(0.4).ignoresSafeArea()
ProgressView("上传中...")
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.foregroundColor(.white)
.padding()
.background(Color.black.opacity(0.7))
.cornerRadius(16)
}
}
}
private struct ActionSheetModifier: ViewModifier {
let viewStore: ViewStoreOf<ImagePickerWithPreviewReducer>
func body(content: Content) -> some View {
content.confirmationDialog(
"请选择图片来源",
isPresented: .init(
get: { viewStore.inner.showActionSheet },
set: { viewStore.send(.inner(.showActionSheet($0))) }
),
titleVisibility: .visible
) {
Button("拍照") { viewStore.send(.inner(.selectSource(.camera))) }
Button("从相册选择") { viewStore.send(.inner(.selectSource(.photoLibrary))) }
Button("取消", role: .cancel) {}
}
}
}
private struct CameraSheetModifier: ViewModifier {
let viewStore: ViewStoreOf<ImagePickerWithPreviewReducer>
func body(content: Content) -> some View {
content.sheet(isPresented: .init(
get: { viewStore.inner.showCamera },
set: { viewStore.send(.inner(.setShowCamera($0))) }
)) {
CameraPicker { image in
viewStore.send(.inner(.cameraImagePicked(image)))
}
}
}
}
private struct PhotosPickerModifier: ViewModifier {
let viewStore: ViewStoreOf<ImagePickerWithPreviewReducer>
@Binding var loadedImages: [UIImage]
@Binding var isLoadingImages: Bool
func body(content: Content) -> some View {
content
.photosPicker(
isPresented: .init(
get: { viewStore.inner.showPhotoPicker },
set: { viewStore.send(.inner(.setShowPhotoPicker($0))) }
),
selection: .init(
get: { viewStore.inner.selectedPhotoItems },
set: { viewStore.send(.inner(.photoPickerItemsChanged($0))) }
),
maxSelectionCount: {
switch viewStore.inner.selectionMode {
case .single: return 1
case .multiple(let max): return max
}
}(),
matching: .images
)
.onChange(of: viewStore.inner.selectedPhotoItems) { items in
guard !items.isEmpty else { return }
isLoadingImages = true
loadedImages = []
let group = DispatchGroup()
var tempImages: [UIImage] = []
for item in items {
group.enter()
item.loadTransferable(type: Data.self) { result in
defer { group.leave() }
if let data = try? result.get(), let uiImage = UIImage(data: data) {
tempImages.append(uiImage)
}
}
}
DispatchQueue.global().async {
group.wait()
DispatchQueue.main.async {
loadedImages = tempImages
isLoadingImages = false
viewStore.send(.inner(.setLoading(false)))
}
}
}
}
}
private struct PreviewCoverModifier: ViewModifier {
let viewStore: ViewStoreOf<ImagePickerWithPreviewReducer>
let loadedImages: [UIImage]
let onUpload: ([UIImage]) -> Void
func body(content: Content) -> some View {
content.fullScreenCover(isPresented: .init(
get: { viewStore.inner.showPreview },
set: { _ in }
)) {
ImagePreviewView(
images: previewImages,
currentIndex: .init(
get: { viewStore.inner.previewIndex },
set: { viewStore.send(.inner(.setPreviewIndex($0))) }
),
onConfirm: {
viewStore.send(.inner(.previewConfirm))
onUpload(previewImages)
},
onCancel: {
viewStore.send(.inner(.previewCancel))
}
)
}
}
private var previewImages: [UIImage] {
if let camera = viewStore.inner.cameraImage {
return [camera]
}
if !loadedImages.isEmpty {
return loadedImages
}
return []
}
}
private struct ErrorToastModifier: ViewModifier {
let viewStore: ViewStoreOf<ImagePickerWithPreviewReducer>
func body(content: Content) -> some View {
content.overlay(
Group {
if let error = viewStore.inner.errorMessage {
VStack {
Spacer()
Text(error)
.foregroundColor(.red)
.padding()
.background(Color.white)
.cornerRadius(12)
.padding(.bottom, 40)
}
}
}
)
}
}

View File

@@ -0,0 +1,56 @@
import SwiftUI
public struct ImagePreviewView: View {
let images: [UIImage]
@Binding var currentIndex: Int
let onConfirm: () -> Void
let onCancel: () -> Void
public init(images: [UIImage], currentIndex: Binding<Int>, onConfirm: @escaping () -> Void, onCancel: @escaping () -> Void) {
self.images = images
self._currentIndex = currentIndex
self.onConfirm = onConfirm
self.onCancel = onCancel
}
public var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VStack {
Spacer()
if !images.isEmpty {
TabView(selection: $currentIndex) {
ForEach(images.indices, id: \ .self) { idx in
Image(uiImage: images[idx])
.resizable()
.aspectRatio(contentMode: .fit)
.tag(idx)
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: images.count > 1 ? .always : .never))
.frame(maxHeight: 400)
}
Spacer()
HStack(spacing: 24) {
Button(action: onCancel) {
Text("取消")
.foregroundColor(.white)
.padding(.horizontal, 32)
.padding(.vertical, 12)
.background(Color.gray.opacity(0.5))
.cornerRadius(20)
}
Button(action: onConfirm) {
Text("确认")
.foregroundColor(.white)
.padding(.horizontal, 32)
.padding(.vertical, 12)
.background(Color.blue)
.cornerRadius(20)
}
}
.padding(.bottom, 40)
}
}
}
}

View File

@@ -33,7 +33,6 @@ struct InternalMainView: View {
GeometryReader { geometry in
contentView(geometry: geometry, viewStore: viewStore)
.navigationDestination(for: MainFeature.Destination.self) { destination in
debugLogSync("[log] navigationDestination: \(destination)")
let view: AnyView
switch destination {
case .appSetting:
@@ -43,17 +42,14 @@ struct InternalMainView: View {
view = AnyView(Text("appSettingState is nil"))
}
case .testView:
debugLogSync("[log] navigationDestination: .testView 渲染")
view = AnyView(TestView())
}
return view
}
.onChange(of: path) { newPath in
debugLogSync("[log] path changed: \(type(of: path)) = \(path)")
viewStore.send(.navigationPathChanged(newPath))
}
.onChange(of: viewStore.navigationPath) { newPath in
debugLogSync("[log] viewStore.navigationPath changed: \(type(of: newPath)) = \(newPath)")
if path != newPath {
path = newPath
}