Я — Тим, разработчик в Гудитворкс. Недавно мы делали приложение-гид по ресторанам. Нам было нужно, чтобы на карте отображалась информация о ресторанах, а пользователь мог бы отмечать понравившиеся. Я расскажу, как работать во 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/