Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Mapbox welcomes participation and contributions from everyone.

## Features ✨ and improvements 🏁
* Expose `FeaturesetFeature.originalFeature` property.
* Add `GestureOptions.singleTapRequiresDoubleTapToFail` so apps can allow single-tap gestures to resolve without waiting for double-tap zoom recognition.

## 11.23.0-rc.1 - 20 April, 2026
* Use TileStore::setRootPath(path) and TileStore::create() instead of deprecated TileStore::create(path).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import UIKit

/// `SingleTapGestureHandler` manages a gesture recognizer looking for single tap touch events
final class SingleTapGestureHandler: GestureHandler {
weak var doubleTapToZoomInGestureRecognizer: UIGestureRecognizer?
var requiresDoubleTapToZoomInGestureRecognizerToFail = true

private let map: MapboxMapProtocol
private let cameraAnimationsManager: CameraAnimationsManagerProtocol

Expand Down Expand Up @@ -40,6 +43,15 @@ extension SingleTapGestureHandler: UIGestureRecognizerDelegate {
return otherGestureRecognizer is UITapGestureRecognizer
}

func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
assert(self.gestureRecognizer == gestureRecognizer)
return requiresDoubleTapToZoomInGestureRecognizerToFail
&& otherGestureRecognizer === doubleTapToZoomInGestureRecognizer
}

func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldReceive touch: UITouch
Expand Down
8 changes: 5 additions & 3 deletions Sources/MapboxMaps/Gestures/GestureManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public final class GestureManager: GestureHandlerDelegate {
rotateGestureHandler.simultaneousRotateAndPinchZoomEnabled = newValue.simultaneousRotateAndPinchZoomEnabled
pitchGestureRecognizer.isEnabled = newValue.pitchEnabled
doubleTapToZoomInGestureRecognizer.isEnabled = newValue.doubleTapToZoomInEnabled
singleTapGestureHandler.requiresDoubleTapToZoomInGestureRecognizerToFail = newValue.singleTapRequiresDoubleTapToFail
doubleTouchToZoomOutGestureRecognizer.isEnabled = newValue.doubleTouchToZoomOutEnabled
quickZoomGestureRecognizer.isEnabled = newValue.quickZoomEnabled
panGestureHandler.panMode = newValue.panMode
Expand All @@ -48,6 +49,7 @@ public final class GestureManager: GestureHandlerDelegate {
gestureOptions.simultaneousRotateAndPinchZoomEnabled = pinchGestureHandler.simultaneousRotateAndPinchZoomEnabled
gestureOptions.pitchEnabled = pitchGestureRecognizer.isEnabled
gestureOptions.doubleTapToZoomInEnabled = doubleTapToZoomInGestureRecognizer.isEnabled
gestureOptions.singleTapRequiresDoubleTapToFail = singleTapGestureHandler.requiresDoubleTapToZoomInGestureRecognizerToFail
gestureOptions.doubleTouchToZoomOutEnabled = doubleTouchToZoomOutGestureRecognizer.isEnabled
gestureOptions.quickZoomEnabled = quickZoomGestureRecognizer.isEnabled
gestureOptions.panMode = panGestureHandler.panMode
Expand Down Expand Up @@ -151,7 +153,7 @@ public final class GestureManager: GestureHandlerDelegate {
private let doubleTapToZoomInGestureHandler: FocusableGestureHandlerProtocol
private let doubleTouchToZoomOutGestureHandler: FocusableGestureHandlerProtocol
private let quickZoomGestureHandler: FocusableGestureHandlerProtocol
private let singleTapGestureHandler: GestureHandler
private let singleTapGestureHandler: SingleTapGestureHandler
private let longPressGestureHandler: GestureHandler
private let anyTouchGestureHandler: GestureHandler
private let interruptDecelerationGestureHandler: GestureHandler
Expand All @@ -168,7 +170,7 @@ public final class GestureManager: GestureHandlerDelegate {
doubleTapToZoomInGestureHandler: FocusableGestureHandlerProtocol,
doubleTouchToZoomOutGestureHandler: FocusableGestureHandlerProtocol,
quickZoomGestureHandler: FocusableGestureHandlerProtocol,
singleTapGestureHandler: GestureHandler,
singleTapGestureHandler: SingleTapGestureHandler,
longPressGestureHandler: GestureHandler,
anyTouchGestureHandler: GestureHandler,
interruptDecelerationGestureHandler: GestureHandler,
Expand Down Expand Up @@ -198,7 +200,7 @@ public final class GestureManager: GestureHandlerDelegate {

panGestureHandler.gestureRecognizer.require(toFail: pitchGestureHandler.gestureRecognizer)
quickZoomGestureHandler.gestureRecognizer.require(toFail: doubleTapToZoomInGestureHandler.gestureRecognizer)
singleTapGestureHandler.gestureRecognizer.require(toFail: doubleTapToZoomInGestureHandler.gestureRecognizer)
singleTapGestureHandler.doubleTapToZoomInGestureRecognizer = doubleTapToZoomInGestureHandler.gestureRecognizer

// Invoke the setter to ensure the defaults are synchronized
self.options = GestureOptions()
Expand Down
9 changes: 9 additions & 0 deletions Sources/MapboxMaps/Gestures/GestureOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ public struct GestureOptions: Equatable, Sendable {
/// Defaults to `true`.
public var doubleTapToZoomInEnabled: Bool

/// Whether single tap gestures require the double tap gesture to fail.
///
/// Defaults to `true`. Set this to `false` to allow single tap gestures to resolve without waiting for double tap recognition.
/// When `false`, a double tap can also trigger single tap interactions for its first tap.
public var singleTapRequiresDoubleTapToFail: Bool

/// Whether single tapping the map with two touches results in a zoom-out animation.
///
/// Defaults to `true`.
Expand Down Expand Up @@ -94,6 +100,7 @@ public struct GestureOptions: Equatable, Sendable {
/// - pinchPanEnabled: Whether pan is enabled during the pinch gesture.
/// - pitchEnabled: Whether the pitch gesture is enabled.
/// - doubleTapToZoomInEnabled: Whether double tapping the map with one touch results in a zoom-in animation.
/// - singleTapRequiresDoubleTapToFail: Whether single tap gestures require the double tap to zoom in gesture to fail.
/// - doubleTouchToZoomOutEnabled: Whether single tapping the map with two touches results in a zoom-out animation.
/// - quickZoomEnabled: Whether the quick zoom gesture is enabled.
/// - panMode: The directions in which the map is allowed to move during a pan gesture.
Expand All @@ -108,6 +115,7 @@ public struct GestureOptions: Equatable, Sendable {
pinchPanEnabled: Bool = true,
pitchEnabled: Bool = true,
doubleTapToZoomInEnabled: Bool = true,
singleTapRequiresDoubleTapToFail: Bool = true,
doubleTouchToZoomOutEnabled: Bool = true,
quickZoomEnabled: Bool = true,
panMode: PanMode = .horizontalAndVertical,
Expand All @@ -122,6 +130,7 @@ public struct GestureOptions: Equatable, Sendable {
self.pinchPanEnabled = pinchPanEnabled
self.pitchEnabled = pitchEnabled
self.doubleTapToZoomInEnabled = doubleTapToZoomInEnabled
self.singleTapRequiresDoubleTapToFail = singleTapRequiresDoubleTapToFail
self.doubleTouchToZoomOutEnabled = doubleTouchToZoomOutEnabled
self.quickZoomEnabled = quickZoomEnabled
self.panMode = panMode
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@ final class MockMapViewDependencyProvider: MapViewDependencyProviderProtocol {
doubleTouchToZoomOutGestureHandler: MockFocusableGestureHandler(
gestureRecognizer: UIGestureRecognizer()),
quickZoomGestureHandler: MockFocusableGestureHandler(gestureRecognizer: UIGestureRecognizer()),
singleTapGestureHandler: makeGestureHandler(),
singleTapGestureHandler: SingleTapGestureHandler(
gestureRecognizer: UITapGestureRecognizer(),
map: mapboxMap,
cameraAnimationsManager: cameraAnimationsManager),
longPressGestureHandler: makeGestureHandler(),
anyTouchGestureHandler: makeGestureHandler(),
interruptDecelerationGestureHandler: makeGestureHandler(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,44 @@ final class SingleTapGestureHandlerTests: XCTestCase {
gestureHandler.assertRecognizedSimultaneously(gestureRecognizer, with: interruptingRecognizers)
}

func testShouldRequireDoubleTapToZoomInGestureRecognizerToFailByDefault() {
let doubleTapToZoomInGestureRecognizer = UITapGestureRecognizer()
gestureHandler.doubleTapToZoomInGestureRecognizer = doubleTapToZoomInGestureRecognizer

let shouldRequireFailure = gestureHandler.gestureRecognizer(
gestureRecognizer,
shouldRequireFailureOf: doubleTapToZoomInGestureRecognizer
)

XCTAssertTrue(shouldRequireFailure)
}

func testShouldNotRequireDoubleTapToZoomInGestureRecognizerToFailWhenDisabled() {
let doubleTapToZoomInGestureRecognizer = UITapGestureRecognizer()
gestureHandler.doubleTapToZoomInGestureRecognizer = doubleTapToZoomInGestureRecognizer
gestureHandler.requiresDoubleTapToZoomInGestureRecognizerToFail = false

let shouldRequireFailure = gestureHandler.gestureRecognizer(
gestureRecognizer,
shouldRequireFailureOf: doubleTapToZoomInGestureRecognizer
)

XCTAssertFalse(shouldRequireFailure)
}

func testShouldNotRequireOtherGestureRecognizerToFail() {
let doubleTapToZoomInGestureRecognizer = UITapGestureRecognizer()
let otherGestureRecognizer = UITapGestureRecognizer()
gestureHandler.doubleTapToZoomInGestureRecognizer = doubleTapToZoomInGestureRecognizer

let shouldRequireFailure = gestureHandler.gestureRecognizer(
gestureRecognizer,
shouldRequireFailureOf: otherGestureRecognizer
)

XCTAssertFalse(shouldRequireFailure)
}

func testShouldNotReceiveTouchTargetingDifferentView() {
let touch = MockUITouch(view: UIView())

Expand Down
39 changes: 32 additions & 7 deletions Tests/MapboxMapsTests/Gestures/GestureManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ final class GestureManagerTests: XCTestCase {
var doubleTapToZoomInGestureHandler: MockFocusableGestureHandler!
var doubleTouchToZoomOutGestureHandler: MockFocusableGestureHandler!
var quickZoomGestureHandler: MockFocusableGestureHandler!
var singleTapGestureHandler: GestureHandler!
var singleTapGestureHandler: SingleTapGestureHandler!
var longPressGestureHandler: GestureHandler!
var anyTouchGestureHandler: GestureHandler!
var interruptDecelerationGestureHandler: GestureHandler!
Expand All @@ -36,7 +36,7 @@ final class GestureManagerTests: XCTestCase {
doubleTouchToZoomOutGestureHandler = MockFocusableGestureHandler(
gestureRecognizer: MockGestureRecognizer())
quickZoomGestureHandler = MockFocusableGestureHandler(gestureRecognizer: MockGestureRecognizer())
singleTapGestureHandler = makeGestureHandler()
singleTapGestureHandler = makeSingleTapGestureHandler()
longPressGestureHandler = makeGestureHandler()
anyTouchGestureHandler = makeGestureHandler()
interruptDecelerationGestureHandler = makeGestureHandler()
Expand Down Expand Up @@ -78,6 +78,13 @@ final class GestureManagerTests: XCTestCase {
return GestureHandler(gestureRecognizer: MockGestureRecognizer())
}

func makeSingleTapGestureHandler() -> SingleTapGestureHandler {
return SingleTapGestureHandler(
gestureRecognizer: MockTapGestureRecognizer(),
map: mapboxMap,
cameraAnimationsManager: cameraAnimationsManager)
}

func testPanGestureRecognizer() {
XCTAssertTrue(gestureManager.panGestureRecognizer === panGestureHandler.gestureRecognizer)
}
Expand Down Expand Up @@ -164,12 +171,10 @@ final class GestureManagerTests: XCTestCase {
=== doubleTapToZoomInGestureHandler.gestureRecognizer)
}

func testSingleTapGestureRecognizerRequiresDoubleTapToZoomInGestureRecognizerToFail() throws {
let singleTapGestureRecognizer = try XCTUnwrap(singleTapGestureHandler.gestureRecognizer as? MockGestureRecognizer)

XCTAssertEqual(singleTapGestureRecognizer.requireToFailStub.invocations.count, 1)
XCTAssertTrue(singleTapGestureRecognizer.requireToFailStub.invocations.first?.parameters
func testSingleTapGestureRecognizerFailureRequirementConfigured() {
XCTAssertTrue(singleTapGestureHandler.doubleTapToZoomInGestureRecognizer
=== doubleTapToZoomInGestureHandler.gestureRecognizer)
XCTAssertTrue(singleTapGestureHandler.requiresDoubleTapToZoomInGestureRecognizerToFail)
}

func testGestureBegan() {
Expand Down Expand Up @@ -308,6 +313,26 @@ final class GestureManagerTests: XCTestCase {
XCTAssertTrue(gestureManager.doubleTapToZoomInGestureRecognizer.isEnabled)
}

func testOptionsSingleTapRequiresDoubleTapToFail() {
XCTAssertTrue(gestureManager.options.singleTapRequiresDoubleTapToFail)
XCTAssertTrue(singleTapGestureHandler.requiresDoubleTapToZoomInGestureRecognizerToFail)

gestureManager.options.singleTapRequiresDoubleTapToFail = false

XCTAssertFalse(gestureManager.options.singleTapRequiresDoubleTapToFail)
XCTAssertFalse(singleTapGestureHandler.requiresDoubleTapToZoomInGestureRecognizerToFail)

gestureManager.options.singleTapRequiresDoubleTapToFail = true

XCTAssertTrue(gestureManager.options.singleTapRequiresDoubleTapToFail)
XCTAssertTrue(singleTapGestureHandler.requiresDoubleTapToZoomInGestureRecognizerToFail)

singleTapGestureHandler.requiresDoubleTapToZoomInGestureRecognizerToFail = false

XCTAssertFalse(gestureManager.options.singleTapRequiresDoubleTapToFail)
XCTAssertFalse(singleTapGestureHandler.requiresDoubleTapToZoomInGestureRecognizerToFail)
}

func testOptionsDoubleTouchToZoomOutEnabled() {
XCTAssertTrue(gestureManager.options.doubleTouchToZoomOutEnabled)
XCTAssertTrue(gestureManager.doubleTouchToZoomOutGestureRecognizer.isEnabled)
Expand Down
1 change: 1 addition & 0 deletions Tests/MapboxMapsTests/Gestures/GestureOptionsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ final class GestureOptionsTests: XCTestCase {
XCTAssertTrue(options.pinchPanEnabled)
XCTAssertTrue(options.pitchEnabled)
XCTAssertTrue(options.doubleTapToZoomInEnabled)
XCTAssertTrue(options.singleTapRequiresDoubleTapToFail)
XCTAssertTrue(options.doubleTouchToZoomOutEnabled)
XCTAssertTrue(options.quickZoomEnabled)
XCTAssertEqual(options.panMode, .horizontalAndVertical)
Expand Down