feat: 新增图片选择与预览功能
- 在ImagePickerWithPreview组件中实现相机和相册选择功能,提升用户体验。 - 新增ImagePreviewView以支持图片预览,增强交互性。 - 更新MainView以移除冗余调试日志,优化代码整洁性。 - 在swift-assistant-style.mdc中添加项目基础信息,确保开发环境一致性。
This commit is contained in:
@@ -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
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 可选:如需自定义相册选择器,可扩展此文件
|
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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
|
||||
}
|
||||
|
Reference in New Issue
Block a user