Flutter Yandex Mapkit: как внедрить основные инструменты Яндекс Карт в проект на Flutter. Часть 1

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Привет! Меня зовут Анна Ахлёстова, я Flutter-разработчик в Friflex. Мы разрабатываем мобильные приложения для бизнеса и специализируемся на Flutter. В статье я расскажу о том, как мы решаем основные задачи бизнеса с использованием возможностей Яндекс Карт на Flutter.

В связи с некоторыми трудностями в оплате сервисов Google Map API в РФ мы во Friflex в реализации новых проектов российских заказчиков стали использовать Яндекс Карты. 

Для подключения Яндекс Карт к приложению Flutter существует специальный плагин yandex_mapkit. Подробно о его преимуществах, недостатках и процессе интеграции в проект можете ознакомиться в статье моего коллеги.

К сожалению, подробная официальная документация об использовании ключевых возможностей плагина отсутствует. Именно поэтому я хочу рассказать о пяти базовых функциях, которые мы применяем для решения основных задач наших заказчиков, а также поделиться опытом их внедрения.

Для начала выделим основной список задач, которые чаще всего стоят перед разработчиками при внедрении карт в коммерческий проект:

  • отобразить список объектов (магазины, пункты выдачи и т.д.) точками на карте по заданным координатам;

  • дать возможность пользователю просмотреть дополнительную информацию об объекте при нажатии на него;

  • определить текущее местоположение пользователя и отобразить его точкой на карте;

  • выделить доступные для взаимодействия зоны на карте (зоны доставки и т.д.);

  • построить дорожные маршруты для перемещения от точки А до точки Б.

В этой статье я расскажу о том, как реализовать первые две задачи – отображение списка маркеров на карте и предоставление пользователю возможности взаимодействовать с ними. А также узнаем, с какими проблемами в реализации можно столкнуться и как легко их решить.

Чтобы подробно разобраться, как решить каждую из этих задач стандартными средствами библиотеки Yandex Mapkit, реализуем простой проект.

После полноценного подключения плагина yandex_mapkit в проект реализуем простое приложение:

Файл main.dart
import 'package:flutter/material.dart';
import 'package:yandex_mapkit_demo/presentation/map_screen.dart';


void main() {
 runApp(const MyApp());
}


class MyApp extends StatelessWidget {
 const MyApp({super.key});


 @override
 Widget build(BuildContext context) {
   return const MaterialApp(
     title: 'Yandex Mapkit Demo',
     home: MapScreen(),
   );
 }
}

Файл map_screen.dart
import 'package:flutter/material.dart';
import 'package:yandex_mapkit/yandex_mapkit.dart';
import 'package:yandex_mapkit_demo/data/map_point.dart';
import 'package:yandex_mapkit_demo/presentation/clusterized_icon_painter.dart';


class MapScreen extends StatefulWidget {
 const MapScreen({super.key});


 @override
 State<MapScreen> createState() => _MapScreenState();
}


class _MapScreenState extends State<MapScreen> {
 late final YandexMapController _mapController;


 @override
 void dispose() {
   _mapController.dispose();
   super.dispose();
 }


 @override
 Widget build(BuildContext context) {
   return Scaffold(
     appBar: AppBar(title: const Text('Yandex Mapkit Demo')),
     body: YandexMap(
       onMapCreated: (controller) async {
         _mapController = controller;
         await _mapController.moveCamera(
           CameraUpdate.newCameraPosition(
             const CameraPosition(
               target: Point(
                 latitude: 50,
                 longitude: 20,
               ),
               zoom: 3,
             ),
           ),
         );
       },
     ),
   );
 }
}

На этом этапе мы получаем следующий результат:

Отображение объектов точками на карте 

Как показывает опыт, это одна из самых востребованных у бизнеса фичей, связанных с картами. Разберем, как можно ее реализовать с использованием Yandex Mapkit.

Создадим модель точки на карте MapPoint, с помощью которой мы сможем хранить не только данные о координатах точки, как экземпляры класса Point, но и название населенного пункта:

Файл map_point.dart
import 'package:equatable/equatable.dart';


/// Модель точки на карте
class MapPoint extends Equatable {
 const MapPoint({
   required this.name,
   required this.latitude,
   required this.longitude,
 });


 /// Название населенного пункта
 final String name;


 /// Широта
 final double latitude;


 /// Долгота
 final double longitude;


 @override
 List<Object?> get props => [name, latitude, longitude];
}

Теперь сгенерируем список объектов MapPoint, которые нам необходимо отобразить на карте. Для этого создадим приватный метод _getMapPoints() в файле map_screen.dart:

/// Метод для генерации точек на карте
List<MapPoint> _getMapPoints() {
 return const [
   MapPoint(name: 'Москва', latitude: 55.755864, longitude: 37.617698),
   MapPoint(name: 'Лондон', latitude: 51.507351, longitude: -0.127696),
   MapPoint(name: 'Рим', latitude: 41.887064, longitude: 12.504809),
   MapPoint(name: 'Париж', latitude: 48.856663, longitude: 2.351556),
   MapPoint(name: 'Стокгольм', latitude: 59.347360, longitude: 18.341573),
 ];
}

Список объектов подготовлен, теперь можно переходить к их отображению на карте.

Основной класс объекта на карте — MapObject. Это абстрактный класс для всех объектов, которых могут быть добавлены на карту (маркеры, зоны, линии и т.д.). 

Основной класс маркера (точки на карте) — PlacemarkMapObject. Экземпляр этого класса позволяет на карте отобразить точку по заданным координатам. 

Обязательные параметры объекта:

  • mapObjectId — уникальный идентификатор, который позволяет отличать текущий объект на карте от других;

  • point — координаты точки, для которой создается маркер. 

Опционально можно задать:

  • opacity — прозрачность маркера на карте (opacity); 

  • icon — иконку для маркера;

  • onTap — функцию по тапу на маркер;

  • isDraggable — возможность пользователя перемещать маркер по карте; 

  • onDrag, onDragStart, onDragEnd — функции, выполняемые во время перемещения и т.д.

К счастью, YandexMapkit дает возможность разработчикам максимально кастомизировать внешний вид маркеров при их построении на карте. Таким образом у нас есть возможность выбрать один из двух объектов иконки:

  • PlacemarkIcon.single — можно использовать, когда иконка простая, состоит из одного изображения;

  • PlacemarkIcon.composite — можно использовать, когда иконка маркера сложная и требует добавления нескольких изображений.

Также стоит учесть, что изображение для иконки маркера может быть передано в двух форматах: либо изображением формата .png из assets приложения, либо в байтовом формате. Это также может быть полезно, когда иконка сложная и требует отрисовки с помощью Canvas. Для добавления такого графического объекта его достаточно конвертировать в формат Uint8List.

В рамках нашего проекта реализуем метод _getPlacemarkObjects(), который по списку точек из метода _getMapPoints() будет генерировать маркеры с простыми иконками, состоящими из одного изображения из assets:

Файл map_screen.dart
/// Метод для генерации объектов маркеров для отображения на карте
List<PlacemarkMapObject> _getPlacemarkObjects(BuildContext context) {
 return _getMapPoints()
     .map(
       (point) => PlacemarkMapObject(
         mapId: MapObjectId('MapObject $point'),
         point: Point(latitude: point.latitude, longitude: point.longitude),
         opacity: 1,
         icon: PlacemarkIcon.single(
           PlacemarkIconStyle(
             image: BitmapDescriptor.fromAssetImage(
               'assets/icons/map_point.png',
             ),
             scale: 2,
           ),
         ),
       ),
     )
     .toList();
}

Для отображения любых объектов карты, наследуемых от MapObject, необходимо в поле mapObjects виджета YandexMap передать список этих объектов.

В нашем случае это будет реализовано так:

Файл map_screen.dart
@override
 Widget build(BuildContext context) {
   return Scaffold(
     appBar: AppBar(title: const Text('Yandex Mapkit Demo')),
     body: YandexMap(
       onMapCreated: (controller) async {
         _mapController = controller;
         await _mapController.moveCamera(
           CameraUpdate.newCameraPosition(
             const CameraPosition(
               target: Point(
                 latitude: 50,
                 longitude: 20,
               ),
               zoom: 3,
             ),
           ),
         );
       },
       mapObjects: _getPlacemarkObjects(context),
     ),
   );
 }

Также стоит упомянуть о том, что очень часто перед разработчиком стоит дополнительная задача — реализовать вывод дополнительной информации о точке на карте при нажатии на маркер. 

Для реализации этой функции достаточно передать необходимые операции в обратный вызов onTap() в объекте PlacemarkMapObject

В нашем примере добавим вызов модального окна и отобразим в нем название населенного пункта и координаты точки. Для этого создадим виджет содержимого модального окна _ModalBodyView:

/// Содержимое модального окна с информацией о точке на карте
class _ModalBodyView extends StatelessWidget {
 const _ModalBodyView({required this.point});


 final MapPoint point;


 @override
 Widget build(BuildContext context) {
   return Padding(
     padding: const EdgeInsets.symmetric(vertical: 40),
     child: Column(mainAxisSize: MainAxisSize.min, children: [
       Text(point.name, style: const TextStyle(fontSize: 20)),
       const SizedBox(height: 20),
       Text(
         '${point.latitude}, ${point.longitude}',
         style: const TextStyle(
           fontSize: 16,
           color: Colors.grey,
         ),
       ),
     ]),
   );
 }
}

Также внесем изменения в метод _getPlacemarkObjects() — передадим вызов модального окна в onTap:

onTap: (_, __) => showModalBottomSheet(
           context: context,
           builder: (context) => _ModalBodyView(
             point: point,
           ),
         ),

В итоге получаем следующий результат:

Файл map_screen.dart
import 'package:flutter/material.dart';
import 'package:yandex_mapkit/yandex_mapkit.dart';
import 'package:yandex_mapkit_demo/data/map_point.dart';


class MapScreen extends StatefulWidget {
 const MapScreen({super.key});


 @override
 State<MapScreen> createState() => _MapScreenState();
}


class _MapScreenState extends State<MapScreen> {
 late final YandexMapController _mapController;


 @override
 void dispose() {
   _mapController.dispose();
   super.dispose();
 }


 @override
 Widget build(BuildContext context) {
   return Scaffold(
     appBar: AppBar(title: const Text('Yandex Mapkit Demo')),
     body: YandexMap(
       onMapCreated: (controller) async {
         _mapController = controller;
         // приближаем вид карты ближе к Европе
         await _mapController.moveCamera(
           CameraUpdate.newCameraPosition(
             const CameraPosition(
               target: Point(
                 latitude: 50,
                 longitude: 20,
               ),
               zoom: 3,
             ),
           ),
         );
       },
       mapObjects: _getPlacemarkObjects(context),
     ),
   );
 }
}


/// Метод для генерации точек на карте
List<MapPoint> _getMapPoints() {
 return const [
   MapPoint(name: 'Москва', latitude: 55.755864, longitude: 37.617698),
   MapPoint(name: 'Лондон', latitude: 51.507351, longitude: -0.127696),
   MapPoint(name: 'Рим', latitude: 41.887064, longitude: 12.504809),
   MapPoint(name: 'Париж', latitude: 48.856663, longitude: 2.351556),
   MapPoint(name: 'Стокгольм', latitude: 59.347360, longitude: 18.341573),
 ];
}


/// Метод для генерации объектов маркеров для отображения на карте
List<PlacemarkMapObject> _getPlacemarkObjects(BuildContext context) {
 return _getMapPoints()
     .map(
       (point) => PlacemarkMapObject(
         mapId: MapObjectId('MapObject $point'),
         point: Point(latitude: point.latitude, longitude: point.longitude),
         opacity: 1,
         icon: PlacemarkIcon.single(
           PlacemarkIconStyle(
             image: BitmapDescriptor.fromAssetImage(
               'assets/icons/map_point.png',
             ),
             scale: 2,
           ),
         ),
         onTap: (_, __) => showModalBottomSheet(
           context: context,
           builder: (context) => _ModalBodyView(
             point: point,
           ),
         ),
       ),
     )
     .toList();
}


/// Содержимое модального окна с информацией о точке на карте
class _ModalBodyView extends StatelessWidget {
 const _ModalBodyView({required this.point});


 final MapPoint point;


 @override
 Widget build(BuildContext context) {
   return Padding(
     padding: const EdgeInsets.symmetric(vertical: 40),
     child: Column(mainAxisSize: MainAxisSize.min, children: [
       Text(point.name, style: const TextStyle(fontSize: 20)),
       const SizedBox(height: 20),
       Text(
         '${point.latitude}, ${point.longitude}',
         style: const TextStyle(
           fontSize: 16,
           color: Colors.grey,
         ),
       ),
     ]),
   );
 }
}

Результат в приложении:

В целом на этом можно было бы и остановиться. Но даже на таком простом примере, где точек на карте совсем немного, бросается в глаза основная проблема — если маркеры расположены слишком близко, при масштабировании карты они могут наслаиваться друг на друга. 

Это в свою очередь вызывает новую проблему — если необходимо обрабатывать нажатие на объект, при тапе на один маркер будет вызываться обработчик из соседнего.  

Эти трудности легко решаются добавлением кластеризации маркеров.

Объединение маркеров в кластеры

Кластеры — это объекты карты, которые позволяют при уменьшении масштаба объединить близлежащие маркеры в один объект и отобразить количество объединенных точек.

Для реализации кластеризации в Yandex Mapkit существует специальный класс —ClusterizedPlacemarkCollection. Обязательные параметры:

  • mapId — уникальный идентификатор объекта;

  • placemarks — список всех маркеров, которые в случае изменения масштаба объединяются в кластеры;

  • radius — минимальное расстояние между точками на карте, которые остаются разделенными и не входят в кластеры;

  • minZoom — минимальный уровень масштабирования, при котором отображаются кластеры.

Если есть необходимость изменить внешний вид кластера, это можно сделать через обратный вызов onClusterAdded(). Он вызывается каждый раз, когда по изменению масштаба карты создаются новые кластеры или удаляются старые.

Cluster — сам объект кластера, который хранит в себе данные об объединенных маркерах, их количестве и своем внешнем виде.

Класс Cluster реализует метод copyWith(), который позволяет обеспечить иммутабельность текущему объекту кластера. С помощью этого метода можно вернуть новый экземпляр класса, который от текущего будет отличаться лишь теми параметрами, которые будут заданы в copyWith().

Чтобы изменить внешний вид в onClusterAdded() необходимо вернуть экземпляр Cluster с измененным через copyWith() параметром appearance

Параметр appearance, в свою очередь, типизирован как PlacemarkMapObject, поэтому принцип построения иконки и внешнего вида в целом у кластера такой же, как у обычного маркера (описан выше).

Рассмотрим сложный случай формирования кластера, когда кластер должен динамически отображать количество маркеров, которые в нем объединены. Также для примера не будем использовать готовые изображения — нарисуем иконку самостоятельно с помощью Canvas.

Для этого создадим файл clusterized_icon_painter.dart и в нем реализуем класс ClusterIconPainter. Этот класс будет отрисовывать круглую иконку желтого цвета с оранжевой обводкой и числом внутри, отображающим количество маркеров в текущем кластере.

/// Класс для отрисовки кластеров на карте
class ClusterIconPainter {
 const ClusterIconPainter(this.clusterSize);


 /// Количество маркеров в кластере
 final int clusterSize;
}

Класс в параметре clusterSize принимает количество объединенных маркеров в текущем кластере.

Добавим метод _paintCirclePlacemark(), который будет отрисовывать фон кластера (в нашем случае это закрашенный круг желтого цвета с оранжевой обводкой):

Файл clusterized_icon_painter.dart
/// Метод, который отрисовывает фигуру кластера (фон и обводка)
Canvas _paintCirclePlacemark({
 required Size size,
 required PictureRecorder recorder,
}) {
 final canvas = Canvas(recorder);


 final radius = size.height / 2.15;


 // внутренний круг - закрашенная часть маркера
 final fillPaint = Paint()
   ..color = Colors.yellow
   ..style = PaintingStyle.fill;


 // внешний круг - обводка маркера
 final strokePaint = Paint()
   ..color = Colors.deepOrangeAccent
   ..style = PaintingStyle.stroke
   ..strokeWidth = 8;


 final circleOffset = Offset(size.height / 2, size.width / 2);


 canvas
   ..drawCircle(circleOffset, radius, fillPaint)
   ..drawCircle(circleOffset, radius, strokePaint);
 return canvas;
}

Далее добавим метод _paintTextCountPlacemarks(), который будет отрисовывать на том же объекте Canvas оранжевый текст с количеством маркеров, объединенных в кластер:

Файл clusterized_icon_painter.dart
/// Метод, который отрисовывает текст,
/// отображающий количество маркеров в кластере
void _paintTextCountPlacemarks({
 required String text,
 required Size size,
 required Canvas canvas,
}) {
 // внешний вид текста, отображающего количество маркеров в кластере
 final textPainter = TextPainter(
   text: TextSpan(
     text: text,
     style: const TextStyle(
       color: Colors.deepOrangeAccent,
       fontSize: 50,
       fontWeight: FontWeight.w800,
     ),
   ),
   textDirection: TextDirection.ltr,
 )..layout(maxWidth: size.width);


 // смещение текста
 // необходимо для размещения текста по центру кластера
 final textOffset = Offset(
   (size.width - textPainter.width) / 2,
   (size.height - textPainter.height) / 2,
 );
 textPainter.paint(canvas, textOffset);
}

Создадим публичный метод getClusterIconBytes(), который будет отрисовывать кластер, вызывая вышеуказанные методы, и преобразовывать рисунок в байтовый формат. Как мы помним, вид иконки объекта можно задать либо через путь к изображению в assets, либо из байтового формата.

Файл clusterized_icon_painter.dart
/// Метод, который формирует фигуру кластера
 /// и преобразует ее в байтовый формат
 Future<Uint8List> getClusterIconBytes() async {
   const size = Size(150, 150);
   final recorder = PictureRecorder();


   // отрисовка маркера
   _paintTextCountPlacemarks(
     text: clusterSize.toString(),
     size: size,
     canvas: _paintCirclePlacemark(
       size: size,
       recorder: recorder,
     ),
   );


   // преобразование в байтовый формат
   final image = await recorder.endRecording().toImage(
         size.width.toInt(),
         size.height.toInt(),
       );
   final pngBytes = await image.toByteData(format: ImageByteFormat.png);


   return pngBytes!.buffer.asUint8List();
 }

Далее вернемся в наш основной файл map_screen.dart.

Здесь теперь мы можем создать экземпляр ClusterizedPlacemarkCollection. Для этого реализуем метод _getClusterizedCollection(). В нем изменяем внешний вид кластера, возвращая в обратном вызове onClusterAdd кластер с новой иконкой, отрисованной через созданный нами ранее класс ClusterIconPainter и метод getClusterIconBytes().

Файл map_screen.dart
/// Метод для получения коллекции кластеризованных маркеров
ClusterizedPlacemarkCollection _getClusterizedCollection({
 required List<PlacemarkMapObject> placemarks,
}) {
 return ClusterizedPlacemarkCollection(
     mapId: const MapObjectId('clusterized-1'),
     placemarks: placemarks,
     radius: 50,
     minZoom: 15,
     onClusterAdded: (self, cluster) async {
       return cluster.copyWith(
         appearance: cluster.appearance.copyWith(
           opacity: 1.0,
           icon: PlacemarkIcon.single(
             PlacemarkIconStyle(
               image: BitmapDescriptor.fromBytes(
                 await ClusterIconPainter(cluster.size).getClusterIconBytes(),
               ),
             ),
           ),
         ),
       );
     },
}

Добавим дополнительную полезную функцию — приближение карты при тапе на кластер. Для этого нам необходимо создать дополнительную переменную типа double _mapZoom, в которую мы будем помещать текущее значение уровня приближения карты. Зададим ей начальное значение 0.0.

С помощью обратного вызова onCameraPositionChanged в виджете YandexMap у нас есть возможность получать сведения о текущем положении карты на экране. Функции в этом поле будут вызываться каждый раз, когда пользователь увеличил или уменьшил масштаб карты, переместил ее и т.д.

Здесь переопределим _mapZoom:

onCameraPositionChanged: (cameraPosition, _, __) {
         setState(() {
           _mapZoom = cameraPosition.zoom;
         });
       },

Таким образом, переменная _mapZoom всегда будет хранить актуальные данные о текущем масштабе карты.

Далее изменим метод _getClusterizedCollection(). В обратном вызове onClusterTap в объекте ClusterizedPlacemarkCollection выполним центрирование карты относительно первого маркера из списка маркеров в кластере и увеличим текущий масштаб на 1 единицу:

onClusterTap: (self, cluster) async {
         await _mapController.moveCamera(
           animation: const MapAnimation(
               type: MapAnimationType.linear, duration: 0.3),
           CameraUpdate.newCameraPosition(
             CameraPosition(
               target: cluster.placemarks.first.point,
               zoom: _mapZoom + 1,
             ),
           ),
         );
       });

Теперь, чтобы отобразить маркеры с функцией кластеризации на карте, необходимо передать созданный объект ClusterizedPlacemarkCollection в список объектов карты mapObjects:

mapObjects: [
         _getClusterizedCollection(
           placemarks: _getPlacemarkObjects(context),
         ),
       ],

В итоге получаем следующий результат:

Файл map_screen.dart
import 'package:flutter/material.dart';
import 'package:yandex_mapkit/yandex_mapkit.dart';
import 'package:yandex_mapkit_demo/data/map_point.dart';
import 'package:yandex_mapkit_demo/presentation/clusterized_icon_painter.dart';


class MapScreen extends StatefulWidget {
 const MapScreen({super.key});


 @override
 State<MapScreen> createState() => _MapScreenState();
}


class _MapScreenState extends State<MapScreen> {
 late final YandexMapController _mapController;
 var _mapZoom = 0.0;


 @override
 void dispose() {
   _mapController.dispose();
   super.dispose();
 }


 @override
 Widget build(BuildContext context) {
   return Scaffold(
     appBar: AppBar(title: const Text('Yandex Mapkit Demo')),
     body: YandexMap(
       onMapCreated: (controller) async {
         _mapController = controller;
         // приближаем вид карты ближе к Европе
         await _mapController.moveCamera(
           CameraUpdate.newCameraPosition(
             const CameraPosition(
               target: Point(
                 latitude: 50,
                 longitude: 20,
               ),
               zoom: 3,
             ),
           ),
         );
       },
       onCameraPositionChanged: (cameraPosition, _, __) {
         setState(() {
           _mapZoom = cameraPosition.zoom;
         });
       },
       mapObjects: [
         _getClusterizedCollection(
           placemarks: _getPlacemarkObjects(context),
         ),
       ],
     ),
   );
 }


 /// Метод для получения коллекции кластеризованных маркеров
 ClusterizedPlacemarkCollection _getClusterizedCollection({
   required List<PlacemarkMapObject> placemarks,
 }) {
   return ClusterizedPlacemarkCollection(
       mapId: const MapObjectId('clusterized-1'),
       placemarks: placemarks,
       radius: 50,
       minZoom: 15,
       onClusterAdded: (self, cluster) async {
         return cluster.copyWith(
           appearance: cluster.appearance.copyWith(
             opacity: 1.0,
             icon: PlacemarkIcon.single(
               PlacemarkIconStyle(
                 image: BitmapDescriptor.fromBytes(
                   await ClusterIconPainter(cluster.size)
                       .getClusterIconBytes(),
                 ),
               ),
             ),
           ),
         );
       },
       onClusterTap: (self, cluster) async {
         await _mapController.moveCamera(
           animation: const MapAnimation(
               type: MapAnimationType.linear, duration: 0.3),
           CameraUpdate.newCameraPosition(
             CameraPosition(
               target: cluster.placemarks.first.point,
               zoom: _mapZoom + 1,
             ),
           ),
         );
       });
 }
}


/// Метод для генерации точек на карте
List<MapPoint> _getMapPoints() {
 return const [
   MapPoint(name: 'Москва', latitude: 55.755864, longitude: 37.617698),
   MapPoint(name: 'Лондон', latitude: 51.507351, longitude: -0.127696),
   MapPoint(name: 'Рим', latitude: 41.887064, longitude: 12.504809),
   MapPoint(name: 'Париж', latitude: 48.856663, longitude: 2.351556),
   MapPoint(name: 'Стокгольм', latitude: 59.347360, longitude: 18.341573),
 ];
}


/// Метод для генерации объектов маркеров для отображения на карте
List<PlacemarkMapObject> _getPlacemarkObjects(BuildContext context) {
 return _getMapPoints()
     .map(
       (point) => PlacemarkMapObject(
         mapId: MapObjectId('MapObject $point'),
         point: Point(latitude: point.latitude, longitude: point.longitude),
         opacity: 1,
         icon: PlacemarkIcon.single(
           PlacemarkIconStyle(
             image: BitmapDescriptor.fromAssetImage(
               'assets/icons/map_point.png',
             ),
             scale: 2,
           ),
         ),
         onTap: (_, __) => showModalBottomSheet(
           context: context,
           builder: (context) => _ModalBodyView(
             point: point,
           ),
         ),
       ),
     )
     .toList();
}


/// Содержимое модального окна с информацией о точке на карте
class _ModalBodyView extends StatelessWidget {
 const _ModalBodyView({required this.point});


 final MapPoint point;


 @override
 Widget build(BuildContext context) {
   return Padding(
     padding: const EdgeInsets.symmetric(vertical: 40),
     child: Column(
       mainAxisSize: MainAxisSize.min,
       children: [
         Text(point.name, style: const TextStyle(fontSize: 20)),
         const SizedBox(height: 20),
         Text(
           '${point.latitude}, ${point.longitude}',
           style: const TextStyle(
             fontSize: 16,
             color: Colors.grey,
           ),
         ),
       ],
     ),
   );
 }
}

Файл clusterized_icon_painter.dart
import 'dart:typed_data';
import 'dart:ui';


import 'package:flutter/material.dart';


/// Класс для отрисовки кластеров на карте
class ClusterIconPainter {
 const ClusterIconPainter(this.clusterSize);


 /// Количество маркеров в кластере
 final int clusterSize;


 /// Метод, который формирует фигуру кластера
 /// и преобразует ее в байтовый формат
 Future<Uint8List> getClusterIconBytes() async {
   const size = Size(150, 150);
   final recorder = PictureRecorder();


   // отрисовка маркера
   _paintTextCountPlacemarks(
     text: clusterSize.toString(),
     size: size,
     canvas: _paintCirclePlacemark(
       size: size,
       recorder: recorder,
     ),
   );


   // преобразование в байтовый формат
   final image = await recorder.endRecording().toImage(
         size.width.toInt(),
         size.height.toInt(),
       );
   final pngBytes = await image.toByteData(format: ImageByteFormat.png);


   return pngBytes!.buffer.asUint8List();
 }
}


/// Метод, который отрисовывает фигуру кластера (фон и обводка)
Canvas _paintCirclePlacemark({
 required Size size,
 required PictureRecorder recorder,
}) {
 final canvas = Canvas(recorder);


 final radius = size.height / 2.15;


 // внутренний круг - закрашенная часть маркера
 final fillPaint = Paint()
   ..color = Colors.yellow
   ..style = PaintingStyle.fill;


 // внешний круг - обводка маркера
 final strokePaint = Paint()
   ..color = Colors.deepOrangeAccent
   ..style = PaintingStyle.stroke
   ..strokeWidth = 8;


 final circleOffset = Offset(size.height / 2, size.width / 2);


 canvas
   ..drawCircle(circleOffset, radius, fillPaint)
   ..drawCircle(circleOffset, radius, strokePaint);
 return canvas;
}


/// Метод, который отрисовывает текст,
/// отображающий количество маркеров в кластере
void _paintTextCountPlacemarks({
 required String text,
 required Size size,
 required Canvas canvas,
}) {
 // внешний вид текста, отображающего количество маркеров в кластере
 final textPainter = TextPainter(
   text: TextSpan(
     text: text,
     style: const TextStyle(
       color: Colors.deepOrangeAccent,
       fontSize: 50,
       fontWeight: FontWeight.w800,
     ),
   ),
   textDirection: TextDirection.ltr,
 )..layout(maxWidth: size.width);


 // смещение текста
 // необходимо для размещения текста по центру кластера
 final textOffset = Offset(
   (size.width - textPainter.width) / 2,
   (size.height - textPainter.height) / 2,
 );
 textPainter.paint(canvas, textOffset);
}

Результат в приложении:

Готово! Мы реализовали простое приложение, которое способно отображать список точек на карте с динамической кластеризацией и с обработкой нажатия как на сам маркер, так и на кластер.

В следующей части статьи мы разберем, как легко отобразить точку пользователя на карте без применения сторонних библиотек геопозиционирования, а также научимся выделять зоны по координатам на карте и строить маршруты от точки А к точке Б.

Если у вас остались вопросы, с удовольствием на них отвечу в комментариях. Код проекта доступен по ссылке.

Почитать об интеграции Яндекс Карт во Flutter можно в статье у моего коллеги.   

Также много интересной информации по теме в Telegram-чате разработчиков по yandex_mapkit.

Источник: https://habr.com/ru/companies/friflex/articles/769662/


Интересные статьи

Интересные статьи

Непрерывная интеграция (CI, англ. Continuous Integration) — это практика разработки программного обеспечения, которая заключается в выполнении частых автоматизированных сборок проекта для скорейшего в...
Описывается пример интеграции библиотеки компонентов пользовательского интерфейса Primefaces, построенной на основе фреймворка JavaServer Faces (JSF), в MVC приложение на Spring Boot.Первая часть | Вт...
Как известно, системные администраторы делятся на 3 категории - кто еще не делает резервные копии, кто уже делает и кто уверен, что из них можно восстановиться. В нашу эпоху DevOps вопрос автоматизаци...
Привет!  Меня зовут Николай, я преподаю географию в школе. Одним из краеугольных камней географии является знание расположения основных географических объектов,...
Привет, Хабровчане! В конце августа в OTUS запускается 2 мощных курса по обратной разработке кода (реверс-инжиниринг). В связи с этим приглашаем вас на День Открытых дверей, где Арту...