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

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

За основу возьмем проект, который был реализован в первой части статьи. Основная страница приложения — 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;
 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,
           ),
         ),
       ],
     ),
   );
 }
}

На этом этапе приложение выглядит следующим образом:

Отображение местоположения пользователя на карте

Когда в проект внедряется карта, часто появляется необходимость определить местоположение пользователя и отобразить его точкой. 

Если определение местоположения требуется не только на карте, но и где-либо еще в приложении (например, при выборе города), имеет смысл внедрить в проект плагин определения геолокации. Одни из самых распространенных библиотек — geolocator и location. Эти плагины позволяют не только определять местоположение, но и проверять состояние сервисов GPS на устройстве, запрашивать разрешения на использование геопозиции и считывать их статусы.

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

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

Включение и отключение слоя местоположения пользователя осуществляется посредством вызова у контроллера карты метода ​​toggleUserLayer(). Чтобы включить слой, в параметр visible этого метода необходимо передать значение true, чтобы выключить —соответственно, false.

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

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

  • на Android в файле AndroidManifest.xml

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
  • на IOS в файле Info.plist:

<key>NSLocationWhenInUseUsageDescription</key>
<string>Доступ к геолокации пользователя необходим для отображения текущей геопозиции</string>
  • также на IOS в файле Podfile:

post_install do |installer|
 installer.pods_project.targets.each do |target|
   flutter_additional_ios_build_settings(target)
   target.build_configurations.each do |config|
     config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
       '$(inherited)',
       'PERMISSION_LOCATION=1',
     ]
   end
 end
end

С помощью средств библиотеки permission_handler нам необходимо сделать запрос в систему на использование средств устройства по определению текущего местоположения. Сделать это можно, вызвав асинхронную функцию Permission.location.request(), которая отобразит пользователю системное окно с возможными вариантами предоставления разрешений, а после взаимодействия пользователя вернет полученный статус.

Здесь важно обработать оба кейса — когда пользователь дал разрешение и когда нет.

В нашем примере сделаем так:

  • если разрешение не дано, отобразим сообщение об этом на экране;

  • если дано — включим слой местоположения пользователя на карте.

Добавим в файл map_screen.dart метод _initLocationLayer(), который реализует вышеописанную логику:

/// Метод, который включает слой местоположения пользователя на карте
 /// Выполняется проверка на доступ к местоположению, в случае отсутствия
 /// разрешения - выводит сообщение
 Future<void> _initLocationLayer() async {
   final locationPermissionIsGranted =
       await Permission.location.request().isGranted;


   if (locationPermissionIsGranted) {
     await _mapController.toggleUserLayer(visible: true);
   } else {
     WidgetsBinding.instance.addPostFrameCallback((_) {
       ScaffoldMessenger.of(context).showSnackBar(
         const SnackBar(
           content: Text('Нет доступа к местоположению пользователя'),
         ),
       );
     });
   }
 }

Чтобы запрос на использование геопозиции отобразился сразу после открытия страницы, вызовем этот метод в обратном вызове onMapCreated виджета YandexMap в файле map_screen.dart:

onMapCreated: (controller) async {
         _mapController = controller;
         await _initLocationLayer();
       },

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

Для этого создадим nullable переменную _userLocation типа CameraPosition. Затем в onUserLocationAdded с помощью метода getUserCameraPosition() переменной _userLocation присвоим значение текущей геопозиции устройства.

Если местоположение будет успешно получено, переместим карту так, чтобы полученная точка была по центру экрана, а затем вернем маркер, которым текущая точка будет отмечена на карте.

Именно здесь при необходимости можно изменить внешний вид точки пользователя с помощью метода copyWith объекта UserLocationView. По умолчанию точка отображается в виде стандартного маркера Яндекса зеленого цвета с прозрачностью 50% . В нашем примере вернем тот же маркер, но зададим ему 100% непрозрачность. Получим следующий код:

onUserLocationAdded: (view) async {
         // получаем местоположение пользователя
         _userLocation = await _mapController.getUserCameraPosition();
         // если местоположение найдено, центрируем карту относительно этой точки
         if (_userLocation != null) {
           await _mapController.moveCamera(
             CameraUpdate.newCameraPosition(
               _userLocation!.copyWith(zoom: 10),
             ),
             animation: const MapAnimation(
               type: MapAnimationType.linear,
               duration: 0.3,
             ),
           );
         }
         // меняем внешний вид маркера - делаем его непрозрачным
         return view.copyWith(
           pin: view.pin.copyWith(
             opacity: 1,
           ),
         );
       },

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

Файл map_screen.dart
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.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;


 CameraPosition? _userLocation;


 @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 _initLocationLayer();
       },
       onCameraPositionChanged: (cameraPosition, _, __) {
         setState(() {
           _mapZoom = cameraPosition.zoom;
         });
       },
       mapObjects: [
         _getClusterizedCollection(
           placemarks: _getPlacemarkObjects(context),
         ),
       ],
       onUserLocationAdded: (view) async {
         // получаем местоположение пользователя
         _userLocation = await _mapController.getUserCameraPosition();
         // если местоположение найдено, центрируем карту относительно этой точки
         if (_userLocation != null) {
           await _mapController.moveCamera(
             CameraUpdate.newCameraPosition(
               _userLocation!.copyWith(zoom: 10),
             ),
             animation: const MapAnimation(
               type: MapAnimationType.linear,
               duration: 0.3,
             ),
           );
         }
         // меняем внешний вид маркера - делаем его непрозрачным
         return view.copyWith(
           pin: view.pin.copyWith(
             opacity: 1,
           ),
         );
       },
     ),
   );
 }


 /// Метод для получения коллекции кластеризованных маркеров
 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,
             ),
           ),
         );
       });
 }


 /// Метод, который включает слой местоположения пользователя на карте
 /// Выполняется проверка на доступ к местоположению, в случае отсутствия
 /// разрешения - выводит сообщение
 Future<void> _initLocationLayer() async {
   final locationPermissionIsGranted =
       await Permission.location.request().isGranted;


   if (locationPermissionIsGranted) {
     await _mapController.toggleUserLayer(visible: true);
   } else {
     WidgetsBinding.instance.addPostFrameCallback((_) {
       ScaffoldMessenger.of(context).showSnackBar(
         const SnackBar(
           content: Text('Нет доступа к местоположению пользователя'),
         ),
       );
     });
   }
 }
}


/// Метод для генерации точек на карте
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,
           ),
         ),
       ],
     ),
   );
 }
}

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

Выделение зон на карте

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

Для реализации этой функции существует класс PolygonMapObject. Экземпляр этого класса обязательными параметрами принимает:

  • mapid — идентификатор объекта;

  • polygon — объект Polygon, который отвечает за отрисовку конкретной области.

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

Для создания объекта Polygon ему необходимо передать объекты внешней и внутренней геометрических форм — так называемых колец:

  • внешнее кольцо (outerRing) позволяет задать границы выделяемой зоны, является обязательным;

  • внутренние кольца (innerRings) являются необязательными, могут быть полезны, когда выделяемая внешним кольцом зона не должна быть сплошной, а должна иметь в себе зоны-«исключения».

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

Для примера добавим в AppBar страницы MapScreen кнопку настроек, по нажатию на которую откроется новая страница, где пользователь может ввести координаты точек для выделения области на карте. Здесь создадим набор текстовых полей для ввода координат, кнопку «Отобразить результат», нажатие на которую вернет нас назад на карту и отобразит выделенную зону. Также на экран настроек добавим кнопки добавления и удаления новых точек из списка.

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

Файл settings_screen.dart
import 'package:flutter/material.dart';
import 'package:yandex_mapkit/yandex_mapkit.dart';


/// Экран с настройками формирования списка точек
/// для построения выделенной области на карте
class SettingsScreen extends StatefulWidget {
 const SettingsScreen({super.key});


 @override
 State<SettingsScreen> createState() => _SettingsScreenState();
}


class _SettingsScreenState extends State<SettingsScreen> {
 /// Список контроллеров для редактирования широты
 late List<TextEditingController> _latControllers;


 /// Список контроллеров для редактирования долготы
 late List<TextEditingController> _lonControllers;


 /// Количество точек на карте
 var _pointCount = 3;


 @override
 void initState() {
   super.initState();
   // создаем список контроллеров для текстовых полей координат,
   // при инициализации добавляем три точки
   _latControllers = _getTextControllersList(_pointCount);
   _lonControllers = _getTextControllersList(_pointCount);
 }


 @override
 void dispose() {
   // отключаем все созданные контроллеры текстовых координат
   for (final controller in _latControllers) {
     controller.dispose();
   }
   for (final controller in _lonControllers) {
     controller.dispose();
   }
   super.dispose();
 }


 @override
 Widget build(BuildContext context) {
   return Scaffold(
     appBar: AppBar(title: const Text('Settings Screen')),
     body: Stack(
       children: [
         Padding(
           padding: const EdgeInsets.all(20.0),
           child: SingleChildScrollView(
             child: Column(
               children: [
                 ...List.generate(
                   _pointCount,
                   (index) => _LatLongTextFields(
                     latController: _latControllers[index],
                     lonController: _lonControllers[index],
                   ),
                 ),
                 const SizedBox(height: 40),
                 ElevatedButton(
                   child: const Text('Отобразить область'),
                   onPressed: () {
                     Navigator.pop(context, _getPointsFromText());
                   },
                 )
               ],
             ),
           ),
         ),
         Align(
           alignment: Alignment.bottomRight,
           child: Padding(
             padding: const EdgeInsets.all(20.0),
             child: Column(
               mainAxisSize: MainAxisSize.min,
               children: [
                 FloatingActionButton(
                   heroTag: 'add',
                   child: const Icon(Icons.add),
                   onPressed: () {
                     setState(() {
                       _pointCount++;
                       _latControllers.add(TextEditingController());
                       _lonControllers.add(TextEditingController());
                     });
                   },
                 ),
                 const SizedBox(height: 16),
                 FloatingActionButton(
                   heroTag: 'remove',
                   child: const Icon(Icons.remove),
                   onPressed: () {
                     setState(() {
                       _pointCount--;
                       _latControllers.removeLast();
                       _lonControllers.removeLast();
                     });
                   },
                 ),
               ],
             ),
           ),
         )
       ],
     ),
   );
 }


 /// Метод для формирования списка контроллеров по количеству полей
 List<TextEditingController> _getTextControllersList(int count) {
   return List.generate(count, (index) => TextEditingController());
 }


 /// Метод для формирования списка точек по введенным координатам в текстовые поля
 List<Point> _getPointsFromText() {
   final points = <Point>[];
   for (int index = 0; index < _pointCount; index++) {
     final doubleLat = double.tryParse(_latControllers[index].text);
     final doubleLon = double.tryParse(_lonControllers[index].text);


     if (doubleLat != null && doubleLon != null) {
       points.add(Point(latitude: doubleLat, longitude: doubleLon));
     }
   }
   return points;
 }
}


/// Виджет, состоящий из текстовых полей для редактирования широты и долготы
class _LatLongTextFields extends StatelessWidget {
 const _LatLongTextFields({
   required this.latController,
   required this.lonController,
 });


 /// Контроллер для редактирования широты
 final TextEditingController latController;


 /// Контроллер для редактирования долготы
 final TextEditingController lonController;


 @override
 Widget build(BuildContext context) {
   return Row(
     children: [
       Expanded(
         child: TextField(
           controller: latController,
           decoration: const InputDecoration(
             labelText: 'Широта',
           ),
           keyboardType: TextInputType.number,
         ),
       ),
       const SizedBox(width: 16),
       Expanded(
         child: TextField(
           controller: lonController,
           decoration: const InputDecoration(
             labelText: 'Долгота',
           ),
           keyboardType: TextInputType.number,
         ),
       ),
     ],
   );
 }
}

Выглядит реализованный экран настроек так:

Чтобы на карте построить и отобразить выделенную зону по введенным пользователем точкам, создадим в файле map_screen.dart метод _getPolygonMapObject(). Он вернет объект зоны голубого цвета с синей обводкой по заданным точкам. При нажатии на любую точку внутри зоны будет открыто модальное окно с информацией о координатах этой точки:

/// Метод для генерации объекта выделенной зоны на карте
PolygonMapObject _getPolygonMapObject(
 BuildContext context, {
 required List<Point> points,
}) {
 return PolygonMapObject(
   mapId: const MapObjectId('polygon map object'),
   polygon: Polygon(
     // внешняя линия зоны
     outerRing: LinearRing(
       points: points,
     ),
     // внутренняя линия зоны, которая формирует пропуски в полигоне
     innerRings: const [],
   ),
   strokeColor: Colors.blue,
   strokeWidth: 3.0,
   fillColor: Colors.blue.withOpacity(0.2),
   onTap: (_, point) => showModalBottomSheet(
     context: context,
     builder: (context) => _ModalBodyView(
       point: MapPoint(
         name: 'Неизвестный населенный пункт',
         latitude: point.latitude,
         longitude: point.longitude,
       ),
     ),
   ),
 );
}

Затем вызовем этот метод в поле mapObjects виджета YandexMap в файле map_screen.dart:

mapObjects: [
         _getClusterizedCollection(
           placemarks: _getPlacemarkObjects(context),
         ),
         _getPolygonMapObject(context, points: _polygonPointsList ?? []),
       ],

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

Файл map_screen.dart
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.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';
import 'package:yandex_mapkit_demo/presentation/settings_screen.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;


 /// Данные о местоположении пользователя
 CameraPosition? _userLocation;


 /// Список точек на карте, по которым строится выделенная область
 List<Point>? _polygonPointsList;


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


 @override
 Widget build(BuildContext context) {
   return Scaffold(
     appBar: AppBar(
       title: const Text('Yandex Mapkit Demo'),
       actions: [
         IconButton(
           onPressed: () async {
             _polygonPointsList = await Navigator.push(
               context,
               MaterialPageRoute(
                 builder: (context) => const SettingsScreen(),
               ),
             );
             setState(() {});
           },
           icon: const Icon(
             Icons.settings_outlined,
             color: Colors.white,
           ),
         ),
       ],
     ),
     body: YandexMap(
       onMapCreated: (controller) async {
         _mapController = controller;
         await _initLocationLayer();
       },
       onCameraPositionChanged: (cameraPosition, _, __) {
         setState(() {
           _mapZoom = cameraPosition.zoom;
         });
       },
       mapObjects: [
         _getClusterizedCollection(
           placemarks: _getPlacemarkObjects(context),
         ),
         _getPolygonMapObject(context, points: _polygonPointsList ?? []),
       ],
       onUserLocationAdded: (view) async {
         // получаем местоположение пользователя
         _userLocation = await _mapController.getUserCameraPosition();
         // если местоположение найдено, центрируем карту относительно этой точки
         if (_userLocation != null) {
           await _mapController.moveCamera(
             CameraUpdate.newCameraPosition(
               _userLocation!.copyWith(zoom: 10),
             ),
             animation: const MapAnimation(
               type: MapAnimationType.linear,
               duration: 0.3,
             ),
           );
         }
         // меняем внешний вид маркера - делаем его непрозрачным
         return view.copyWith(
           pin: view.pin.copyWith(
             opacity: 1,
           ),
         );
       },
     ),
   );
 }


 /// Метод для получения коллекции кластеризованных маркеров
 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,
             ),
           ),
         );
       });
 }


 /// Метод, который включает слой местоположения пользователя на карте
 /// Выполняется проверка на доступ к местоположению, в случае отсутствия
 /// разрешения - выводит сообщение
 Future<void> _initLocationLayer() async {
   final locationPermissionIsGranted =
       await Permission.location.request().isGranted;


   if (locationPermissionIsGranted) {
     await _mapController.toggleUserLayer(visible: true);
   } else {
     WidgetsBinding.instance.addPostFrameCallback((_) {
       ScaffoldMessenger.of(context).showSnackBar(
         const SnackBar(
           content: Text('Нет доступа к местоположению пользователя'),
         ),
       );
     });
   }
 }
}


/// Метод для генерации точек на карте
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();
}


/// Метод для генерации объекта выделенной зоны на карте
PolygonMapObject _getPolygonMapObject(
 BuildContext context, {
 required List<Point> points,
}) {
 return PolygonMapObject(
   mapId: const MapObjectId('polygon map object'),
   polygon: Polygon(
     // внешняя линия зоны
     outerRing: LinearRing(
       points: points,
     ),
     // внутренняя линия зоны, которая формирует пропуски в полигоне
     innerRings: const [],
   ),
   strokeColor: Colors.blue,
   strokeWidth: 3.0,
   fillColor: Colors.blue.withOpacity(0.2),
   onTap: (_, point) => showModalBottomSheet(
     context: context,
     builder: (context) => _ModalBodyView(
       point: MapPoint(
         name: 'Неизвестный населенный пункт',
         latitude: point.latitude,
         longitude: point.longitude,
       ),
     ),
   ),
 );
}


/// Содержимое модального окна с информацией о точке на карте
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,
           ),
         ),
       ],
     ),
   );
 }
}

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

Построение маршрута от точки А до точки Б

Еще одна полезная функция в YandexMapkit — поиск автомобильного маршрута по заданным точкам. Разберем, что необходимо сделать, чтобы определить и отобразить маршрут на карте. 

Для начала нужно задать обязательные точки — точку начала и точку конца маршрута. Опционально можно выбрать промежуточную точку, через которую маршрут должен пройти.

Чтобы выбрать точку начала и конца маршрута, воспользуемся возможностью карт обработать долгое нажатие на карту — обратный вызов onMapLongTap в виджете YandexMap.

Добавим на текущую страницу map_screen.dart переменную списка _drivingPointsList, в которую будем добавлять точки начала и конца, если в списке меньше двух добавленных точек. Реализуем эту логику в onMapLongTap:

onMapLongTap: (argument) {
         setState(() {
           // добавляем точку маршрута на карте, если еще не выбраны две точки
           if (_drivingPointsList.length < 2) {
             _drivingPointsList.add(argument);
           } else {
             _drivingPointsList = [];
           }
         });
       },

Чтобы выбранные точки были видны пользователю, создадим метод _getDrivingPlacemarks(), который будет создавать объекты маркеров (подробнее об этом в первой части статьи):

/// Метод для генерации точек начала и конца маршрута
List<PlacemarkMapObject> _getDrivingPlacemarks(
 BuildContext context, {
 required List<Point> drivingPoints,
}) {
 return drivingPoints
     .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/car_point.png',
             ),
             scale: 2,
           ),
         ),
       ),
     )
     .toList();
}

Передадим созданные маркеры в объекты карты:

mapObjects: [
         _getClusterizedCollection(
           placemarks: _getPlacemarkObjects(context),
         ),
         _getPolygonMapObject(context, points: _polygonPointsList ?? []),
         ..._getDrivingPlacemarks(context, drivingPoints: _drivingPointsList),
       ],

Далее для формирования маршрута создадим nullable переменную _drivingResultWithSession типа DrivingResultWithSession. Объекты этого класса содержат в себе всю полученную информацию о текущих вариантах маршрута.

Создадим метод _getDrivingResultWithSession() для получения доступных маршрутов по заданным точкам:

/// Метод для получения маршрутов проезда от точки начала к точке конца
DrivingResultWithSession _getDrivingResultWithSession({
 required Point startPoint,
 required Point endPoint,
}) {
 var drivingResultWithSession = YandexDriving.requestRoutes(
   points: [
     RequestPoint(
       point: startPoint,
       requestPointType: RequestPointType.wayPoint, // точка начала маршрута
     ),
     RequestPoint(
       point: endPoint,
       requestPointType: RequestPointType.wayPoint, // точка конца маршрута
     ),
   ],
   drivingOptions: const DrivingOptions(
     initialAzimuth: 0,
     routesCount: 5,
     avoidTolls: true,
     avoidPoorConditions: true,
   ),
 );


 return drivingResultWithSession;
}

Здесь c помощью класса YandexDriving и его статического метода requestRoutes() получаем данные о возможных маршрутах по текущим точкам. Для этого в текущий метод передаем координаты точек начала и конца (определив их тип RequestPointType.wayPoint). По необходимости можно добавить координаты промежуточных точек (тип RequestPointType.viaPoint). 

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

Вызовем созданный метод _getDrivingResultWithSession() сразу после выбора второй точки маршрута в onMapLongTap:

onMapLongTap: (argument) {
         setState(() {
           // добавляем точку маршрута на карте, если еще не выбраны две точки
           if (_drivingPointsList.length < 2) {
             _drivingPointsList.add(argument);
           } else {
             _drivingPointsList = [];
             _drivingResultWithSession = null;
           }


           // когда выбраны точки начала и конца,
           // получаем данные предложенных маршрутов
           if (_drivingPointsList.length == 2) {
             _drivingResultWithSession = _getDrivingResultWithSession(
               startPoint: _drivingPointsList.first,
               endPoint: _drivingPointsList.last,
             );
           }
         });
       },

Для отображения полученных маршрутов необходимо добавить на карту объекты PolylineMapObject, которые представляют собой линию, построенную по набору точек координат. 

Добавим в текущей странице список объектов линий _drivingMapLines и передадим его в mapObjects виджета YandexMap:

mapObjects: [
         _getClusterizedCollection(
           placemarks: _getPlacemarkObjects(context),
         ),
         _getPolygonMapObject(context, points: _polygonPointsList ?? []),
         ..._getDrivingPlacemarks(context, drivingPoints: _drivingPointsList),
         ..._drivingMapLines,
       ],

Далее реализуем метод _buildRoutes(), который будет строить линии по полученным маршрутам и добавлять их в список _drivingMapLines:

/// Метод построения маршрутов линиями на карте
 /// Получает список возможных маршрутов и добавляет их линиями на карту
 Future<void> _buildRoutes() async {
   final drivingResult = await _drivingResultWithSession?.result;


   setState(() {
     for (var element in drivingResult?.routes ?? []) {
       _drivingMapLines.add(
         PolylineMapObject(
           mapId: MapObjectId('route $element'),
           polyline: Polyline(points: element.geometry),
           strokeColor:
               // генерируем случайный цвет для каждого маршрута
               Colors.primaries[Random().nextInt(Colors.primaries.length)],
           strokeWidth: 3,
         ),
       );
     }
   });
 }

Вызовем этот метод сразу после добавления новой точки конца и получения результатов поиска маршрутов в обратном вызове onMapLongTap:

onMapLongTap: (argument) {
         setState(() {
           // добавляем точку маршрута на карте, если еще не выбраны две точки
           if (_drivingPointsList.length < 2) {
             _drivingPointsList.add(argument);
           } else {
             _drivingPointsList = [];
             _drivingMapLines = [];
             _drivingResultWithSession = null;
           }


           // когда выбраны точки начала и конца,
           // получаем данные предложенных маршрутов
           if (_drivingPointsList.length == 2) {
             _drivingResultWithSession = _getDrivingResultWithSession(
               startPoint: _drivingPointsList.first,
               endPoint: _drivingPointsList.last,
             );
           }
         });


         _buildRoutes();
       },

 В итоге получим такой вид файла map_screen.dart:

map_screen.dart
import 'dart:math';


import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.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';
import 'package:yandex_mapkit_demo/presentation/settings_screen.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;


 /// Данные о местоположении пользователя
 CameraPosition? _userLocation;


 /// Список точек на карте, по которым строится выделенная область
 List<Point>? _polygonPointsList;


 /// Список точек на карте, по которым строится автомобильный маршрут
 List<Point> _drivingPointsList = [];


 /// Результаты поиска маршрутов на карте
 DrivingResultWithSession? _drivingResultWithSession;


 /// Список объектов линий на карте, которые отображают маршруты
 List<PolylineMapObject> _drivingMapLines = [];


 @override
 void dispose() {
   _mapController.dispose();
   _drivingResultWithSession?.session.close();
   super.dispose();
 }


 @override
 Widget build(BuildContext context) {
   return Scaffold(
     appBar: AppBar(
       title: const Text('Yandex Mapkit Demo'),
       actions: [
         IconButton(
           onPressed: () async {
             _polygonPointsList = await Navigator.push(
               context,
               MaterialPageRoute(
                 builder: (context) => const SettingsScreen(),
               ),
             );
             setState(() {});
           },
           icon: const Icon(
             Icons.settings_outlined,
             color: Colors.white,
           ),
         ),
       ],
     ),
     body: YandexMap(
       onMapCreated: (controller) async {
         _mapController = controller;
         await _initLocationLayer();
       },
       onCameraPositionChanged: (cameraPosition, _, __) {
         setState(() {
           _mapZoom = cameraPosition.zoom;
         });
       },
       onMapLongTap: (argument) {
         setState(() {
           // добавляем точку маршрута на карте, если еще не выбраны две точки
           if (_drivingPointsList.length < 2) {
             _drivingPointsList.add(argument);
           } else {
             _drivingPointsList = [];
             _drivingMapLines = [];
             _drivingResultWithSession = null;
           }


           // когда выбраны точки начала и конца,
           // получаем данные предложенных маршрутов
           if (_drivingPointsList.length == 2) {
             _drivingResultWithSession = _getDrivingResultWithSession(
               startPoint: _drivingPointsList.first,
               endPoint: _drivingPointsList.last,
             );
           }
         });


         _buildRoutes();
       },
       mapObjects: [
         _getClusterizedCollection(
           placemarks: _getPlacemarkObjects(context),
         ),
         _getPolygonMapObject(context, points: _polygonPointsList ?? []),
         ..._getDrivingPlacemarks(context, drivingPoints: _drivingPointsList),
         ..._drivingMapLines,
       ],
       onUserLocationAdded: (view) async {
         // получаем местоположение пользователя
         _userLocation = await _mapController.getUserCameraPosition();
         // если местоположение найдено, центрируем карту относительно этой точки
         if (_userLocation != null) {
           await _mapController.moveCamera(
             CameraUpdate.newCameraPosition(
               _userLocation!.copyWith(zoom: 10),
             ),
             animation: const MapAnimation(
               type: MapAnimationType.linear,
               duration: 0.3,
             ),
           );
         }
         // меняем внешний вид маркера - делаем его непрозрачным
         return view.copyWith(
           pin: view.pin.copyWith(
             opacity: 1,
           ),
         );
       },
     ),
   );
 }


 /// Метод для получения коллекции кластеризованных маркеров
 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,
             ),
           ),
         );
       });
 }


 /// Метод, который включает слой местоположения пользователя на карте
 /// Выполняется проверка на доступ к местоположению, в случае отсутствия
 /// разрешения - выводит сообщение
 Future<void> _initLocationLayer() async {
   final locationPermissionIsGranted =
       await Permission.location.request().isGranted;


   if (locationPermissionIsGranted) {
     await _mapController.toggleUserLayer(visible: true);
   } else {
     WidgetsBinding.instance.addPostFrameCallback((_) {
       ScaffoldMessenger.of(context).showSnackBar(
         const SnackBar(
           content: Text('Нет доступа к местоположению пользователя'),
         ),
       );
     });
   }
 }


 /// Метод построения маршрутов линиями на карте
 /// Получает список возможных маршрутов и добавляет их линиями на карту
 Future<void> _buildRoutes() async {
   final drivingResult = await _drivingResultWithSession?.result;


   setState(() {
     for (var element in drivingResult?.routes ?? []) {
       _drivingMapLines.add(
         PolylineMapObject(
           mapId: MapObjectId('route $element'),
           polyline: Polyline(points: element.geometry),
           strokeColor:
               // генерируем случайный цвет для каждого маршрута
               Colors.primaries[Random().nextInt(Colors.primaries.length)],
           strokeWidth: 3,
         ),
       );
     }
   });
 }
}


/// Метод для генерации точек на карте
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();
}


/// Метод для генерации точек начала и конца маршрута
List<PlacemarkMapObject> _getDrivingPlacemarks(
 BuildContext context, {
 required List<Point> drivingPoints,
}) {
 return drivingPoints
     .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/car_point.png',
             ),
             scale: 2,
           ),
         ),
       ),
     )
     .toList();
}


/// Метод для генерации объекта выделенной зоны на карте
PolygonMapObject _getPolygonMapObject(
 BuildContext context, {
 required List<Point> points,
}) {
 return PolygonMapObject(
   mapId: const MapObjectId('polygon map object'),
   polygon: Polygon(
     // внешняя линия зоны
     outerRing: LinearRing(
       points: points,
     ),
     // внутренняя линия зоны, которая формирует пропуски в полигоне
     innerRings: const [],
   ),
   strokeColor: Colors.blue,
   strokeWidth: 3.0,
   fillColor: Colors.blue.withOpacity(0.2),
   onTap: (_, point) => showModalBottomSheet(
     context: context,
     builder: (context) => _ModalBodyView(
       point: MapPoint(
         name: 'Неизвестный населенный пункт',
         latitude: point.latitude,
         longitude: point.longitude,
       ),
     ),
   ),
 );
}


/// Метод для получения маршрутов проезда от точки начала к точке конца
DrivingResultWithSession _getDrivingResultWithSession({
 required Point startPoint,
 required Point endPoint,
}) {
 var drivingResultWithSession = YandexDriving.requestRoutes(
   points: [
     RequestPoint(
       point: startPoint,
       requestPointType: RequestPointType.wayPoint, // точка начала маршрута
     ),
     RequestPoint(
       point: endPoint,
       requestPointType: RequestPointType.wayPoint, // точка конца маршрута
     ),
   ],
   drivingOptions: const DrivingOptions(
     initialAzimuth: 0,
     routesCount: 5,
     avoidTolls: true,
     avoidPoorConditions: true,
   ),
 );


 return drivingResultWithSession;
}


/// Содержимое модального окна с информацией о точке на карте
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,
           ),
         ),
       ],
     ),
   );
 }
}

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

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

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

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

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

P.S. Мы ведем дружелюбный канал про Flutter в Telegram. Присоединяйтесь!