Photo Multi Select Grid
A photo grid where a long press enters a multi-select mode that shows checkmarks on the chosen photos.

- Images
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- photo grid · multi select · selection · long press · checkmark · gallery · cuadrícula de fotos · selección múltiple · seleccionar · pulsación larga · marca de verificación · galería
Description
Section titled “Description”A photo grid where a long press enters a multi-select mode with checkmarks.
In that mode a tap toggles a photo. Out of it, a tap calls onOpen. Photos that disappear also leave the selection. With Reduce Motion on, photos change state without animating.
struct PhotoMultiSelectGridDemo: View { @State private var selection: Set<String> = [] @State private var isSelecting = false @Environment(\.accessibilityReduceMotion) private var reduceMotion
private let photos = (1...15).map { number in PhotoMultiSelectGridPhoto(id: "\(number)", image: demoPhoto(number), label: "Photo \(number)") }
var body: some View { VStack(spacing: 12) { HStack { Text(isSelecting ? "\(selection.count) selected" : "Long press a photo") .font(.title3.bold()) .monospacedDigit() Spacer() if isSelecting { Button("Done") { withAnimation(reduceMotion ? nil : .snappy(duration: 0.25)) { isSelecting = false selection = [] } } .buttonStyle(.bordered) } } .frame(minHeight: 44) .padding(.horizontal, 16)
PhotoMultiSelectGrid(photos: photos, selection: $selection, isSelecting: $isSelecting) } }}
private func demoPhoto(_ number: Int) -> Image { let palettes: [[Color]] = [ [.indigo, .pink], [.teal, .green], [.orange, .yellow], [.purple, .blue], [.red, .orange], ] let symbols = ["mountain.2.fill", "leaf.fill", "sun.max.fill", "moon.stars.fill", "flame.fill"] let colors = palettes[number % palettes.count] let symbol = symbols[number % symbols.count] let size = CGSize(width: 300, height: 300)
return Image(size: size, label: Text("Photo \(number)")) { context in context.fill( Path(CGRect(origin: .zero, size: size)), with: .linearGradient(Gradient(colors: colors), startPoint: .zero, endPoint: CGPoint(x: 300, y: 300)) ) var icon = context.resolve(Image(systemName: symbol)) icon.shading = .color(.white.opacity(0.85)) context.draw(icon, in: CGRect(x: 90, y: 90, width: 120, height: 120)) }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
photos | [PhotoMultiSelectGridPhoto] | Required |
selection | Binding<Set<String>> | Required |
isSelecting | Binding<Bool> | Required |
onOpen | (PhotoMultiSelectGridPhoto) -> Void | { _ in } |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
struct PhotoMultiSelectGridPhoto: Identifiable { let id: String let image: Image let label: LocalizedStringResource}
/// A photo grid where a long press enters a multi-select mode with checkmarks.////// In that mode a tap toggles a photo. Out of it, a tap calls `onOpen`. Photos that disappear/// also leave the selection. With Reduce Motion on, photos change state without animating.struct PhotoMultiSelectGrid: View { let photos: [PhotoMultiSelectGridPhoto] @Binding var selection: Set<String> @Binding var isSelecting: Bool var onOpen: (PhotoMultiSelectGridPhoto) -> Void = { _ in }
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var animation: Animation? { reduceMotion ? nil : .snappy(duration: 0.25) }
var body: some View { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 110), spacing: 4)], spacing: 4) { ForEach(photos) { photo in cell(photo) } } } .sensoryFeedback(.selection, trigger: selection) .onChange(of: photos.map(\.id)) { _, ids in selection.formIntersection(ids) } }
private func cell(_ photo: PhotoMultiSelectGridPhoto) -> some View { let isChosen = selection.contains(photo.id)
return Color.clear .aspectRatio(1, contentMode: .fit) .overlay { photo.image .resizable() .scaledToFill() } .clipShape(.rect(cornerRadius: isChosen ? 14 : 0)) .scaleEffect(isChosen ? 0.86 : 1) .overlay(alignment: .bottomTrailing) { if isSelecting { checkmark(isChosen: isChosen) } } .contentShape(.rect) .onTapGesture { handleTap(photo) } .onLongPressGesture(minimumDuration: 0.35) { enterSelection(with: photo) } .accessibilityElement(children: .ignore) .accessibilityLabel(photo.label) .accessibilityValue(isSelecting ? (isChosen ? "Selected" : "Not selected") : "") .accessibilityAddTraits(isChosen ? [.isButton, .isImage, .isSelected] : [.isButton, .isImage]) .accessibilityAction { handleTap(photo) } .accessibilityActions { if !isSelecting { Button("Select") { enterSelection(with: photo) } } } }
private func checkmark(isChosen: Bool) -> some View { Image(systemName: isChosen ? "checkmark.circle.fill" : "circle") .font(.title2) .symbolRenderingMode(.palette) .foregroundStyle(.white, isChosen ? Color.accentColor : Color.black.opacity(0.25)) .shadow(color: .black.opacity(0.3), radius: 3) .padding(8) .transition(.opacity) }
private func handleTap(_ photo: PhotoMultiSelectGridPhoto) { guard isSelecting else { onOpen(photo) return } withAnimation(animation) { if selection.remove(photo.id) == nil { selection.insert(photo.id) } } }
private func enterSelection(with photo: PhotoMultiSelectGridPhoto) { guard !isSelecting else { return } withAnimation(animation) { isSelecting = true selection.insert(photo.id) } }}
// MARK: - Demo
struct PhotoMultiSelectGridDemo: View { @State private var selection: Set<String> = [] @State private var isSelecting = false @Environment(\.accessibilityReduceMotion) private var reduceMotion
private let photos = (1...15).map { number in PhotoMultiSelectGridPhoto(id: "\(number)", image: demoPhoto(number), label: "Photo \(number)") }
var body: some View { VStack(spacing: 12) { HStack { Text(isSelecting ? "\(selection.count) selected" : "Long press a photo") .font(.title3.bold()) .monospacedDigit() Spacer() if isSelecting { Button("Done") { withAnimation(reduceMotion ? nil : .snappy(duration: 0.25)) { isSelecting = false selection = [] } } .buttonStyle(.bordered) } } .frame(minHeight: 44) .padding(.horizontal, 16)
PhotoMultiSelectGrid(photos: photos, selection: $selection, isSelecting: $isSelecting) } }}
private func demoPhoto(_ number: Int) -> Image { let palettes: [[Color]] = [ [.indigo, .pink], [.teal, .green], [.orange, .yellow], [.purple, .blue], [.red, .orange], ] let symbols = ["mountain.2.fill", "leaf.fill", "sun.max.fill", "moon.stars.fill", "flame.fill"] let colors = palettes[number % palettes.count] let symbol = symbols[number % symbols.count] let size = CGSize(width: 300, height: 300)
return Image(size: size, label: Text("Photo \(number)")) { context in context.fill( Path(CGRect(origin: .zero, size: size)), with: .linearGradient(Gradient(colors: colors), startPoint: .zero, endPoint: CGPoint(x: 300, y: 300)) ) var icon = context.resolve(Image(systemName: symbol)) icon.shading = .color(.white.opacity(0.85)) context.draw(icon, in: CGRect(x: 90, y: 90, width: 120, height: 120)) }}
#Preview { PhotoMultiSelectGridDemo()}