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
}
}
}
)
}
}
}
Instead of using a ScrollView
with nested VStack
s, try converting to a List
. Then delete functionality comes as standard.
The main changes needed:
ScrollView
with List
.VStack
and use a nested Section
for each of the sections.VStack
around the ForEach
.header
..swipeActions
modifier to the ForEach
content, with a button for deleting a row.withAnimation
, because it is animated anyway..alert
to the List
.onDelete
callback from the cards.ZStack
and delete button from the cards..offset
modifier and DragGesture
from the cards..contentShape
modifier to the cards, if you want taps in blank areas to work.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
}
}