【Flutter】コピペでできる!GoogleMap+カードの滑らかなUIの作り方


こういうUIを作りたいねんか

「カードとマーカーが連携したUIを作りたいんだ。。。!」
「でも下のカードの部分、どう作るんだ...?!」
悩み続けること半世紀。ついにその方法を見つけることができたのであった。。。(結構簡単です)

方法

GoogleMap+Card
一見下のカード部分を実装するのが難しそうに見えるがわかればとても簡単です。
カード部分はPageViewで実装しています!
つまりGoogleMapにStackでPageViewを重ね合わせているだけなのです。
PageViewでは端っこがちょっと見えてる感じだったり、カードをスワイプしていく動きを簡単に実装することができます!あとはスワイプ時に任意のマーカーにカメラを移動させるという動きを連携させるだけです!それではコードを見ていきましょう

実装

Shopクラスを作成

お店の情報をマップ上に表示させることを想定してShopクラスを作成。
名前や座標情報を持たせています。

class Shop {
  String uid;
  double latitude;
  double longitude;
  String name;

  Shop(this.uid, this.latitude, this.longitude, this.name);
}

ShopのListを作成

今回はローカルでShopのListを作成。実際はFirestoreやDBにお店の情報を登録しておいてそれを取ってくる感じになると思います。今回は適当に北海道の名所を設定

final shops = [
  Shop('1', 43.0779575, 141.337819, '北海道大学'),
  Shop('2', 43.0692162, 141.3473406, '175°DENO坦々麺札幌駅北口店'),
  Shop('3', 43.05432, 141.3517185, 'UTAGE SAPPORO'),
  Shop('4', 43.0673817, 141.3416878, 'ラーメン二郎札幌店'),
  Shop('5', 43.072069, 141.331253, '焼肉と料理シルクロード'),
];

Map部分

markersの部分が重要になってくるかなと思います。先程のList<Shop>をマーカーとしてプロットしています。
またマーカーをタップした時に下のカードにタップしたお店を表示させるようにしています。

 Widget _mapSection() {
    return GoogleMap(
      mapType: MapType.normal,
      initialCameraPosition: _initialCameraPosition,
      onMapCreated: (GoogleMapController controller) {
        _mapController = controller;
      },
      markers: shops.map(
        (selectedShop) {
          return Marker(
            markerId: MarkerId(selectedShop.uid),
            position: LatLng(selectedShop.latitude, selectedShop.longitude),
            icon: BitmapDescriptor.defaultMarker,
            onTap: () async {
              //タップしたマーカー(shop)のindexを取得
              final index = shops.indexWhere((shop) => shop == selectedShop);
              //タップしたお店がPageViewで表示されるように飛ばす
              _pageController.jumpToPage(index);
            },
          );
        },
      ).toSet(),
    );
  }

カード(PageView)部分

PageViewで下のカード部分を表示しています。
またonPageChangedでスワイプ後のList<shop>のindexを取得。つまりPageViewでスワイプ後に真ん中にくるShopがわかります。あとはその座標までGoogleMapControllerでカメラを移動させるだけです!

 Widget _cardSection() {
    return Container(
      height: 148,
      padding: const EdgeInsets.fromLTRB(0, 0, 0, 20),
      child: PageView(
        onPageChanged: (int index) async {
          //スワイプ後のページのお店を取得
          final selectedShop = shops.elementAt(index);
          //現在のズームレベルを取得
          final zoomLevel = await _mapController.getZoomLevel();
          //スワイプ後のお店の座標までカメラを移動
          _mapController.animateCamera(
            CameraUpdate.newCameraPosition(
              CameraPosition(
                target: LatLng(selectedShop.latitude, selectedShop.longitude),
                zoom: zoomLevel,
              ),
            ),
          );
        },
        controller: _pageController,
        children: _shopTiles(),
      ),
    );
  }
	
  //カード1枚1枚について
  List<Widget> _shopTiles() {
    final _shopTiles = shops.map(
      (shop) {
        return Card(
          child: SizedBox(
            height: 100,
            child: Center(
              child: Text(shop.name),
            ),
          ),
        );
      },
    ).toList();
    return _shopTiles;
  }	

マップとカードを重ねる

それぞれをStackで重ね合わせる。

 
  Widget build(BuildContext context) {
    return Stack(
      alignment: Alignment.bottomCenter,
      children: [
        _mapSection(),
        _cardSection(),
      ],
    );
  }	

コピペでok!全文サンプルコード

main.dart
import 'package:flutter/material.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);

  
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Flutter Google Maps Demo',
      home: MapSample(),
    );
  }
}

class MapSample extends StatefulWidget {
  const MapSample({Key? key}) : super(key: key);

  
  State<MapSample> createState() => MapSampleState();
}

class MapSampleState extends State<MapSample> {
  late GoogleMapController _mapController;

  final _pageController = PageController(
    viewportFraction: 0.85,//0.85くらいで端っこに別のカードが見えてる感じになる
  );

  //初期位置を札幌駅に設定してます
  final CameraPosition _initialCameraPosition = const CameraPosition(
    target: LatLng(43.0686606, 141.3485613),
    zoom: 12,
  );

  
  void initState() {
    super.initState();
  }

  
  Widget build(BuildContext context) {
    return Stack(
      alignment: Alignment.bottomCenter,
      children: [
        _mapSection(),
        _cardSection(),
      ],
    );
  }

  Widget _mapSection() {
    return GoogleMap(
      mapType: MapType.normal,
      initialCameraPosition: _initialCameraPosition,
      onMapCreated: (GoogleMapController controller) {
        _mapController = controller;
      },
      markers: shops.map(
        (selectedShop) {
          return Marker(
            markerId: MarkerId(selectedShop.uid),
            position: LatLng(selectedShop.latitude, selectedShop.longitude),
            icon: BitmapDescriptor.defaultMarker,
            onTap: () async {
              //タップしたマーカー(shop)のindexを取得
              final index = shops.indexWhere((shop) => shop == selectedShop);
              //タップしたお店がPageViewで表示されるように飛ばす
              _pageController.jumpToPage(index);
            },
          );
        },
      ).toSet(),
    );
  }

  Widget _cardSection() {
    return Container(
      height: 148,
      padding: const EdgeInsets.fromLTRB(0, 0, 0, 20),
      child: PageView(
        onPageChanged: (int index) async {
          //スワイプ後のページのお店を取得
          final selectedShop = shops.elementAt(index);
          //現在のズームレベルを取得
          final zoomLevel = await _mapController.getZoomLevel();
          //スワイプ後のお店の座標までカメラを移動
          _mapController.animateCamera(
            CameraUpdate.newCameraPosition(
              CameraPosition(
                target: LatLng(selectedShop.latitude, selectedShop.longitude),
                zoom: zoomLevel,
              ),
            ),
          );
        },
        controller: _pageController,
        children: _shopTiles(),
      ),
    );
  }

  List<Widget> _shopTiles() {
    final _shopTiles = shops.map(
      (shop) {
        return Card(
          child: SizedBox(
            height: 100,
            child: Center(
              child: Text(shop.name),
            ),
          ),
        );
      },
    ).toList();
    return _shopTiles;
  }
}

/// お店の情報を持つクラス。マップに表示させるために座標を持たせている
class Shop {
  String uid;
  double latitude;
  double longitude;
  String name;

  Shop(this.uid, this.latitude, this.longitude, this.name);
}

/// 北海道の名所
final shops = [
  Shop('1', 43.0779575, 141.337819, '北海道大学'),
  Shop('2', 43.0692162, 141.3473406, '175°DENO坦々麺札幌駅北口店'),
  Shop('3', 43.05432, 141.3517185, 'UTAGE SAPPORO'),
  Shop('4', 43.0673817, 141.3416878, 'ラーメン二郎札幌店'),
  Shop('5', 43.072069, 141.331253, '焼肉と料理シルクロード'),
];

まとめ

いかがだったでしょうか?個人的にはWidgetの使い方がわかればそこまで難しくはないかなと思います! FlutterのUI作成においてはWidgetの知識が重要で、知ってるか知らないかではかなりの差ができると思うので一度簡単に全てのWidgetに触れておくことがいいかもしれないですね!