If you have worked with React-Native in building a ride-hailing/Carpooling app or just any app that would require you to animate a marker(your custom image) on the map by coordinates, then you might have encountered the problems I did while building a carpooling app recently.
My initial set-up required me to use the onUserLocationChange prop on the MapView from react-native-maps to animate the custom marker as illustrated here:
const { operations, models } = useTripsMapScreen({navigation, rider_id });
//In the useTripMapsScreen hook
const animateMarker = (newCoordinate: LatLng) => {
if (Platform.OS == 'android') {
if (animatedMarkerRef.current) {
animatedMarkerRef.current.animateMarkerToCoordinate(newCoordinate, 500);
}
} else {
animatedMarkerCoord
.timing({
duration: 500,
useNativeDriver: true,
latitude: newCoordinate.latitude,
longitude: newCoordinate.longitude,
})
.start();
}
};
const handleUserLocationChange = ({
nativeEvent: {coordinate},
}: UserLocationChangeEvent) => {
const newUserLocation = {
coords: {
latitude: coordinate?.latitude as number,
longitude: coordinate?.longitude as number,
heading: coordinate?.heading ?? 0,
},
};
setUserLocation(newUserLocation);
animateMarker({
latitude: coordinate?.latitude,
longitude: coordinate?.longitude,
});
};
// end of useTripsMapScreen
<StyledMapView
ref={models.mapRef}
showsCompass={false}
showsUserLocation={true}
onUserLocationChange={operations.handleUserLocationChange}
showsMyLocationButton={false}
provider={PROVIDER_GOOGLE}
customMapStyle={mapStyle}>
{renderMapMarkers()}
<Marker.Animated
ref={models.animatedMarkerRef}
coordinate={models.animatedMarkerCoord}>
<Image
source={carmaps}
style={{
width: 40,
height: 40,
transform: [{rotate: `${models.heading}deg`}],
}}
resizeMode="contain"
/>
</Marker.Animated>
<MapViewDirections
origin={models.mapMarkers[0]}
destination={models.mapMarkers[1]}
apikey={GOOGLE_MAPS_API_KEY}
strokeColor={theme?.colors?.screens?.mapScreen?.directionsStroke}
strokeWidth={scale(5)}
onReady={operations.handleMapDirectionsReady}
/>
</StyledMapView>;
This set-up worked perfectly on Android devices, but on iOS devices it behaved very strangely irrespective of whether I used the native driver or not. The marker, in my case a car image in png, would vibrate vigorously as it moved. I just could not get it to move smoothly.
After so much frustration, I had to look for a solution with react-native-reanimated. Under the hood react-native-maps uses the native Animated library, but what I did was to make react-native-maps work with react-native-reanimated.
Firstly, with some help from other devs, I had to create a useAnimatedRegion hook that did the actual animation using Reanimated's withTiming:
import React, {useCallback} from 'react';
import {MapMarker, MapMarkerProps} from 'react-native-maps';
import Animated, {
Easing,
EasingFunction,
EasingFunctionFactory,
SharedValue,
useAnimatedProps,
useAnimatedReaction,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
interface LatLng {
latitude: number;
longitude: number;
longitudeDelta?: number;
latitudeDelta?: number;
}
interface AnimateOptions extends LatLng {
duration?: number;
easing?: EasingFunction | EasingFunctionFactory;
rotation?: number;
callback?: () => void;
}
type MarkerProps = Omit<MapMarkerProps, 'coordinate'> & {
coordinate?: MapMarkerProps['coordinate'];
};
export const AnimatedMarker = Animated.createAnimatedComponent(
MapMarker as React.ComponentClass<MarkerProps>,
);
export const useAnimatedRegion = (
location: Partial<LatLng> = {},
animatedPosition?: SharedValue,
) => {
const latitude = useSharedValue(location.latitude);
const longitude = useSharedValue(location.longitude);
const latitudeDelta = useSharedValue(location.latitudeDelta);
const longitudeDelta = useSharedValue(location.longitudeDelta);
const rotation = useSharedValue(undefined);
const animatedProps = useAnimatedProps(() => ({
coordinate: {
latitude: latitude.value ?? 0,
longitude: longitude.value ?? 0,
latitudeDelta: latitudeDelta.value ?? 0,
longitudeDelta: longitudeDelta.value ?? 0,
rotation: undefined,
},
}));
useAnimatedReaction(
() => {
return {
latitude: latitude.value ?? 0,
longitude: longitude.value ?? 0,
};
},
(result, previous) => {
if (animatedPosition) {
animatedPosition.value = result;
}
},
[],
);
const animate = useCallback(
(options: AnimateOptions) => {
const {duration = 500, easing = Easing.linear} = options;
const animateValue = (
value: SharedValue<number | undefined>,
toValue?: number,
callback?: () => void,
) => {
if (!toValue) {
return;
}
value.value = withTiming(
toValue,
{
duration,
easing,
},
callback,
);
};
animateValue(latitude, options.latitude);
animateValue(longitude, options.longitude, options.callback);
animateValue(latitudeDelta, options?.latitudeDelta);
animateValue(longitudeDelta, options?.longitudeDelta);
//@ts-ignore
animateValue(rotation, options?.rotation);
},
[latitude, longitude, latitudeDelta, longitudeDelta, rotation],
);
return {
props: animatedProps,
animate,
};
};
Next I had to create the actual marker component that took a ref and used the useImperativeHandle hook from react to call the animate function on my png image.
import React, { forwardRef, useImperativeHandle, useRef } from "react";
import { SharedValue } from "react-native-reanimated";
import { AnimatedMarker, useAnimatedRegion } from "./useAnimatedMarker";
import { LatLng } from "react-native-maps";
import { LATITUDE_DELTA, LONGITUDE_DELTA } from "@env";
import { Image } from "react-native";
const carmaps = require("../../../assets/images/carmaps.png");
export interface MovingCarMarkerProps {
defaultLocation: {
latitude: number;
longitude: number;
heading: number;
};
heading?: number;
animatedPosition?: SharedValue;
}
export interface MovingCarMarker {
animateCarToPosition: (
newCoords: LatLng,
speed?: number,
callback?: () => void
) => void;
}
export const MovingCarMarker = forwardRef(
(props: MovingCarMarkerProps, ref) => {
const defaultCarRef = useRef({
latitude: props?.defaultLocation?.latitude,
longitude: props?.defaultLocation?.longitude,
latitudeDelta: LATITUDE_DELTA,
longitudeDelta: LONGITUDE_DELTA,
});
const animatedRegion = useAnimatedRegion(
{
latitude: parseFloat(
defaultCarRef?.current?.latitude
? defaultCarRef?.current?.latitude?.toString()
: "0"
),
longitude: parseFloat(
defaultCarRef?.current?.longitude
? defaultCarRef?.current?.longitude?.toString()
: "0"
),
},
props?.animatedPosition
);
useImperativeHandle(ref, () => ({
animateCarToPosition: (
newCoords: LatLng,
speed?: number,
callback?: () => void
) => {
animatedRegion.animate({
latitude: newCoords?.latitude,
longitude: newCoords?.longitude,
duration: speed || 500,
callback,
});
},
}));
return (
<AnimatedMarker
zIndex={20}
anchor={{ x: 0.5, y: 0.5 }}
rotation={props?.defaultLocation?.heading}
animatedProps={animatedRegion.props}
>
<Image
source={carmaps}
style={{
width: 40,
height: 40
}}
resizeMode="contain"
/>
</AnimatedMarker>
);
}
);
Once I had these two set up, all I had to do was to use my MovingCarMarker component within the styled MapView from react-native-maps:
// in my custom hook
const carMarkerRef = useRef<MovingCarMarker | null>(null);
const handleUserLocationChange = ({
nativeEvent: {coordinate},
}: UserLocationChangeEvent) => {
const newUserLocation = {
coords: {
latitude: coordinate?.latitude as number,
longitude: coordinate?.longitude as number,
heading: coordinate?.heading ?? 0,
},
};
setUserLocation(newUserLocation);
carMarkerRef?.current?.animateCarToPosition({
latitude: newUserLocation?.coords?.latitude,
longitude: newUserLocation?.coords?.longitude,
});
};
// end of custom hook
<StyledMapView
ref={models.mapRef}
showsCompass={false}
showsUserLocation={true}
onUserLocationChange={operations.handleUserLocationChange}
showsMyLocationButton={false}
provider={PROVIDER_GOOGLE}
customMapStyle={mapStyle}
onRegionChangeComplete={operations.onregionChangeComplete}>
{renderMapMarkers()}
<MovingCarMarker
ref={models?.carMarkerRef}
defaultLocation={models?.defaultLocation}
/>
{operations?.returnPolyLine()}
</StyledMapView>;
Note that in the handleUserLocationChange function, I not only use the ref to animate to a new position, I also update the models?.defaultLocation state been passed to the MovingCarMarker as defaultLocation with the new coordinates.
With this set-up your custom marker would animate very smoothly between coordinates in your react-native app. Please let me know if this helps you in any way. Questions and contributions are also very welcome.
Even with this solution, there's still a lot of room for improvement, so feel free to customize the implementation as you deem fit.
Cheers!
Top comments (3)
I noticed you set a default duration of 500ms in useAnimatedRegion. Have you experimented with adjusting the withTiming duration or easing for scenarios with frequent coordinate updates to further optimize smoothness on lower-end devices?
Thank you for the question @onearmycode. Yes I have. 500ms seems to be the most optimal for a local user. As much as it's good to be as inclusive as possible, I also wanted to keep things simple. Trying to conditionally pass the speed based on a device's api level would be quite complex.
In the case of a remote user, it would be best to sync with the frequency of the api that fetches the remote coordinates. So if the api fetches every 10 seconds for example, it would be better to use 10,000ms as the duration. This way the waiting time is not noticed as new coordinates are received once a lap of the 10,000ms animation is completed.
Nice work, will be working on a project that needs this and this article will definitely help me achieve it faster. Hope i can reach out to you if i need an assistance ?