Я — Тим, разработчик в Гудитворкс. Недавно мы делали приложение-гид по ресторанам. Нам было нужно, чтобы на карте отображалась информация о ресторанах, а пользователь мог бы отмечать понравившиеся. Я расскажу, как работать во 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
уже полностью завершён.
Финальный вид карты:
Ветка репозитория: https://github.com/gooditcollective/flutter-google-maps-exmaple/tree/custom-icon-drawer
Итак, мы увидели, как во Flutter подключить Google Maps, как использовать стандартные и нестандартные маркеры. Если у вас остались вопросы — с удовольствием отвечу.