# Animating map region with Animated API

This is a rewrite using functional components and PanGesture API of [the example provided in the react-native-maps](https://github.com/react-native-maps/react-native-maps#using-the-mapview-with-the-animated-api). I couldn't get the example provided in the package to properly work because it uses a [custom](https://github.com/react-native-maps/react-native-maps/blob/master/example/src/examples/AnimatedViews.tsx) pan controller.

![The example we'll reproduce using hooks (functional component)](https://cdn.hashnode.com/res/hashnode/image/upload/v1682422520966/b7d6709a-d878-44b9-85c8-f4773d12ff95.gif align="center")

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](https://reactnative.dev/docs/typescript)  
Add react-native-maps to your project  
Run `yarn add react-native-maps` and please follow [these instructions](https://github.com/react-native-maps/react-native-maps/blob/master/docs/installation.md) to get it to properly work in your project.

### Implementation

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

```typescript
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

```typescript
// 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.
