【Flutter】ユーザの入力した文字列から場所情報を取得し、GoogleMap上に表示する

## やりたいこと

以下の挙動を実現する。

<img width=400 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/270898/b3c0f92d-6dde-8738-9219-9630fc9eaf8f.gif">


対象：
Flutterで作成しているネイティブアプリ

機能：

- 入力された場所名から候補となるスポット情報を取得し一覧表示。
- 一覧内のアイテムをタップすることで、以下を表示する。
    - GoogleMap上で選択された場所にMarkerを設置。
    - Marker上に、場所名や住所などの場所情報をwindow表示。
    - 選択された場所のoogleMapに登録されている画像を表示。

## 実装

### GoogleMapApiからの情報取得
GoogleMapApiからの情報の取得方法は、以下のページが参考になる。

https://blog.toru-takagi.dev/article/19

### コード全文

とりあえず目的の動きをするコードは、以下。
pointは以下の点。

- 検索窓が画面上部、画像が画面下部というレイアウト。
- 検索結果表示の背景色を選択状態によって切り分け。
- 検索結果内のアイテムタップ時の処理に関して
    - setStateの外にMarkerの設定処理を出す。
    - 特定のmakerを表示させる処理をタップ時の処理の最後におく。
- null値を取りうるものは、nullの際は空の`Container`を返すようにする。


```dart:
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:google_maps_webservice/places.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';

import 'package:map_test_2/models/spots.dart';
import 'package:map_test_2/repository/place_repository.dart';

class PostScreen extends StatefulWidget {
  @override
  _PostScreenState createState() => _PostScreenState();
}

class _PostScreenState extends State<PostScreen> {
  final _initialPosition = LatLng(37.7786, -122.4375);
  GoogleMapController? mapController;

  Spot? selectedSpot;
  String? selectedPhotoUrl;
  List<Map<String, dynamic>> searchedValue = [];
  Set<Marker> markers = {};
  
  // 取得結果をクリアする
  void _resetResult() {
    setState(() {
      selectedSpot = null;
      selectedPhotoUrl = null;
      searchedValue = [];
      markers = {};
    });
  }

  // 入力文字列による検索結果をGoogleMapApiから取得する
  void _searchPossiblePlacesList(String string) async {
    List<Map<String, dynamic>> result = [];
    // 文字列から関連する建物名などを取得
    PlacesAutocompleteResponse placesAutocompleteResponse =
        await placesApiClient.autocomplete(string, language: 'ja');
    // 概要情報を追加
    placesAutocompleteResponse.predictions.forEach((Prediction prediction) async {
      result.add({"placeId": prediction.placeId, "description": prediction.description });
      }
    );
    
    // viewを再描画させる
    setState(() {
      searchedValue = result;
    });
  }

  // リストをタップされたら、placeIdから詳細情報を取ってくる
  void _onTapList(int index) async {
    final placeId = searchedValue[index]["placeId"];
    // 上記で取得した情報から詳細情報（緯度経度など）を取得
    PlacesDetailsResponse placesDetailsResponse =
        await placesApiClient.getDetailsByPlaceId(placeId);

    String name = placesDetailsResponse.result.name;
    String address = placesDetailsResponse.result.formattedAddress!;
    Location location = placesDetailsResponse.result.geometry!.location;
    
    // googleMap上にMarkerを設置するように、値を更新する
    markers = {Marker(
      markerId: MarkerId(placeId),
      icon: BitmapDescriptor.defaultMarkerWithHue(350),
      position: LatLng(
        location.lat,
        location.lng,
      ),
      infoWindow: InfoWindow(
        title: name,
        snippet: address,
      ),
    )};
    
    // 影響箇所を再描画させる
    setState(() {
      selectedSpot = Spot(placeId: placeId, name: name, address: address, location: GeoPoint(location.lat, location.lng), comment: "");

      if (placesDetailsResponse.result.photos.length != 0) {
        selectedPhotoUrl = placesApiClient.buildPhotoUrl(
          photoReference: placesDetailsResponse.result.photos[0].photoReference,
          maxHeight: 300,
        );
      } else {
        selectedPhotoUrl = null;
      }
    });
    
    // mapの中心を、選択したスポットの位置にする。
    await mapController!.animateCamera(
      CameraUpdate.newCameraPosition(
        CameraPosition(
          target: LatLng(
            selectedSpot!.location.latitude,
            selectedSpot!.location.longitude,
          ),
          zoom: 16,
        ),
      ),
    );
    
    // markerの上のwindowを開く
    if (markers.length != 0) {
      await mapController!.showMarkerInfoWindow(MarkerId(placeId));
    }
  }

  // 検索欄
  Widget _buildInput() {
    return Container(
        decoration: BoxDecoration(
          color: Colors.white,
          borderRadius: BorderRadius.circular(20),
        ),
        height: 40,
        child: TextFormField(
          autofocus: true,
          style: TextStyle(fontSize: 13.5),
          decoration: InputDecoration(
            prefixIcon: Icon(Icons.search),
            hintText: '場所名/住所で検索',
          ),
          textInputAction: TextInputAction.search,
          onFieldSubmitted: (value) {
            // 検索ボタンを押したら検索を実行する
            _searchPossiblePlacesList(value);
          },
          onChanged: (inputString) {
            // 一度検索したのちに再度文字列を変更したら、一旦情報をリセットする
            _resetResult();
          },
        )
    );
  }

  // 検索結果の一覧
  Widget _resultList() {
    return searchedValue.length == 0 ? Container() : Container(
      height: 150,
      padding: EdgeInsets.fromLTRB(5, 0, 5, 5),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(10),
      ),
      child: ListView.builder(
          shrinkWrap: true,
          itemCount: searchedValue.length,
          itemBuilder: (BuildContext context, int index) {
            return GestureDetector(
              onTap: () {
                _onTapList(index);
              },
              child: Card(
                // 選択しているものとそうでないもので背景色を変える
                color: selectedSpot != null
                    ? searchedValue[index]["placeId"] == selectedSpot!.placeId
                    ? Colors.white
                    : Colors.grey[200]
                    : Colors.grey[200],
                child: Padding(
                  child: Text(searchedValue[index]["description"]),
                  padding: EdgeInsets.fromLTRB(20, 1, 20, 1),),
              ),
            );
          }),
    );
  }

  // map
  Widget _buildMap() {
    return GoogleMap(
        myLocationButtonEnabled: false,
        initialCameraPosition: CameraPosition(
          target: _initialPosition,
          zoom: 12,
        ),
        markers: markers,
        onMapCreated: (tempMapController) {
          mapController = tempMapController;
        },
      );
  }

  Widget _buildSpotImage() {
    if (selectedPhotoUrl == null) {
      return Container();
    }

    return ClipRRect(
        borderRadius: const BorderRadius.all(Radius.circular(2)),
        child: Image.network(selectedPhotoUrl!, fit: BoxFit.cover)
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('撮影場所を追加'),
      ),
      body: Stack(
        children: [
          _buildMap(),
          SafeArea(
            child: Container(
              margin: EdgeInsets.fromLTRB(10, 0, 10, 0),
              child: Stack(
                  children: <Widget>[
                    // 画像だけ下に配置する
                    Align(
                      alignment: Alignment.bottomCenter,
                      child: _buildSpotImage(),
                    ),
                    // 検索フォームと結果は上側に順番に表示する
                    Column(
                      children: [
                        _buildInput(),
                        _resultList(),
                      ],
                    ),
                  ]
              ),
            ),
          ),
        ]
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
      floatingActionButton: selectedSpot == null ? Container() :
        FloatingActionButton.extended(
          onPressed: () => {},
          label: Row(
            children: [
              Icon(Icons.create_outlined),
              Text("登録する")
            ],
          ),
        ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

```

## 最後に
コードが汚いとか、デザインもっとこうしたら方がいいとか、なんかあったらコメントもらえると嬉しいです！

## 参考ページ
実装するときに詰まったことを解決してくれた記事をまとめています。

mapの使い方を網羅的に記述された読み物。

https://qiita.com/aoinakanishi/items/25f2526fc7c1fe8350c6

stackを用いた画面のレイアウト方法がわかる。

https://nzigen.com/reference/flutter/2018-04-18-stack/

listViewを入れ子にするときに遭遇するエラーの解決方法がわかる。

https://qiita.com/code-cutlass/items/3a8b759056db1e8f7639