UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

ScrollView's onTapGesture getting called for child events

Forums > SwiftUI

Hello,

Long time lurker, first time poster :-) Thanks for all of the amazing resources on hackingwithswift.com -- the articles and code examples there have saved me more than once!

I stumbled upon a strange problem and have been able to reduce it to a short snippet of code. Please see below:

struct ContentView: View {
  var body: some View {
    ScrollView() {
      Rectangle()
      .fill(Color.red)
      .frame(width: 200, height: 200)
      .onTapGesture {
        print("Rectangle onTapGesture")
      }
    }
    .onTapGesture {
      print("ScrollView onTapGesture")
    }
  }
}

When tapping outside of the Rectangle, I see in the console:

ScrollView onTapGesture

However, when tapping the Rectangle, I see two lines being printed:

Rectangle onTapGesture

ScrollView onTapGesture

The ScrollView seems to be responding to its child's event as well... that's not supposed to happen, right? What can I do to stop this?

(my goal was to use an onTapGesture on the ScrollView to catch "dismissing" taps -- i.e. taps that were not caught/handled by any of the ScrollView's children)

Thank you very much!

2      

After a bit of playing around and investigating I have discovered the following -

The ScrollView as well as the List both conform to a protocol called DynamicViewContent. One thing we can deduce from this is that both views are Dynamic in nature. This kind of makes sense when you look at a basic level as to what both can do... ScrollView obviously allows you to scroll its content and List view does the same.

Because of this scrolling feature the content within those views are constantly changing position within the scroll view ( or list ). Now im not saying anything im saying here is the reason for your problem but i would think that at some level it would. Because of this dynamic feature of the scroll view, this is allowing the on tap gesture of the scroll view to be picked up along the the tapping of the rectangle.

To support my somewhat dubious theory I tried putting the rectangly in another static view, like a VStack and put an on tap gesture on both and when i tapped the rectangle that was the only on tap gesture that was picked up. So on static views what you have is fine but on dynamic views it appears it is not.

Im not too sure about how to get around this so i wont be much help yet in regards to a solution but i just wanted to give you a couple of things that i found to try help you understand a bit.

Can i just ask you for a bit more detail about what you are trying to achieve? From what you have said above are you wanting to dismiss a view if user presses outside of the ScrollViews children?

Dave

2      

Thank you very much, Dave, that's very interesting!

I do follow the logic that you're describing, but it does also seem strange - and atypical - for both parent and child views to receive the same event. Since this is not what happens with any other kind of view hierarchy (by default), this does introduce a new "model" of event bubbling that needs to be taken care of in a different way. It looks like it might simply be a bug with SwiftUI..? -- but maybe it's also more complicated than that. Your point about both ScrollViews and Lists being DynamicViewContents is very interesting -- something I'll try to explore.

Finally, yes, we do want to capture events on the child elements (those are the elements that are scrollable) but also taps that are made to the "background", i.e. to the ScrollView. In our case, we are showing a list of musical tracks in the ScrollView. Tapping a track "opens" or "selects" that track, highlighting it. We want users to be able to tap elsewhere (outside of the tracks) to un-select and un-highlight the current track selection.

Thanks again!

2      

Hi Greg,

I had a look some more into the DynamicViewContent protocol and it appears that its just the protocol that constains the onDelete, onMove and onInsert methods that a ScrollView and List mush implement so im not sure if this is the cause. I agree with your comment about the parent and receiving child view events and im note sure if this is intentional or just one of those underlying SwiftUI bugs.

Something that may help you with your project is what i am doing to the moment. I am working on an app which i have a Scroll View. In that Scroll View there are a number of Cards. Each card contains basic info regarding a struct called Category. When the user touches the card it opens up a detailed view regarding that category.

At a very basic level this is how i have my project set up -

struct ContentView: View {

@State private var show = false 

  var body: some View {
    VStack {
      MyScrollView(showDetail: $show)
     }
    }
 }

 struct MyScrollView: View {

   @Binding var showDetail: Bool
   let categories = // array of Categorys here

   var body: some View {
       ScrollView {
            ForEach(categories, id: \.id) { category in
                 CategoryCardView(category: category, showDetail: $showDetail)
           }
       }
       // This is where you could put an onTapGesture for your scroll view to set the individual card boolean back to false (see below)
   }

   struct CategoryCardView: View {
       @Binding var showDetail: Bool
       let category: Category

       var body: someView {
           // All my Card view code here
           .onTapGesture {
             self.showDetail.toggle()
            }
        }
    }

As you can see i have just set up some bindings which go back to the content view. I have put a tap gesture at the card level so that the showDetail boolean for each individual card is only toggled when touched. Maybe if you use this and then have an onTapGesure included on the ScrollView to toggle the showDetail boolean back to false it would set whatever card was selected back to false as well. Not sure if it would suit your situation. As to the scrollview detecting the card presses, i dont think there is a way to stop this, well none that i have found as yet. I hope this helps.

Dave

2      

As always, Dave is right. He's helped me out a bunch, too...

I ran into this same issue of different gestures acting as one, and found a solution over at stackoverflow. Unfortunately, I've lost the link.

As Dave said, tie it to a binding. But each onTapGesture will require it's own binding...in other words for your example, showDetailRectangle and showDetailScrollView.

Side note: there is a documented issue with Apple if you tried this using buttons. The workaround is to set the ButtonStyle to Borderless (.buttonStyle(BorderlessButtonStyle()). It's also recommended to add .padding or even better, a Spacer() between the buttons.

2      

Thank you for your reply!

I'm not too sure that I follow your example -- it seems to me that the show state variable, passed down as showDetail to both the ScrollView and the CategoryCardView is just one variable, correct..? What I mean by that -- there isn't one bool variable per CategoryCardView. Rather, there is a single state for all of the cards, correct?

I understand that each CategoryCardView could toggle that single "show" variable (the way I do it in my code is that instead of using a single bool variable to track the "show" state, I track the currently selected "track" as an optional variable -- this would be the equivalent in your example of tracking the currently selected "card"), but the issue is that the onTapGesture on the ScrollView will also be called when CategoryCardView is toggled / handles the onTapGesture.

Sorry if I didn't understand your example -- did it work for you when you tried it with the onTapGesture on the ScrollView?

Thanks again

2      

HI Greg

That is correct, there is just one variable (the show one) shared by all the card views. Unfortunately with my app i have the detail view covering the scroll view. What i did was tweak my code a little so my detail view would only cover the scroll view slightly allowing my to access the scroll view still. I tested it and my theory didn't work. The issue that you raised is causing the problem. If i use that single variable that i was talking about and toggle it in the on tap gesture for the scroll view then touching the actual card doesn;t do anything. I have conflicting on tap gesture modifiers now for the card view and the scroll view which both toggle the same variable.

I did a bit of searching and playing around and couldn't find a solution so i kind of came up with one by myself. Its probably not the best solution but have a look and see what you think.

My Solution - I set the showDetail bool in the on tap gesture after a delay using DispatchQueue.main.asynce like so:

struct CategoryCardView: View {
       @Binding var showDetail: Bool
       let category: Category

       var body: someView {
           // All my Card view code here
           .onTapGesture {
             DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                self.showDetail.toggle()
            }
            }
        }
    }

In the ScrollView onTapGesture i now do the following:

var body: some View {
       ScrollView {
            ForEach(categories, id: \.id) { category in
                 CategoryCardView(category: category, showDetail: $showDetail)
           }
       }
       .onTapGesture {
           if self.showDetail { self.showDetail.toggle() }
       }
   }

So the asyncAfter is giving the onTapGesture in the scroll view time to check the boolean showDetail. If there was no async on the card view then we would run into the same problem as the start -

  1. The card is touched and the showDetail is toggled to true
  2. The scroll view onTap is picked up and checks if the showDetail is true. If true it toggles it back to false
  3. Touching the card doesn;t achieve anything

Now with the async we get the following -

  1. The card is touch and after 0.1 seconds, the showDetail is toggled to true
  2. Before the 0.1 second deadline for the async the scroll view ontap checks if showDetail is true. Its not, it is still false because of the async. Therefore it doesn't do anything
  3. Detail view shows after the 0.1 and everyone is happy at this stage.
  4. Now if you touch the scroll view only the showDetail view is not true and it will be toggled to false and the detail view will disappear.

Hope this makes sense Greg. Like i said its not an ideal solution but the best i could do for now. Im still getting my head around SwiftUI myself. Hope it helps

Dave

2      

For anyone still reading, tristanlabbe posted an elegant solution on StackOverflow for this problem: https://stackoverflow.com/a/62441709

Cheers

2      

Hacking with Swift is sponsored by RevenueCat

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

Archived topic

This topic has been closed due to inactivity, so you can't reply. Please create a new topic if you need to.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.