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!

@First please use onLongPressGesture(minimumDuration:maximumDistance:perform:onPressingChanged:) and its pressing callback instead of LongPressGesture. There was a behavior change to prevent SwiftUI views with long press gestures from eating events when the gesture begins its pressing phase.

@DTS Engineer Thanks for the insight. We face a similar problem. Could you please explain further what you mean? How would one use the onLongPressGesture modifier in combination with .sequenced? I understand that onLongPressGesture now works differently to LongPressGesture, but I'm not sure how that would help in this case.

Thanks for your help!

I am facing the same exact issue and would love to know how to create a workaround while ensuring the usage of .sequenced

I am in the same boat. @DTS Engineer do you have any more insight into how to combine the onLongPressGesture modifier with .sequenced?

SwiftUI Gestures: Sequenced Long Press and Drag
 
 
Q