Инструкции по использованию нативных карт в React Native (react-native-maps, react-native-maps-super-cluster).
- Инициализация карты и кластеризация
- Улучшение производительности карты
- Домашняя работа #1
- Описание дополнительных параметров
- Использование изображений в маркерах
- Домашняя работа #2
- Трек на карте
- Анимированный маркер
Пример применения вышеуказанных рекомендаций (UI 60 FPS, 7500+ маркеров)
Для успешного запуска проекта необходимо воспользоваться решением Expo по быстрой сборке проектов React Native.
Инструкция по установке Expo https://docs.expo.io/get-started/installation/
Скачать приложение Expo Go для запуска проектов на собственном устройстве iOS: https://apps.apple.com/ru/app/expo-go/id982107779
После клонирования проекта необходимо установить зависимости:
yarn
Запустить проект:
expo start
После запуска проекта необходимо запустить приложение Expo Go и выбрать наш проект в списке
Для реализации карты будем использовать библиотеку react-native-maps-super-cluster, https://github.com/novalabio/react-native-maps-super-cluster
Разработчики которой объединили оригинальную библиотеку react-native-maps с другой популярной библиотекой supercluster для кластеризации гео-точек на карте. https://github.com/mapbox/supercluster
Импортируем библиотеку
import ClusteredMapView from 'react-native-maps-super-cluster';
Добавим главный компонент ClusteredMapView в раздел render нашего кода с обязательными параметрами:
<ClusteredMapView
initialRegion={INITIAL_REGION}
data={this.state.data}
renderCluster={this.renderCluster}
renderMarker={this.renderMarker}
animateClusters={false}
style={styles.map}
/>
Описание обязательных параметров:
Prop | Description |
---|---|
initialRegion | Изначальная позиция камера ны карте |
data | Массив данных для маркеров на карте |
renderCluster | Функция колл-бек для отображения маркеров кластеризации на карте (1 или больше гео-точек) |
renderMarker | Функция колл-бек для отображения маркера, к которому не применима кластеризация (только 1 гео-точка) |
animateClusters | Встроенная функция для автоматической анимации маркеров на карте |
style | Параметры стиля |
initialRegion
Данный параметр должен принимать объект в определенном формате из константы, в котором
const INITIAL_REGION = {
latitude: 55.7414887,
longitude: 37.5790672,
latitudeDelta: 0.0922,
longitudeDelta: 0.0461,
};
latitude, longitude отвечают за позицию камеры на карте latitudeDelta, longitudeDelta отвечает за размер карты (zoom)
Подробнее про latitudeDelta и longitudeDelta можно почитать здесь https://stackoverflow.com/questions/50882700/react-native-mapview-what-is-latitudedelta-longitudedelta
data
Данный параметр должен принимать массив объеков, данные которых мы ходим отобразить на карте. Формат объектов по типу GeoPoint + уникальный id (желательно в формате uuid).
[
{
id: 'cb3d1012-6182-434c-a21e-2bc86c8e1e00',
location: {
latitude: 55.7414887,
longitude: 37.5790672
},
},
{
id: '385917c8-e03d-4fdc-817c-1bc80ace17d0',
location: {
latitude: 55.6414887,
longitude: 37.4790672
},
},
...
]
Если вы принимаете из Вашего API данные другого формата, вы должны их преобразовать в этот формат.
renderCluster
Данный параметр принимает callback-функцию для отображения точек кластеризации. Например, это могут быть кружки с количественным числом.
renderCluster(cluster, onPress) {
const pointCount = cluster.pointCount;
const coordinate = cluster.coordinate;
return (
<Marker
coordinate={coordinate}
onPress={onPress}>
<View style={styles.myClusterStyle}>
<Text style={styles.myClusterTextStyle}>{pointCount}</Text>
</View>
</Marker>
);
}
Компонент Marker импортируется напрямую из react-native-maps
import {Marker} from 'react-native-maps';
Описание параметров входящих данных:
Prop | Description |
---|---|
cluster | Объект с данными по определенному кластеру |
cluster.clusterId | Уникальный id кластера |
cluster.coordinate | Местонахождение кластера по координатам |
cluster.pointCount | Количество объектов в данном кластере |
onPress | Функция, выполняющая передвижение камеры к внутренним объектам указанного кластера |
renderMarker
Данный параметр принимает callback-функцию для отображения маркера, который находится в единственнои числе. Например, это могут быть так же круг с числом "1".
renderMarker(data) {
const {location} = data;
return (
<Marker coordinate={location}>
<View style={styles.myMarkerStyle}>
<Text style={styles.myMarkerTextStyle}>1</Text>
</View>
</Marker>
);
}
Таким образом, мы получаем решение с картой и кластеризацией объектов на ней.
animateClusters
Данный параметр отвечает за анимацию маркеров на карте. Но, я рекомендую устанавливать данный параметр false, потому что под капотом этой функции выполняется работа с LayoutAnimation из react-native.
LayoutAnimation выполняет анимацию при изменении любого UI элемента, и вместе с маркерами у вас будут анимироваться и другие компоненты.
К примеру, могут возникнуть проблемы с react-navigation, при перехода на экран с картой.
Решение выше имеет недостатки в производительности.
Если воспользоваться console.log в функции renderMarker или renderCluster и начать простое передвижение камеры по карте, то мы увидим множественные re-renders в консоли.
Дело в том, что библиотека react-native-maps-super-cluster взаимодействует с supercluster по "реактивному методу", и функции renderMarker и renderCluster вызываются в любом случае, даже если ваши действия не произвели визуальных изменений на карте.
Представьте, что было бы, если бы мы имели больше чем 10 000 маркеров на карте.
Наша задача избавиться от множественных re-renders, которые "притормаживают" наше приложение:
- Вынесем контент из renderMarker и renderCluster в отдельные компоненты и
- Установим key, identifier, tracksViewChanges и tracksInfoWindowChanges в компоненте
- Воспользуемся функцией жизненного цикла shouldComponentUpdate
ClusterMarker.js
export default class ClusterMarker extends Component {
shouldComponentUpdate() {
return false;
}
render() {
const clusterId = this.props.cluster.clusterId;
const pointCount = this.props.cluster.pointCount;
const coordinate = this.props.cluster.coordinate;
const onPress = this.props.onPress;
const identifier = `cluster-marker-${clusterId}`;
return (
<Marker
identifier={identifier}
coordinate={coordinate}
tracksViewChanges={false}
tracksInfoWindowChanges={false}
onPress={onPress}>
<View style={styles.myClusterStyle}>
<Text style={styles.myClusterTextStyle}>{pointCount}</Text>
</View>
</Marker>
);
}
}
MyMarker.js
export default class MyMarker extends Component {
shouldComponentUpdate(nextProps, nextState) {
return false;
}
render() {
const {id, location} = this.props;
const identifier = `marker-${id}`;
return (
<Marker
identifier={identifier}
tracksViewChanges={false}
tracksInfoWindowChanges={false}
coordinate={location}>
<View style={styles.myMarkerStyle}>
<Text style={styles.myMarkerTextStyle}>1</Text>
</View>
</Marker>
);
}
}
Заменить функции renderCluster и renderMarker в компоненте с картой
renderCluster(cluster, onPress) {
const key = `cluster-marker-${cluster.clusterId}`;
return <ClusterMarker key={key} cluster={cluster} onPress={onPress} />;
}
renderMarker(data) {
const {id, location} = data;
const key = `marker-${id}`;
return <MyMarker key={key} id={id} location={location} />;
}
Описание указанных параметров:
Prop | Description | Additional url |
---|---|---|
key | В React любые итерированные компоненты должны иметь уникальный ключ | https://ru.reactjs.org/docs/lists-and-keys.html |
identifier | Суть применения похожа на key. Используется в компоненте Marker из react-native-maps. | https://github.com/react-native-maps/react-native-maps/blob/master/docs/marker.md |
tracksViewChanges | Отслеживать ли изменения маркера. Используется в компоненте Marker. | https://github.com/react-native-maps/react-native-maps/blob/master/docs/marker.md |
tracksInfoWindowChanges | Отслеживать ли изменения информационного окна в маркере. Используется в компоненте Marker. | https://github.com/react-native-maps/react-native-maps/blob/master/docs/marker.md |
shouldComponentUpdate | Функция жизненого цикла. Встроенный функционал React для ручного управления по обновлению компонента (re-render). | https://ru.reactjs.org/docs/react-component.html#shouldcomponentupdate |
tracksViewChanges и tracksInfoWindowChanges мы должны установить false. Это рекомендация от разработчиков react-native-maps.
shouldComponentUpdate должен вернуть false, чтобы исключить любые переотрисовки компонента.
В данном случае это работает отлично. Но в случае использовании изображений в маркере, либо какого-лобо полезного контента, мы будем обязаны оперировать данными параметрами. Примеры мы рассмотрем далее.
Мы имеем карту из примера #2, в котором добавили метод onRegionChange, с помощью которого мы записываем в state текущую позицию камеры на карте. Имея данную информацию, мы прокидываем её в другой компонент через props
Необходимо увеличить производительность в коде файла HomeWork1Screen.js (избежать лишние re-renders)
Prop | Description | Default value |
---|---|---|
radius | Радиус работы кластера на экране | screen width * 4.5% |
edgePadding | Отступы между краев экрана и маркеров при нажатии на кластер | { top: 10, left: 10, bottom: 10, right: 10 } |
accessor | Название key с содержимым координат в значении data | location |
minZoom | Минимальное значение Zoom для работы кластеризации | 1 |
maxZoom | Максимальное значение Zoom для работы кластеризации | 16 |
minZoomLevel | Минимальный предел Zoom для карты | 0 |
maxZoomLevel | Максимальный предел Zoom для карты | 20 |
showsPointsOfInterest | Отображать ли достопримечательности на карте | true |
showsBuildings | Отображать ли 3D-эффект домов при максимальном Zoom | true |
showsTraffic | Отображать ли информацию о дорожном движении | true |
showsIndoors | Отображать ли карту помещений | true |
Рассмотрим несколько способов использования изображений в маркерах:
- Подключение изображения через встроенный проп маркера image
const MARKER_IMAGE = require('../../../../assets/favicon.png');
export default class MyMarkerExample1 extends Component {
shouldComponentUpdate(nextProps, nextState) {
return false;
}
render() {
const {id, location} = this.props;
const identifier = `marker-${id}`;
return (
<Marker
image={MARKER_IMAGE}
identifier={identifier}
tracksViewChanges={false}
tracksInfoWindowChanges={false}
coordinate={location}
/>
);
}
}
- Через компонент Image внутри Marker
const MARKER_IMAGE = require('../../../../assets/favicon.png');
export default class MyMarkerExample2 extends Component {
shouldComponentUpdate(nextProps, nextState) {
return false;
}
render() {
const {id, location} = this.props;
const identifier = `marker-${id}`;
return (
<Marker
identifier={identifier}
tracksViewChanges={false}
tracksInfoWindowChanges={false}
coordinate={location}>
<View style={styles.myMarkerStyle}>
<Image source={MARKER_IMAGE} style={styles.imageStyle} />
</View>
</Marker>
);
}
}
- Использование изображение в Base64 формате
const LOGO_BASE64 = `iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAQAAAGKDAGaAAAFgUlEQVRYw7WXa2wUVRTH/20p7fZBW0p5iAplaUELCqEFlUCMYKwJKMYgaEwIUpQYNCIWRYgvQtTS6AeiKEqIQDBIAEFAEEm1DUVUoIqPVqhCC/IoammhC9vt/vywM7szu7NLLfHMl7n3nP/533vuPefMSHap8wNIoG57pxIYSErZDyCgB8qogTYUeKT+v2C+SlLanqA3v4EBwSdAasAI4CUUcA7wRsiBMbDIjQDQd4M0ogimtkrbMWSgAVzibUIouTIwfc4kJaVIkpT+PTxLiGplkHCo6Wq+uSOydlrYl3jD1xOSAQ5zdzcA1CJJK/ygOEk91kIhAMsQmeMDq9MRg7ojGExIINkrOAm8D4DHSg9QwiQApiDmeQzFWaDE2LUQ4DXjedimsAQaShgbqXj4NM4ISZpmVeTbdt6vGISrWtElfs76Fuig9+9hij7qFjbTs6GecEnxSdKa4DjbK0nFrzWFTI4yE/jVsvDsMaauP0JxX0ARviBgOg0A3GnZaVpO4N3llZRYYZrez8dhC/KRxrBVYSt37TPVgSh6GQN8FFxSQPLOqbsBSP3Wy2gLwJIRQcC91mPIOGyaTaTKARBxbllH7EtqZkJsgCRlvrTbF21JQiS3KN7pkFNH/BMOiJ+nq8vQTTNI8ChZnZabXJs7bxzf9zjAO/8kTLfNxynXwTpze6PlnG8/o16B+fzTfqCRgq1W62nXtYff1X1orjRiqzn2M8TgySlqAFjNuqDxFdIRKpWeag/Vo7zfJWXXei1ep3AaKDYDWypNvhLQnETk1+kWf/hC3iUrdGil0i3lAE8iRM/r1cpm3g0aNzAdgB8sACnvm8Bo0AuSLgLwKMeBQjosTEuCAJu0BSPgjsjo5WS0KyUM4Amqf6aO2cFRPUKU0cLAOhvgsgUAUM4u/PQz9lAGwC5/75eDgFlhAHuxKzNmynGtMwDtvMWOmIBahEjbYAACBWYCzY6A18kx3jI2WXolQJvRKMLqb6g5bbO0AnMPlbwSFdBrpwMA4FkOOwJ6mw08q/J8jChZnhtCR9HdfTo2IP6JyGQrfKbVGZBeETWdB723PwzQ/ZISr1IDchs9IcCtnasbWWMvCC3SNUhm2ophJ2raq9pyjycvjbju1yTje/604IzHllMXmH0256BGRBoPTipaO/jv4ktTPaNaC+rd02I5dqUsdTdUXiaGfOYbdKbPi2bnLkjLO3cszGJx+00fRLoemXlg1qkLVDOOxVyM6v4MM4kLlpQh6cObI22OIcatNh0nJj3X/8S2S3aTFhZwJ9/Z5raTZ71npZKUOWVxhPtDuBD5jZI7c+8DJ8/Giga7GcObzCMxMkFKJWlUYm6ztU0cYbihH7lccbv3cw9z+Tuq+yrG8gptNDGHBEcCSXGDjz7NDG6zWQyYLylhTyDSl1nK7VRYHLfxKmOpjCDcaH5dR5T1obPddekel9f95/BPs/ubwa+wB76acTzPRJ6hOWbYGnkEEb/wqlc96eu2CHAJ1cznLg5Fdf8lBQjxBqv87qbcx2MQJFd5HAjMyrSD0bxN6ABbWUiSQ9f4jQnNA7epjwNBSvWVGATmrX+M+xjjUGbLbHbtLLvU7w/dYf/0OrCV0ZTjjUEQ/WOxzJYh+QiRut5GkP6dz/jBms0kartA0ByWIekbbQQ9DnXYHG2gkIJOE7gc5jK32AgyavwOZ/A504N/AjG/px2entvtxf5Hoh5yByspZMN/JMjZbQ/RwRrf1W5RLZMY3Pkd7Ii8q5N71y9rae/CLbI/PY5qfKyk7ttvy13nj3aBIN6XslwZnW2TcX1KMlre8vk7RZB6QsVd7ccD3dUPXTwVhSCuI+lD80fi2iQhb1H+X5ssBEmn9KD+B7k54yut0XX/HfgvpUkmTvPggOsAAAAASUVORK5CYII=`;
export default class MyMarkerExample3 extends Component {
shouldComponentUpdate(nextProps, nextState) {
return false;
}
render() {
const {id, location} = this.props;
const identifier = `marker-${id}`;
return (
<Marker
identifier={identifier}
tracksViewChanges={false}
tracksInfoWindowChanges={false}
coordinate={location}>
<View style={styles.myMarkerStyle}>
<Image
source={{
uri: `data:image/png;base64,${LOGO_BASE64}`,
}}
style={styles.imageStyle}
/>
</View>
</Marker>
);
}
}
На карте используется пример из пункта 5, с изображением в маркере (вариант 2). В компоненте добавлена возможность смены изображения, но изображение не изменяется, необходимо это пофиксить.
Файл HomeWork2Screen.js
Для отрисовки трека на карте необходимо иметь массив точек по типу LatLng и воспользоваться компонентом PolyLine https://github.com/react-native-maps/react-native-maps/blob/master/docs/polyline.md
<ClusteredMapView
...
>
<Polyline
coordinates={track} // массив данных
strokeColor={'#FFFFFF'} // Цвет трека
strokeWidth={4} // Толщина линии
/>
</ClusteredMapView>
Для его использования мы должны:
- Использовать компонент Marker.Animated
- В координатах должны присутствовать latitudeDelta и longitudeDelta
- Для маркера в пропс coordinate использовать анимированный объект с коорднатами AnimatedRegion
- При изменении координат избегать ре-рендера компонента, с последующим прямым изменением в пропсе coordinate
- Для выполнения анимации выполнить функцию animateMarkerToCoordinate (для android из рефа маркера) или .timing({...params}).start (для iOS из рефа координат)
import React, {Component, useCallback, useEffect, useRef, useMemo} from 'react';
import {View, Image, StyleSheet, Platform, Easing} from 'react-native';
import {Polyline, Marker, AnimatedRegion} from 'react-native-maps';
import ClusteredMapView from 'react-native-maps-super-cluster';
const MY_CAR_IMAGE = require('../../assets/my_car.png');
const DURATION_ANIMATION_MARKER = 2500;
const INITIAL_DELTA = {
latitudeDelta: 0.0922,
longitudeDelta: 0.0922,
};
const INITIAL_REGION = {
latitude: 55.730348,
longitude: 37.550411,
...INITIAL_DELTA,
};
const track = [
{
latitude: 55.740567,
longitude: 37.535095,
...INITIAL_DELTA,
},
{
latitude: 55.737191,
longitude: 37.538607,
...INITIAL_DELTA,
},
{
latitude: 55.733348,
longitude: 37.543411,
...INITIAL_DELTA,
},
{
latitude: 55.725491,
longitude: 37.549565,
...INITIAL_DELTA,
},
{
latitude: 55.719765,
longitude: 37.563177,
...INITIAL_DELTA,
},
{
latitude: 55.716154,
longitude: 37.573824,
...INITIAL_DELTA,
},
];
class Example6Screen extends Component {
render() {
return (
<ClusteredMapView
animateClusters={false}
initialRegion={INITIAL_REGION}
data={[]}
style={styles.map}>
<Polyline
coordinates={track}
strokeColor={'#0000FF'}
strokeWidth={4}
/>
<MarkerAnimated track={track} />
</ClusteredMapView>
);
}
}
const MarkerAnimated = ({track}) => {
const markerRef = useRef();
const coordianteIndex = useRef(0);
const coordinate = useRef(
new AnimatedRegion(track[coordianteIndex.current]),
);
const onAnimate = useCallback(
(newCoordinate) => {
if (!Platform.OS === 'android') {
markerRef.current?.animateMarkerToCoordinate?.(
newCoordinate,
DURATION_ANIMATION_MARKER,
);
} else {
if (coordinate.current) {
coordinate.current.stopAnimation();
coordinate.current
.timing({
...newCoordinate,
easing: Easing.linear,
duration: DURATION_ANIMATION_MARKER,
isInteraction: false,
useNativeDriver: false,
})
.start();
}
}
},
[markerRef, coordinate.current],
);
const onChangeCoordinate = useCallback(() => {
const nextIndex = (coordianteIndex.current + 1) % track.length;
coordianteIndex.current = nextIndex;
onAnimate(track[nextIndex]);
}, [track, coordianteIndex.current]);
useEffect(() => {
const timeoutRef = setInterval(
onChangeCoordinate,
DURATION_ANIMATION_MARKER,
);
return () => {
clearInterval(timeoutRef);
};
}, []);
return useMemo(
() => (
<Marker.Animated
ref={markerRef}
key={'animated-marker'}
identifier={'animated-marker'}
tracksViewChanges={false}
coordinate={coordinate.current}>
<View style={styles.container}>
<Image
source={MY_CAR_IMAGE}
resizeMethod={'auto'}
resizeMode={'contain'}
style={styles.iconImage}
/>
</View>
</Marker.Animated>
),
[markerRef, coordinate],
);
};
const styles = StyleSheet.create({
map: {
flex: 1,
},
});
export default Example6Screen;