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

473 Views Asked by At

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.

Thanks in advance!


Update:

Thanks for the solution by @nickreps:

Fixed scrolling and long tap

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) { value, state, transaction in
        state = value
        transaction.animation = .easeOut(duration: 0.2)
      }
    
    Text(text)
      .frame(width: 400, height: 200)
      .background(isLongPressure ? .white : color)
      .delaysTouches(for: 0.01) {
        //some code here, if needed
      }
      .gesture(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)
      }
    }
}

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
        }
    }
}


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

There are 3 best solutions below

0
nickreps On BEST ANSWER

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
        }
    }
}
3
Kreetchy On

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)
    }
  }
0
sugar baron On

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) }
    
}