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

Комментарии (1)


  1. Tuxman
    02.08.2022 19:32

    Чтобы подключиться к картам, понадобится API-ключ

    Хотелось узнать подробнее о API ключах к платным сервисам Гугла. У меня есть аккаунт, я плачу денежку, я могу создать API ключи, а дальше я этот ключ прямо хардкожу в приложение? Есть какой-то механизм ротейта ключа? А если кто-то достанет ключ из приложения и начнёт пользоваться вне приложения, то это будет увеличивать мой счёт от Гугла потом?