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
15 changes: 15 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@ Some notes about example app:

The project uses Mapbox Maps SDK v11 and React Native's New Architecture (Fabric/TurboModules) exclusively.

### iOS long-press camera regression

Install the example app on a booted iOS simulator, then run the native UI suite from the repository root. For a debug app, keep Metro running and set its host below; omit `TEST_RUNNER_RNMBX_METRO_HOST` for an app with a bundled JavaScript build. Replace `<simulator-udid>` with the installed app's simulator ID from `xcrun simctl list devices booted`.

```sh
TEST_RUNNER_RNMBX_METRO_HOST=localhost:8081 xcodebuild test \
-project example/ios/RNMapboxExample.xcodeproj \
-scheme LongPressCamera \
-destination 'platform=iOS Simulator,id=<simulator-udid>' \
-parallel-testing-enabled NO \
CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual
```

The scheme builds only the UI test runner and launches the installed example app. It opens **Camera > Long Press Camera** and checks `flyTo`, eased and immediate `setCamera`, `moveBy`, and `scaleBy` using native held-finger drift, camera readings after release, and panning on the next touch. This suite runs separately from `yarn test` and the Detox screenshot suite.

### Running example app for Web

```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ class CameraUpdateQueue {
}
val stop = mQueue.poll() ?: return
val item = stop.toCameraUpdate(map!!)
map.cancelLongPressForCameraUpdate()
item.run()
execute(map)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ class RNMBXCameraModule(context: ReactApplicationContext, val viewTagResolver: V
withViewportOnUIThread(viewRef, promise) {
it.mapboxMap?.let { map ->
val animationOptions = getAnimationOptions(animationMode, animationDuration)
it.withMapView { mapView -> mapView.cancelLongPressForCameraUpdate() }
map.moveBy(ScreenCoordinate(x, y), animationOptions)

promise.resolve(null)
Expand All @@ -106,6 +107,7 @@ class RNMBXCameraModule(context: ReactApplicationContext, val viewTagResolver: V
val animationOptions =
getAnimationOptions(animationMode, animationDuration)

it.withMapView { mapView -> mapView.cancelLongPressForCameraUpdate() }
map.scaleBy(scaleFactor, ScreenCoordinate(x, y), animationOptions)

promise.resolve(null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import android.content.Context
import android.graphics.BitmapFactory
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.View.OnLayoutChangeListener
import android.view.ViewGroup
Expand Down Expand Up @@ -50,6 +52,7 @@ import com.rnmapbox.rnmbx.components.location.LocationComponentManager
import com.rnmapbox.rnmbx.components.location.RNMBXNativeUserLocation
import com.rnmapbox.rnmbx.components.mapview.helpers.CameraChangeReason
import com.rnmapbox.rnmbx.components.mapview.helpers.CameraChangeTracker
import com.rnmapbox.rnmbx.components.mapview.helpers.LongPressCameraGesture
import com.rnmapbox.rnmbx.components.styles.layers.RNMBXLayer
import com.rnmapbox.rnmbx.components.styles.light.RNMBXLight
import com.rnmapbox.rnmbx.components.styles.sources.RNMBXSource
Expand Down Expand Up @@ -179,6 +182,24 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie
private var mStyleURL: String? = null
val isDestroyed = false
private var mCamera: RNMBXCamera? = null
private val longPressCameraGesture = LongPressCameraGesture()

override fun dispatchTouchEvent(event: MotionEvent): Boolean {
if (longPressCameraGesture.onTouch(event.actionMasked)) return true
return super.dispatchTouchEvent(event)
}

fun cancelLongPressForCameraUpdate() {
if (!longPressCameraGesture.takeCameraControl()) return
val now = SystemClock.uptimeMillis()
val cancel = MotionEvent.obtain(now, now, MotionEvent.ACTION_CANCEL, 0f, 0f, 0)
try {
// Cancel the SDK gesture, then consume its remaining touches so panning cannot restart.
super.dispatchTouchEvent(cancel)
} finally {
cancel.recycle()
}
}
private val mFeatures = mutableListOf<FeatureEntry>()
private var mQueuedFeatures: MutableList<AbstractMapFeature>? = ArrayList()
private val mCameraChangeTracker = CameraChangeTracker()
Expand Down Expand Up @@ -812,6 +833,7 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie
if (screenPointPx != null) {
val screenPointDp = toDp(screenPointPx)
val event = MapClickEvent(_this, LatLng(point), screenPointDp, EventTypes.MAP_LONG_CLICK)
longPressCameraGesture.onLongPress()
mManager.handleEvent(event)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.rnmapbox.rnmbx.components.mapview.helpers

import android.view.MotionEvent

internal class LongPressCameraGesture {
private var touchActive = false
private var longPressActive = false
private var cameraOwnsTouch = false

fun onTouch(action: Int): Boolean {
if (action == MotionEvent.ACTION_DOWN) {
touchActive = true
longPressActive = false
cameraOwnsTouch = false
}
val consume = cameraOwnsTouch
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
touchActive = false
longPressActive = false
cameraOwnsTouch = false
}
return consume
}

fun onLongPress() {
longPressActive = touchActive
}

fun takeCameraControl(): Boolean {
if (!longPressActive || cameraOwnsTouch) return false
cameraOwnsTouch = true
return true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.rnmapbox.rnmbx.components.camera

import com.rnmapbox.rnmbx.components.mapview.RNMBXMapView
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import org.mockito.kotlin.*

class CameraUpdateQueueTest {
@Test
fun `camera update cancels active long press before moving camera`() {
val map = mock<RNMBXMapView>()
val update = mock<CameraUpdateItem>()
val stop = mock<CameraStop> {
on { toCameraUpdate(map) } doReturn update
}
val queue = CameraUpdateQueue()
queue.offer(stop)

queue.execute(map)

inOrder(map, update) {
verify(map).cancelLongPressForCameraUpdate()
verify(update).run()
}
}

@Test
fun `empty queue leaves gestures alone`() {
val map = mock<RNMBXMapView>()
CameraUpdateQueue().execute(map)
verifyNoInteractions(map)
}

@Test
fun `invalid camera update does not cancel gestures`() {
val map = mock<RNMBXMapView>()
val stop = mock<CameraStop> {
on { toCameraUpdate(map) } doThrow IllegalArgumentException("Invalid coordinate")
}
val queue = CameraUpdateQueue()
queue.offer(stop)

assertThrows(IllegalArgumentException::class.java) { queue.execute(map) }
verifyNoInteractions(map)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.rnmapbox.rnmbx.components.mapview.helpers

import android.view.MotionEvent
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test

class LongPressCameraGestureTest {
@Test
fun `camera command during long press consumes remaining touch sequence`() {
val gesture = LongPressCameraGesture()
assertFalse(gesture.onTouch(MotionEvent.ACTION_DOWN))
gesture.onLongPress()
assertTrue(gesture.takeCameraControl())
assertFalse(gesture.takeCameraControl())
assertTrue(gesture.onTouch(MotionEvent.ACTION_MOVE))
assertTrue(gesture.onTouch(MotionEvent.ACTION_POINTER_DOWN))
assertTrue(gesture.onTouch(MotionEvent.ACTION_POINTER_UP))
assertTrue(gesture.onTouch(MotionEvent.ACTION_UP))
assertFalse(gesture.onTouch(MotionEvent.ACTION_DOWN))
assertFalse(gesture.onTouch(MotionEvent.ACTION_MOVE))
}

@Test
fun `long press without camera command still pans`() {
val gesture = LongPressCameraGesture()
gesture.onTouch(MotionEvent.ACTION_DOWN)
gesture.onLongPress()
assertFalse(gesture.onTouch(MotionEvent.ACTION_MOVE))
assertFalse(gesture.onTouch(MotionEvent.ACTION_UP))
assertFalse(gesture.takeCameraControl())
}

@Test
fun `ordinary touch and late long press do not cancel gestures`() {
val gesture = LongPressCameraGesture()
gesture.onTouch(MotionEvent.ACTION_DOWN)
assertFalse(gesture.takeCameraControl())
gesture.onTouch(MotionEvent.ACTION_UP)
gesture.onLongPress()
assertFalse(gesture.takeCameraControl())
gesture.onTouch(MotionEvent.ACTION_DOWN)
assertFalse(gesture.takeCameraControl())
}

@Test
fun `touch cancellation releases camera control`() {
val gesture = LongPressCameraGesture()
gesture.onTouch(MotionEvent.ACTION_DOWN)
gesture.onLongPress()
gesture.takeCameraControl()
assertTrue(gesture.onTouch(MotionEvent.ACTION_CANCEL))
assertFalse(gesture.takeCameraControl())
assertFalse(gesture.onTouch(MotionEvent.ACTION_DOWN))
gesture.onLongPress()
assertTrue(gesture.takeCameraControl())
}
}
2 changes: 1 addition & 1 deletion docs/MapView.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ func
Map long press listener, called when a user long presses the map.
*signature:*`(feature:GeoJSON.Feature) =&gt; void`

[Screen Coordinates](../examples/Map/ScreenCoordinates)
[Screen Coordinates](../examples/Map/ScreenCoordinates), [Long Press Camera](../examples/Camera/LongPress)

### onRegionWillChange

Expand Down
14 changes: 14 additions & 0 deletions docs/examples.json
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,20 @@
"fullPath": "example/src/examples/Camera/GetZoom.js",
"relPath": "Camera/GetZoom.js",
"name": "GetZoom"
},
{
"metadata": {
"title": "Long Press Camera",
"tags": [
"Camera",
"MapView#onLongPress"
],
"docs": "Move the camera during a held long press, then lift and pan again.",
"disableSync": true
},
"fullPath": "example/src/examples/Camera/LongPress.js",
"relPath": "Camera/LongPress.js",
"name": "LongPress"
}
]
}
Expand Down
113 changes: 113 additions & 0 deletions example/e2e/LongPressCameraTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import XCTest

final class LongPressCameraTests: XCTestCase {
private let app = XCUIApplication(bundleIdentifier: "org.reactjs.native.example.RNMapboxExample")
private let initialCenter = [-0.15, 51.51]

private struct CameraState: Decodable {
let target: [Double]?
let center: [Double]
let zoom: Double
let gestureActive: Bool
}

override func setUpWithError() throws {
continueAfterFailure = false
app.launchArguments = []
if let host = ProcessInfo.processInfo.environment["RNMBX_METRO_HOST"], !host.isEmpty {
app.launchArguments = ["-RCT_jsLocation", host]
}
XCUIDevice.shared.orientation = .portrait
app.launch()
let result = app.staticTexts["long-press-result"]
if !result.waitForExistence(timeout: 5) {
let camera = app.otherElements.matching(NSPredicate(format: "label BEGINSWITH %@", "Camera,")).firstMatch
XCTAssertTrue(camera.waitForExistence(timeout: 60), app.debugDescription)
camera.tap()
let example = app.otherElements.matching(NSPredicate(format: "label BEGINSWITH %@", "Long Press Camera,")).firstMatch
for _ in 0..<10 {
if example.isHittable { break }
app.swipeUp()
}
XCTAssertTrue(example.isHittable, app.debugDescription)
example.tap()
}
XCTAssertTrue(result.waitForExistence(timeout: 30), app.debugDescription)
}

func testFlyTo() throws { try checkCamera("flyTo") }
func testEaseTo() throws { try checkCamera("easeTo") }
func testMoveTo() throws { try checkCamera("moveTo") }
func testMoveBy() throws { try checkCamera("moveBy") }
func testScaleBy() throws { try checkCamera("scaleBy") }

private func readState() throws -> CameraState {
let result = app.staticTexts["long-press-result"]
XCTAssertTrue(result.waitForExistence(timeout: 10))
let data = try XCTUnwrap(result.label.data(using: .utf8), result.label)
let state = try JSONDecoder().decode(CameraState.self, from: data)
XCTAssertEqual(state.center.count, 2)
return state
}

private func distance(_ a: [Double], _ b: [Double]) -> Double {
zip(a, b).map { abs($0 - $1) }.max()!
}

private func hold(_ start: XCUICoordinate, drift: CGFloat) throws -> CameraState {
start.press(
forDuration: 1.5,
thenDragTo: start.withOffset(CGVector(dx: drift, dy: 0)),
withVelocity: XCUIGestureVelocity(rawValue: 8),
thenHoldForDuration: 3
)
// The example samples 2.2 seconds after its long-press callback, before release.
return try readState()
}

private func checkCamera(_ mode: String) throws {
let modeButton = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Mode: ")).firstMatch
for _ in 0..<6 {
if modeButton.label == "Mode: \(mode)" { break }
modeButton.tap()
}
XCTAssertEqual(modeButton.label, "Mode: \(mode)")
let map = app.descendants(matching: .any)["long-press-map"].firstMatch
XCTAssertTrue(map.waitForExistence(timeout: 10))
let start = map.coordinate(withNormalizedOffset: CGVector(dx: 0.75, dy: 0.65))

// Relative destinations come from a stationary run of the same camera command.
var stationary: CameraState?
if mode == "moveBy" || mode == "scaleBy" {
app.buttons["Reset camera"].tap()
stationary = try hold(start, drift: 0)
if mode == "moveBy" {
XCTAssertGreaterThan(distance(stationary!.center, initialCenter), 0.00001)
}
XCTAssertEqual(stationary!.zoom, mode == "scaleBy" ? 17 : 16, accuracy: 0.0001)
}

app.buttons["Reset camera"].tap()
let held = try hold(start, drift: 8)
let expected = try XCTUnwrap(stationary?.center ?? held.target)
XCTAssertEqual(expected.count, 2)
XCTAssertLessThan(distance(held.center, expected), 0.000001)
XCTAssertEqual(held.zoom, mode == "scaleBy" ? 17 : 16, accuracy: 0.0001)
XCTAssertFalse(held.gestureActive)

app.buttons["Read camera"].tap()
let released = try readState()
XCTAssertLessThan(distance(released.center, expected), 0.000001)
XCTAssertEqual(released.zoom, held.zoom, accuracy: 0.0001)
print("IOS_LONG_PRESS \(mode) held=\(held.center) released=\(released.center) zoom=\(held.zoom)")

start.press(
forDuration: 0,
thenDragTo: start.withOffset(CGVector(dx: -80, dy: 0)),
withVelocity: .slow,
thenHoldForDuration: 0
)
app.buttons["Read camera"].tap()
XCTAssertGreaterThan(distance(try readState().center, released.center), 0.00001)
}
}
Loading