Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature Gestures #30

Merged
merged 3 commits into from
Apr 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions Sources/MapLibreSwiftUI/Examples/Gestures.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import CoreLocation
import MapLibre
import MapLibreSwiftDSL
import SwiftUI

#Preview("Tappable Circles") {
let tappableID = "simple-circles"
return MapView(styleURL: demoTilesURL) {
// Simple symbol layer demonstration with an icon
CircleStyleLayer(identifier: tappableID, source: pointSource)
.radius(16)
.color(.systemRed)
.strokeWidth(2)
.strokeColor(.white)

SymbolStyleLayer(identifier: "simple-symbols", source: pointSource)
.iconImage(UIImage(systemName: "mappin")!.withRenderingMode(.alwaysTemplate))
.iconColor(.white)
}
.onTapMapGesture(on: [tappableID], onTapChanged: { _, features in
print("Tapped on \(features.first)")
})
.ignoresSafeArea(.all)
}

#Preview("Tappable Countries") {
MapView(styleURL: demoTilesURL)
.onTapMapGesture(on: ["countries-fill"], onTapChanged: { _, features in
print("Tapped on \(features.first)")
})
.ignoresSafeArea(.all)
}
20 changes: 19 additions & 1 deletion Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ extension MapView {
let gestureRecognizer = UITapGestureRecognizer(target: context.coordinator,
action: #selector(context.coordinator.captureGesture(_:)))
gestureRecognizer.numberOfTapsRequired = numberOfTaps
if numberOfTaps == 1 {
// If a user double taps to zoom via the built in gesture, a normal
// tap should not be triggered.
if let doubleTapRecognizer = mapView.gestureRecognizers?
.first(where: {
$0 is UITapGestureRecognizer && ($0 as! UITapGestureRecognizer).numberOfTapsRequired == 2
})
{
gestureRecognizer.require(toFail: doubleTapRecognizer)
}
}
Comment on lines +18 to +28
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to review this a bit further... Feels like there should be a slightly cleaner way...

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is a very specific fix and I didn't go deeper to check if there's any other setups we would need to check for.

MapLibre comes with 8 built in gestures, which also have own "require to fail" dependencies:

(lldb) po mapView.gestureRecognizers
▿ Optional<Array<UIGestureRecognizer>>
  ▿ some : 8 elements
    - 0 : <UIPanGestureRecognizer: 0x1056231f0; state = Possible; view = <MLNMapView: 0x1078d6800>; target= <(action=handlePanGesture:, target=<MLNMapView 0x1078d6800>)>; must-fail-for = {
        <UIPanGestureRecognizer: 0x105623910; state = Possible; view = <MLNMapView: 0x1078d6800>; target= <(action=handleTwoFingerDragGesture:, target=<MLNMapView 0x1078d6800>)>>
    }>
    - 1 : <UIPinchGestureRecognizer: 0x1056233a0; state = Possible; view = <MLNMapView: 0x1078d6800>; target= <(action=handlePinchGesture:, target=<MLNMapView 0x1078d6800>)>; must-fail-for = {
        <UITapGestureRecognizer: 0x105623cc0; state = Possible; view = <MLNMapView: 0x1078d6800>; target= <(action=handleTwoFingerTapGesture:, target=<MLNMapView 0x1078d6800>)>; numberOfTouchesRequired = 2>
    }>
    - 2 : <UIRotationGestureRecognizer: 0x105623540; state = Possible; view = <MLNMapView: 0x1078d6800>; target= <(action=handleRotateGesture:, target=<MLNMapView 0x1078d6800>)>; must-fail-for = {
        <UITapGestureRecognizer: 0x105623cc0; state = Possible; view = <MLNMapView: 0x1078d6800>; target= <(action=handleTwoFingerTapGesture:, target=<MLNMapView 0x1078d6800>)>; numberOfTouchesRequired = 2>
    }>
    - 3 : <UITapGestureRecognizer: 0x1056236a0; state = Possible; view = <MLNMapView: 0x1078d6800>; target= <(action=handleDoubleTapGesture:, target=<MLNMapView 0x1078d6800>)>; numberOfTapsRequired = 2; must-fail-for = {
        <UITapGestureRecognizer: 0x1056243c0; state = Possible; view = <MLNMapView: 0x1078d6800>; target= <(action=handleSingleTapGesture:, target=<MLNMapView 0x1078d6800>)>>,
        <UILongPressGestureRecognizer: 0x105623de0; state = Possible; view = <MLNMapView: 0x1078d6800>; target= <(action=handleQuickZoomGesture:, target=<MLNMapView 0x1078d6800>)>>
    }>
    - 4 : <UIPanGestureRecognizer: 0x105623910; state = Possible; view = <MLNMapView: 0x1078d6800>; target= <(action=handleTwoFingerDragGesture:, target=<MLNMapView 0x1078d6800>)>; must-fail = {
        <UIPanGestureRecognizer: 0x1056231f0; state = Possible; view = <MLNMapView: 0x1078d6800>; target= <(action=handlePanGesture:, target=<MLNMapView 0x1078d6800>)>>
    }; must-fail-for = {
        <UITapGestureRecognizer: 0x105623cc0; state = Possible; view = <MLNMapView: 0x1078d6800>; target= <(action=handleTwoFingerTapGesture:, target=<MLNMapView 0x1078d6800>)>; numberOfTouchesRequired = 2>
    }>
    - 5 : <UITapGestureRecognizer: 0x105623cc0; state = Possible; view = <MLNMapView: 0x1078d6800>; target= <(action=handleTwoFingerTapGesture:, target=<MLNMapView 0x1078d6800>)>; numberOfTouchesRequired = 2; must-fail = {
        <UIPinchGestureRecognizer: 0x1056233a0; state = Possible; view = <MLNMapView: 0x1078d6800>; target= <(action=handlePinchGesture:, target=<MLNMapView 0x1078d6800>)>>,
        <UIPanGestureRecognizer: 0x105623910; state = Possible; view = <MLNMapView: 0x1078d6800>; target= <(action=handleTwoFingerDragGesture:, target=<MLNMapView 0x1078d6800>)>>,
        <UIRotationGestureRecognizer: 0x105623540; state = Possible; view = <MLNMapView: 0x1078d6800>; target= <(action=handleRotateGesture:, target=<MLNMapView 0x1078d6800>)>>
    }>
    - 6 : <UILongPressGestureRecognizer: 0x105623de0; state = Possible; view = <MLNMapView: 0x1078d6800>; target= <(action=handleQuickZoomGesture:, target=<MLNMapView 0x1078d6800>)>; must-fail = {
        <UITapGestureRecognizer: 0x1056236a0; state = Possible; view = <MLNMapView: 0x1078d6800>; target= <(action=handleDoubleTapGesture:, target=<MLNMapView 0x1078d6800>)>; numberOfTapsRequired = 2>
    }; must-fail-for = {
        <UITapGestureRecognizer: 0x1056243c0; state = Possible; view = <MLNMapView: 0x1078d6800>; target= <(action=handleSingleTapGesture:, target=<MLNMapView 0x1078d6800>)>>
    }>
    - 7 : <UITapGestureRecognizer: 0x1056243c0; state = Possible; view = <MLNMapView: 0x1078d6800>; target= <(action=handleSingleTapGesture:, target=<MLNMapView 0x1078d6800>)>; must-fail = {
        <UITapGestureRecognizer: 0x1056236a0; state = Possible; view = <MLNMapView: 0x1078d6800>; target= <(action=handleDoubleTapGesture:, target=<MLNMapView 0x1078d6800>)>; numberOfTapsRequired = 2>,
        <UILongPressGestureRecognizer: 0x105623de0; state = Possible; view = <MLNMapView: 0x1078d6800>; target= <(action=handleQuickZoomGesture:, target=<MLNMapView 0x1078d6800>)>>
    }>

Since we don't want to expose the gesture recognizers or MapView to the user, I feel we have to help set up the gestures that a user adds to work with the built in gestures - just like SwiftUI where the motto is "convention over configuration" - the defaults should cover the most likely usage scenarios, ideally with a way of opting out of default behavior. So maybe we should add a further flag called "requireBuiltInGesturesToFail" which per default is true, but a user can set to false if they'd like their gestures to trigger regardless of the built in ones.

There are actually a total of 3 built in gesture recognizers that have numberOfTapsRequired = 2, so more code might be required, but in my testing, this code fixed the "user double tapped the map to zoom in, and yet the onMapGesture closure was run" problem, so I opted for the least invasive implementation for now.

mapView.addGestureRecognizer(gestureRecognizer)
gesture.gestureRecognizer = gestureRecognizer

Expand Down Expand Up @@ -50,7 +61,14 @@ extension MapView {
// Process the gesture into a context response.
let context = processContextFromGesture(mapView, gesture: gesture, sender: sender)
// Run the context through the gesture held on the MapView (emitting to the MapView modifier).
gesture.onChange(context)
switch gesture.onChange {
case let .context(action):
action(context)
case let .feature(action, layers):
let point = sender.location(in: sender.view)
let features = mapView.visibleFeatures(at: point, styleLayerIdentifiers: layers)
action(context, features)
}
}

/// Convert the sender data into a MapGestureContext
Expand Down
1 change: 1 addition & 0 deletions Sources/MapLibreSwiftUI/MapView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public struct MapView: UIViewRepresentable {
let userLayers: [StyleLayerDefinition]

var gestures = [MapGesture]()

var onStyleLoaded: ((MLNStyle) -> Void)?

public var mapViewContentInset: UIEdgeInsets = .zero
Expand Down
30 changes: 27 additions & 3 deletions Sources/MapLibreSwiftUI/MapViewModifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ public extension MapView {
///
/// - Parameters:
/// - count: The number of taps required to run the gesture.
/// - onTapChanged: Emits the context whenever the gesture changes (e.g. began, ended, etc).
/// - onTapChanged: Emits the context whenever the gesture changes (e.g. began, ended, etc), that also contains
/// information like the latitude and longitude of the tap.
/// - Returns: The modified map view.
func onTapMapGesture(count: Int = 1,
onTapChanged: @escaping (MapGestureContext) -> Void) -> MapView
Expand All @@ -59,7 +60,30 @@ public extension MapView {

// Build the gesture and link it to the map view.
let gesture = MapGesture(method: .tap(numberOfTaps: count),
onChange: onTapChanged)
onChange: .context(onTapChanged))
newMapView.gestures.append(gesture)

return newMapView
}

/// Add an tap gesture handler to the MapView that returns any visible map features that were tapped.
///
/// - Parameters:
/// - count: The number of taps required to run the gesture.
/// - on layers: The set of layer ids that you would like to check for visible features that were tapped. If no
/// set is provided, all map layers are checked.
/// - onTapChanged: Emits the context whenever the gesture changes (e.g. began, ended, etc), that also contains
/// information like the latitude and longitude of the tap. Also emits an array of map features that were tapped.
/// Returns an empty array when nothing was tapped on the "on" layer ids that were provided.
/// - Returns: The modified map view.
func onTapMapGesture(count: Int = 1, on layers: Set<String>?,
onTapChanged: @escaping (MapGestureContext, [any MLNFeature]) -> Void) -> MapView
{
var newMapView = self

// Build the gesture and link it to the map view.
let gesture = MapGesture(method: .tap(numberOfTaps: count),
onChange: .feature(onTapChanged, layers: layers))
newMapView.gestures.append(gesture)

return newMapView
Expand All @@ -78,7 +102,7 @@ public extension MapView {

// Build the gesture and link it to the map view.
let gesture = MapGesture(method: .longPress(minimumDuration: minimumDuration),
onChange: onPressChanged)
onChange: .context(onPressChanged))
newMapView.gestures.append(gesture)

return newMapView
Expand Down
10 changes: 8 additions & 2 deletions Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import MapLibre
import UIKit

public class MapGesture: NSObject {
Expand All @@ -19,7 +20,7 @@ public class MapGesture: NSObject {
let method: Method

/// The onChange action that runs when the gesture changes on the map view.
let onChange: (MapGestureContext) -> Void
let onChange: GestureAction

/// The underlying gesture recognizer
weak var gestureRecognizer: UIGestureRecognizer?
Expand All @@ -29,8 +30,13 @@ public class MapGesture: NSObject {
/// - Parameters:
/// - method: The gesture recognizer method
/// - onChange: The action to perform when the gesture is changed
init(method: Method, onChange: @escaping (MapGestureContext) -> Void) {
init(method: Method, onChange: GestureAction) {
self.method = method
self.onChange = onChange
}
}

public enum GestureAction {
case context((MapGestureContext) -> Void)
case feature((MapGestureContext, [any MLNFeature]) -> Void, layers: Set<String>?)
}
8 changes: 4 additions & 4 deletions Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ final class MapViewGestureTests: XCTestCase {
// MARK: Gesture Processing

@MainActor func testTapGesture() {
let gesture = MapGesture(method: .tap(numberOfTaps: 2)) { _ in
let gesture = MapGesture(method: .tap(numberOfTaps: 2), onChange: .context { _ in
// Do nothing
}
})

let mockTapGesture = MockUIGestureRecognizing()

Expand All @@ -53,9 +53,9 @@ final class MapViewGestureTests: XCTestCase {
}

@MainActor func testLongPressGesture() {
let gesture = MapGesture(method: .longPress(minimumDuration: 1)) { _ in
let gesture = MapGesture(method: .longPress(minimumDuration: 1), onChange: .context { _ in
// Do nothing
}
})

let mockTapGesture = MockUIGestureRecognizing()

Expand Down
16 changes: 12 additions & 4 deletions Tests/MapLibreSwiftUITests/Models/Gesture/MapGestureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,39 @@ import XCTest
final class MapGestureTests: XCTestCase {
func testTapGestureDefaults() {
let gesture = MapGesture(method: .tap(),
onChange: { _ in })
onChange: .context { _ in

})

XCTAssertEqual(gesture.method, .tap())
XCTAssertNil(gesture.gestureRecognizer)
}

func testTapGesture() {
let gesture = MapGesture(method: .tap(numberOfTaps: 3),
onChange: { _ in })
onChange: .context { _ in

})

XCTAssertEqual(gesture.method, .tap(numberOfTaps: 3))
XCTAssertNil(gesture.gestureRecognizer)
}

func testLongPressGestureDefaults() {
let gesture = MapGesture(method: .longPress(),
onChange: { _ in })
onChange: .context { _ in

})

XCTAssertEqual(gesture.method, .longPress())
XCTAssertNil(gesture.gestureRecognizer)
}

func testLongPressGesture() {
let gesture = MapGesture(method: .longPress(minimumDuration: 3),
onChange: { _ in })
onChange: .context { _ in

})

XCTAssertEqual(gesture.method, .longPress(minimumDuration: 3))
XCTAssertNil(gesture.gestureRecognizer)
Expand Down
Loading