feat: 多选图片

This commit is contained in:
jinyaqiu 2025-08-20 15:36:24 +08:00
parent 89112e36e6
commit 1fca9f413c
6 changed files with 340 additions and 487 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
wake/CoreData/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,58 @@
import SwiftUI
import PhotosUI
struct ImagePicker: UIViewControllerRepresentable {
@Binding var images: [UIImage]
var selectionLimit: Int
var onDismiss: (() -> Void)?
func makeUIViewController(context: Context) -> PHPickerViewController {
var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
configuration.filter = .images
configuration.selectionLimit = selectionLimit
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, PHPickerViewControllerDelegate {
let parent: ImagePicker
init(_ parent: ImagePicker) {
self.parent = parent
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
let group = DispatchGroup()
var newImages: [UIImage] = []
for result in results {
group.enter()
if result.itemProvider.canLoadObject(ofClass: UIImage.self) {
result.itemProvider.loadObject(ofClass: UIImage.self) { (image, error) in
if let image = image as? UIImage {
newImages.append(image)
}
group.leave()
}
} else {
group.leave()
}
}
group.notify(queue: .main) {
self.parent.images = newImages
self.parent.onDismiss?()
picker.dismiss(animated: true)
}
}
}
}

View File

@ -1,258 +1,175 @@
import SwiftUI
import PhotosUI
import os.log
import os
@available(iOS 16.0, *)
public struct MultiImageUploader: View {
@State private var selectedImages: [UIImage] = []
public struct MultiImageUploader<Content: View>: View {
@State var selectedImages: [UIImage] = []
@State private var uploadResults: [UploadResult] = []
@State private var isUploading = false
@State private var showingImagePicker = false
@State private var uploadProgress: [UUID: Double] = [:] //
@State private var needsViewUpdate = false // Add this line to trigger view updates
private let maxSelection: Int
private let onUploadComplete: ([UploadResult]) -> Void
public var onUploadComplete: ([UploadResult]) -> Void
private let uploadService = ImageUploadService.shared
private let logger = Logger(subsystem: "com.yourapp.uploader", category: "MultiImageUploader")
//
private let content: ((_ isUploading: Bool, _ selectedCount: Int) -> Content)?
///
@Binding var isImagePickerPresented: Bool
///
@Binding var selectedImagesBinding: [UIImage]?
///
@State private var showingImagePreview = false
// - 使
public init(
maxSelection: Int = 10,
isImagePickerPresented: Binding<Bool>,
selectedImagesBinding: Binding<[UIImage]?>,
@ViewBuilder content: @escaping (_ isUploading: Bool, _ selectedCount: Int) -> Content,
onUploadComplete: @escaping ([UploadResult]) -> Void
) {
self.maxSelection = maxSelection
self._isImagePickerPresented = isImagePickerPresented
self._selectedImagesBinding = selectedImagesBinding
self.content = content
self.onUploadComplete = onUploadComplete
}
// - 使
public init(
maxSelection: Int = 10,
isImagePickerPresented: Binding<Bool>,
selectedImagesBinding: Binding<[UIImage]?>,
onUploadComplete: @escaping ([UploadResult]) -> Void
) where Content == EmptyView {
self.maxSelection = maxSelection
self._isImagePickerPresented = isImagePickerPresented
self._selectedImagesBinding = selectedImagesBinding
self.content = nil
self.onUploadComplete = onUploadComplete
}
public var body: some View {
VStack(spacing: 16) {
//
Button(action: {
showingImagePicker = true
}) {
Label("选择图片", systemImage: "photo.on.rectangle")
//
if let content = content {
Button(action: {
showingImagePicker = true
}) {
content(isUploading, selectedImages.count)
}
.buttonStyle(PlainButtonStyle())
} else {
//
Button(action: {
showingImagePicker = true
}) {
Label(
!selectedImages.isEmpty ?
"已选择 \(selectedImages.count) 张图片" :
"选择图片",
systemImage: "photo.on.rectangle"
)
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(Color.blue)
.cornerRadius(10)
}
.padding(.horizontal)
.sheet(isPresented: $showingImagePicker) {
ImagePicker(images: $selectedImages, selectionLimit: maxSelection) {
//
if !selectedImages.isEmpty {
uploadImages()
}
}
}
//
ScrollView {
LazyVStack(spacing: 16) {
ForEach($uploadResults) { $result in
VStack(spacing: 8) {
//
Image(uiImage: result.image)
.resizable()
.scaledToFill()
.frame(height: 200)
.frame(maxWidth: .infinity)
.clipped()
.cornerRadius(8)
//
if case .uploading = result.status {
ProgressView(value: uploadProgress[result.id] ?? 0, total: 1.0)
.progressViewStyle(LinearProgressViewStyle())
.padding(.horizontal)
Text("上传中: \(Int((uploadProgress[result.id] ?? 0) * 100))%")
.font(.caption)
.foregroundColor(.secondary)
} else if case .success = result.status {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("上传成功")
.font(.caption)
.foregroundColor(.green)
}
} else if case .failure = result.status {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text("上传失败")
.font(.caption)
.foregroundColor(.red)
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(12)
.shadow(radius: 2)
}
}
.padding()
}
//
if !selectedImages.isEmpty && !isUploading {
Button(action: uploadImages) {
Text("开始上传 (\(selectedImages.count)张)")
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(Color.blue)
.cornerRadius(10)
}
.padding(.horizontal)
.padding(.bottom)
}
}
.background(Color(.systemGroupedBackground))
.sheet(isPresented: $showingImagePicker) {
ImagePicker(images: $selectedImages, selectionLimit: maxSelection) {
//
showingImagePicker = false
if !selectedImages.isEmpty {
Task {
_ = await startUpload()
}
}
}
}
.onChange(of: isImagePickerPresented) { newValue in
if newValue {
showingImagePicker = true
}
}
.onChange(of: showingImagePicker) { newValue in
if !newValue {
isImagePickerPresented = false
}
}
.onChange(of: selectedImages) { newValue in
selectedImagesBinding = newValue
}
.onChange(of: needsViewUpdate) { _ in
// Trigger view update
}
}
private func uploadImages() {
guard !isUploading else { return }
///
@MainActor
public func startUpload() async -> [UploadResult] {
guard !isUploading && !selectedImages.isEmpty else { return [] }
isUploading = true
let group = DispatchGroup()
var results = selectedImages.map { image in
UploadResult(fileId: "", previewFileId: "", image: image, status: .uploading(progress: 0))
uploadResults = selectedImages.map {
UploadResult(image: $0, status: .uploading(progress: 0))
}
// UI
uploadResults = results
// DispatchQueue
let uploadQueue = DispatchQueue(label: "com.wake.uploadQueue", attributes: .concurrent)
let semaphore = DispatchSemaphore(value: 3) // 3
let group = DispatchGroup()
for (index, image) in selectedImages.enumerated() {
semaphore.wait()
group.enter()
uploadQueue.async {
let resultId = results[index].id
//
DispatchQueue.main.async {
if let idx = results.firstIndex(where: { $0.id == resultId }) {
results[idx].status = .uploading(progress: 0)
uploadProgress[resultId] = 0
uploadResults = results
}
}
//
uploadService.uploadImage(image, progress: { progress in
// 使 ImageUploadService
uploadService.uploadOriginalAndCompressedImage(
image,
compressionQuality: 0.7,
progress: { progress in
//
DispatchQueue.main.async {
uploadProgress[resultId] = progress.progress
if let idx = results.firstIndex(where: { $0.id == resultId }) {
results[idx].status = .uploading(progress: progress.progress)
uploadResults = results
if index < self.uploadResults.count {
self.uploadResults[index].status = .uploading(progress: progress.progress)
self.needsViewUpdate.toggle() // Trigger view update
}
}
}) { uploadResult in
defer {
semaphore.signal()
group.leave()
}
},
completion: { result in
DispatchQueue.main.async {
if let idx = results.firstIndex(where: { $0.id == resultId }) {
switch uploadResult {
case .success(let uploadResult):
//
results[idx].fileId = uploadResult.fileId
// 使 previewFileId ImageUploaderGetID.UploadResult
results[idx].previewFileId = ""
results[idx].status = .success
uploadResults = results
logger.info("图片上传成功: \(uploadResult.fileId)")
case .failure(let error):
//
results[idx].status = .failure(error)
uploadResults = results
logger.error("图片上传失败: \(error.localizedDescription)")
}
}
}
}
}
}
//
group.notify(queue: .main) {
self.isUploading = false
let successCount = results.filter { $0.status == .success }.count
logger.info("上传完成,成功: \(successCount)/\(results.count)")
self.onUploadComplete(results)
}
}
}
// MARK: -
@available(iOS 14.0, *)
struct ImagePicker: UIViewControllerRepresentable {
@Binding var images: [UIImage]
var selectionLimit: Int = 10
var onDismiss: (() -> Void)?
func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration(photoLibrary: .shared())
config.filter = .images
config.selectionLimit = selectionLimit
config.preferredAssetRepresentationMode = .current
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, PHPickerViewControllerDelegate {
let parent: ImagePicker
init(_ parent: ImagePicker) {
self.parent = parent
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
let group = DispatchGroup()
var newImages: [UIImage] = []
for result in results {
group.enter()
let itemProvider = result.itemProvider
if itemProvider.canLoadObject(ofClass: UIImage.self) {
itemProvider.loadObject(ofClass: UIImage.self) { [weak self] (image, error) in
if let image = image as? UIImage {
newImages.append(image)
guard index < self.uploadResults.count else { return }
switch result {
case .success(let uploadResults):
self.uploadResults[index].status = .success
self.uploadResults[index].fileId = uploadResults.original.fileId
self.uploadResults[index].previewFileId = uploadResults.compressed.fileId
self.needsViewUpdate.toggle() // Trigger view update
case .failure(let error):
self.uploadResults[index].status = .failure(error)
self.needsViewUpdate.toggle() // Trigger view update
self.logger.error("图片上传失败: \(error.localizedDescription)")
}
group.leave()
}
} else {
group.leave()
}
}
)
}
return await withCheckedContinuation { continuation in
group.notify(queue: .main) {
self.parent.images = newImages
self.parent.onDismiss?()
self.isUploading = false
self.needsViewUpdate.toggle() // Trigger view update
continuation.resume(returning: self.uploadResults)
}
}
}

View File

@ -1,284 +0,0 @@
import SwiftUI
import os.log
///
/// 使 MultiImageUploader
@available(iOS 16.0, *)
struct MultiImageUploadExampleView: View {
// MARK: -
@State private var uploadResults: [UploadResult] = []
@State private var isShowingUploader = false
@State private var showUploadAlert = false
@State private var alertMessage = ""
@State private var isUploading = false
private let logger = Logger(subsystem: "com.yourapp.uploader", category: "MultiImageUploadExample")
// MARK: -
var body: some View {
NavigationView {
VStack(spacing: 16) {
//
uploadButton
//
if !uploadResults.isEmpty {
uploadStatsView
}
//
uploadProgressList
Spacer()
}
.navigationTitle("多图上传示例")
.toolbar {
//
if !uploadResults.isEmpty && !isUploading {
Button("清空") {
withAnimation {
uploadResults.removeAll()
}
}
}
}
.sheet(isPresented: $isShowingUploader) {
//
MultiImageUploader(
maxSelection: 10
) { results in
processUploadResults(results)
}
}
.alert("上传结果", isPresented: $showUploadAlert) {
Button("确定", role: .cancel) { }
} message: {
Text(alertMessage)
}
}
}
// MARK: -
///
private var uploadButton: some View {
Button(action: { isShowingUploader = true }) {
Label("选择并上传图片", systemImage: "photo.on.rectangle.angled")
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(Color.blue)
.cornerRadius(10)
}
.padding(.horizontal)
.padding(.top)
}
///
private var uploadStatsView: some View {
VStack(alignment: .leading, spacing: 8) {
let successCount = uploadResults.filter { $0.status == .success }.count
let inProgressCount = uploadResults.filter {
if case .uploading = $0.status { return true }
return false
}.count
let failedCount = uploadResults.count - successCount - inProgressCount
Text("上传统计")
.font(.headline)
HStack {
StatView(
value: "\(uploadResults.count)",
label: "总数量",
color: .blue
)
StatView(
value: "\(successCount)",
label: "成功",
color: .green
)
StatView(
value: "\(inProgressCount)",
label: "上传中",
color: .orange
)
StatView(
value: "\(failedCount)",
label: "失败",
color: .red
)
}
//
if inProgressCount > 0 {
let progress = uploadResults.reduce(0.0) { result, uploadResult in
if case .uploading(let progress) = uploadResult.status {
return result + progress
} else if uploadResult.status == .success {
return result + 1.0
}
return result
} / Double(uploadResults.count)
VStack(alignment: .leading, spacing: 4) {
Text("总进度: \(Int(progress * 100))%")
.font(.subheadline)
ProgressView(value: progress)
.progressViewStyle(LinearProgressViewStyle(tint: .blue))
}
.padding(.top, 8)
}
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(10)
.padding(.horizontal)
}
///
private var uploadProgressList: some View {
List {
ForEach($uploadResults) { $result in
VStack(alignment: .leading, spacing: 8) {
//
HStack {
//
Image(uiImage: result.image)
.resizable()
.scaledToFill()
.frame(width: 60, height: 60)
.clipped()
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(borderColor(for: result.status), lineWidth: 1)
)
//
VStack(alignment: .leading, spacing: 4) {
Text("图片 \(result.id.uuidString.prefix(8))...")
.font(.subheadline)
switch result.status {
case .uploading(let progress):
VStack(alignment: .leading, spacing: 4) {
Text("上传中: \(Int(progress * 100))%")
.font(.caption)
ProgressView(value: progress)
.progressViewStyle(LinearProgressViewStyle())
}
case .success:
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("上传成功")
.font(.caption)
.foregroundColor(.green)
}
case .failure(let error):
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text("上传失败: \(error.localizedDescription)")
.font(.caption)
.foregroundColor(.red)
}
case .idle:
Text("等待上传...")
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
}
.padding(.vertical, 4)
}
.padding(.vertical, 8)
.listRowSeparator(.hidden)
}
}
.listStyle(PlainListStyle())
.animation(.easeInOut, value: uploadResults)
}
// MARK: -
///
private func borderColor(for status: UploadStatus) -> Color {
switch status {
case .success: return .green
case .failure: return .red
case .uploading: return .blue
case .idle: return .gray
}
}
///
private func processUploadResults(_ results: [UploadResult]) {
//
isUploading = results.contains { result in
if case .uploading = result.status { return true }
return false
}
//
withAnimation {
uploadResults = results
isShowingUploader = false
}
//
let allFinished = !results.contains { result in
if case .uploading = result.status { return true }
if case .idle = result.status { return true }
return false
}
if allFinished {
let successCount = results.filter { $0.status == .success }.count
let totalCount = results.count
alertMessage = "上传完成\n成功: \(successCount)/\(totalCount)"
showUploadAlert = true
isUploading = false
logger.info("上传完成,成功: \(successCount)/\(totalCount)")
}
}
}
// MARK: -
///
private struct StatView: View {
let value: String
let label: String
let color: Color
var body: some View {
VStack {
Text(value)
.font(.title3.bold())
.foregroundColor(color)
Text(label)
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
}
}
// MARK: -
@available(iOS 16.0, *)
struct MultiImageUploadExampleView_Previews: PreviewProvider {
static var previews: some View {
MultiImageUploadExampleView()
}
}

View File

@ -0,0 +1,162 @@
import SwiftUI
import PhotosUI
struct MultiImageUploadExampleView: View {
@State private var isImagePickerPresented = false
@State private var selectedImages: [UIImage]? = []
@State private var uploadResults: [UploadResult] = []
@State private var showAlert = false
@State private var alertMessage = ""
var body: some View {
ScrollView {
VStack(spacing: 20) {
// Custom upload button with image count
MultiImageUploader(
maxSelection: 10,
isImagePickerPresented: $isImagePickerPresented,
selectedImagesBinding: $selectedImages,
content: { isUploading, count in
VStack(spacing: 8) {
Image(systemName: "photo.stack")
.font(.system(size: 24))
if isUploading {
ProgressView()
.padding(.vertical, 4)
Text("上传中...")
.font(.subheadline)
} else {
Text(count > 0 ? "已选择 \(count) 张图片" : "选择图片")
.font(.headline)
Text("最多可选择10张图片")
.font(.caption)
.foregroundColor(.secondary)
}
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue.opacity(0.1))
.cornerRadius(12)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.blue, lineWidth: 1)
)
},
onUploadComplete: handleUploadComplete
)
.padding(.horizontal)
.padding(.top, 20)
// Selected images preview with progress
if let images = selectedImages, !images.isEmpty {
VStack(alignment: .leading, spacing: 12) {
Text("已选择图片")
.font(.headline)
.padding(.horizontal)
LazyVGrid(columns: [
GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8)
], spacing: 8) {
ForEach(Array(images.enumerated()), id: \.offset) { index, image in
ZStack(alignment: .topTrailing) {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(height: 100)
.clipped()
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
)
// Upload progress indicator
if index < uploadResults.count {
let result = uploadResults[index]
VStack {
Spacer()
ZStack(alignment: .leading) {
Rectangle()
.fill(Color.gray.opacity(0.2))
.frame(height: 4)
if case .uploading(let progress) = result.status {
Rectangle()
.fill(Color.blue)
.frame(width: CGFloat(progress) * 100, height: 4)
} else if case .success = result.status {
Rectangle()
.fill(Color.green)
.frame(height: 4)
} else if case .failure = result.status {
Rectangle()
.fill(Color.red)
.frame(height: 4)
}
}
.cornerRadius(2)
.padding(.horizontal, 2)
.padding(.bottom, 2)
}
.frame(height: 20)
}
// Status indicator
if index < uploadResults.count {
let result = uploadResults[index]
Circle()
.fill(statusColor(for: result.status))
.frame(width: 12, height: 12)
.padding(4)
.background(Circle().fill(Color.white))
.padding(4)
}
}
}
}
.padding(.horizontal)
}
}
Spacer()
}
}
.navigationTitle("多图上传示例")
.alert(isPresented: $showAlert) {
Alert(title: Text("上传结果"), message: Text(alertMessage), dismissButton: .default(Text("确定")))
}
}
private func handleUploadComplete(_ results: [UploadResult]) {
self.uploadResults = results
let successCount = results.filter {
if case .success = $0.status { return true }
return false
}.count
alertMessage = "上传完成!共 \(results.count) 张图片,成功 \(successCount)"
showAlert = true
}
private func statusColor(for status: UploadStatus) -> Color {
switch status {
case .uploading:
return .blue
case .success:
return .green
case .failure:
return .red
default:
return .gray
}
}
}
#Preview {
NavigationView {
MultiImageUploadExampleView()
}
}