Repository navigation
feat: move markers along paths natively - #110
Conversation
Marker takes a `path` (keyframe coordinates, times, optional rotations, a wall-clock start time, loop) and native code moves it: Core Animation keyframes on Google Maps for iOS, a frame timer on Apple Maps and Android, and requestAnimationFrame on web. Each segment turns the short way. `pathPositionAt` gives the same position in JS. MapView takes `idleFrameRate`: on Google Maps for iOS the map renders at 60, 30 or 15 fps while the camera is still and at full rate while it moves; on Apple Maps and Android it sets how often path markers move.
- path: duration (constant speed) or times, optional startTime - rotateAlongPath, onPathProgress, transitionDuration built on path - Drop pathPositionAt export; idleFrameRate 'power-save' - Skip icon re-rasterize on position-only updates (iOS) - Only set changed Google marker values (iOS, Android)
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
erickreutz
left a comment
There was a problem hiding this comment.
Ran this branch in an app with 24 path markers that use custom times and rotations, plus idleFrameRate:
- iPhone Air, Release, Google Maps: works. With the camera still, app CPU was 20.1% plus 10.2% for backboardd, against 19.6–19.8% plus 8.8–9.0% on
eric/marker-path-prototype. That's one 30 s run, so no change. - Android emulator (API 36), debug: works. Markers move, with no JS errors or crashes in logcat.
- Web (production export): works, with no console errors.
The app needed two changes: 'power-save', and the typing note on Marker.types.ts.
| now = Date.now() | ||
| ): NativeMarkerPath | undefined { | ||
| if (path) { | ||
| if (path !== this.source || rotateAlongPath !== this.rotateAlongPath) { |
There was a problem hiding this comment.
[P1] A new path object restarts the marker, because the default startTime is stamped whenever the object changes.
The example in the PR description does this: path={{ coordinates: route, duration: 60_000, loop: true }} with onPathProgress={(e) => setLeg(e.nativeEvent.index)}. When the marker passes keyframe 1, setLeg(1) re-renders the parent. The inline object is new, so resolvePath stamps startTime = now, and the marker jumps back to keyframe 0. The restart emits index 0, and the cycle repeats at every pass, so the marker never gets past its second point. I reproduced it with this file: resolving the same route again 31 s in puts the marker back at the first keyframe.
The docs do say to keep the object stable, but inline objects are the normal React habit, and nothing warns when it goes wrong. Smallest fix: compare the fields instead of the wrapper object, so a stable route inside an inline object keeps its start time.
const samePath = (a: MarkerPath, b?: MarkerPath) =>
!!b &&
a.coordinates === b.coordinates &&
a.times === b.times &&
a.duration === b.duration &&
a.rotations === b.rotations &&
a.startTime === b.startTime &&
a.loop === b.loop;|
|
||
| - (void)markerViewDidUpdate:(LuggMarkerView *)markerView { | ||
| [self syncMarkerView:markerView]; | ||
| [self syncMarkerView:markerView restyle:markerView.styleChanged]; |
There was a problem hiding this comment.
[P2] On Google Maps, skipping the icon redraw on position-only updates measured worse on a real phone.
I tried the same change on an iPhone Air in Release. There were 24 markers moved by Reanimated at 30 Hz, the icon was re-created only when the scale changed, and rotation was set only when it changed. App CPU went from 47–51% to 63%, and backboardd went from 23–26% to 21%. Time Profiler showed the map drawing about 2.4× as much without a new icon on each update, and CATransaction setDisableActions: only partly helped. It paid off only together with preferredFrameRate = kGMSFrameRatePowerSave (38% plus 10%).
The 78% → 47% in the description is Apple Maps on the simulator in Debug. Path markers don't hit this, since they get no position updates. Any marker moved by Reanimated or JS does, such as a live-tracking marker. Can we keep the gate for Apple Maps only, or measure Google on a device in Release before merging?
| const fromRotation = at?.rotation ?? last.rotate; | ||
| const to = { latitude, longitude }; | ||
| const moved = from.latitude !== latitude || from.longitude !== longitude; | ||
| const toRotation = !rotateAlongPath |
There was a problem hiding this comment.
[P2] With rotateAlongPath, any move turns the marker toward the new fix, however small. A parked vehicle's GPS fixes drift a few meters, so the marker spins to a new heading on every fix. The docs suggest this combination when fixes have no course, which is when it shows up most. The tracking demo on eric/marker-path-prototype hit the same thing, which is why it turned to the route's course instead of toward the fix.
Smallest fix: keep fromRotation unless the move is longer than a few meters.
| * A route that a marker follows natively, with no React renders or prop updates per frame. | ||
| * Time it with `duration`, or with `times` for exact timing. | ||
| */ | ||
| export type MarkerPath = MarkerPathBase & |
There was a problem hiding this comment.
[P3] With this union, code that builds a path with times and reads path.times or path.startTime back gets undefined in the type. A helper typed (): MarkerPath that tests its keyframes stopped typechecking on this branch. It now returns MarkerPath & { times: number[]; startTime: number }. A line in the docs, or an exported timed variant, would save the next person the detour.
|
Thanks for the review and the device runs. Here is what we're doing with each point. The changes for 1, 3 and 4 are pushed in 0738957. [P1] A new [P2] [P3] The [P2] Google iOS icon redraw: we're keeping the skip. I measured both builds in Release on an iPhone 17 Pro Max simulator, alternating them. The camera was still, and CPU is % of one core (app + backboardd):
Time Profiler, Google, Reanimated, 50 markers, main thread:
I see the same thing you saw: the map draws about 2× as much when we skip the icon. But re-creating the icon fills the main thread, and the run loop goes idle less than half as often. I think the extra drawing is the map rendering more frames, not more work per frame. Side by side, the build that skips the icon is clearly smoother. Confirmed on a device: we installed both Release builds side by side on an iPhone 17 Pro Max (Google Maps, Reanimated fleet), and the build that re-creates the icon on each update is visibly laggy. The build that skips it is smooth. I couldn't get clean CPU numbers on the phone, because it reached a "Serious" thermal state during the automated runs. |
Summary
Moves markers natively, without a JS update on every frame, so large fleets and live GPS tracking stay smooth even while JS is busy (for example while the map is being dragged). Builds on @erickreutz's native path engine (
cfc53a5,ca01ccf) and simplifies the public API on top of it (46c87fa).Marker
path:{ coordinates, duration | times, rotations?, startTime?, loop? }. Withduration, time is spread by distance, so the marker moves at a constant speed.startTimedefaults to when the path is set.rotateAlongPath: faces the direction of travel and settles each turn within 300 ms of the corner.transitionDuration: each newcoordinateglides from where the marker is now. It's built as a two-pointpath.onPathProgress: fires when the marker passes each keyframe, and once when the path starts. It runs on a JS timer, one timeout per keyframe.MapView
idleFrameRate:'maximum' | 'conservative' | 'power-save'. Lowers the redraw rate while the camera is still.Performance fixes
Linear: ENG-1719
Type of Change
Test Plan
All on the iOS simulator and Android emulator, Debug builds.
src/__tests__/markerPath.test.tscovers keyframe resolution, distance timing, turn keyframes, transitions from mid-move, and progress events.pathScreenshots / Videos
TODO
Checklist