I implemented a simple SwiftUI view that lists photos saved with SwiftData.
Users pick photos from PhotosPicker and the photos are saved in SwiftData.
Users can long tap any photo to toggle its isSelected state.
The problem is the Scrollview freezes when users add more photos. I can't figure out how many photos to add to reproduce this issue.
Here's the code:
ContentView
struct ContentView: View {
@Environment(\.modelContext) private var context
@Query private var photoItems: [PhotoItem]
static private let numberOfColumns: Int = UIDevice.current.userInterfaceIdiom == .phone ? 3 : 7
private var columns: [GridItem] = Array(repeating: GridItem(.flexible()), count: numberOfColumns)
@State private var isPhotosPickerPresented: Bool = false
@State private var photosPickerPickedItems: [PhotosPickerItem] = []
@State private var isEditing: Bool = false
@State private var allSelected: Bool = false
@State private var numberOfSelectedItems: Int = 0
var body: some View {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(photoItems) { photoItem in
PhotoItemCellView(photoItem: photoItem)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.onTapGesture(perform: {
if isEditing {
photoItem.isSelected.toggle()
if photoItem.isSelected {
numberOfSelectedItems += 1
}
else {
numberOfSelectedItems -= 1
}
return
}
print("open photo")
})
.simultaneousGesture(
LongPressGesture()
.onEnded({ _ in
photoItem.isSelected = true
numberOfSelectedItems += 1
})
)
}
}
}
.onChange(of: numberOfSelectedItems, { oldValue, newValue in
isEditing = newValue > 0
allSelected = newValue == photoItems.count
})
.onChange(of: photoItems, { oldValue, newValue in
numberOfSelectedItems = newValue.filter({ $0.isSelected }).count
})
//MARK: - Photo Picker
.photosPicker(isPresented: $isPhotosPickerPresented, selection: $photosPickerPickedItems, matching: .any(of: [.images]), preferredItemEncoding: .compatible, photoLibrary: .shared())
.onChange(of: photosPickerPickedItems, { oldValue, newValue in
guard !newValue.isEmpty else {return}
for i in 0..<newValue.count {
let item = newValue[i]
Task {
if let data = try? await item.loadTransferable(type: Data.self) {
let photoItem = PhotoItem(data: data)
savePhotoItem(photoItem)
}
}
}
photosPickerPickedItems.removeAll()
})
//MARK: - Toolbar
.toolbar {
if isEditing {
ToolbarItem(placement: .topBarTrailing){
Button {
deleteSelectedPhotos()
} label: {
Image(systemName: "trash")
}
}
ToolbarItem(placement: .topBarLeading){
Button {
toggleSelection(selected: !allSelected)
} label: {
Text( allSelected ? "Deselct all" : "Select all")
}
}
}
ToolbarItem(placement: .topBarTrailing){
Button {
isPhotosPickerPresented = true
} label: {
Image(systemName: "photo.badge.plus.fill")
}
}
}
//MARK: - Content Unavailable
.overlay {
if photoItems.isEmpty {
ContentUnavailableView {
Label("No Photos", systemImage: "photo.badge.plus")
} description: {
Text("Start adding photos & videos to see your list.")
} actions: {
Button {
isPhotosPickerPresented = true
} label: {
Text("Add Photos & Videos")
}
}
}
}
}
private func savePhotoItem(_ item: PhotoItem) {
context.insert(item)
}
private func deleteSelectedPhotos() {
for item in photoItems {
if item.isSelected {
context.delete(item)
}
}
}
private func toggleSelection(selected: Bool) {
for item in photoItems {
item.isSelected = selected
}
numberOfSelectedItems = selected ? photoItems.count : 0
}
}
PhotoItemCellView
struct PhotoItemCellView: View {
@Bindable var photoItem: PhotoItem
@State private var image: UIImage = UIImage(systemName: "photo.artframe")!
var body: some View {
Image(uiImage: image)
.resizable()
.aspectRatio(1, contentMode: .fit)
.overlay {
if photoItem.isSelected {
VStack {
Spacer()
HStack {
Spacer()
Image(systemName: "checkmark.circle.fill")
.resizable()
.frame(width: 25, height: 25)
.aspectRatio(1, contentMode: .fit)
.foregroundStyle(.white, .green)
}
}
.padding([.bottom, .trailing], 8)
}
}
.onAppear {
if let image = UIImage(data: photoItem.data) {
self.image = image
}
}
}
}
PhotoItem
@Model
class PhotoItem {
let data: Data
@Attribute(.ephemeral) var isSelected: Bool = false
init(data: Data) {
self.data = data
}
}
Update Removing the .toolbar modifier enhances the performance of the ScrollView.
The problem arises when using simultaneousGesture
in the ContentView
file. It directly affects other gestures. When you scroll by touching the image, it conflicts with the simultaneousGesture and scrollview gesture, causing the image not to move. However, if you scroll on empty spaces, there is no conflict with simultaneousGesture, and it works smoothly. Therefore, I recommend implementing it with .onLongPressGesture
directly instead of using LongPressGesture()
within simultaneousGesture
.
.onLongPressGesture {
photoItem.isSelected = true
numberOfSelectedItems += 1
}
Here's how your ContentView file should look:
struct ContentView: View {
@Environment(\.modelContext) private var context
@Query private var photoItems: [PhotoItem]
static private let numberOfColumns: Int = UIDevice.current.userInterfaceIdiom == .phone ? 3 : 7
private var columns: [GridItem] = Array(repeating: GridItem(.flexible()), count: numberOfColumns)
@State private var isPhotosPickerPresented: Bool = false
@State private var photosPickerPickedItems: [PhotosPickerItem] = []
@State private var isEditing: Bool = false
@State private var allSelected: Bool = false
@State private var numberOfSelectedItems: Int = 0
var body: some View {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(photoItems) { photoItem in
PhotoItemCellView(photoItem: photoItem)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.onTapGesture(perform: {
if isEditing {
photoItem.isSelected.toggle()
if photoItem.isSelected {
numberOfSelectedItems += 1
}
else {
numberOfSelectedItems -= 1
}
return
}
print("open photo")
})
.onLongPressGesture {
print("LongPressGesture")
photoItem.isSelected = true
numberOfSelectedItems += 1
}
// .simultaneousGesture(
// LongPressGesture()
// .onEnded({ _ in
// print("LongPressGesture")
// photoItem.isSelected = true
// numberOfSelectedItems += 1
// })
// )
}
}
}
.onChange(of: numberOfSelectedItems, { oldValue, newValue in
isEditing = newValue > 0
allSelected = newValue == photoItems.count
})
.onChange(of: photoItems, { oldValue, newValue in
numberOfSelectedItems = newValue.filter({ $0.isSelected }).count
})
//MARK: - Photo Picker
.photosPicker(isPresented: $isPhotosPickerPresented, selection: $photosPickerPickedItems, matching: .any(of: [.images]), preferredItemEncoding: .compatible, photoLibrary: .shared())
.onChange(of: photosPickerPickedItems, { oldValue, newValue in
guard !newValue.isEmpty else {return}
for i in 0..<newValue.count {
let item = newValue[i]
Task {
if let data = try? await item.loadTransferable(type: Data.self) {
let photoItem = PhotoItem(data: data)
savePhotoItem(photoItem)
}
}
}
photosPickerPickedItems.removeAll()
})
//MARK: - Toolbar
.toolbar {
if isEditing {
ToolbarItem(placement: .topBarTrailing){
Button {
deleteSelectedPhotos()
} label: {
Image(systemName: "trash")
}
}
ToolbarItem(placement: .topBarLeading){
Button {
toggleSelection(selected: !allSelected)
} label: {
Text( allSelected ? "Deselct all" : "Select all")
}
}
}
ToolbarItem(placement: .topBarTrailing){
Button {
isPhotosPickerPresented = true
} label: {
Image(systemName: "photo.badge.plus.fill")
}
}
}
//MARK: - Content Unavailable
.overlay {
if photoItems.isEmpty {
ContentUnavailableView {
Label("No Photos", systemImage: "photo.badge.plus")
} description: {
Text("Start adding photos & videos to see your list.")
} actions: {
Button {
isPhotosPickerPresented = true
} label: {
Text("Add Photos & Videos")
}
}
}
}
}
private func savePhotoItem(_ item: PhotoItem) {
context.insert(item)
}
private func deleteSelectedPhotos() {
for item in photoItems {
if item.isSelected {
context.delete(item)
}
}
}
private func toggleSelection(selected: Bool) {
for item in photoItems {
item.isSelected = selected
}
numberOfSelectedItems = selected ? photoItems.count : 0
}
}