How to have onTap gestures on Map and MapAnnotation both, without the two interferring with each other?
Asked Answered
D

3

7

I have a SwiftUI Map with MapAnnotations. I would like to have an onTap gesture on the Map, so it deselects the selected annotations, and dissmisses a bottom sheet, etc. Also would like to have an onTap gesture on the annotation item (or just having a button as annotation view with an action there), which selects the annotation and do stuff. The problem: whenever I tap the annotation, the map's ontap gesture is triggered too. (When I tap on the map, it only triggers the map's action, so no problems there.) Here's some sample code:

import SwiftUI
import MapKit
import CoreLocation

struct ContentView: View {

   @State var region: MKCoordinateRegion =
   MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 47.333,
                                                     longitude: 19.222),
                      span: MKCoordinateSpan(latitudeDelta: 0.002, longitudeDelta: 0.002))


   var body: some View {

       Map(coordinateRegion: $region,
annotationItems: AnnotationItem.sample) { annotation  in
           MapAnnotation(coordinate: annotation.location.coordinate) {
               VStack {
                   Circle()
                       .foregroundColor(.red)
                       .frame(width: 50)
                   Text(annotation.name)
               }
               .onTapGesture {
                   print(">> tapped child")
               }
           }
       }
       .onTapGesture {
           print(">> tapped parent")
       }
   }
}

I tap on the annotation, then:

>> tapped parent
>> tapped child

I tap on the map, then:

>> tapped parent

EDIT:

I have tried and didn't work:

  • make parent action depend on a boolean, which is set to prevent map's action when child is tapped. See in comment: I can only delay the parents action with this, cannot cancel it.
  • add on custom tap gesture for each, and set .exclusivelyBefore(:) modifier on one of them
Dockage answered 4/12, 2022 at 13:17 Comment(6)
Did you test Apple's GestureExample in the docs?Closegrained
Yes, I did. The problem, is that api only allows me to control downstream gestures, so I can control, how it effects childs. So I can set the 'including:' mask to none on the child - but it's gonna cancel the tap on itself, giving it to the parent. Setting the same on the parent has the same effect in child's perspective. Setting that to .gesture though behaves the same way described in the original post.Dockage
Did you try my corrected solution in the edited answer? I would be interested if it works in your case.Closegrained
@ReinhardMänner, yes I have tried DispatchQueues before, and now - just to verify - tried your exact solution too. The problem is that this only seems to work, until you tap the the same annotation always. As soon as you tap another one, the parent gets triggered. But long story short - and I was experiencing this; - this solution gives an uncontrolled behaviour. Sometimes seems to work, but from time time it triggers the parent too, when tapping other annotations back and forth.Dockage
Actually I have decided to wrap the UIKit version, as I have to use this gestures in production. I had no other choice - SwitUI is not ready for this yet. Also as of now - SwiftUI Map performs pretty bad in general.Dockage
Interesting! Thanks for testing my workaround!Closegrained
T
4

Another little hack inspired by @Gergely Kovacs's above answer!

var body: some View {

    Map(coordinateRegion: $region, interactionModes: [.zoom, .pan], annotationItems: AnnotationItem.sample) { annotation  in
        MapAnnotation(coordinate: annotation.location.coordinate) {
            VStack {
                Circle()
                    .foregroundColor(.red)
                    .frame(width: 50)
                Text(annotation.name)
            }
            .onLongPressGesture(minimumDuration: .zero, maximumDistance: .zero) {
               print(">> Tapped child")
            }
        }
    }
    .onTapGesture {
        print(">> tapped parent")
    }
}
  • So we don’t interfere with drag gesture
  • Works fine as onLongPressGesture has higher priority than onTapGesture
  • Doesn't work in reverse order, e.g onLongPressGesture for parent and onTapGesture for child!
Thurifer answered 27/4, 2023 at 6:24 Comment(1)
Perfect solution! Thanks. Can we mark this as the solution @Gergely?Lineolate
D
1

Currently one little hack seems to work! I change the onTap gesture to a DragGesture with a minimum distance of 0.

var body: some View {

        Map(coordinateRegion: $region, interactionModes: [.zoom, .pan], annotationItems: AnnotationItem.sample) { annotation  in
            MapAnnotation(coordinate: annotation.location.coordinate) {
                VStack {
                    Circle()
                        .foregroundColor(.red)
                        .frame(width: 50)
                    Text(annotation.name)
                }.gesture(childTapGesture)
            }
        }
        .onTapGesture {
            print(">> tapped parent")
        }
    }
   
    let childTapGesture = DragGesture(minimumDistance: 0).onEnded {_ in
        print(">> Tapped child")
    }
} 

and it works! The problem with this solution, is that touching one of the pins while dragging the map, triggers the pin action unintentionally. Thus my answer will not be the accepted one

Dockage answered 5/12, 2022 at 8:55 Comment(0)
C
0

Warning: The workaround shown in the edit below works apparently only in special cases, see the comment of Gergely Kovacs below.

This seems to me to be a bug, since the default behavior is that only one gesture recognizer fires at a time, see here.
A similar problem occurs in a ScrollView, but there exists a property .delaysContentTouches to solve it, see here. This does unfortunately not exist for a View.
A possible workaround is to delay the parent tap action until it is ensured that no child tap action follows. You could add to your ContentView a @State var childTapTriggered = false and set this var to true if it triggered. Then you could use as parent tap gesture closure something like

DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
  if !childTapTriggered {
       // do parent action
    }
}  

EDIT (due to the comment of Gergely Kovacs):

The above workaround does not work, sorry, pls see the comment.
But I tested the following workaround, and it works in my case:

I added to the ContentView a state var:

@State var childTapped = false  

On the annotation view (the child), I have the following modifier:

.onTapGesture {
    print(">> tapped child")
    childTapped = true
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
        childTapped = false
    }
}  

On the map (the parent) I have the following modifier:

.onTapGesture {
    if !childTapped {
        print(">> tapped parent")
    }
}  

Of course this is again a hack, and the delay to reset childTapped to false had to be adjusted right.
Anyway, maybe this solves your problem!

Caricature answered 4/12, 2022 at 14:23 Comment(1)
Thanks! I have already tried that, unfortunately that is not working in my case. I have put a @State var flag: Bool = false i my content view, and set it true, when child was tapped and make parent action depend on whether the property is false. But when I am done with the child's action, I need the map action in case it is tapped. So I have to raise the flag for the parent when child is done. That is when it triggers unfortunately, when it should not - I cannot cancel the action, only delay it.Dockage

© 2022 - 2024 — McMap. All rights reserved.