【Flutter】geoflutterfireという範囲内の座標のみを取得する神パッケージの紹介


geoflutterfireとは?

皆さん、geoflutterfireという神パッケージをご存知だろうか。。。
geoflutterfireを用いることで以下のように範囲内の座標のみをFirestoreから取得できるようになるのである。(円はだいたいの目安です)

geoflutterfireデモ

今回はgeoflutterfireに関する日本語記事がほとんど見当たらなかったためここに記す。

最後にサンプルコードも置いとくよ!!

geoflutterfireの使い方

https://pub.dev/packages/geoflutterfire
こちらのドキュメントによるとgeoflutterfireの特徴は以下になります!

  • 地理的な位置(座標)を文字列(GeoHash)として保存する
  • エリア内の座標をリアルタイムで取得できる
  • 特定の場所に近いデータのみをロードするため、非常に大きなデータセットでも軽く動かせる。

具体的にどのようにできるのか見ていきましょう。

前提

Firebaseの接続及びFirestoreを使用できる状態にする

https://firebase.google.com/docs/flutter/setup?hl=ja&platform=ios

初期化

import 'package:geoflutterfire/geoflutterfire.dart';
import 'package:cloud_firestore/cloud_firestore.dart';

// Init firestore and geoFlutterFire
final geo = Geoflutterfire();
final _firestore = FirebaseFirestore.instance;

地理情報を書きこむ

GeoFirePointを使用する

GeoFirePoint geoFirePoint = geo.point(latitude: 12.960632, longitude: 77.641603);

GeoFirePointとは

地理情報を良い感じに扱ってるクラス。3つのgetter(privateになってる変数)がある。

  1. geoFirePoint.hash : GeoHashという9文字の文字列を返す。geohashについては後述します。 例:s1b8yu2nj
  2. geoFirePoint.geoPoint : Firestoreで使われるGeoPointを返す。 GeoPoint (double latitude, double longitude) 緯度経度を持つ。
  3. geoFirePoint.data : Firestoreへの保存に適したデータを返す。例:{geopoint: Instance of 'GeoPoint', geohash: s1b8yu2nj}

GeoFirePointをFirestoreに追加する

_firestore
        .collection('shop')
        .add({'name': 'random name', 'position': geoFirePoint.data});

実際にはこんな感じでFirestoreに保存されます
Firestore

地理情報を取得する

ある地点から50km以内のドキュメントを取得するクエリ。Streamで値を受け取る。

// Create a geoFirePoint
GeoFirePoint center = geo.point(latitude: 12.960632, longitude: 77.641603);

// get the collection reference or query
var collectionReference = _firestore.collection('shop');

double radius = 50;
String field = 'position';

Stream<List<DocumentSnapshot>> stream = 
		geo.collection(collectionRef: collectionReference)
		.within(center: center, radius: radius, field: field);

受け取ったStreamを監視。

stream.listen((List<DocumentSnapshot> documentList) {
        // doSomething()
      });

Geohashとは

https://techblog.yahoo.co.jp/entry/20191210786752/
Yahoo!さんの記事にとてもわかりやすくGeohashについて書かれていました!
Geohashについて一部引用させてもらいます。

Geohashは、地図を格子状に分割し、その1区画を短い文字列で表現できるというものです。 例えば、東京駅周辺はGeohash6桁では「xn76ur」と表現されます。
近隣の区画は似た文字列で表現できることから、あるGeohashの区画の隣の区画は簡単に計算で求めることができます。

簡単に説明すると Geohash

  • 座標を文字列で表現
  • 桁数が大きいほど精度が高くなる
  • より多くの文字列が一致すれば、2点がより近いことを表す。

という特徴を持っています。
geoflutterfireでは9桁の文字列として返ってくるので精度は高いです。
Yahoo!のテックブログから引用
※Yahoo!さんのテックブログから引用

geoflutterfireを使ってみる

こちらのサンプルコードを最後に置いておきます!

https://github.com/naokiwakata/map_sample

準備

Firebaseの接続及びfirestoreを使用できるようにする。

https://firebase.google.com/docs/flutter/setup?hl=ja&platform=ios

FirestoreにgeoFlutterPointを追加する

GoogleMapを使えるようにする

https://zenn.dev/wakanao/articles/3820bcd67e4130

使用しているライブラリ

pubspec.yaml
dependencies:
  google_maps_flutter: ^2.1.2
  geolocator: ^8.2.0
  firebase_core: ^1.13.1
  cloud_firestore: ^3.1.10
  geoflutterfire: ^3.0.3

サンプルコード

main.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:geoflutterfire/geoflutterfire.dart';
import 'package:geolocator/geolocator.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:rxdart/rxdart.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  runApp(
    MaterialApp(
      title: 'Geo Flutter Fire example',
      home: MyApp(),
      debugShowCheckedModeBanner: true,
    ),
  );
}

class MyApp extends StatefulWidget {
  
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  GoogleMapController? _mapController;
  TextEditingController? _latitudeController, _longitudeController;

  // firestore init
  final radius = BehaviorSubject<double>.seeded(1.0);
  final _firestore = FirebaseFirestore.instance;
  final markers = <MarkerId, Marker>{};

  late Stream<List<DocumentSnapshot>> stream;
  late Geoflutterfire geo;

  double _value = 20.0;
  String _label = '';
  double _ratio = 0;

  double screenWidthKms = 600;

  
  void initState() {
    super.initState();
    _latitudeController = TextEditingController();
    _longitudeController = TextEditingController();

    geo = Geoflutterfire();
    GeoFirePoint center =
        geo.point(latitude: 43.0779575, longitude: 142.337819);
    stream = radius.switchMap(
      (rad) {
        final collectionReference = _firestore.collection('shop');

        return geo.collection(collectionRef: collectionReference).within(
            center: center, radius: rad, field: 'position', strictMode: true);
      },
    );

    Future(() async {
      //_mapControllerがinitializeされるのを待つ1秒
      await Future.delayed(const Duration(seconds: 1));
      final region = await _mapController?.getVisibleRegion();
      final distanceInMeters = Geolocator.distanceBetween(
          region!.northeast.latitude,
          region.northeast.longitude,
          region.southwest.latitude,
          region.northeast.longitude);
      screenWidthKms = distanceInMeters / 1000;
      print('画面の横幅の距離 $screenWidthKms km');
    });
  }

  
  void dispose() {
    _latitudeController?.dispose();
    _longitudeController?.dispose();
    radius.close();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          children: <Widget>[
            Center(
              child: Card(
                elevation: 4,
                margin: const EdgeInsets.symmetric(vertical: 8),
                child: SizedBox(
                  height: 550,
                  child: Stack(
                    children: [
                      GoogleMap(
                        onMapCreated: _onMapCreated,
                        initialCameraPosition: const CameraPosition(
                          target: LatLng(43.0779575, 142.337819),
                          zoom: 6.5,
                        ),
                        markers: Set<Marker>.of(markers.values),
                      ),
                      Center(
                        child: Container(
                          width: MediaQuery.of(context).size.width * (_ratio),
                          height: MediaQuery.of(context).size.width * (_ratio),
                          decoration: BoxDecoration(
                            color: Colors.grey.withOpacity(0.5),
                            shape: BoxShape.circle,
                          ),
                        ),
                      ),
                    ],
                  ),
                ),
              ),
            ),
            Padding(
              padding: const EdgeInsets.only(top: 8.0),
              child: Slider(
                min: 1,
                max: screenWidthKms / 2,
                divisions: 10,
                value: _value,
                label: _label,
                activeColor: Colors.blue,
                inactiveColor: Colors.blue.withOpacity(0.2),
                onChanged: (double value) {
                  setState(() {
                    _value = value;
                    _label = '${_value.toInt().toString()} kms';
                    _ratio = _value / (screenWidthKms / 2);
                    markers.clear();
                  });
                  radius.add(value);
                },
              ),
            ),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: <Widget>[
                SizedBox(
                  width: 100,
                  child: TextField(
                    controller: _latitudeController,
                    keyboardType: TextInputType.number,
                    textInputAction: TextInputAction.next,
                    decoration: InputDecoration(
                      labelText: 'lat',
                      border: OutlineInputBorder(
                        borderRadius: BorderRadius.circular(8),
                      ),
                    ),
                  ),
                ),
                SizedBox(
                  width: 100,
                  child: TextField(
                    controller: _longitudeController,
                    keyboardType: TextInputType.number,
                    decoration: InputDecoration(
                        labelText: 'lng',
                        border: OutlineInputBorder(
                          borderRadius: BorderRadius.circular(8),
                        )),
                  ),
                ),
                MaterialButton(
                  color: Colors.blue,
                  onPressed: () {
                    final lat =
                        double.parse(_latitudeController?.text ?? '0.0');
                    final lng =
                        double.parse(_longitudeController?.text ?? '0.0');
                    _addPoint(lat, lng);
                  },
                  child: const Text(
                    'ADD',
                    style: TextStyle(color: Colors.white),
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  void _onMapCreated(GoogleMapController controller) async {
    setState(() {
      _mapController = controller;
      //start listening after map is created
      stream.listen((List<DocumentSnapshot> documentList) {
        _updateMarkers(documentList);
      });
    });
  }

  void _addPoint(double lat, double lng) {
    GeoFirePoint geoFirePoint = geo.point(latitude: lat, longitude: lng);
    print(geoFirePoint.hash);
    print(geoFirePoint.geoPoint);
    print(geoFirePoint.data);
    print(geoFirePoint);
    _firestore
        .collection('shop')
        .add({'name': 'random name', 'position': geoFirePoint.data}).then((_) {
      print('added ${geoFirePoint.hash} successfully');
    });
  }

  void _addMarker(double lat, double lng) {
    final id = MarkerId(lat.toString() + lng.toString());
    final _marker = Marker(
      markerId: id,
      position: LatLng(lat, lng),
      icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueViolet),
      infoWindow: InfoWindow(title: 'latLng', snippet: '$lat,$lng'),
    );
    setState(() {
      markers[id] = _marker;
    });
  }

  void _updateMarkers(List<DocumentSnapshot> documentList) {
    documentList.forEach((DocumentSnapshot document) {
      final data = document.data() as Map<String, dynamic>;
      final GeoPoint point = data['position']['geopoint'];
      _addMarker(point.latitude, point.longitude);
    });
  }
}

まとめ

geoflutterfireについて少しは理解していただけたでしょうか! マップとFirebaseを使う際にはぜひ使用してみてください!