Skip to content

Commit 7f3f21a

Browse files
authored
Новый пикер фотографий для iOS 16+ (#275)
Новый пикер должен быть лучше, но можно оптимизировать процесс вывода картинок из галереи на экран - сейчас во время этого UI блокируется, что видно по дергающемуся индикатору загрузки.
1 parent c49376e commit 7f3f21a

File tree

5 files changed

+306
-60
lines changed

5 files changed

+306
-60
lines changed

SwiftUI-WorkoutApp.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
67138D922974851F00BBF450 /* XCUIElement+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67138D912974851F00BBF450 /* XCUIElement+.swift */; };
1414
67138D942974854F00BBF450 /* XCTestCase+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67138D932974854F00BBF450 /* XCTestCase+.swift */; };
1515
6718BCA42AD5327F002846A6 /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6718BCA32AD5327F002846A6 /* SnapshotHelper.swift */; };
16+
671B4AE92D4F623100286996 /* ModernPickedImagesGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 671B4AE82D4F623100286996 /* ModernPickedImagesGrid.swift */; };
17+
671B4AEB2D4F683E00286996 /* ImagePickerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 671B4AEA2D4F683E00286996 /* ImagePickerViews.swift */; };
1618
671D7DEC28210D2F0068E728 /* EmptyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 671D7DEB28210D2F0068E728 /* EmptyContentView.swift */; };
1719
67419ACF282E70B9004F5339 /* ParksListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67419ACE282E70B9004F5339 /* ParksListScreen.swift */; };
1820
6747575628113419002F0A24 /* ChangePasswordScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6747575528113419002F0A24 /* ChangePasswordScreen.swift */; };
@@ -97,6 +99,8 @@
9799
67138D912974851F00BBF450 /* XCUIElement+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCUIElement+.swift"; sourceTree = "<group>"; };
98100
67138D932974854F00BBF450 /* XCTestCase+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+.swift"; sourceTree = "<group>"; };
99101
6718BCA32AD5327F002846A6 /* SnapshotHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SnapshotHelper.swift; path = fastlane/SnapshotHelper.swift; sourceTree = SOURCE_ROOT; };
102+
671B4AE82D4F623100286996 /* ModernPickedImagesGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModernPickedImagesGrid.swift; sourceTree = "<group>"; };
103+
671B4AEA2D4F683E00286996 /* ImagePickerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePickerViews.swift; sourceTree = "<group>"; };
100104
671D7DEB28210D2F0068E728 /* EmptyContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyContentView.swift; sourceTree = "<group>"; };
101105
67419ACE282E70B9004F5339 /* ParksListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParksListScreen.swift; sourceTree = "<group>"; };
102106
6747575528113419002F0A24 /* ChangePasswordScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangePasswordScreen.swift; sourceTree = "<group>"; };
@@ -265,6 +269,8 @@
265269
children = (
266270
67515698283FEC3100501346 /* PickedImagesGrid.swift */,
267271
67A079F12A758E7D005EAF70 /* PickedPhotoView.swift */,
272+
671B4AE82D4F623100286996 /* ModernPickedImagesGrid.swift */,
273+
671B4AEA2D4F683E00286996 /* ImagePickerViews.swift */,
268274
);
269275
path = ImagePicker;
270276
sourceTree = "<group>";
@@ -609,9 +615,11 @@
609615
67419ACF282E70B9004F5339 /* ParksListScreen.swift in Sources */,
610616
67EA685C2A71A99700697C88 /* PhotoDetailScreen.swift in Sources */,
611617
674DF0402B11257D00828016 /* NavigationLink+.swift in Sources */,
618+
671B4AEB2D4F683E00286996 /* ImagePickerViews.swift in Sources */,
612619
67BD2D012AF7D21B00F44064 /* ParksManager.swift in Sources */,
613620
6705E7EE283B703400DABCC8 /* JournalSettingsScreen.swift in Sources */,
614621
6798AA40280AEDC900DB76F1 /* RootScreen.swift in Sources */,
622+
671B4AE92D4F623100286996 /* ModernPickedImagesGrid.swift in Sources */,
615623
675EC64F2814126800C2E229 /* TextEntryScreen.swift in Sources */,
616624
674D0623282A9896007E75C6 /* SearchUsersScreen.swift in Sources */,
617625
67A4710D2AEED8F8004D341D /* PastEventStorage.swift in Sources */,

SwiftUI-WorkoutApp/Resources/Localizable.xcstrings

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,42 @@
211211
}
212212
}
213213
},
214+
"ImageErrorDataLoadingFailed" : {
215+
"comment" : "PhotosPicker",
216+
"extractionState" : "manual",
217+
"localizations" : {
218+
"en" : {
219+
"stringUnit" : {
220+
"state" : "translated",
221+
"value" : "Failed to retrieve data from the gallery"
222+
}
223+
},
224+
"ru" : {
225+
"stringUnit" : {
226+
"state" : "translated",
227+
"value" : "Не удалось получить данные из галереи"
228+
}
229+
}
230+
}
231+
},
232+
"ImageErrorImageCreationFailed" : {
233+
"comment" : "PhotosPicker",
234+
"extractionState" : "manual",
235+
"localizations" : {
236+
"en" : {
237+
"stringUnit" : {
238+
"state" : "translated",
239+
"value" : "Failed to create the image"
240+
}
241+
},
242+
"ru" : {
243+
"stringUnit" : {
244+
"state" : "translated",
245+
"value" : "Не удалось создать изображение"
246+
}
247+
}
248+
}
249+
},
214250
"journalsCount" : {
215251
"extractionState" : "manual",
216252
"localizations" : {
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import SWDesignSystem
2+
import SwiftUI
3+
4+
enum ImagePickerViews {}
5+
6+
extension ImagePickerViews {
7+
static func makeHeaderString(for count: Int) -> String {
8+
String.localizedStringWithFormat(
9+
"photoSectionHeader".localized,
10+
count
11+
)
12+
}
13+
14+
@ViewBuilder @MainActor
15+
static func makeSubtitleView(selectionLimit: Int, isEmpty: Bool) -> some View {
16+
let subtitle = if selectionLimit > 0 {
17+
isEmpty
18+
? String(format: NSLocalizedString("Добавьте фото, максимум %lld", comment: ""), selectionLimit)
19+
: String(format: NSLocalizedString("Можно добавить ещё %lld", comment: ""), selectionLimit)
20+
} else {
21+
"Добавлено максимальное количество фотографий".localized
22+
}
23+
Text(subtitle)
24+
.font(.subheadline)
25+
.foregroundStyle(Color.swMainText)
26+
.multilineTextAlignment(.leading)
27+
}
28+
29+
@ViewBuilder @MainActor
30+
static func makeGridView(
31+
items: [PickedImageView.Model],
32+
action: @escaping (_ index: Int, _ action: PickedImageView.Action) -> Void
33+
) -> some View {
34+
LazyVGrid(
35+
columns: .init(
36+
repeating: .init(
37+
.flexible(minimum: UIScreen.main.bounds.size.width * 0.287),
38+
spacing: 11
39+
),
40+
count: 3
41+
),
42+
spacing: 12
43+
) {
44+
ForEach(Array(zip(items.indices, items)), id: \.0) { index, model in
45+
GeometryReader { geo in
46+
PickedImageView(
47+
model: model,
48+
height: geo.size.width,
49+
action: { action(index, $0) }
50+
)
51+
}
52+
.aspectRatio(1, contentMode: .fit)
53+
.cornerRadius(8)
54+
}
55+
}
56+
}
57+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import PhotosUI
2+
import SWDesignSystem
3+
import SwiftUI
4+
import SWUtils
5+
6+
/// Сетка для добавления фотографий с использованием `PhotosPicker`
7+
@available(iOS 16.0, *)
8+
struct ModernPickedImagesGrid: View {
9+
private var imagesArray: [PickedImageView.Model] {
10+
var realImages = images.map(PickedImageView.Model.image)
11+
if selectionLimit > 0 {
12+
realImages.append(.addImageButton)
13+
}
14+
return realImages
15+
}
16+
17+
@State private var fullscreenImageInfo: PhotoDetailScreen.Model?
18+
@State private var selectedItems = [PhotosPickerItem]()
19+
@State private var isLoading = false
20+
@Binding var images: [UIImage]
21+
@Binding var showImagePicker: Bool
22+
let selectionLimit: Int
23+
24+
var body: some View {
25+
SectionView(header: .init(header), mode: .regular) {
26+
VStack(alignment: .leading, spacing: 12) {
27+
ImagePickerViews.makeSubtitleView(
28+
selectionLimit: selectionLimit,
29+
isEmpty: images.isEmpty
30+
)
31+
ImagePickerViews.makeGridView(
32+
items: imagesArray,
33+
action: { index, option in
34+
switch option {
35+
case .addImage:
36+
showImagePicker.toggle()
37+
case .deleteImage:
38+
deletePhoto(at: index)
39+
case let .showDetailImage(uiImage):
40+
fullscreenImageInfo = .init(uiImage: uiImage, id: index)
41+
}
42+
}
43+
)
44+
}
45+
.fullScreenCover(item: $fullscreenImageInfo) {
46+
fullscreenImageInfo = nil
47+
} content: { model in
48+
PhotoDetailScreen(
49+
model: model,
50+
canDelete: true,
51+
reportPhotoClbk: {},
52+
deletePhotoClbk: deletePhoto
53+
)
54+
}
55+
}
56+
.loadingOverlay(if: isLoading)
57+
.photosPicker(
58+
isPresented: $showImagePicker,
59+
selection: $selectedItems,
60+
matching: .any(of: [.images, .panoramas])
61+
)
62+
.task(id: selectedItems) {
63+
do {
64+
isLoading.toggle()
65+
// TODO: Вывод картинок тяжелая задача, можно оптимизировать
66+
let newImages = try await loadImages(from: selectedItems)
67+
images.append(contentsOf: newImages)
68+
} catch {
69+
SWAlert.shared.presentDefaultUIKit(message: error.localizedDescription)
70+
}
71+
selectedItems.removeAll()
72+
isLoading.toggle()
73+
}
74+
}
75+
}
76+
77+
@available(iOS 16.0, *)
78+
private extension ModernPickedImagesGrid {
79+
var header: String { ImagePickerViews.makeHeaderString(for: images.count) }
80+
81+
func loadImages(from selectedItems: [PhotosPickerItem]) async throws -> [UIImage] {
82+
try await withThrowingTaskGroup(of: UIImage.self) { group in
83+
for item in selectedItems {
84+
group.addTask {
85+
guard let data = try await item.loadTransferable(type: Data.self) else {
86+
throw ImageError.dataLoadingFailed
87+
}
88+
guard let image = UIImage(data: data) else {
89+
throw ImageError.imageCreationFailed
90+
}
91+
return image
92+
}
93+
}
94+
var images = [UIImage]()
95+
for try await image in group {
96+
images.append(image)
97+
}
98+
return images
99+
}
100+
}
101+
102+
func deletePhoto(at index: Int) {
103+
images.remove(at: index)
104+
fullscreenImageInfo = nil
105+
}
106+
107+
enum ImageError: Error, LocalizedError {
108+
case dataLoadingFailed
109+
case imageCreationFailed
110+
111+
var errorDescription: String? {
112+
switch self {
113+
case .dataLoadingFailed: "ImageErrorDataLoadingFailed".localized
114+
case .imageCreationFailed: "ImageErrorImageCreationFailed".localized
115+
}
116+
}
117+
}
118+
}
119+
120+
#if DEBUG
121+
@available(iOS 16.0, *)
122+
#Preview("Лимит 10, есть 0") {
123+
ModernPickedImagesGrid(
124+
images: .constant([]),
125+
showImagePicker: .constant(false),
126+
selectionLimit: 10
127+
)
128+
}
129+
130+
@available(iOS 16.0, *)
131+
#Preview("Лимит 7, есть 3") {
132+
let images: [UIImage] = Array(1 ... 3).map {
133+
.init(systemName: "\($0).circle.fill")!
134+
}
135+
ModernPickedImagesGrid(
136+
images: .constant(images),
137+
showImagePicker: .constant(false),
138+
selectionLimit: 7
139+
)
140+
}
141+
142+
@available(iOS 16.0, *)
143+
#Preview("Лимит 0, есть 10") {
144+
let images: [UIImage] = Array(1 ... 10).map {
145+
.init(systemName: "\($0).circle.fill")!
146+
}
147+
ModernPickedImagesGrid(
148+
images: .constant(images),
149+
showImagePicker: .constant(false),
150+
selectionLimit: 0
151+
)
152+
}
153+
154+
@available(iOS 16.0, *)
155+
#Preview("Лимит 0, есть 0") {
156+
ModernPickedImagesGrid(
157+
images: .constant([]),
158+
showImagePicker: .constant(false),
159+
selectionLimit: 0
160+
)
161+
}
162+
#endif

0 commit comments

Comments
 (0)