Help Debugging Performance of Grid with Many Circles SwiftUI

Hello everyone, I've been trying to improve the performance of a grid view that I've made for an app. Basically, it's like one of those sensory boards and there are circles that, when dragged over, change color and play a little haptic feedback.

However, I want the grid to span the entire screen and so by increasing the dimensions of the grid to say, 30x30, I am noticing significant performance decreases. CPU usage increases to 99% and the haptic feedback and animation slow down.

I've narrowed down the problem to the drag gesture (not the haptics). Just the drag gesture makes the CPU usage approach 40%. The part where I verify if the drag location is within the bounds of any of the circles increase the CPU though. This is like O(n) but with like 900 grid points doesn't sound like it should be that bad?

Is there any way that I can improve the code performance? I've tried putting each row of the grid into a Group and also tried switching to UIKit and using CAReplicatorLayers to construct the grid but ran into a wall when I found out you can't do hit testing on those layers.

struct SimpleGrid<H: HapticPlaying>: View {
    @EnvironmentObject private var hapticEngine: H

    @State private var touchedGridPoints: Set<GridPoint> = Set<GridPoint>()
    @State private var hapticDotData: Set<HapticDotPreferenceData> = Set<HapticDotPreferenceData>()
    @State private var gridScale: CGSize = .zero

    private var gridDim: (Int, Int) = (30, 30)  // (row, column)

    var body: some View {
        GeometryReader { viewGeo in
            VStack(spacing: 0) {
                ForEach(0..<gridDim.0, id: \.self) { row in
                    Group {
                        HStack(spacing: 0) {
                            ForEach(0..<gridDim.1, id: \.self) { column in
                                HapticDot(
                                    size: determineIdealDotSize(viewGeo: viewGeo, defaultDotSize: 10, gridDim: gridDim)
                                )
                                .padding(.all, 5)
                                .foregroundStyle(
                                    touchedGridPoints.contains(GridPoint(x: row, y: column)) ? Color.random : Color.primary
                                )
                                .opacity(touchedGridPoints.contains(GridPoint(x: row, y: column)) ? 0.5 : 1.0)
                                .background(
                                    // Use a geometry reader to get this dot's
                                    // location in the view.
                                    GeometryReader { dotGeo in
                                        Rectangle()
                                            .fill(.clear)
                                            .updateHapticDotPreferenceData(Set([
                                                HapticDotPreferenceData(
                                                    gridPoint: GridPoint(x: row, y: column),
                                                    bounds: dotGeo.frame(in: .global)
                                                )
                                            ]))
                                    }
                                )
                            }
                        }
                    }
                }
            }
            .scaleEffect(gridScale, anchor: .center)
            .onAppear {
                withAnimation(.spring(duration: 0.6, bounce: 0.4)) {
                    gridScale = CGSize(width: 1.0, height: 1.0)
                }
            }
            .frame(width: viewGeo.size.width, height: viewGeo.size.height, alignment: .center)
        }
        // This PreferenceKey allows us to monitor the location and index
        // of each HapticDot and do stuff with that information.
        .onPreferenceChange(HapticDotPreferenceKey.self) { value in
            hapticDotData = value
        }
        .gesture(
            DragGesture(minimumDistance: 0, coordinateSpace: .global)
                .onChanged { dragValue in
                    hapticEngine.asyncPlayHaptic(
                        intensity: 1.0, sharpness: 1.0
                    )

                    if let touchedDotData = hapticDotData.first(where: { $0.bounds.contains(dragValue.location) }) {
                        // Don't perform the animation if this haptic dot
                        // is still in touchedGridPoints.
                        if !touchedGridPoints.contains(touchedDotData.gridPoint) {
                            withAnimation(.linear(duration: 0.5)) {
                                let _ = touchedGridPoints.insert(touchedDotData.gridPoint)
                            }

                            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                                // There is a bug here more visible when the animationDuration is long.
                                // When the point is removed, the colors for
                                // the dots still in touchedGridPoints get recalculated,
                                // so they change colors every time one gets removed.
                                withAnimation {
                                    _ = touchedGridPoints.remove(touchedDotData.gridPoint)
                                }
                            }
                        }
                    }
                }
        )
    }

    private func determineIdealDotSize(viewGeo: GeometryProxy, defaultDotSize: CGFloat, gridDim: (Int, Int)) -> CGFloat {
        let idealWidth = min(defaultDotSize, (viewGeo.size.width - (CGFloat(gridDim.1)*5*2)) / CGFloat(gridDim.1))
        let idealHeight = min(defaultDotSize, (viewGeo.size.height - (CGFloat(gridDim.0)*5*2)) / CGFloat(gridDim.0))
        let idealSize = max(0, min(idealWidth, idealHeight))

        return idealSize
    }
}