SwiftUI Gestures: Sequenced Long Press and Drag

In creating a sequenced gesture combining a LongPressGesture and a DragGesture, I found that the combined gesture exhibits two problems:

  1. The @GestureState does not properly update as the gesture progresses through its phases. Specifically, the updating(_:body:) closure (documented here) is only ever executed during the drag interaction. Long presses and drag-releases do not call the updating(_:body:) closure.
  2. Upon completing the long press gesture and activating the drag gesture, the drag gesture remains empty until the finger or cursor has moved. The expected behavior is for the drag gesture to begin even when its translation is of size .zero.

This second problem – the nonexistence of a drag gesture once the long press has completed – prevents access to the location of the long-press-then-drag. Access to this location is critical for displaying to the user that the drag interaction has commenced.

The below code is based on Apple's example presented here. I've highlighted the failure points in the code with // *.

My questions are as follows:

  • What is required to properly update the gesture state?
  • Is it possible to have a viable drag gesture immediately upon fulfilling the long press gesture, even with a translation of .zero?
  • Alternatively to the above question, is there a way to gain access to the location of the long press gesture?
import SwiftUI
import Charts

enum DragState {
    case inactive
    case pressing
    case dragging(translation: CGSize)
    
    var isDragging: Bool {
        switch self {
        case .inactive, .pressing:
            return false
        case .dragging:
            return true
        }
    }
}

struct ChartGestureOverlay<Value: Comparable & Hashable>: View {
    
    @Binding var highlightedValue: Value?
    let chartProxy: ChartProxy
    let valueFromChartProxy: (CGFloat, ChartProxy) -> Value?
    let onDragChange: (DragState) -> Void
    
    @GestureState private var dragState = DragState.inactive
    
    var body: some View {
        Rectangle()
            .fill(Color.clear)
            .contentShape(Rectangle())
            .onTapGesture { location in
                if let newValue = valueFromChartProxy(location.x, chartProxy) {
                    highlightedValue = newValue
                }
            }
            .gesture(longPressAndDrag)
    }
    
    private var longPressAndDrag: some Gesture {
        
        let longPress = LongPressGesture(minimumDuration: 0.2)
        
        let drag = DragGesture(minimumDistance: .zero)
            .onChanged { value in
                if let newValue = valueFromChartProxy(value.location.x, chartProxy) {
                    highlightedValue = newValue
                }
            }
        
        return longPress.sequenced(before: drag)
            .updating($dragState) { value, gestureState, _ in
                switch value {
                case .first(true):
                    // * This is never called
                    gestureState = .pressing
                case .second(true, let drag):
                    // * Drag is often nil
                    // * When drag is nil, we lack access to the location
                    gestureState = .dragging(translation: drag?.translation ?? .zero)
                default:
                    // * This is never called
                    gestureState = .inactive
                }
                onDragChange(gestureState)
            }
    }
}

struct DataPoint: Identifiable {
    let id = UUID()
    let category: String
    let value: Double
}

struct ContentView: View {
    
    let dataPoints = [
        DataPoint(category: "A", value: 5),
        DataPoint(category: "B", value: 3),
        DataPoint(category: "C", value: 8),
        DataPoint(category: "D", value: 2),
        DataPoint(category: "E", value: 7)
    ]
    
    @State private var highlightedCategory: String? = nil
    @State private var dragState = DragState.inactive
    
    var body: some View {
        VStack {
            Text("Bar Chart with Gesture Interaction")
                .font(.headline)
                .padding()
            
            Chart {
                ForEach(dataPoints) { dataPoint in
                    BarMark(
                        x: .value("Category", dataPoint.category),
                        y: .value("Value", dataPoint.value)
                    )
                    .foregroundStyle(highlightedCategory == dataPoint.category ? Color.red : Color.gray)
                    .annotation(position: .top) {
                        if highlightedCategory == dataPoint.category {
                            Text("\(dataPoint.value, specifier: "%.1f")")
                                .font(.caption)
                                .foregroundColor(.primary)
                        }
                    }
                }
            }
            .frame(height: 300)
            .chartOverlay { chartProxy in
                
                ChartGestureOverlay<String>(
                    highlightedValue: $highlightedCategory,
                    chartProxy: chartProxy,
                    valueFromChartProxy: { xPosition, chartProxy in
                        if let category: String = chartProxy.value(atX: xPosition) {
                            return category
                        }
                        return nil
                    },
                    onDragChange: { newDragState in
                        dragState = newDragState

                    }
                )
                
            }
            .onChange(of: highlightedCategory, { oldCategory, newCategory in

            })
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

Thank you!

SwiftUI Gestures: Sequenced Long Press and Drag
 
 
Q