Маркеры на Google Maps во Flutter: от простого к сложному

Я — Тим, разработчик в Гудитворкс. Недавно мы делали приложение-гид по ресторанам. Нам было нужно, чтобы на карте отображалась информация о ресторанах, а пользователь мог бы отмечать понравившиеся. Я расскажу, как работать во Flutter с картами, а также стандартными и нестандартными маркерами. В конце каждой части рассказа — ссылка на репозиторий с полным кодом примера.

Подключение карты

В качестве картографической основы я выбрал Google Maps. Для работы с ним во Flutter есть пакет google_maps_flutter. Пакет добавляется как зависимость в файл pubspec.yaml:

dependencies:   ...   google_maps_flutter: ^2.1.8   ...

Чтобы подключиться к картам, понадобится API-ключ: о том, как его получить, подробно написано в документации Maps SDK. Для Android добавляем ключ в файл android/app/src/main/AndroidManifest.xml:

<manifest ...    <application ...         <meta-data android:name="com.google.android.geo.API_KEY"                    android:value="API-КЛЮЧ"/>

После этого добавляем виджет с картой в файл main.dart:

import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart';  void main() => runApp(const MyApp());  class MyApp extends StatelessWidget {   const MyApp({Key? key}) : super(key: key);    @override   Widget build(BuildContext context) {     return const MaterialApp(       home: Scaffold(         body: CustomMap(),       ),     );   } }  class CustomMap extends StatefulWidget {   const CustomMap({Key? key}) : super(key: key);    @override   _CustomMapState createState() => _CustomMapState(); }  class _CustomMapState extends State<CustomMap> {   GoogleMapController? _controller;   static const LatLng _center = LatLng(48.864716, 2.349014);    void _onMapCreated(GoogleMapController controller) {     setState(() {       _controller = controller;     });      rootBundle.loadString('assets/map_style.json').then((mapStyle) {       _controller?.setMapStyle(mapStyle);     });   }    @override   Widget build(BuildContext context) {     return GoogleMap(       onMapCreated: _onMapCreated,       initialCameraPosition: const CameraPosition(         target: _center,         zoom: 12,       ),     );   } }

Стоит обратить внимание на:

  • метод _onMapCreated: он вызывается при создании карты и получает в качестве параметра GoogleMapController,

  • параметр initialCameraPosition: определяет первичное позиционирование карты,

  • GoogleMapController: управляет картой — позиционированием, анимацией, зумом.

Чтобы карта была красивее, я прописал стили в файле assets/map_style.json. Стилизовать карту удобно сервисом mapstyle.withgoogle.com. Теперь карта выглядит так:

Ветка репозитория: https://github.com/gooditcollective/flutter-google-maps-exmaple/tree/init-gm

Стандартные маркеры

На карту можно поместить стандартные маркеры. Для этого нужны координаты: в моем случае они, как и другие данные ресторанов — в файле datasource.dart

Метод _upsertMarker создает маркеры:

void _upsertMarker(Place place) {     setState(() {       _markers.add(Marker(         markerId: MarkerId(place.id),         position: place.location,         infoWindow: InfoWindow(           title: place.name,           snippet:               [...place.occasions, ...place.vibes, ...place.budget].join(", "),         ),         icon: BitmapDescriptor.defaultMarker,       ));     });   }

Класс infoWindow по тапу показывает пин с информацией о ресторане, а на карту маркеры добавляются с помощью атрибута markers виджета GoogleMap:

void _mapPlacesToMarkers() {   for (final place in _places) {     _upsertMarker(place);   } } ... @override initState() {   super.initState();   _mapPlacesToMarkers(); }  @override Widget build(BuildContext context) {   return GoogleMap(     onMapCreated: _onMapCreated,     initialCameraPosition: const CameraPosition(       target: _center,       zoom: 12,     ),     markers: _markers,   ); }

Выглядит это так:

Ветка репозитория: https://github.com/gooditcollective/flutter-google-maps-exmaple/tree/default-markers

Карточки по тапу

Но пина с информацией показалось недостаточно. Хотелось, чтобы была полноценная карточка с фотографией ресторана.

Добавлю переменную для хранения выбранного места и методы для его выбора в _CustomMapState. Карточка будет показываться по тапу на маркер (метод _selectPlace), а исчезать по тапу там, где маркера нет (метод _unselectPlace). Карточки подключаются с помощью виджета Positioned:

class _CustomMapState extends State<CustomMap> {   ...   final List<Place> _places = places;   Place? _selectedPlace;      void _unselectPlace() {     setState(() {       _selectedPlace = null;     });   }      void _selectPlace(Place place) {     setState(() {       _selectedPlace = place;     });   }    void _upsertMarker(Place place) {     setState(() {       _markers.add(Marker(         ...         onTap: () => _selectPlace(place),         ...       ));     });   }   ...     @override   Widget build(BuildContext context) {     return Stack(       ...       children: <Widget>[         GoogleMap(           ...           ),           markers: _markers,           onTap: (_) => _unselectPlace(),         ),         if (_selectedPlace != null)           Positioned(             bottom: 76,             child: PhysicalModel(               color: Colors.black,               shadowColor: Colors.black.withOpacity(0.6),               borderRadius: BorderRadius.circular(12),               elevation: 12,               child: Container(                 decoration: const BoxDecoration(                   color: Colors.white,                   borderRadius: BorderRadius.all(Radius.circular(12)),                 ),                 child: MapPlaceCard(                   place: _selectedPlace!,                 ),               ),             ),           ),       ],     );   }

Теперь карта — с карточками:

Ветка репозитория: https://github.com/gooditcollective/flutter-google-maps-exmaple/tree/cards-on-tap

Меняющиеся маркеры

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

Маркеры будут добавляться методом _upsertMarker:

Future<void> _upsertMarker(Place place) async {     final selectedPrefix = place.id == _selectedPlace?.id ? "selected_" : "";     final favoritePostfix =         _likedPlaceIds.contains(place.id) ? "_favorite" : "";      final icon = await BitmapDescriptor.fromAssetImage(       const ImageConfiguration(),       "assets/icons/${selectedPrefix}map_place$favoritePostfix.png",     );      setState(() {       _markers.add(Marker(         markerId: MarkerId(place.id),         position: place.location,         onTap: () => _selectPlace(place),         icon: icon,       ));     });   }

Сердечко-лайк ставится методом _likeTapHandler:

void _likeTapHandler() async {     if (_selectedPlace == null) return;     setState(() {       if (_likedPlaceIds.contains(_selectedPlace!.id)) {         _likedPlaceIds.removeAt(_likedPlaceIds.indexOf(_selectedPlace!.id));       } else {         _likedPlaceIds.add(_selectedPlace!.id);       }     });      _upsertMarker(_selectedPlace!);   }

Метод вызывается в виджете MapPlaceCard:

@override   Widget build(BuildContext context) {     return Stack(       ...       children: <Widget>[         ...         if (_selectedPlace != null)           Positioned(             ...             child: PhysicalModel(               ...               child: Container(                 ...                 child: MapPlaceCard(                   place: _selectedPlace!,                   isLiked: _likedPlaceIds.contains(_selectedPlace!.id),                   likeTapHandler: _likeTapHandler,                 ),               ),             ),           ),       ],     );   }

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

class _CustomMapState extends State<CustomMap> {   ...   Future<void> _unselectPlace() async {     if (_selectedPlace == null) return;      final place = _selectedPlace;     setState(() {       _selectedPlace = null;     });      await _upsertMarker(place!);   }    Future<void> _selectPlace(Place place) async {     await _unselectPlace();      setState(() {       _selectedPlace = place;     });      await _upsertMarker(place);   }   ...   @override   Widget build(BuildContext context) {     return Stack(       ...       children: <Widget>[         GoogleMap(           ...           ),           markers: _markers,           onTap: (_) => _unselectPlace(),         ),         if (_selectedPlace != null)           Positioned(             bottom: 76,             child: PhysicalModel(               color: Colors.black,               shadowColor: Colors.black.withOpacity(0.6),               borderRadius: BorderRadius.circular(12),               elevation: 12,               child: Container(                 decoration: const BoxDecoration(                   color: Colors.white,                   borderRadius: BorderRadius.all(Radius.circular(12)),                 ),                 child: MapPlaceCard(                   place: _selectedPlace!,                   isLiked: _likedPlaceIds.contains(_selectedPlace!.id),                   likeTapHandler: _likeTapHandler,                 ),               ),             ),           ),       ],     );   } }

Теперь наша карта выглядит так:

Слева — неотмеченный ресторан, справа — отмеченный
Слева — неотмеченный ресторан, справа — отмеченный

Ветка репозитория: https://github.com/gooditcollective/flutter-google-maps-exmaple/tree/different-icons

Нестандартные маркеры

Осталось немного — чтобы название ресторана было видно всегда, а не только по тапу. Для этого пришлось сделать отдельную утилиту для рисования маркера utils/custom_marker_drawer.dart

... class CustomMarkerDrawer {   ...   Future<CustomMarker> createCustomMarkerBitmap({     ...   }) async {     ...     PictureRecorder recorder = PictureRecorder();     Canvas canvas = Canvas(       recorder,       Rect.fromLTWH(         0,         0,         scaledCanvasWidth,         scaledCanvasHeight,       ),     );     ...     Picture picture = recorder.endRecording();     ByteData? pngBytes = await (await picture.toImage(       scaledCanvasWidth.toInt(),       scaledCanvasHeight.toInt(),     ))         .toByteData(format: ImageByteFormat.png);      Uint8List data = Uint8List.view(pngBytes!.buffer);      final marker = BitmapDescriptor.fromBytes(data);     const double anchorDx = .5;     final anchorDy = imageHeight / scaledCanvasHeight;      return CustomMarker(marker: marker, anchorDx: anchorDx, anchorDy: anchorDy);   }   ... }

Мы рисуем на виртуальном Canvas, который потом преобразуем в Picture с помощью PictureRecorder. Результат преобразуем в Uint8List — список 8-битных беззнаковых целых чисел, который отправляем в BitmapDescriptor — объект, который определяет битмэп, которую Google Maps потом отрисовывает на карте.

Для рендера Flutter использует логические пиксели. Но, в зависимости от устройства, на один логический пиксель может приходиться несколько реальных. Чтобы иконки выглядели корректно независимо от устройства, используется параметр scale.

Вот как это выглядит в main.dart:

class _CustomMapState extends State<CustomMap> {   GoogleMapController? _controller;   final Set<Marker> _markers = {};     final CustomMarkerDrawer _markerDrawer = CustomMarkerDrawer();   double _scale = 1;    ...   @override   initState() {     super.initState();     Future.delayed(Duration.zero, () {       _likedPlaceIds.addAll([_places[0].id, _places[3].id]);       _scale = MediaQuery.of(context).devicePixelRatio;       _mapPlacesToMarkers();     });   }   ... }

Этот параметр отдает только класс MediaQueryData — он может быть только у потомков Material-виджетов, его нет в корневом виджете приложения. MediaQueryData.of(context) сработает только после полной инициализации initState, поэтому я обернул его в Future.delayed(Duration.zero, () {...} — это переводит исполнение лежащего в нем кода на следующий тик обработки, в котором initState уже полностью завершён.

Финальный вид карты:

Ветка репозитория

Итак, мы увидели, как во Flutter подключить Google Maps, как использовать стандартные и нестандартные маркеры. Если у вас остались вопросы — с удовольствием отвечу.


ссылка на оригинал статьи https://habr.com/ru/post/680092/

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *