Animating map region with Animated API

Use the react-native-maps package with Animated API

This is a rewrite using functional components and PanGesture API of the example provided in the react-native-maps. I couldn't get the example provided in the package to properly work because it uses a custom pan controller.

The example we'll reproduce using hooks (functional component)

If you already have a React Native project set up, you can skip this section.

Setting up the project

Start by creating a react native project
Add react-native-maps to your project
Run yarn add react-native-maps and please follow these instructions to get it to properly work in your project.

Implementation

Create a file named useAnimatedRegion.tsx and place in this content

import { useEffect, useState, useMemo } from 'react';
import { Animated, Dimensions } from 'react-native';
import { AnimatedRegion, Region } from 'react-native-maps';

const screen = Dimensions.get('window');

const ASPECT_RATIO = screen.width / screen.height;
const LATITUDE_DELTA = 0.0922;
const LONGITUDE_DELTA = LATITUDE_DELTA * ASPECT_RATIO;

const ITEM_SPACING = 10;
const ITEM_PREVIEW = 10;
const ITEM_WIDTH = screen.width - 2 * ITEM_SPACING - 2 * ITEM_PREVIEW;
const SNAP_WIDTH = ITEM_WIDTH + ITEM_SPACING;
const BREAKPOINT1 = 246;

export interface MarkerItem {
  id: number;
  amount: number;
  coordinate: {
    latitude: number;
    longitude: number;
  };
}

export interface AnimatedMapState {
  panX: Animated.Value;
  panY: Animated.Value;
  index: number;
  canMoveHorizontal: boolean;
  scrollY: Animated.AnimatedInterpolation;
  scrollX: Animated.AnimatedInterpolation;
  scale: Animated.AnimatedInterpolation;
  translateY: Animated.AnimatedInterpolation;
  markers: MarkerItem[];
  region: AnimatedRegion;
}

export const useAnimatedRegion = (
  initialRegion: Region,
  displayedMarkers: any,
) => {
  const initialState = useMemo(() => {
    const panX = new Animated.Value(0);
    const panY = new Animated.Value(0);

    const scrollY = panY.interpolate({
      inputRange: [-1, 1],
      outputRange: [1, -1],
    });

    const scrollX = panX.interpolate({
      inputRange: [-1, 1],
      outputRange: [1, -1],
    });

    const scale = scrollY.interpolate({
      inputRange: [0, BREAKPOINT1],
      outputRange: [1, 1.6],
      extrapolate: 'clamp',
    });

    const translateY = scrollY.interpolate({
      inputRange: [0, BREAKPOINT1],
      outputRange: [0, -100],
      extrapolate: 'clamp',
    });

    return {
      panX,
      panY,
      index: 0,
      canMoveHorizontal: true,
      scrollY,
      scrollX,
      scale,
      translateY,
      markers: displayedMarkers,
      region: new AnimatedRegion(initialRegion),
    };
  }, []);

  const [state, setState] = useState<AnimatedMapState>(initialState);

  const setListeners = () => {
    const { region, panX, panY, scrollX, markers } = state;

    panX.addListener(onPanXChange);
    panY.addListener(onPanYChange);

    region.stopAnimation(() => {});
    region
      .timing({
        latitude: scrollX.interpolate({
          inputRange: markers.map((_m: any, i: any) => i * SNAP_WIDTH),
          outputRange: markers.map((m: any) => m.coordinate.latitude),
        }) as unknown as number,
        longitude: scrollX.interpolate({
          inputRange: markers.map((_m: any, i: any) => i * SNAP_WIDTH),
          outputRange: markers.map((m: any) => m.coordinate.longitude),
        }) as unknown as number,
        useNativeDriver: false,
        duration: 0,
        toValue: 0,
        latitudeDelta: LATITUDE_DELTA,
        longitudeDelta: LONGITUDE_DELTA,
      })
      .start();
  };

  const onPanXChange = ({ value }: any) => {
    const { index } = state;
    const newIndex = Math.floor((-1 * value + SNAP_WIDTH / 2) / SNAP_WIDTH);
    if (index !== newIndex) {
      setState({ ...state, index: newIndex });
    }
  };

  const onPanYChange = ({ value }: any) => {
    const { canMoveHorizontal, region, scrollY, scrollX, markers, index } =
      state;
    const shouldBeMovable = Math.abs(value) < 2;
    if (shouldBeMovable !== canMoveHorizontal) {
      setState({ ...state, canMoveHorizontal: shouldBeMovable });
      if (!shouldBeMovable) {
        const { coordinate } = markers[index];
        region.stopAnimation(() => {});
        region
          .timing({
            latitude: scrollY.interpolate({
              inputRange: [0, BREAKPOINT1],
              outputRange: [
                coordinate.latitude,
                coordinate.latitude - LATITUDE_DELTA * 0.5 * 0.375,
              ],
              extrapolate: 'clamp',
            }) as unknown as number,
            latitudeDelta: scrollY.interpolate({
              inputRange: [0, BREAKPOINT1],
              outputRange: [LATITUDE_DELTA, LATITUDE_DELTA * 0.5],
              extrapolate: 'clamp',
            }) as unknown as number,
            longitudeDelta: scrollY.interpolate({
              inputRange: [0, BREAKPOINT1],
              outputRange: [LONGITUDE_DELTA, LONGITUDE_DELTA * 0.5],
              extrapolate: 'clamp',
            }) as unknown as number,
            useNativeDriver: false,
            duration: 0,
            toValue: 0,
            longitude: coordinate.longitude,
          })
          .start();
      } else {
        region.stopAnimation(() => {});
        region
          .timing({
            latitude: scrollX.interpolate({
              inputRange: markers.map((_m: any, i: any) => i * SNAP_WIDTH),
              outputRange: markers.map((m: any) => m.coordinate.latitude),
            }) as unknown as number,
            longitude: scrollX.interpolate({
              inputRange: markers.map((_m: any, i: any) => i * SNAP_WIDTH),
              outputRange: markers.map((m: any) => m.coordinate.longitude),
            }) as unknown as number,
            useNativeDriver: false,
            duration: 0,
            toValue: 0,
            latitudeDelta: region.latitudeDelta,
            longitudeDelta: region.longitudeDelta,
          })
          .start();
      }
    }
  };
  useEffect(() => {
    setListeners();
  }, []);

  return state;
};

This hook helps handle all the region update business. The whole feature is happening in the onPanYChange function. This adjusts the latitude and deltas with the panY is changing (when the user is scrolling up). Those adjustments give the feeling that the map is zooming in. The interaction is smooth because internally the AnimatedRegion applies a timing of 1 millisecond.

Create a file named AnimatedViews.tsx file with this content

// AnimatedViews.tsx
import React, { useMemo, useRef, useState } from 'react';
import {
  StyleSheet,
  Dimensions,
  Animated,
  Text,
  PanResponder,
  View,
} from 'react-native';

import {
  Animated as AnimatedMap,
  Marker,
  PROVIDER_GOOGLE,
  Region,
} from 'react-native-maps';

import { useAnimatedRegion } from './useAnimatedRegion';

const screen = Dimensions.get('window');

const ASPECT_RATIO = screen.width / screen.height;
const LATITUDE = 37.78825;
const LONGITUDE = -122.4324;
const LATITUDE_DELTA = 0.0922;
const LONGITUDE_DELTA = LATITUDE_DELTA * ASPECT_RATIO;

const ITEM_SPACING = 10;
const ITEM_PREVIEW = 10;
const ITEM_WIDTH = screen.width - 2 * ITEM_SPACING - 2 * ITEM_PREVIEW;
const ITEM_PREVIEW_HEIGHT = 150;

const markersData = [
  {
    id: 0,
    amount: 99,
    coordinate: {
      latitude: LATITUDE,
      longitude: LONGITUDE,
    },
  },
  {
    id: 1,
    amount: 199,
    coordinate: {
      latitude: LATITUDE + 0.004,
      longitude: LONGITUDE - 0.004,
    },
  },
  {
    id: 2,
    amount: 285,
    coordinate: {
      latitude: LATITUDE - 0.004,
      longitude: LONGITUDE - 0.004,
    },
  },
];

const AnimatedViews = () => {
  const state = useAnimatedRegion(
    {
      latitude: LATITUDE,
      longitude: LONGITUDE,
      latitudeDelta: LATITUDE_DELTA,
      longitudeDelta: LONGITUDE_DELTA,
    },
    markersData,
  );

  const { markers, region, panY } = state;

  const onRegionChange = (_region: any) => {
    region.setValue(_region);
  };

  return (
    <>
      <AnimatedMap
        provider={PROVIDER_GOOGLE}
        style={styles.map}
        region={region as unknown as Animated.WithAnimatedObject<Region>}
        onRegionChange={onRegionChange}
      >
        {markers.map((marker) => {
          return (
            <Marker key={marker.id} coordinate={marker.coordinate}>
              <>
                <Text style={styles.dollar}>$</Text>
                <Text style={styles.amount}>{marker.amount}</Text>
              </>
            </Marker>
          );
        })}
      </AnimatedMap>
      <View style={styles.itemContainer}>
        {markers.map((marker) => (
          <PanItem marker={marker} panY={panY} />
        ))}
      </View>
    </>
  );
};

const styles = StyleSheet.create({
  container: {
    ...StyleSheet.absoluteFillObject,
  },
  itemContainer: {
    flexDirection: 'row',
    paddingHorizontal: ITEM_SPACING / 2 + ITEM_PREVIEW,
    position: 'absolute',
    top: screen.height - ITEM_PREVIEW_HEIGHT - 64,
  },
  map: {
    backgroundColor: 'transparent',
    ...StyleSheet.absoluteFillObject,
  },
  item: {
    width: ITEM_WIDTH,
    height: screen.height + 2 * ITEM_PREVIEW_HEIGHT,
    backgroundColor: 'red',
    marginHorizontal: ITEM_SPACING / 2,
    overflow: 'hidden',
    borderRadius: 3,
    borderColor: '#000',
  },

  dollar: {
    color: '#fff',
    fontSize: 10,
  },
  amount: {
    color: '#fff',
    fontSize: 13,
  },
});

const PanItem = ({ marker, panY }) => {
  const localScroll = useMemo(() => {
    const localPanY = new Animated.Value(0);

    const scrollY = localPanY.interpolate({
      inputRange: [-1, 1],
      outputRange: [1, -1],
    });

    const translateY = scrollY.interpolate({
      inputRange: [0, 300],
      outputRange: [0, -100],
      extrapolate: 'clamp',
    });

    return {
      localPanY,
      scrollY,
      translateY,
    };
  }, []);

  const [state] = useState(localScroll);

  const panResponder = useRef(
    PanResponder.create({
      onMoveShouldSetPanResponder: () => true,
      onPanResponderMove: (...args) =>
        [
          Animated.event([null, { dy: panY }], {
            useNativeDriver: false,
          }),
          Animated.event([null, { dy: state.localPanY }], {
            useNativeDriver: false,
          }),
        ].map((el) => el?.(...args)),
      onPanResponderRelease: () => {
        //panY.extractOffset();
      },
    }),
  ).current;

  return (
    <Animated.View
      key={marker.id}
      style={[
        styles.item,
        {
          transform: [{ translateY: state.translateY }],
        },
      ]}
      {...panResponder.panHandlers}
    />
  );
};

export default AnimatedViews;

Notice how to run multiple animated events with a onPanResponderMove or any event callback. The panX is not yet handled but a ScrollView might do the trick.

You can now import the component <AnimatedViews /> into your App.tsx file and play with it.