From 1ff27060f12fd06e47349b50a0aceb45571d9648 Mon Sep 17 00:00:00 2001 From: Nick Kartashov <1384532+nkartashov@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:22:51 +0100 Subject: [PATCH 1/2] [gestures] Add option to avoid single tap delay --- CHANGELOG.md | 2 + .../SingleTapGestureHandler.swift | 12 ++++++ .../MapboxMaps/Gestures/GestureManager.swift | 8 ++-- .../MapboxMaps/Gestures/GestureOptions.swift | 9 +++++ .../Mocks/MockMapViewDependencyProvider.swift | 5 ++- .../SingleTapGestureHandlerTests.swift | 38 ++++++++++++++++++ .../Gestures/GestureManagerTests.swift | 39 +++++++++++++++---- .../Gestures/GestureOptionsTests.swift | 1 + 8 files changed, 103 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca6c161e32b0..afdaf8901282 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Mapbox welcomes participation and contributions from everyone. ## Features ✨ and improvements 🏁 * Expose `FeaturesetFeature.originalFeature` property. +### Features ✨ and improvements 🏁 +* 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). diff --git a/Sources/MapboxMaps/Gestures/GestureHandlers/SingleTapGestureHandler.swift b/Sources/MapboxMaps/Gestures/GestureHandlers/SingleTapGestureHandler.swift index 950b8603ca0e..ccd49daf95d3 100644 --- a/Sources/MapboxMaps/Gestures/GestureHandlers/SingleTapGestureHandler.swift +++ b/Sources/MapboxMaps/Gestures/GestureHandlers/SingleTapGestureHandler.swift @@ -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 @@ -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 diff --git a/Sources/MapboxMaps/Gestures/GestureManager.swift b/Sources/MapboxMaps/Gestures/GestureManager.swift index 71fac294cb75..6c37f452b1fa 100644 --- a/Sources/MapboxMaps/Gestures/GestureManager.swift +++ b/Sources/MapboxMaps/Gestures/GestureManager.swift @@ -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 @@ -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 @@ -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 @@ -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, @@ -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() diff --git a/Sources/MapboxMaps/Gestures/GestureOptions.swift b/Sources/MapboxMaps/Gestures/GestureOptions.swift index dba0795f167a..e74c6672c217 100644 --- a/Sources/MapboxMaps/Gestures/GestureOptions.swift +++ b/Sources/MapboxMaps/Gestures/GestureOptions.swift @@ -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`. @@ -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. @@ -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, @@ -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 diff --git a/Tests/MapboxMapsTests/Foundation/Mocks/MockMapViewDependencyProvider.swift b/Tests/MapboxMapsTests/Foundation/Mocks/MockMapViewDependencyProvider.swift index 665819d25e75..8c569cca4b64 100644 --- a/Tests/MapboxMapsTests/Foundation/Mocks/MockMapViewDependencyProvider.swift +++ b/Tests/MapboxMapsTests/Foundation/Mocks/MockMapViewDependencyProvider.swift @@ -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(), diff --git a/Tests/MapboxMapsTests/Gestures/GestureHandlers/SingleTapGestureHandlerTests.swift b/Tests/MapboxMapsTests/Gestures/GestureHandlers/SingleTapGestureHandlerTests.swift index fcfd8c415125..3e8a4ff80d9e 100644 --- a/Tests/MapboxMapsTests/Gestures/GestureHandlers/SingleTapGestureHandlerTests.swift +++ b/Tests/MapboxMapsTests/Gestures/GestureHandlers/SingleTapGestureHandlerTests.swift @@ -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()) diff --git a/Tests/MapboxMapsTests/Gestures/GestureManagerTests.swift b/Tests/MapboxMapsTests/Gestures/GestureManagerTests.swift index 40279fea9560..15db9627958f 100644 --- a/Tests/MapboxMapsTests/Gestures/GestureManagerTests.swift +++ b/Tests/MapboxMapsTests/Gestures/GestureManagerTests.swift @@ -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! @@ -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() @@ -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) } @@ -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() { @@ -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) diff --git a/Tests/MapboxMapsTests/Gestures/GestureOptionsTests.swift b/Tests/MapboxMapsTests/Gestures/GestureOptionsTests.swift index 23702b44c7fa..e95d248e88de 100644 --- a/Tests/MapboxMapsTests/Gestures/GestureOptionsTests.swift +++ b/Tests/MapboxMapsTests/Gestures/GestureOptionsTests.swift @@ -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) From d489915bf3f33888c627c9c4815ed77a49948c63 Mon Sep 17 00:00:00 2001 From: Nick Kartashov <1384532+nkartashov@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:11:33 +0100 Subject: [PATCH 2/2] Update changelog --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afdaf8901282..219a388e6af5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,6 @@ Mapbox welcomes participation and contributions from everyone. ## Features ✨ and improvements 🏁 * Expose `FeaturesetFeature.originalFeature` property. -### Features ✨ and improvements 🏁 * 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