SwiftUI ScrollView's onTapGesture getting called for child events

Here's a straightforward solution using GeometryReader and a VStack container for your ScrollView content:

struct ContentView: View {
  var body: some View {
    GeometryReader { contentView in
      ScrollView {
        VStack {
          Rectangle()
            .fill(Color.red)
            .frame(width: 200, height: 200)
            .onTapGesture {
              print("Rectangle onTapGesture")
          }
        }
        .frame(minWidth: contentView.size.width, minHeight: contentView.size.height, alignment: .top)
        .contentShape(Rectangle())
        .onTapGesture {
          print("ScrollViewArea onTapGesture")
        }
      }
    }
  }
}

This gives you a VStack container that is always exactly the same size as its parent ScrollView because of the dynamic values we get from GeometryReader's size property.

Note that the alignment: .top on this container is there to make it behave like a normal ScrollView would, anchoring scrolling items to the top. The added bonus is that if you remove that alignment attribute your scrolling items will start from the middle of the screen—something I've found to be impossible to do before stumbling on that solution. This could be interesting UX-wise as shorter lists could make sense to be centered vertically. I digress.

Final note is the .contentShape modifier being used to make the new VStack's empty space tappable, which fixes your problem.

This idea was taken from this Hacking with Swift ScrollView effects using GeometryReader article outlining how you can push this idea to another level, transforming elements as you scroll. Very fun stuff!


Gesture in SwiftUI works differently to how UITapGestureRecognizer previously worked. i.e it recognises gestures simultaneously by default. If you want to capture only certain gestures with precedence, then you need to use an ExclusiveGesture view modifier onto a gesture, and not use the default onTapGesture.

TapGesture erroneously detected touches as it's too responsive (it has a minuscule minimum duration ~ 0.0001s). I easily fixed this by replacing TapGesture with a more reasonable minimumDuration:

LongPressGesture(minimumDuration: 0.001)

Note that you can reduce or increase the minimum duration as per your needs, but this was the most stable when I tested.

This is probably a SwiftUI bug by the way, and I would encourage you to file a bug report outlining the issue.

Here's the code:

struct ContentView: View {

  var tap: some Gesture {
    LongPressGesture(minimumDuration: 0.001)
      .exclusively(before: scrollTap)
      .onEnded { _ in
        print("Rectangle onTapGesture")
    }
  }

  var scrollTap: some Gesture {
    LongPressGesture(minimumDuration: 0.001)
      .onEnded { _ in
        print("ScrollView onTapGesture")
    }
  }

  var body: some View {
    ScrollView() {
      Rectangle()
      .fill(Color.red)
      .frame(width: 200, height: 200)
      .gesture(tap)
    }.gesture(scrollTap, including: .gesture)

  }
}

If you have multiple views you want to exclusively ignore, use the exclusively view modifier multiple times (chained), or the sequenced if you want to reverse the capturing order. You can check the Gesture docs for details.

Tags:

Ios

Swift

Swiftui