Ivan Pomortsev
Ivan Pomortsev

Reputation: 238

How to make LongPressGesture and scrolling in ScrollView work together at the same time?

Let's imagine, here is a ScrollView with some elements and I want to make some actions (e.g. changing of color) on long tap on these elements. But also I want to make possible to scroll this view.

Here is an example:

Example of scrolling and long taps together

import SwiftUI

struct TextBox: View {
  var text: String
  var color: Color
  @GestureState private var isLongPressure: Bool = false
  
  var body: some View {
    let longTap = LongPressGesture(minimumDuration: 0.3)
      .updating($isLongPressure) { state, newState, transaction in
        newState = state
        transaction.animation = .easeOut(duration: 0.2)
      }
    
    Text(text)
      .frame(width: 400, height: 200)
      .background(isLongPressure ? .white : color)
      .simultaneousGesture(longTap)
  }
}

struct TestGestures: View {
    var body: some View {
      ScrollView {
        TextBox(text: "Test 1", color: .red)
        TextBox(text: "Test 2", color: .green)
        TextBox(text: "Test 3", color: .blue)
        TextBox(text: "Test 4", color: .red)
        TextBox(text: "Test 5", color: .green)
        TextBox(text: "Test 6", color: .blue)
      }
    }
}

struct TestGestures_Previews: PreviewProvider {
    static var previews: some View {
        TestGestures()
    }
}

So, if I comment .simultaneousGesture(longTap) – scrolling works, but if I uncomment it – scrolling stopped work.

P.S.: I've tried to add onTapGesture before adding longTap and it doesn't help.

Upvotes: 4

Views: 1156

Answers (5)

Codelaby
Codelaby

Reputation: 2873

This work launch print, but no change states with @GestureState, maybe change with @State

import SwiftUI

struct ScrollDetectGestures: View {

    struct TextBox: View {
        var text: String
        var color: Color
        @GestureState private var isLongPressure: Bool = false
        @GestureState private var isTapped: Bool = false

        var body: some View {
            let longTap = LongPressGesture(minimumDuration: 0.3)
                .updating($isLongPressure) { state, newState, transaction in
                    newState = state
                    transaction.animation = .easeOut(duration: 0.2)
                }
                .onEnded { _ in
                    print("Long press: " + text)
                }

            let tap = TapGesture()
                .updating($isTapped) { state, newState, transaction in
                    newState = true
                    transaction.animation = .easeOut(duration: 0.2)
                }
                .onEnded { _ in
                    print("Tap press: " + text)
                }

            VStack {
                Text(text)
                Text("tap or long press")
                    .border(Color.black, width: isTapped ? 2 : 0)

            }
            .frame(width: 200, height: 100)
            .background(color)
            .simultaneousGesture(longTap)
            .simultaneousGesture(tap)
        }
    }

    var body: some View {
        VStack {

            ScrollView {
                ForEach(ColorItem.allItems) { item in
                    TextBox(text: item.summary, color: item.color)
                }

            }
            
        }
    }
}

#Preview {
    ScrollDetectGestures()
}

model data

struct ColorItem: Identifiable {
    let id: UUID
    let title: String
    let summary: String
    let color: Color
    
    static let allItems: [ColorItem] = [
        ColorItem(id: UUID(), title: "Red", summary: "This is red", color: .red),
        ColorItem(id: UUID(), title: "Green", summary: "This is green", color: .green),
        ColorItem(id: UUID(), title: "Blue", summary: "This is blue", color: .blue),
        ColorItem(id: UUID(), title: "Yellow", summary: "This is yellow", color: .yellow),
        ColorItem(id: UUID(), title: "Orange", summary: "This is orange", color: .orange),
        ColorItem(id: UUID(), title: "Pink", summary: "This is pink", color: .pink),
        ColorItem(id: UUID(), title: "Mint", summary: "This is Mint", color: .mint),
        ColorItem(id: UUID(), title: "Teal", summary: "This is teal", color: .teal),
        ColorItem(id: UUID(), title: "Cyan", summary: "This is cyan", color: .cyan),
        ColorItem(id: UUID(), title: "Indigo", summary: "This is indigo", color: .indigo),
        ColorItem(id: UUID(), title: "Purple", summary: "This is purple", color: .purple),
        ColorItem(id: UUID(), title: "Gray", summary: "This is Gray", color: .gray),
        ColorItem(id: UUID(), title: "Brown", summary: "This is Brown", color: .brown)
        
    ]
}

Upvotes: -1

petepetepete
petepetepete

Reputation: 1

I also struggled a lot with this topic. But found this really cool approach, which is working perfectly: https://danielsaidi.com/blog/2022/11/16/using-complex-gestures-in-a-scroll-view He did it with a buttonStyle:

struct ScrollViewGestureButtonStyle: ButtonStyle {

    init(
        pressAction: @escaping () -> Void,
        doubleTapTimeoutout: TimeInterval,
        doubleTapAction: @escaping () -> Void,
        longPressTime: TimeInterval,
        longPressAction: @escaping () -> Void,
        endAction: @escaping () -> Void
    ) {
        self.pressAction = pressAction
        self.doubleTapTimeoutout = doubleTapTimeoutout
        self.doubleTapAction = doubleTapAction
        self.longPressTime = longPressTime
        self.longPressAction = longPressAction
        self.endAction = endAction
    }

    private var doubleTapTimeoutout: TimeInterval
    private var longPressTime: TimeInterval

    private var pressAction: () -> Void
    private var longPressAction: () -> Void
    private var doubleTapAction: () -> Void
    private var endAction: () -> Void

    @State
    var doubleTapDate = Date()

    @State
    var longPressDate = Date()

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .onChange(of: configuration.isPressed) { isPressed in
                longPressDate = Date()
                if isPressed {
                    pressAction()
                    doubleTapDate = tryTriggerDoubleTap() ? .distantPast : .now
                    tryTriggerLongPressAfterDelay(triggered: longPressDate)
                } else {
                    endAction()
                }
            }
    }
}

private extension ScrollViewGestureButtonStyle {

    func tryTriggerDoubleTap() -> Bool {
        let interval = Date().timeIntervalSince(doubleTapDate)
        guard interval < doubleTapTimeoutout else { return false }
        doubleTapAction()
        return true
    }

    func tryTriggerLongPressAfterDelay(triggered date: Date) {
        DispatchQueue.main.asyncAfter(deadline: .now() + longPressTime) {
            guard date == longPressDate else { return }
            longPressAction()
        }
    }
}

Upvotes: 0

sugar baron
sugar baron

Reputation: 331

Here is a version of @nickreps solution, but a bit shorter:

struct MyView : View {
    
    var body: some View {
        ScrollView { VStack { ForEach(0..<40) { _ in Item() } } }
    }
    
}

struct Item : View {
    
    var body: some View {
        Color.yellow
            .frame(height: 100)
            .border(Color.black, width: 1)
            .yieldTouches() // <- this solves gestures conflict
            .gesture(
                LongPressGesture(minimumDuration: 1, maximumDistance: 10)
                        .onEnded { _ in print("hello!") }
             )
    }
    
}

public extension View {
    
    func yieldTouches() -> some View { modifier(YieldTouches()) }
    
}

private struct YieldTouches : ViewModifier {
    
    @State private var disabled = false
    
    func body(content: Content) -> some View {
        content
            .disabled(disabled)
            .onTapGesture { onMain { disabled = true; onMain { disabled = false } } }
    }
    
    private func onMain(_ action: @escaping () -> Void) { DispatchQueue.main.async(execute: action) }
    
}

Upvotes: 1

nickreps
nickreps

Reputation: 1080

I was able to get it working by utilizing a button rather than a TextView. Although this does directly utilize the code you provided, you should be able to modify some pieces to have it meet your needs (I can help with this, if needed!)

import SwiftUI

struct ScrollTest: View {
    let testData = [1]
    
    var body: some View {
        ScrollView(.vertical, showsIndicators: false) {
            AnimatedButtonView(color: .red, text: "Test 1")
            AnimatedButtonView(color: .green, text: "Test 2")
            AnimatedButtonView(color: .blue, text: "Test 3")
        }
    }
}



struct AnimatedButtonView: View {
    @GestureState var isDetectingLongPress = false
    let color: Color
    let text: String
    var body: some View {
        ZStack {
            RoundedRectangle(cornerRadius: 12.5, style: .continuous)
                .fill(color)
                .frame(width: UIScreen.main.bounds.width, height: 200)
                .padding(25)
                .scaleEffect(!isDetectingLongPress ? 1.0 : 0.875)
                .brightness(!isDetectingLongPress ? 0.0 : -0.125)
                .animation(.easeInOut(duration: 0.125), value: isDetectingLongPress)
            Text(text)
        }
        
        .delaysTouches(for: 0.01) {
            //some code here, if needed
        }
        .gesture(
            LongPressGesture(minimumDuration: 3)
                .updating($isDetectingLongPress) { currentState, gestureState,
                    transaction in
                    gestureState = currentState
                    transaction.animation = Animation.easeIn(duration: 2.0)
                }
                .onEnded { finished in
                    print("gesture ended")
                })
        
    }
}

extension View {
    func delaysTouches(for duration: TimeInterval = 0.25, onTap action: @escaping () -> Void = {}) -> some View {
        modifier(DelaysTouches(duration: duration, action: action))
    }
}

fileprivate struct DelaysTouches: ViewModifier {
    @State private var disabled = false
    @State private var touchDownDate: Date? = nil
    
    var duration: TimeInterval
    var action: () -> Void
    
    func body(content: Content) -> some View {
        Button(action: action) {
            content
        }
        .buttonStyle(DelaysTouchesButtonStyle(disabled: $disabled, duration: duration, touchDownDate: $touchDownDate))
        .disabled(disabled)
    }
}

fileprivate struct DelaysTouchesButtonStyle: ButtonStyle {
    @Binding var disabled: Bool
    var duration: TimeInterval
    @Binding var touchDownDate: Date?
    
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .onChange(of: configuration.isPressed, perform: handleIsPressed)
    }
    
    private func handleIsPressed(isPressed: Bool) {
        if isPressed {
            let date = Date()
            touchDownDate = date
            
            DispatchQueue.main.asyncAfter(deadline: .now() + max(duration, 0)) {
                if date == touchDownDate {
                    disabled = true
                    
                    DispatchQueue.main.async {
                        disabled = false
                    }
                }
            }
        } else {
            touchDownDate = nil
            disabled = false
        }
    }
}

Upvotes: 2

Kreetchy
Kreetchy

Reputation: 800

I'm not sure I understand the exact context, but you could add a condition so your LongPressGesture only triggers an action when gesture is not being used for scrolling.

let longTap = LongPressGesture(minimumDuration: 0.3)
  .updating($isLongPressure) { value, state, transaction in
    if value {
      state = true
      transaction.animation = .easeOut(duration: 0.2)
    }
  }

Upvotes: 0

Related Questions