Flutterで地図上のピンを押下時、BottomViewを表示する

## はじめに
地図を利用したアプリケーションで、地図上のピンを押下した時に下からViewが出てくることよくありますよね。あれ、いいなぁと思っていたんですがどうやって実装するんだろうと気になったので、実装してみました。
せっかくなので、記事にして共有していきます！！

## 成果物

![map_aicon.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599049/f4b2455c-0387-982e-d03d-86b64b4e79d8.gif)

## ソースコード

```shell:ディレクトリ構成
~/develop/work_cafe (feat/ui)$ tree lib 
lib
├── main.dart                            # アプリのエントリーポイント
├── models
│   └── place.dart                       # データモデル（場所情報など）を定義
├── router
│   └── routes.dart                      # アプリ内のルーティング設定
├── screen
│   └── map_screen.dart                  # 地図を表示する画面のレイアウトとロジック
└── widgets
    ├── bottom_sheet_widget.dart         # ピンをタップした際に表示されるボトムシートのウィジェット
    ├── custom_map.dart                  # 地図表示用のカスタムウィジェット
    └── map_marker.dart                  # 地図上にピンを表示するためのウィジェット
```

```dart:lib/main.dart
import 'package:flutter/material.dart';
import 'router/routes.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: router,
      title: 'Custom Map Pins',
    );
  }
}
```

```dart:lib/router/routes.dart
import 'package:go_router/go_router.dart';

import '../screen/map_screen.dart';

final GoRouter router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => MapScreen(),
    ),
  ],
);
```
:::note info
ここではルーティングを実施していきます。
現状定義しているのが1つのみのため、あまり恩恵が得られないですが、ルーティングは分けておく方が扱いやすいです。
:::


```dart:lib/screen/map_screen.dart
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import '../widgets/custom_map.dart';
import '../widgets/bottom_sheet_widget.dart';
import '../widgets/map_marker.dart';

class MapScreen extends StatefulWidget {
  @override
  _MapScreenState createState() => _MapScreenState();
}

class _MapScreenState extends State<MapScreen> {
  final Set<Marker> _markers = {};
  LatLng? _selectedLocation;
  String? _selectedStoreName;
  String? _selectedOpeningHours;

  @override
  void initState() {
    super.initState();
    _markers.addAll(getMapMarkers(onMarkerTapped));
  }

  void onMarkerTapped(LatLng location, String storeName, String openingHours) {
    setState(() {
      _selectedLocation = location;
      _selectedStoreName = storeName;
      _selectedOpeningHours = openingHours;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('近隣のコンビニマップ'),
      ),
      body: Stack(
        children: [
          CustomMap(
            markers: _markers,
          ),
          if (_selectedLocation != null)
            BottomSheetWidget(
              selectedLocation: _selectedLocation,
              storeName: _selectedStoreName,
              openingHours: _selectedOpeningHours,
            ),
        ],
      ),
    );
  }
}
```
:::note info
アプリケーション全体を表示するスクリーンを定義しています。
GoogleMap上に表示するマーカー(ピン)を定義しています。ピンが選択された場合のみBottomViewを表示しています。
:::



```dart:lib/widgets/bottom_sheet_widget.dart
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';

class BottomSheetWidget extends StatelessWidget {
  final LatLng? selectedLocation;
  final String? storeName;
  final String? openingHours;

  BottomSheetWidget({
    required this.selectedLocation,
    this.storeName,
    this.openingHours,
  });

  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: Alignment.bottomCenter,
      child: Container(
        width: double.infinity,
        padding: EdgeInsets.all(16.0),
        margin: EdgeInsets.all(16.0),
        decoration: BoxDecoration(
          color: Colors.white,
          borderRadius: BorderRadius.circular(16.0),
          boxShadow: [
            BoxShadow(
              color: Colors.black26,
              blurRadius: 10.0,
              offset: Offset(0, 10),
            ),
          ],
        ),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            if (storeName != null)
              Text(
                storeName!,
                style: TextStyle(
                  fontSize: 18.0,
                  fontWeight: FontWeight.bold,
                ),
              ),
            SizedBox(height: 16.0),
            Row(
              children: [
                Image.asset(
                  'assets/store.png', // サンプル画像
                  width: 50.0, // 画像の横幅
                  height: 50.0, // 画像の高さ
                ),
                SizedBox(width: 8.0),
                if (openingHours != null)
                  Text('営業時間: $openingHours'), // 営業時間を表示
              ],
            ),
          ],
        ),
      ),
    );
  }
}
```
:::note info
Map上を選択した場合、下から表示されるBottomViewです。
:::

```dart:lib/widgets/custom_map.dart
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';

class CustomMap extends StatelessWidget {
  final Set<Marker> markers;

  CustomMap({required this.markers});

  @override
  Widget build(BuildContext context) {
    return GoogleMap(
      initialCameraPosition: CameraPosition(
        target: LatLng(34.7057145, 135.5017749), // sample実装のため、デフォルト位置は固定しています。
        zoom: 14,
      ),
      markers: markers,
      onMapCreated: (GoogleMapController controller) {},
    );
  }
}
```

```dart:lib/widgets/map_marker.dart
import 'package:google_maps_flutter/google_maps_flutter.dart';

List<Marker> getMapMarkers(
    void Function(LatLng, String, String) onMarkerTapped) {
  return [
    Marker(
      markerId: MarkerId('place1'),
      position: LatLng(34.7057145, 135.5017749),
      infoWindow: InfoWindow(title: 'ローソン 梅田センタービル店'),
      onTap: () => onMarkerTapped(
        LatLng(34.7057145, 135.5017749),
        'ローソン 梅田センタービル店',
        '24 Hours',
      ),
    ),
    Marker(
      markerId: MarkerId('place2'),
      position: LatLng(34.709691, 135.514128),
      infoWindow: InfoWindow(title: 'ローソンストア100 北区菅栄町店'),
      onTap: () => onMarkerTapped(
        LatLng(34.709691, 135.514128),
        'ローソンストア100 北区菅栄町店',
        '24 Hours',
      ),
    ),
  ];
}
```


## 感想
思ったより簡単に実装できました。皆さんもよければ参考に実装してみてください！