♨️

【Flutter】サウナライフで実装したマップ機能の仕組み

2024/11/25に公開

マップで近くのサウナを簡単に検索できるアプリ「サウナライフ」の開発・運用しています。
実装したマップ機能の仕組みを解説します。

サウナライフのダウンロードはこちらから👇
iOS版, Android版

主な機能は以下の通りです。

  • マップにマーカーピンを表示
  • マップを操作してマーカーピンを表示する(自動)
  • マーカーピンをカスタマイズする

サウナライフ

google_maps_flutterを利用しています。ネイティブアプリでマップを表示するだけであれば無料で利用できます(Mobile Native Dynamic Maps

マップにマーカーピンを表示

マップ画面上にマーカーピンを表示するため、画面範囲が分かる緯度と経度を取得する必要があります。

GoogleMapにあるonMapCreatedからGoogleMapControllerを取得できます。

GoogleMapControllerはマップ操作や情報取得できるもので、getVisibleRegionという関数があり、そこから現在表示されているマップ画面の南西と北東の緯度と経度を取得できます。

この緯度と経度をDBのクエリとして利用して、表示するマーカーピンのデータを取得します。

GoogleMap(
    ...
    onMapCreated: (GoogleMapController googleMapController) async {
        final region = await googleMapController.getVisibleRegion();
        final southwest = region.southwest; // 南西の緯度と経度
        final northeast = region.northeast; // 北東の緯度と経度
        // southwestとnortheastを利用したクエリをリクエストする
    },
    ...
)

取得したデータをマーカーピンとして表示します。

GoogleMapにはfinal Set<Marker> markersというパラメータがあるので、そこにMarkerに変換してセットします。

GoogleMap(
    ...
    markers: markers.toSet(),
)
Future<void> fetchSpots() async {
    // 緯度と経度を取得
    final region = await googleMapController.getVisibleRegion();
    
    // データを取得
    final spots = await fetchSaunaSpots(region);
    
    // Markerに変換
    final markers = spots.map(
      (element) {
        final spot = element.saunaSpotInfo.saunaSpot;
        final markerId = MarkerId(spot.id);
        return Marker(
          markerId: markerId, // Setの識別子
          icon: element.bitmap, // ピンの画像
          zIndex: spot.medalScore?.toDouble() ?? 0, // 表示順
          anchor: const Offset(0.5, 0.7), // アンカー
          position: LatLng(spot.latitude, spot.longitude), // 位置
          onTap: () {
            // タップイベント実装する
            // カメラをマーカーピンへ移動したり、同期するカルーセルのindexを変更したり等
          },
        );
      },
    ).toList();
    
    // 状態を更新 setState or Notifier(state = xxx)
}

マップを操作してマーカーピンを表示する(自動)

サウナライフでは、データをキャッシュしているため、マップを移動する毎に画面範囲内のデータを取得してマーカーピンとして表示します。

GoogleMapにはgestureRecognizersがあるので、DragGestureを追加して、マップ上をドラッグされたらデータを取得する処理を実施します。GoogleMaponCameraMoveはカメラが動く度に発火し、onCameraMoveStartedだとピンチ操作してから動かすと発火しない挙動であったため、こちらのコールバック関数は使いません。

GoogleMap(
    ...
    gestureRecognizers: {
        Factory<OneSequenceGestureRecognizer>(
          () => DragGesture(
            () => saunaMapController.onSearch(), // 取得処理
          ),
        ),
    },
),

操作毎に取得すると、データの取得タイミングによっては意図しない挙動になるためいくつか制御を加えます。

サウナライフでは、マーカーピンをタップするとその位置へカメラが移動するようにしています。その際にDragGestureの処理が発火するためここの制御もします。

まずは、データ取得中に再度取得しようとしても後の処理を実行しないよう処理中フラグで制御します。

次に、マーカーピンのタップによるカメラ移動でデータを再取得しないよう処理が開始してもその処理をキャンセルするようにします。

また、マップ操作してから500msの遅延処理を加えて取得します。遅延処理を加えると、マップを動かして止まった位置のデータ取得がちょうど良いタイミングだと感じたためです(色々試してみた結果)

これらの制御を管理するSaunaMapControllerを実装して利用します。コードは以下の通りです。

class SaunaMapController extends StateNotifier<SaunaMapState> {

    // キャンセル操作
    CancelableOperation<void>? _co;

    // ドラッグ操作によりデータを取得してマーカーピンを表示する
    Future<void> onSearch({int milliseconds = 500}) async {
        
        Future<void> search() async {
          return Future.delayed(Duration(milliseconds: milliseconds), () async {
            // 処理中に実行されても処理しない
            if (state.loading == SearchLoading.loading) {
              return;
            }

            // マーカーピンタップによるカメラ移動で実行されても処理しない
            if (_co?.isCanceled == true) {
              return;
            }

            // 取得する
            state = state.copyWith(loading: SearchLoading.loading);
            final region = await googleMapController.getVisibleRegion();
            await fetchSpots(region);
            state = state.copyWith(loading: SearchLoading.completed);
          });
        }
        
        _co = CancelableOperation<void>.fromFuture(search());
    }

    // マーカーピンタップ時の処理
    Future<void> onTapMarker(SaunaSpot spot) async {
        await _co?.cancel();
        ...
    }
}

実際にアプリを触ってみてください。いい感じに表示されると思います。

マーカーピンをカスタマイズする

サウナライフ

GoogleMapでは、マーカーピンをカスタマイズしたものを利用するためにはカスタマイズしたBitmapDescriptorを用意します。マーカーピンのWidgetをそのまま適用することはできませんので画像にする必要があります。

静的な画像であれば、svgなどの画像ファイルをBitmapDescriptorに変更してセットすればいいですが、動的な画像であれば工夫が必要です。

静的な画像の場合、AssetMapBitmapを利用してBitmapDescriptorに変換できます。

BitmapDescriptor createMarkerIconFromAsset(
    String filePath,
    BuildContext context, {
    double? width,
    double? height,
}) {
    return AssetMapBitmap(
          filePath,
          width: width,
          height: height,
          imagePixelRatio: MediaQuery.maybeDevicePixelRatioOf(context),
    );
}

動的な画像の場合、以下のような手順が必要です。

  1. 必要なデータを取得する
    • 施設画像、施設名、レビュースコア
  2. 必要なデータが揃ったら、マーカーピンにするWidgetを構築する
  3. Widget → 画像 → BitmapDescriptorに変換して、マーカーピンとしてセットする。

DBから必要なデータを取得します、画像は別ストレージで管理しているため、画像のダウンロードも同期させます。

構築したWidgetをRepaintBoundaryを利用して画像化するため、現在表示されてるルートページ上でWidgetを構築します。表示外の別のルートページ上で構築するとFailed assertion: line 3369 pos 12: '!debugNeedsPaint': is not trueで失敗してしまうためです。

サウナライフでは、マップ画面を保持するボトムナビゲーションページでStackを利用して裏側でマーカーピンのWidgetを構築します。構築が完了するまで遅延処理を設け、BitmapDescriptorへ変換してマップにセットします。

main_page.dart
class MainPage extends ConsumerWidget {
    ...
    
    
    Widget build(BuildContext context, WidgetRef ref) {
        ...

        // カスタマイズしたマーカーピンWidget
        final repaintSaunaMarkers = ref.watch(repaintSaunaMarkersProvider);

        // タップされた時のマーカーピンWidget
        final focusRepaintSaunaMarker = ref.watch(focusRepaintSaunaMarkerProvider);
        
        return Scaffold(
          body: Stack(
            children: [
                // 裏側でマーカーピンWidgetを表示する
                ...repaintSaunaMarkers,
                if (focusRepaintSaunaMarker != null) focusRepaintSaunaMarker,
                
                // マップ画面等を保持するStack
                IndexedStack(
                    ...
                ),
            ],
          ),
          bottomNavigationBar: ...
        );
    }
}

repaintSaunaMarkersProviderでWidgetの状態を保持し、保持したWidgetのBitmapDescriptorを取得する処理を実装します(focusRepaintSaunaMarkerも同様)

repaint_sauna_markers.dart
typedef Param = ({
    SaunaSpotInfo saunaSpotInfo,
    GlobalKey<State<StatefulWidget>> globalKey,
    String? imageUrl,
});

typedef IconResult = ({
    String saunaSpotId,
    BitmapDescriptor? bitmapDescriptor,
});


class RepaintSaunaMarkers extends _$RepaintSaunaMarkers {

    // SaunaMarkerRepaintはWidget
    
    List<SaunaMarkerRepaint> build() {
        return [];
    }

    // Widgetに必要なデータをセットする
    void setState(List<Param> list) {
        // カラーを特定の条件によって変更したいため取得
        final color = ref.read(fetchMarkerFontColorProvider)();
        state = list
            .map(
              (e) => ref.read(
                repaintSaunaMarkerProvider(
                  e,
                  color.fontColor,
                  color.fontBorderColor,
                ),
              ),
            )
            .toList();
    }

    // WidgetからBitmapDescriptorへ変換して返却する
    Future<List<IconResult>> createIcons(List<Param> list) async {
        final result = await list.map((e) async {
            final icon = await createMarkerIconFromWidget(e.globalKey);
            return (
                saunaSpotId: e.saunaSpotInfo.saunaSpot.id,
                bitmapDescriptor: icon,
            );
        }).wait;
        state = []; // 変換後はメモリ節約のためstateをリセットする
        return result;
    }
}
create_marker_icon_from_widget.dart
Future<BitmapDescriptor?> createMarkerIconFromWidget(
    GlobalKey key, {
    double? width,
    double? height,
}) async {
    try {
        final context = key.currentContext;
        if (context == null) {
            return null;
        }
        final boundary = context.findRenderObject() as RenderRepaintBoundary?;
        if (boundary == null) {
            return null;
        }
        final image = await boundary.toImage();
        final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
        if (byteData == null) {
            return null;
        }
        if (!context.mounted) {
            return null;
        }
        return BytesMapBitmap(
            byteData.buffer.asUint8List(),
            width: width,
            height: height,
            imagePixelRatio: MediaQuery.maybeDevicePixelRatioOf(context),
        );
        // ignore: avoid_catches_without_on_clauses
    } catch (e) {
        debugPrint(e);
        return null;
    }
}

SaunaMarkerRepaintは実際にマーカーピンとして構築したいStatelessWidgetです。施設画像、施設名、レビュースコアを反映したWidgetです。

画像はキャッシュする前提で扱います。パッケージはextended_imageを利用しています。

SaunaMarkerRepaintの実装
sauna_marker_repaint.dart
class SaunaMarkerRepaint extends StatelessWidget {
  const SaunaMarkerRepaint({
    super.key,
    required this.globalKey,
    required this.title,
    required this.isCheckIn,
    required this.isBookmark,
    required this.score,
    this.imageUrl,
    required this.fontColor,
    this.fontBorderColor,
    required this.isSelected,
  });

  final GlobalKey globalKey;
  final String title;
  final String? imageUrl;
  final bool isCheckIn;
  final bool isBookmark;
  final int score;
  final Color fontColor;
  final Color? fontBorderColor;
  final bool isSelected;

  
  Widget build(BuildContext context) {
    final isBig = context.isHighHeight;
    final baseWidth = (isBig ? 180.0 : 120.0) * (isSelected ? 1.5 : 1);
    final baseSize = Size(baseWidth, baseWidth - 8);
    final markerSize = Size(baseWidth, baseWidth);
    final markerBaseSize = Size(baseWidth, baseWidth);
    final imageSize = markerSize.width * 0.65;
    final imageBorderRadius = (isBig ? 50.0 : 32.0) * (isSelected ? 1.5 : 1);
    final imagePaddingBottom = (isBig ? 12.0 : 8.0) * (isSelected ? 2 : 1);
    final selectedBasePadding = const EdgeInsets.symmetric(horizontal: 108)
        .copyWith(top: 8 * (isBig ? 2 : 1));

    final fontSize = (isBig ? 30.0 : 26.0) * (isSelected ? 1.1 : 1);

    final textWidth = markerSize.width * 1.9;

    final imageUrl = this.imageUrl;
    final fontBorderColor = this.fontBorderColor;

    final scorePadding = EdgeInsets.all((isBig ? 12 : 4) * (isSelected ? 3 : 1))
        .copyWith(top: 0, left: 0);
    final markerColor = isCheckIn
        ? kGreen3Color
        : isBookmark
            ? kOrangeColor
            : Colors.white;
    return RepaintBoundary(
      key: globalKey,
      child: SizedBox(
        width: textWidth,
        child: Stack(
          children: [
            if (isSelected)
              Padding(
                padding: selectedBasePadding,
                child: SvgPicture.asset(
                  Assets.images.markerSelectedBase,
                  width: markerBaseSize.width,
                  height: markerBaseSize.height,
                  fit: BoxFit.fitWidth,
                  alignment: Alignment.bottomCenter,
                ),
              ),
            Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                SizedBox.fromSize(
                  size: baseSize,
                  child: Stack(
                    fit: StackFit.expand,
                    children: [
                      SvgPicture.asset(
                        Assets.images.markerFukidashi,
                        width: markerSize.width,
                        height: markerSize.height,
                        colorFilter: ColorFilter.mode(
                          markerColor,
                          BlendMode.srcIn,
                        ),
                        fit: BoxFit.fitWidth,
                        alignment: Alignment.topCenter,
                      ),
                      Padding(
                        padding: EdgeInsets.only(
                          bottom: imagePaddingBottom,
                        ),
                        child: CircleAvatar(
                          backgroundColor: Colors.transparent,
                          child: imageUrl != null
                              ? ExtendedImage.network(
                                  imageUrl,
                                  width: imageSize,
                                  height: imageSize,
                                  fit: BoxFit.cover,
                                  loadStateChanged: (ExtendedImageState state) {
                                    return switch (
                                        state.extendedImageLoadState) {
                                      LoadState.completed => DecoratedBox(
                                          decoration: BoxDecoration(
                                            borderRadius: BorderRadius.circular(
                                              imageBorderRadius,
                                            ),
                                            image: DecorationImage(
                                              image: state.imageWidget.image,
                                              fit: BoxFit.cover,
                                            ),
                                          ),
                                        ),
                                      _ => Image.asset(
                                          Assets.images.markerSauna.path,
                                        ),
                                    };
                                  },
                                )
                              : Image.asset(
                                  Assets.images.markerSauna.path,
                                  width: imageSize,
                                  height: imageSize,
                                  fit: BoxFit.cover,
                                ),
                        ),
                      ),
                      if (score > 0)
                        Padding(
                          padding: scorePadding,
                          child: Align(
                            alignment: Alignment.bottomRight,
                            child: Card(
                              color: Colors.white,
                              shape: const StadiumBorder(),
                              child: Padding(
                                padding: const EdgeInsets.symmetric(
                                  vertical: 8,
                                  horizontal: 16,
                                ),
                                child: Text(
                                  score.toString(),
                                  style: context.bodyStyle.copyWith(
                                    color: kPrimaryColor,
                                    fontSize: fontSize,
                                    fontWeight: FontWeight.bold,
                                    height: 1.3,
                                  ),
                                  maxLines: 1,
                                  textAlign: TextAlign.center,
                                ),
                              ),
                            ),
                          ),
                        ),
                    ],
                  ),
                ),
                SizedBox(
                  width: textWidth,
                  child: Text(
                    title,
                    style: context.bodyStyle.copyWith(
                      inherit: true,
                      fontWeight: FontWeight.bold,
                      color: fontColor,
                      fontSize: fontSize,
                      height: 1.3,
                      shadows: fontBorderColor != null
                          ? [
                              Shadow(
                                offset: const Offset(-1.5, -1.5),
                                color: fontBorderColor,
                              ),
                              Shadow(
                                offset: const Offset(1.5, -1.5),
                                color: fontBorderColor,
                              ),
                              Shadow(
                                offset: const Offset(1.5, 1.5),
                                color: fontBorderColor,
                              ),
                              Shadow(
                                offset: const Offset(-1.5, 1.5),
                                color: fontBorderColor,
                              ),
                            ]
                          : null,
                    ),
                    maxLines: 2,
                    textAlign: TextAlign.center,
                    overflow: TextOverflow.ellipsis,
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

これらを制御するfetchSpotsは以下の通りです。

Future<void> fetchSpots() async {
    // 緯度と経度を取得
    final region = await googleMapController.getVisibleRegion();
    
    // データを取得
    final spots = await fetchSaunaSpots(region);

    // 必要なデータをマーカーピンWidgetにセットする
    final repaintSaunaMarkersController =
        _ref.read(repaintSaunaMarkersProvider.notifier);
    final repaintParams = spots
          .map(
            (e) => (
              saunaSpotInfo: e,
              globalKey: GlobalKey(),
              imageUrl: e.saunaSpot.getThumbnailUrl(storageUrl)
            ),
          )
          .toList();
    repaintSaunaMarkersController.setState(repaintParams);

    // Widgetが構築されるまで待つ
    // 画像のダウンロード完了を待っても良い
    await Future<void>.delayed(
        Duration(milliseconds: Platform.isAndroid ? 400 : 200), // 調整値
    );

    // 構築されたWidgetから変換されたbitmapDescriptorを取得する
    final icons = await repaintSaunaMarkersController.createIcons(repaintParams);

    // Markerに変換
    final markers = ... // iconsのbitmapDescriptorをMarkerにセットする
    
    // 状態を更新 setState or Notifier(state = xxx)
}

Widgetが構築されるまで待つのに加え、画像のURLから画像のダウンロードが完了するのを待っても良いです。ただ、その分マーカーピンの表示が遅れてしまうのでサウナライフでは実施していません。

ダウンロード完了前にマーカーピンが表示されてしまう場合がありますが、その場合はデフォルト画像を表示している点と、ユーザーのドラッグ操作でその都度再取得するのでそこでカバーできるためです。

1度で取得する確実さより、速さを重視しました。

その他

マップタイプを変更

以下のように、enumで定義されています。MapTypeを変更するだけで、容易にマップタイプを変更できます。

MapType
enum MapType {
  /// Do not display map tiles.
  none,

  /// Normal tiles (traffic and labels, subtle terrain information).
  normal,

  /// Satellite imaging tiles (aerial photos)
  satellite,

  /// Terrain tiles (indicates type and height of terrain)
  terrain,

  /// Hybrid tiles (satellite images with some labels/overlays)
  hybrid,
}
GoogleMap(
    ...
    mapType: MapType.satellite,
),

ダークモード対応

ダークモードにするためには、styleにダークモードのstyleを設定します。パラメータで簡単にOn/Offできるものではありません。

Styling Wizard: Google Maps APIsから、ダークモードのstyleをエクスポートして適応します。style自体は文字列で指定するので、エクスポートしたJSONファイルの文字列を直接セットしています。

ダークモードのスタイル
const darkStyle = '''
[
  {
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#242f3e"
      }
    ]
  },
  {
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#746855"
      }
    ]
  },
  {
    "elementType": "labels.text.stroke",
    "stylers": [
      {
        "color": "#242f3e"
      }
    ]
  },
  {
    "featureType": "administrative.locality",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#d59563"
      }
    ]
  },
  {
    "featureType": "administrative.neighborhood",
    "stylers": [
      {
        "visibility": "off"
      }
    ]
  },
  {
    "featureType": "poi",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#d59563"
      }
    ]
  },
  {
    "featureType": "poi.business",
    "stylers": [
      {
        "visibility": "off"
      }
    ]
  },
  {
    "featureType": "poi.park",
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#263c3f"
      }
    ]
  },
  {
    "featureType": "poi.park",
    "elementType": "labels.text",
    "stylers": [
      {
        "visibility": "off"
      }
    ]
  },
  {
    "featureType": "poi.park",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#6b9a76"
      }
    ]
  },
  {
    "featureType": "road",
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#38414e"
      }
    ]
  },
  {
    "featureType": "road",
    "elementType": "geometry.stroke",
    "stylers": [
      {
        "color": "#212a37"
      }
    ]
  },
  {
    "featureType": "road",
    "elementType": "labels",
    "stylers": [
      {
        "visibility": "off"
      }
    ]
  },
  {
    "featureType": "road",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#9ca5b3"
      }
    ]
  },
  {
    "featureType": "road.highway",
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#746855"
      }
    ]
  },
  {
    "featureType": "road.highway",
    "elementType": "geometry.stroke",
    "stylers": [
      {
        "color": "#1f2835"
      }
    ]
  },
  {
    "featureType": "road.highway",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#f3d19c"
      }
    ]
  },
  {
    "featureType": "transit",
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#2f3948"
      }
    ]
  },
  {
    "featureType": "transit.station",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#d59563"
      }
    ]
  },
  {
    "featureType": "water",
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#17263c"
      }
    ]
  },
  {
    "featureType": "water",
    "elementType": "labels.text",
    "stylers": [
      {
        "visibility": "off"
      }
    ]
  },
  {
    "featureType": "water",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#515c6d"
      }
    ]
  },
  {
    "featureType": "water",
    "elementType": "labels.text.stroke",
    "stylers": [
      {
        "color": "#17263c"
      }
    ]
  }
]
''';
GoogleMap(
    ...
    style: darkStyle,
),
株式会社Never

Discussion