fix: avoid swipe conflict when seeking voice notes

Fixes #1658.

The private conversation attaches a high-priority right-swipe-to-leave DragGesture to the message list, while WaveformView used DragGesture(minimumDistance: 0) for seeking. A horizontal drag over a playing voice note could therefore close the conversation instead of seeking.

Use SpatialTapGesture for deterministic tap-to-seek behavior, preserve bounded progress mapping, and cover normal mapping, bounds clamping, and zero-width geometry with regression tests.
This commit is contained in:
Mayank Bhaskar 2026-08-19 01:51:45 +05:30 committed by GitHub
parent 9b84b36122
commit 2c3e8d4fcf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -8,6 +8,16 @@ struct WaveformView: View {
let isInteractive: Bool
@ThemedPalette private var palette
/// Converts a tap location into a bounded playback fraction.
///
/// Keep this separate from the gesture so the coordinate contract can be
/// tested without mounting SwiftUI. A zero-sized geometry can occur while
/// a row is being laid out and must not result in a bogus seek.
static func seekFraction(forX x: CGFloat, inWidth width: CGFloat) -> Double? {
guard width > 0 else { return nil }
return max(0, min(1, Double(x / width)))
}
private var clampedPlayback: Double {
max(0, min(1, playbackProgress))
}
@ -52,11 +62,20 @@ struct WaveformView: View {
if isInteractive, let onSeek = onSeek {
Color.clear
.contentShape(Rectangle())
// The private conversation owns a high-priority
// swipe-to-leave drag on the message list. A drag here
// is therefore interpreted as navigation instead of
// seeking and can close the conversation. Seeking is
// intentionally a tap: it preserves the swipe gesture
// while keeping the waveform's direct-manipulation
// affordance deterministic.
.gesture(
DragGesture(minimumDistance: 0)
SpatialTapGesture(count: 1, coordinateSpace: .local)
.onEnded { value in
guard geometry.size.width > 0 else { return }
let fraction = max(0, min(1, value.location.x / geometry.size.width))
guard let fraction = Self.seekFraction(
forX: value.location.x,
inWidth: geometry.size.width
) else { return }
onSeek(fraction)
}
)