iosswiftswiftuilazyvgrid

LazyVGrid item hit test area is not expected


I am learning SwiftUI and trying to impl a photo gallery app, using LazyVGrid. In the ForEach loop of my ContentView's body() method, I have a thumbnail view for each photo fetched from the library.

import SwiftUI
import Photos
import CoreImage
import ImageIO
import AVKit
import AVFoundation

struct Content: Identifiable {
    let id = UUID()
    let phAsset: PHAsset
    let index: Int
}

struct ContentView: View {
    @State private var contents: [Content] = []
    @State private var selectedContent: Content? = nil
    @State private var isLoading: Bool = true

    var body: some View {
        NavigationView {
            let minItemWidth: CGFloat = 100
            let spacing: CGFloat = 2
            let screenWidth = UIScreen.main.bounds.width
            let columns = Int((screenWidth + spacing) / (minItemWidth + spacing))
            let totalSpacing = spacing * CGFloat(columns - 1)
            let itemWidth = (screenWidth - totalSpacing) / CGFloat(columns)
            ZStack {
                ScrollView {
                    LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 2) {
                        ForEach(contents) { content in
                            ThumbnailView(content: content.phAsset)
                                .frame(width: itemWidth, height: itemWidth)
                                .onTapGesture {
                                    selectedContent = content
                                }
                                .padding(4)
                                .background(Color.blue.opacity(0.5))
                                .clipped()
                        }
                    }
                    .padding(2)
                }
                .navigationTitle("Photos Library")
                .onAppear(perform: loadContents)
                .sheet(item: $selectedContent) { content in
                    NavigationView {
                        DetailImageView(asset: content.phAsset)
                            .navigationBarItems(trailing: Button("Done") {
                                selectedContent = nil
                            })
                    }
                }

                if isLoading {
                    ProgressView("Loading...")
                        .progressViewStyle(CircularProgressViewStyle())
                        .scaleEffect(1.5, anchor: .center)
                }
            }
        }
    }

    func loadContents() {
        PHPhotoLibrary.requestAuthorization { status in
            if status == .authorized {
                fetchContents()
            }
        }
    }

    func fetchContents() {
        let fetchOptions = PHFetchOptions()

        DispatchQueue.global(qos: .background).async {
            self.isLoading = true
            let fetchResult = PHAsset.fetchAssets(with: fetchOptions)
            var fetchedContents: [Content] = []
            var index = 0
            fetchResult.enumerateObjects { (phAsset, _, _) in
                fetchedContents.append(Content(phAsset: phAsset, index: index))
                index += 1
            }
            self.isLoading = false

            DispatchQueue.main.async {
                contents = fetchedContents
            }
        }
    }
}

struct ThumbnailView: View {
    let content: PHAsset
    @State private var image: UIImage? = nil

    var body: some View {
        Group {
            if let image = image {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFill()
            } else {
                Color.gray
            }
        }
        .onAppear(perform: loadImage)
    }

    func loadImage() {
        let imageManager = PHImageManager.default()
        let requestOptions = PHImageRequestOptions()
        requestOptions.isSynchronous = false
        imageManager.requestImage(for: content, targetSize: CGSize(width: 100, height: 100), contentMode: .aspectFill, options: requestOptions) { (image, _) in
            self.image = image
        }
    }
}

struct DetailImageView: View {
    let asset: PHAsset
    @State private var image: UIImage?

    var body: some View {
        Group {
            if let image = image {
                Image(uiImage: image)
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .edgesIgnoringSafeArea(.all)
            } else {
                ProgressView()
            }
        }
        .onAppear(perform: loadFullImage)
    }

    func loadFullImage() {
        let manager = PHImageManager.default()
        let options = PHImageRequestOptions()
        options.deliveryMode = .highQualityFormat
        options.isNetworkAccessAllowed = true

        manager.requestImage(
            for: asset,
            targetSize: PHImageManagerMaximumSize,
            contentMode: .aspectFit,
            options: options
        ) { result, _ in
            image = result
        }
    }
}

The problem is that when I click on certain area of an item, the opened item is not expected. I tried debugging this issue for a whole day and found if I comment out the ".clipped()" modifier on the ThumbnailView, I can see the item views of the LazyVGrid is overlapped, that is the reason why the view on top is opened other than the view I thought I clicked. This is pretty strange to me as I believe if I specify the size of a view by .frame() modifier, it should not be able to receive any touch event outside the area.

I am new to SwiftUI and iOS programming, any help is very appreciated.


Solution

  • When an image is scaled to fill, the overflow is still receptive to taps, even if it is clipped.

    To fix, try applying .contentShape immediately after the .frame modifier. This needs to be before the .onTapGesture modifier:

    ThumbnailView(content: content.phAsset)
        .frame(width: itemWidth, height: itemWidth)
        .contentShape(Rectangle()) // 👈 here
        .onTapGesture {
            selectedContent = content
        }
        // + other modifiers