swiftuiscrollviewlazyvgridswiftdata

SwiftUI Scrollview Freeze with SwiftData Photos


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.


Solution

  • 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
        }
    }