swiftswiftuiscrollviewdraggesture

SwiftUI ScrollView not scrolling with DragGesture inside a ForEach Item


I have a ScrollView in SwiftUI that displays a list of items using a ForEach, where each item is shown as a CardView. To enable deleting items, I added a DragGesture to the CardView, allowing users to swipe and reveal a delete button.

However, after adding the DragGesture, the ScrollView only scrolls when touching the space between the cards. Swiping directly on a card does not allow the ScrollView to scroll, as it seems the DragGesture is intercepting the scroll gesture.

Without the DragGesture, the ScrollView behaves as expected. How can I ensure that the ScrollView scrolls normally while still allowing the DragGesture to work on the cards? Any guidance would be appreciated!

var body: some View {
    ScrollView {
        VStack {
            bestLapTimesSection()
            allLapTimesSection()
        }
    }
    .navigationTitle(NSLocalizedString("l_lap_times", comment: ""))
    .navigationBarTitleDisplayMode(.inline)
}

private func allLapTimesSection() -> some View {
    VStack(alignment: .leading) {
        Text("l_all_lap_times")
            .font(.largeTitle)
            .fontWeight(.medium)
            .padding(.horizontal)
        
        
        ForEach(lapTimes, id: \.id) { lapTime in
            TrackDetailVerticalLapTimeCardView(lapTime: lapTime, position: nil, onDelete: {
                withAnimation {
                    // deleteLapTime(lapTime)
                }
            })
            .padding(.horizontal)
            .onTapGesture {
                if let valueGroup = getValueGroupForLapTime(lapTime: lapTime) {
                    path.append(TrackValueDetailNavigation(track: track, valueGroup: valueGroup))
                } else {
                    alertMessage = NSLocalizedString("l_missing_values_alert", comment: "")
                    showAlert = true
                }
            }
        }
    }
    .alert(isPresented: $showAlert) {
        Alert(
            title: Text(NSLocalizedString("l_oops", comment: "")),
            message: Text(alertMessage),
            dismissButton: .default(Text(NSLocalizedString("l_ok", comment: "")))
        )
    }
}

And the CardView:

  struct TrackDetailVerticalLapTimeCardView: View {
    let lapTime: LocalLapTime
    let position: Int?
    let onDelete: () -> Void 

    @State private var offset: CGFloat = 0
    @State private var isButtonRevealed: Bool = false

    var body: some View {
        ZStack {
            HStack {
                Spacer()
                Button(action: {
                    onDelete() 
                }) {
                    Text(NSLocalizedString("l_delete", comment: "Delete"))
                        .font(.headline)
                        .foregroundColor(.white)
                        .padding()
                        .background(Color.red)
                        .cornerRadius(10)
                        .frame(width: 120)
                }
            }

            // Vordergrund-Karte
            HStack(alignment: .center, spacing: 12) {
                if let position = position {
                    Image(systemName: getTrophyImage(for: position))
                        .resizable()
                        .scaledToFit()
                        .frame(width: 36, height: 36)
                        .foregroundColor(getTrophyColor(for: position))
                        .padding(.leading)
                }

                VStack(alignment: .leading, spacing: 4) {
                    if let position = position {
                        HStack {
                            Text(getPositionText(for: position))
                                .font(.headline)
                                .foregroundColor(.primary)
                                .padding(.trailing)

                            Text(formatDate(lapTime.createdAt))
                                .font(.subheadline)
                                .foregroundColor(.secondary)
                        }
                    } else {
                        Text(formatDate(lapTime.createdAt))
                            .font(.subheadline)
                            .foregroundColor(.secondary)
                    }

                    Text(formatTime(lapTime.lapTime))
                        .font(.title3)
                        .fontWeight(.bold)
                        .foregroundColor(.primary)
                }

                Spacer()

                Image(systemName: "chevron.right")
                    .foregroundColor(.gray)
            }
            .padding()
            .background(Color(UIColor.secondarySystemBackground))
            .cornerRadius(10)
            .offset(x: offset)
            .gesture(
                DragGesture()
                    .onChanged { gesture in
                        let buttonWidth: CGFloat = 120
                        if isButtonRevealed {
                            offset = min(max(gesture.translation.width - buttonWidth, -buttonWidth), 0)
                        } else {
                            offset = min(max(gesture.translation.width, -buttonWidth), 0)
                        }
                    }
                    .onEnded { gesture in
                        let buttonWidth: CGFloat = 120
                        if offset <= -buttonWidth * 0.5 {
                            withAnimation {
                                offset = -buttonWidth
                                isButtonRevealed = true
                            }
                        } else {
                            withAnimation {
                                offset = 0
                                isButtonRevealed = false
                            }
                        }
                    }
            )
        }
    }
}

Solution

  • Instead of using a ScrollView with nested VStacks, try converting to a List. Then delete functionality comes as standard.

    The main changes needed:

    Here is a rough adaption of your example to show how it can work:

    var body: some View {
        List {
    
            // bestLapTimesSection: implement like allLapTimesSection, below
    
            Section {
                allLapTimesSection()
            } header: {
                Text("l_all_lap_times")
                    .font(.largeTitle)
                    .fontWeight(.medium)
                    .textCase(nil)
            }
            .listRowSeparator(.hidden)
        }
        .listRowSpacing(10)
        .navigationTitle(NSLocalizedString("l_lap_times", comment: ""))
        .navigationBarTitleDisplayMode(.inline)
        .alert(isPresented: $showAlert) {
            // ...
        }
    }
    
    private func allLapTimesSection() -> some View {
        ForEach(lapTimes, id: \.id) { lapTime in
            TrackDetailVerticalLapTimeCardView(lapTime: lapTime, position: nil)
                .swipeActions {
                    Button("l_delete", systemImage: "trash", role: .destructive) {
                        // deleteLapTime(lapTime)
                    }
                }
                .contentShape(Rectangle())
                .onTapGesture {
                    // ...
                }
        }
    }
    
    // TrackDetailVerticalLapTimeCardView
    
    //let onDelete: () -> Void
    
    var body: some View {
    
       // Vordergrund-Karte
       HStack(alignment: .center, spacing: 12) {
           // ... content as before
       }
    }
    

    Animation