diff --git a/.github/workflows/native-build.yml b/.github/workflows/native-build.yml index ff2fe29..6d2dd53 100644 --- a/.github/workflows/native-build.yml +++ b/.github/workflows/native-build.yml @@ -161,6 +161,24 @@ jobs: CODE_SIGN_IDENTITY="" \ | xcbeautify --renderer github-actions + - name: Run marker drag UI test + if: matrix.provider == 'apple' + run: | + ruby example/scripts/configure-marker-drag-ui-tests.rb example/ios/NitroMapsExample.xcodeproj + simulator_id="$(xcrun simctl list devices available -j | ruby -rjson -e 'devices = JSON.parse(STDIN.read).fetch("devices").values.flatten; device = devices.find { |entry| entry["isAvailable"] && entry["name"].start_with?("iPhone") }; abort "No available iPhone simulator was found." unless device; puts device.fetch("udid")')" + set -o pipefail + NSUnbufferedIO=YES xcodebuild test \ + -workspace example/ios/NitroMapsExample.xcworkspace \ + -scheme NitroMapsExample \ + -configuration Release \ + -destination "platform=iOS Simulator,id=${simulator_id}" \ + -derivedDataPath "$RUNNER_TEMP/MarkerDragUITests" \ + -resultBundlePath "$RUNNER_TEMP/MarkerDragUITests.xcresult" \ + -parallel-testing-enabled NO \ + -only-testing:NitroMapsExampleUITests/MarkerDragUITests/testDraggingPinAndImageMarkersOnlyFiresDragEnd \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" \ + | xcbeautify --renderer github-actions + android: name: Android # Pinned: the Android SDK and NDK contents differ between runner images. diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 155cffd..42a7b19 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -34,6 +34,11 @@ on: - 'package/tsconfig.plugin.json' - 'example/app.config.js' - 'example/app.json' + - 'example/App.tsx' + - 'example/assets/markerred.png' + - 'example/examples/**' + - 'example/ios-ui-tests/**' + - 'example/scripts/**' - 'example/package.json' - 'bun.lock' pull_request: @@ -54,6 +59,11 @@ on: - 'package/tsconfig.plugin.json' - 'example/app.config.js' - 'example/app.json' + - 'example/App.tsx' + - 'example/assets/markerred.png' + - 'example/examples/**' + - 'example/ios-ui-tests/**' + - 'example/scripts/**' - 'example/package.json' - 'bun.lock' diff --git a/example/App.tsx b/example/App.tsx index 074f756..424af21 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -59,6 +59,7 @@ import { createCustomMarkerImagesScenario, createScenarioOverlayProps, CUSTOM_MARKER_IMAGES_SCENARIO_ID, + MARKER_DRAG_REGRESSION_SCENARIO_ID, nextApplePoiDetailPresentation, } from './examples'; @@ -272,6 +273,8 @@ const ScenarioChip = memo(function ScenarioChip({ > + ) : ( + + ); + + if (scenario.id === MARKER_DRAG_REGRESSION_SCENARIO_ID) { + return ( + + {mapView} + ); } - return ; + return mapView; }); type StatusHeaderProps = { @@ -689,6 +701,7 @@ type StatusHeaderProps = { scenarioIndex: number; mapReady: boolean; showsScale: boolean; + verificationStatus?: string; }; const StatusHeader = memo(function StatusHeader({ @@ -696,6 +709,7 @@ const StatusHeader = memo(function StatusHeader({ scenarioIndex, mapReady, showsScale, + verificationStatus, }: StatusHeaderProps) { const badgeScale = useSharedValue(1); @@ -743,6 +757,14 @@ const StatusHeader = memo(function StatusHeader({ + {verificationStatus != null ? ( + + {verificationStatus} + + ) : null} ); @@ -757,6 +779,10 @@ export default function App() { const [animationOptionIndex, setAnimationOptionIndex] = useState(0); const [status, setStatus] = useState('Waiting for map...'); const [mapReady, setMapReady] = useState(false); + const [markerDragEndCounts, setMarkerDragEndCounts] = useState< + Record + >({}); + const [mapLongPressCount, setMapLongPressCount] = useState(0); const [dockExpanded, setDockExpanded] = useState(false); const [customMarkerRotation, setCustomMarkerRotation] = useState(45); const [customMarkerFlat, setCustomMarkerFlat] = useState(true); @@ -788,6 +814,7 @@ export default function App() { const animationOption = ANIMATION_OPTIONS[animationOptionIndex]; const showsScale = scenario.advanced?.showsScale === true; const showsCompass = scenario.advanced?.showsCompass === true; + const markerDragVerificationStatus = `Drag ends: pin=${markerDragEndCounts.pin ?? 0}, image=${markerDragEndCounts.image ?? 0}; long=${mapLongPressCount}; ready=${mapReady}`; const mapPadding = useMemo( () => mergeMapPadding(scenario.advanced?.mapPadding, showsScale, showsCompass), @@ -869,6 +896,8 @@ export default function App() { } setScenarioIndex(index); setMapReady(false); + setMarkerDragEndCounts({}); + setMapLongPressCount(0); setStatus(MAP_SCENARIOS[index].name); }, [scenarioIndex], @@ -900,6 +929,10 @@ export default function App() { const handleMarkerDragEnd = useCallback( (id: string, coordinate: Coordinate) => { + setMarkerDragEndCounts((counts) => ({ + ...counts, + [id]: (counts[id] ?? 0) + 1, + })); setStatus( `${id} → ${coordinate.latitude.toFixed(4)}, ${coordinate.longitude.toFixed(4)}`, ); @@ -963,6 +996,7 @@ export default function App() { }, []); const handleMapLongPress = useCallback((coordinate: Coordinate) => { + setMapLongPressCount((count) => count + 1); setStatus( `Long · ${coordinate.latitude.toFixed(4)}, ${coordinate.longitude.toFixed(4)}`, ); @@ -1010,6 +1044,11 @@ export default function App() { scenarioIndex={scenarioIndex} mapReady={mapReady} showsScale={showsScale} + verificationStatus={ + scenario.id === MARKER_DRAG_REGRESSION_SCENARIO_ID + ? markerDragVerificationStatus + : undefined + } /> &2 + exit 2 +fi + +command -v bun >/dev/null || { echo "bun is required" >&2; exit 2; } +command -v pod >/dev/null || { echo "CocoaPods is required" >&2; exit 2; } +command -v xcodebuild >/dev/null || { echo "Xcode command line tools are required" >&2; exit 2; } + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +bun install +bun run --filter react-native-better-maps build:plugin +bun run nitrogen + +( + cd example + bunx expo prebuild --platform ios --no-install + cd ios + pod install +) + +ruby example/scripts/configure-marker-drag-ui-tests.rb example/ios/NitroMapsExample.xcodeproj + +SIMULATOR_ID="$(xcrun simctl list devices available -j | ruby -rjson -e ' +devices = JSON.parse(STDIN.read).fetch("devices").values.flatten +device = devices.find { |entry| entry["isAvailable"] && entry["name"].start_with?("iPhone") } +abort "No available iPhone simulator was found." unless device +puts device.fetch("udid") +')" + +DESTINATION="platform=iOS Simulator,id=${SIMULATOR_ID}" +DERIVED_DATA_PATH="${DERIVED_DATA_PATH:-$ROOT/.tmp/marker-drag-ui-tests}" +RESULT_BUNDLE_PATH="${RESULT_BUNDLE_PATH:-$ROOT/.tmp/marker-drag-ui-tests.xcresult}" +rm -rf "$RESULT_BUNDLE_PATH" + +set -o pipefail +if command -v xcbeautify >/dev/null; then + NSUnbufferedIO=YES xcodebuild test \ + -workspace example/ios/NitroMapsExample.xcworkspace \ + -scheme NitroMapsExample \ + -configuration Release \ + -destination "$DESTINATION" \ + -derivedDataPath "$DERIVED_DATA_PATH" \ + -resultBundlePath "$RESULT_BUNDLE_PATH" \ + -parallel-testing-enabled NO \ + -only-testing:NitroMapsExampleUITests/MarkerDragUITests/testDraggingPinAndImageMarkersOnlyFiresDragEnd \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" \ + | xcbeautify --renderer github-actions +else + xcodebuild test \ + -workspace example/ios/NitroMapsExample.xcworkspace \ + -scheme NitroMapsExample \ + -configuration Release \ + -destination "$DESTINATION" \ + -derivedDataPath "$DERIVED_DATA_PATH" \ + -resultBundlePath "$RESULT_BUNDLE_PATH" \ + -parallel-testing-enabled NO \ + -only-testing:NitroMapsExampleUITests/MarkerDragUITests/testDraggingPinAndImageMarkersOnlyFiresDragEnd \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" +fi diff --git a/package/ios/HybridMapViewDelegate.swift b/package/ios/HybridMapViewDelegate.swift index 7e68e23..f929a3f 100644 --- a/package/ios/HybridMapViewDelegate.swift +++ b/package/ios/HybridMapViewDelegate.swift @@ -7,6 +7,15 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni private var handledMapFeatureSelections = Set() private var shouldSuppressNextBackgroundPress = false + private let hitTest: (UIView, CGPoint) -> UIView? + + init(hitTest: @escaping (UIView, CGPoint) -> UIView? = { view, point in + view.hitTest(point, with: nil) + }) { + self.hitTest = hitTest + super.init() + } + func installGestureRecognizers(on mapView: MKMapView) { let tapRecognizer = UITapGestureRecognizer( target: self, @@ -61,6 +70,21 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni parent.notifyLongPress(at: point) } + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch + ) -> Bool { + guard gestureRecognizer is UILongPressGestureRecognizer, let parent else { + return true + } + + return shouldReceiveLongPress(at: touch.location(in: parent.view), in: parent.view) + } + + func shouldReceiveLongPress(at point: CGPoint, in view: UIView) -> Bool { + !isAnnotationView(at: point, in: view) + } + func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer @@ -68,8 +92,8 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni true } - private func isAnnotationView(at point: CGPoint, in mapView: MKMapView) -> Bool { - var view: UIView? = mapView.hitTest(point, with: nil) + private func isAnnotationView(at point: CGPoint, in view: UIView) -> Bool { + var view: UIView? = hitTest(view, point) while let current = view { if current is MKAnnotationView { return true diff --git a/package/ios/NitroImageAnnotationView.swift b/package/ios/NitroImageAnnotationView.swift index ab01643..4a9ab20 100644 --- a/package/ios/NitroImageAnnotationView.swift +++ b/package/ios/NitroImageAnnotationView.swift @@ -21,6 +21,9 @@ final class NitroImageAnnotationView: MKAnnotationView { func configure(for marker: MapMarkerAnnotation) { annotation = marker + isAccessibilityElement = true + accessibilityIdentifier = "react-native-better-maps.marker.\(marker.id)" + accessibilityLabel = marker.title ?? marker.id isDraggable = marker.draggable canShowCallout = marker.title != nil || marker.subtitle != nil alpha = marker.opacity diff --git a/package/ios/NitroPinAnnotationView.swift b/package/ios/NitroPinAnnotationView.swift index b317b7f..999abd7 100644 --- a/package/ios/NitroPinAnnotationView.swift +++ b/package/ios/NitroPinAnnotationView.swift @@ -24,6 +24,9 @@ final class NitroPinAnnotationView: MKMarkerAnnotationView { alpha = 1 transform = .identity annotation = marker + isAccessibilityElement = true + accessibilityIdentifier = "react-native-better-maps.marker.\(marker.id)" + accessibilityLabel = marker.title ?? marker.id animatesWhenAdded = marker.enteringAnimation.kind == .system isDraggable = marker.draggable canShowCallout = marker.title != nil || marker.subtitle != nil diff --git a/package/iosTests/HybridMapViewDelegateTests.swift b/package/iosTests/HybridMapViewDelegateTests.swift new file mode 100644 index 0000000..00e8c64 --- /dev/null +++ b/package/iosTests/HybridMapViewDelegateTests.swift @@ -0,0 +1,36 @@ +import MapKit +import UIKit +import XCTest + +@testable import NitroMaps + +final class HybridMapViewDelegateTests: XCTestCase { + func testRejectsLongPressWhenTouchHitsAnAnnotationView() { + let annotationView = MKAnnotationView(annotation: nil, reuseIdentifier: nil) + let delegate = HybridMapViewDelegate(hitTest: { _, _ in annotationView }) + + XCTAssertFalse(delegate.shouldReceiveLongPress(at: .zero, in: UIView())) + } + + func testRejectsLongPressWhenTouchHitsSubviewInsideAnnotationView() { + let annotationView = MKAnnotationView(annotation: nil, reuseIdentifier: nil) + let nestedView = UIView() + annotationView.addSubview(nestedView) + let delegate = HybridMapViewDelegate(hitTest: { _, _ in nestedView }) + + XCTAssertFalse(delegate.shouldReceiveLongPress(at: .zero, in: UIView())) + } + + func testAllowsLongPressWhenTouchHitsOrdinaryView() { + let backgroundView = UIView() + let delegate = HybridMapViewDelegate(hitTest: { _, _ in backgroundView }) + + XCTAssertTrue(delegate.shouldReceiveLongPress(at: .zero, in: UIView())) + } + + func testAllowsLongPressWhenHitTestReturnsNoView() { + let delegate = HybridMapViewDelegate(hitTest: { _, _ in nil }) + + XCTAssertTrue(delegate.shouldReceiveLongPress(at: .zero, in: UIView())) + } +}