Skip to content
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
56 changes: 55 additions & 1 deletion example/e2e/firstTest.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

const MAP_LOAD_TIMEOUT = 60000;
const TAP_ATTEMPTS = 3;
const MAX_HIDDEN_PUCK_FRAMES = 10;
const { expect: expectValue } = require('@jest/globals');

async function openExample(groupTitle, exampleTitle) {
async function openExample(groupTitle, exampleTitle, disableSync = false) {
await waitFor(element(by.text(groupTitle)))
.toBeVisible()
.whileElement(by.id('example-list'))
Expand All @@ -13,9 +15,24 @@ async function openExample(groupTitle, exampleTitle) {
.toBeVisible()
.whileElement(by.id('example-list'))
.scroll(50, 'down');
if (disableSync) await device.disableSynchronization();
await element(by.text(exampleTitle)).tap();
}

async function puckStateWhen(predicate) {
const deadline = Date.now() + 15000;
let state;
do {
const attributes = await element(by.id('puck-state')).getAttributes();
state = JSON.parse(attributes.label);
if (predicate(state)) return state;
await new Promise((resolve) => setTimeout(resolve, 250));
} while (Date.now() < deadline);
throw new Error(
`Puck state did not satisfy assertion: ${JSON.stringify(state)}`,
);
}

// A main thread stall between Detox's touch down and up turns a tap into a
// long press, which the map doesn't report as a press, so tap again.
async function tapUntilVisible(target, expected) {
Expand Down Expand Up @@ -45,6 +62,7 @@ describe('Maps Example App', () => {

afterEach(async () => {
await device.reloadReactNative();
await device.enableSynchronization();
});

it('should show initial screen', async () => {
Expand Down Expand Up @@ -74,4 +92,40 @@ describe('Maps Example App', () => {
by.id('location-bubble-latitude'),
);
});

it('should stop hidden puck rendering while preserving camera following', async () => {
await openExample('E2E', 'Location Puck Lifecycle', true);
await waitFor(element(by.id('puck-state')))
.toExist()
.withTimeout(15000);
const initial = await puckStateWhen((state) => state.ready);
// Settle initial rendering, then observe three more location/heading updates.
const before = await puckStateWhen(
(state) => state.locations >= initial.locations + 3,
);
const hidden = await puckStateWhen(
(state) => state.locations >= before.locations + 3,
);
// Allow incidental CI repaints while still rejecting continuous puck rendering.
expectValue(hidden.frames - before.frames).toBeLessThanOrEqual(
MAX_HIDDEN_PUCK_FRAMES,
);

await element(by.id('puck-follow')).tap();
const start = await puckStateWhen((state) => state.follow);
const followed = await puckStateWhen(
(state) => state.locations >= start.locations + 2,
);
expectValue(followed.center[0]).toBeGreaterThan(hidden.center[0]);
expectValue(
Math.abs(followed.center[0] - followed.coordinate[0]),
).toBeLessThan(0.0002);
expectValue(
Math.abs(followed.center[1] - followed.coordinate[1]),
).toBeLessThan(0.0002);
const headingError = Math.abs(
((followed.heading - followed.targetHeading + 540) % 360) - 180,
);
expectValue(headingError).toBeLessThan(20);
});
});
111 changes: 111 additions & 0 deletions example/src/examples/E2E/LocationPuckLifecycle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Text, View } from 'react-native';
import Mapbox from '@rnmapbox/maps';

const origin = [-71.107, 42.337];
const camera = { centerCoordinate: origin, zoomLevel: 16 };
const styleJSON = JSON.stringify({
version: 8,
sources: {},
layers: [
{
id: 'background',
type: 'background',
paint: { 'background-color': '#d9e5da' },
},
],
});
export default function LocationPuckLifecycle() {
const map = useRef(null);
const frames = useRef(0);
const heading = useRef(0);
const ready = useRef(false);
const [follow, setFollow] = useState(false);
const [tick, setTick] = useState(0);
const [state, setState] = useState('{}');
const coordinate = [origin[0] + tick * 0.00004, origin[1]];
const targetHeading = follow ? 90 : (tick * 10) % 360;
const current = useRef(null);
current.current = { follow, locations: tick, coordinate, targetHeading };

useEffect(() => {
const timer = setInterval(() => setTick((value) => value + 1), 1000);
return () => clearInterval(timer);
}, []);

useEffect(() => {
let mounted = true;
const timer = setInterval(async () => {
if (!ready.current || !map.current) return;
const center = await map.current.getCenter();
if (mounted) {
setState(
JSON.stringify({
...current.current,
ready: true,
frames: frames.current,
center,
heading: heading.current,
}),
);
}
}, 500);
return () => {
mounted = false;
clearInterval(timer);
};
}, []);

return (
<View style={{ flex: 1 }}>
<Button
testID="puck-follow"
title="Follow hidden"
onPress={() => setFollow(true)}
/>
<Text
testID="puck-state"
accessibilityLabel={state}
style={{ height: 140 }}
numberOfLines={6}
>
{state}
</Text>
<Mapbox.MapView
ref={map}
testID="puck-map"
style={{ flex: 1 }}
styleJSON={styleJSON}
onDidFinishLoadingMap={() => {
ready.current = true;
}}
onDidFinishRenderingFrameFully={() => {
frames.current += 1;
}}
onCameraChanged={(event) => {
heading.current = event.properties.heading;
}}
>
<Mapbox.Camera
defaultSettings={camera}
followUserLocation={follow}
followUserMode="compass"
followZoomLevel={16}
/>
<Mapbox.CustomLocationProvider
coordinate={coordinate}
heading={targetHeading}
/>
<Mapbox.LocationPuck
visible={false}
puckBearingEnabled
puckBearing="heading"
/>
</Mapbox.MapView>
</View>
);
}

LocationPuckLifecycle.metadata = {
title: 'Location Puck Lifecycle',
};
1 change: 1 addition & 0 deletions example/src/examples/E2E/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { default as MapLoaded } from './MapLoaded';
export { default as LocationPuckLifecycle } from './LocationPuckLifecycle';
export { default as ScreenshotFillLine } from './ScreenshotFillLine';
export { default as ScreenshotCircleSymbol } from './ScreenshotCircleSymbol';
export { default as ScreenshotBasemapStreets } from './ScreenshotBasemapStreets';
Expand Down
12 changes: 2 additions & 10 deletions ios/RNMBX/RNMBXNativeUserLocation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,16 +173,8 @@ public class RNMBXNativeUserLocation: UIView, RNMBXMapAndMapViewComponent {
}

if (!visible) {
let emptyImage = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)).image { _ in }
location.options.puckType = .puck2D(
Puck2DConfiguration(
topImage: emptyImage,
bearingImage: emptyImage,
shadowImage: emptyImage,
scale: Value.constant(1.0)
)
)
return
// Camera following consumes location data independently of puck rendering.
location.options.puckType = nil
} else if let model = model, let configuration = makePuck3DConfiguration(model) {
location.options.puckType = .puck3D(configuration)
} else {
Expand Down
Loading