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.
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.