Animating Custom Marker Motion On React-Native-Maps With Reanimated



This content originally appeared on DEV Community and was authored by Victor Olufade

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!


This content originally appeared on DEV Community and was authored by Victor Olufade