NCMBのFlutter SDKを使って地図検索アプリを作る（その3：位置情報検索を実装する）

NCMBでは公式SDKとしてSwift/Objective-C/Java/Unity/JavaScript SDKを用意しています。また、それ以外にもコミュニティSDKとして、非公式ながらFlutter/React Native/Google Apps Script/C#/Ruby/Python/PHPなど幅広い言語向けにSDKが開発されています。

今回はコミュニティSDKの一つ、Flutter SDKを使って地図検索アプリを作ってみます。[前回は位置情報をデータストアにインポートする流れを解説](https://qiita.com/goofmint/items/95dd22c6e0d65d4a5896)しました。今回は取り込んだ位置情報を検索し、地図上に表示する処理を実装します。

## 完成版のコード

作成したデモアプリのコードは[NCMBMania/flutter_map_app](https://github.com/NCMBMania/flutter_map_app)にアップロードしてあります。

## 追加するライブラリ

地図は[map | Flutter Package](https://pub.dev/packages/map)を利用します。Webにも対応した地図ライブラリは、現状mapくらいのようです。

```
flutter pub add map
```

## 地図画面について

地図画面はメイン画面のタブで `MapPage` クラスとして読み込まれています。

```dart
class _MainPageState extends State<MainPage> {
  // タイトル
  final title = '地図アプリ';

  // 表示するタブ
  final _tab = <Tab>[
    const Tab(text: '地図', icon: Icon(Icons.map_outlined)),
    const Tab(text: 'インポート', icon: Icon(Icons.settings)),
  ];

  // AppBarとタブを表示
  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: _tab.length,
      child: Scaffold(
        appBar: AppBar(
          title: Text(title),
          bottom: TabBar(
            tabs: _tab,
          ),
        ),
        body: const TabBarView(children: [
          MapPage(),
          SettingPage(),
        ]),
      ),
    );
  }
}
```

MapPageは_MapPageStateを読み込んでいるだけです。

```dart
// 地図画面用のStatefulWidget
class MapPage extends StatefulWidget {
  const MapPage({Key? key}) : super(key: key);
  @override
  State<MapPage> createState() => _MapPageState();
}

// 地図画面
class _MapPageState extends State<MapPage> {
	// 省略
}
```

## UIについて

[mapのexample](https://pub.dev/packages/map/example)に則って作りますが、若干コードが長めです。詳細はコメントを参照してください。

```dart
@override
Widget build(BuildContext context) {
	return Scaffold(
		appBar: null,
		body: MapLayoutBuilder(
			controller: controller,
			builder: (context, transformer) {
				return GestureDetector(
					behavior: HitTestBehavior.opaque,
					// タップした際のズーム処理
					onDoubleTap: () {
						controller.zoom += 0.8;
						setState(() {});
					},
					// ピンチ/パン処理
					onScaleStart: _onScaleStart,
					onScaleUpdate: _onScaleUpdate,
					// タップした際の処理
					onTapUp: (details) async {
						_onTapUp(transformer, details);
					},
					child: Listener(
						behavior: HitTestBehavior.opaque,
						onPointerSignal: (event) {
							if (event is PointerScrollEvent) {
								final delta = event.scrollDelta;
								controller.zoom -= delta.dy / 1000.0;
								setState(() {});
							}
						},
						child: Stack(
								// Mapboxのウィジェットが初期化されているかどうかで処理分け
								children: _map != null
										? [_map!, ..._markers]
										: [const Text('Loading...')]),
					),
				);
			},
		),
		// フローティングボタン
		floatingActionButton: FloatingActionButton(
			onPressed: () {
				// タップ、検索結果のマーカーを消す
				setState(() {
					_clickLocations.clear();
					_markers.clear();
				});
			},
			child: const Icon(Icons.remove_circle_outline),
		),
	);
}
```

![localhost_53739_.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/5007feec-131a-e76a-e75b-00164bc71488.png)


controllerはインスタンス変数として定義しています。他の変数も合わせてリストアップしておきます。デフォルト（初期表示の中心）の位置情報は東京タワーとしています。

```dart
// 地図画面
class _MapPageState extends State<MapPage> {
  // 地図コントローラーの初期化
  final controller = MapController(
    location: LatLng(35.6585805, 139.7454329),
    zoom: 13,
  );
  // ドラッグ操作用
  Offset? _dragStart;
  double _scaleStart = 1.0;

  Map? _map;

  // タップした位置の情報
  final List<LatLng> _clickLocations = [];
  // 表示するマーカー
  List<Widget> _markers = [];
	// 省略
}
```

各イベントハンドリングのメソッドは次の通りです。

```dart
// 初期のスケール情報
void _onScaleStart(ScaleStartDetails details) {
	_dragStart = details.focalPoint;
	_scaleStart = 1.0;
}

// ピンチ/パンによるズーム操作用
void _onScaleUpdate(ScaleUpdateDetails details) {
	final scaleDiff = details.scale - _scaleStart;
	_scaleStart = details.scale;
	if (scaleDiff > 0) {
		controller.zoom += 0.4;
		setState(() {});
	} else if (scaleDiff < 0) {
		controller.zoom -= 0.4;
		setState(() {});
	} else {
		final now = details.focalPoint;
		final diff = now - _dragStart!;
		_dragStart = now;
		controller.drag(diff.dx, diff.dy);
		setState(() {});
	}
}
```

そして画面を表示した際に、地図も初期化します。

```dart
// 初期化用
@override
void initState() {
	super.initState();
	initMap();
}
```

これはMapboxのアクセストークンを.envファイルから非同期で読み込んでいるためです。ソースに直書きする場合には、直接_mapを初期化できるでしょう。

```dart
// Mapbox用のアクセストークンを非同期で取得してウィジェットの初期化を行う関数
Future<void> initMap() async {
	// .envから読み込み
	await dotenv.load(fileName: '.env');
	// アクセストークン取得
	var mapboxAccessToken = dotenv.get('MAPBOX_ACCESS_TOKEN',
			fallback: 'No mapbox access token found.');
	// ウィジェットを作成
	setState(() {
		_map = Map(
			controller: controller,
			builder: (context, x, y, z) {
				// MapboxのURLを指定
				final url =
						'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/$z/$x/$y?access_token=$mapboxAccessToken';
				return Image(image: NetworkImage(url));
			},
		);
	});
}
```

## タップした際の処理

地図をタップした際には_onTapUpが実行されます。この時、1回目のタップだった時にはその付近の駅を、2回目以降のタップだった場合には1回目のタップしたマーカーと合わせて矩形検索を行います。

```dart
// 地図をタップした際のイベント
void _onTapUp(MapTransformer transformer, TapUpDetails details) async {
	// 地図上のXY
	final location = transformer.fromXYCoordsToLatLng(details.localPosition);
	// すでに2つクリックした箇所がある場合は、最初のデータを削除
	if (_clickLocations.length == 2) {
		_clickLocations[0] = _clickLocations[1];
		_clickLocations.removeAt(1);
	}
	// 位置情報を追加
	_clickLocations.add(location);
	// 位置情報をNCMBGeoPointに変換する
	final clicked = getClickedGeoPoint();
	// タップした位置情報をマーカーに変換する
	final markers = getMarkers(transformer, clicked, Colors.purple);
	// NCMBを検索して駅情報を取得
	final stations = await getStations();
	setState(() {
		// 駅情報をマーカーに変換して_markersを更新
		_markers = getMarkers(
				transformer,
				stations.map((obj) => obj.get('geo') as NCMBGeoPoint).toList(),
				Colors.red);
		// タップしたマーカーを_markersに追加
		for (var m in markers) {
			_markers.add(m);
		}
	});
}
```

getClickedGeoPointはタップした位置情報を返す関数です。

```dart
// タップした位置情報をNCMBGeoPointに変換する
List<NCMBGeoPoint> getClickedGeoPoint() {
	return _clickLocations
			.map((location) => NCMBGeoPoint(location.latitude, location.longitude))
			.toList();
}
```

getMarkersは位置情報をマーカーウィジェットに変換します。

```dart
// NCMBGeoPointをマーカーウィジェットに変換する
List<Widget> getMarkers(
		MapTransformer transformer, List<NCMBGeoPoint> geos, Color color) {
	return geos.map((geo) {
		var pos = transformer
				.fromLatLngToXYCoords(LatLng(geo.latitude!, geo.longitude!));
		return _buildMarkerWidget(pos, color);
	}).toList();
}
```

そして位置情報検索時に、タップしたマーカーの個数に応じて検索処理を切り替えています。

```dart
// 駅情報検索用
Future<List<NCMBObject>> getStations() async {
	if (_clickLocations.length == 2) {
		// マーカーが2つある場合は矩形検索
		return await getStationsSquare();
	} else {
		// マーカーが1つの場合は付近の検索
		return await getStationsNear();
	}
}
```

1回目のタップではgetStationsSquareを実行しています。

```dart
// 1つのマーカーをターゲットにした付近検索
Future<List<NCMBObject>> getStationsNear() async {
	// 位置情報
	var location = _clickLocations[0];
	// NCMBGeoPointに変換
	var geo = NCMBGeoPoint(location.latitude, location.longitude);
	// 検索用のクエリークラス
	var query = NCMBQuery('Station');
	// 位置情報を中心に2.5km範囲で検索
	query.withinKilometers('geo', geo, 2.5);
	// レスポンスを取得
	var ary = await query.fetchAll();
	// List<NCMBObject>に変換
	return ary.map((obj) => obj as NCMBObject).toList();
}
```

![localhost_53739_ (3).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/e2a09eac-3b23-3ffe-ec58-f1f6d9400732.png)


2回目以降のタップではgetStationsSquareを実行しています。

```dart
// 2つのマーカーをターゲットした矩形検索
Future<List<NCMBObject>> getStationsSquare() async {
	// 位置情報をNCMBGeoPointに変換
	var locations = getClickedGeoPoint();
	// 検索用のクエリークラス
	var query = NCMBQuery('Station');
	// 2つの位置情報を使って、その中にある駅情報を検索
	query.withinSquare('geo', locations[0], locations[1]);
	// レスポンスを取得
	var ary = await query.fetchAll();
	// List<NCMBObject>に変換
	return ary.map((obj) => obj as NCMBObject).toList();
}
```

![localhost_53739_ (4).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/253abad5-3f09-cb0b-f5d7-6b0a6abff902.png)


これで地図検索アプリの完成です。

## まとめ

NCMBを使えば位置情報を使った検索も簡単に実装できます。スマートフォンアプリでは位置情報をよく使いますので、ぜひNCMBの位置情報機能を役立ててください。

[mBaaSでサーバー開発不要！ | ニフクラ mobile backend](https://mbaas.nifcloud.com/)