📍

Flutter + Mapbox でピン(Symbol)の情報を DB(SQLite)に保存する

2021/10/02に公開

以前書いた、

の続きです。

前回、

ただし、アプリケーションを終了するなどして一旦地図上から消えたピン(Symbol)をデータベースから読み込んだ(緯度・経度・ラベルなどの)情報を使って再配置するケースでは再配置の都度idが変わってしまうため、データベースにidを保存するのではなく、表示中のピン(Symbol)のidをデータベース上の主キー列に変換するMapが必要になりそうです(後日トライして記事化する予定)。

と書きましたが、実際にやってみました。

準備

SQLite を使う関係で sqflite が必要です。

  • pubspack.yamldependencies:に追記
  sqflite: ^2.0.0

データベース操作

  • MapboxMaponMapCreatedで、_controller.complete(controller)の後にデータベースをオープン
    • テーブルが存在しなければ作成
  • その直後、テーブルにピンのデータがあればすべて読み込んで画面にピンを配置
  • ピンを立てたときにピン情報をテーブルに挿入
  • ピンを削除するときにテーブルからピン情報を削除
  • _MapPageStatedisposeが呼び出されたときにデータベースをクローズ

テーブル構造

テーブルsymbol_infoの定義は以下のとおりです。

CREATE TABLE symbol_info (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title TEXT,
  describe TEXT,
  date_time INTEGER,
  latitude REAL,
  longtitude REAL
)
  • DB の id(主キー/オートインクリメント値)
  • タイトル(title
  • 説明文(describe
  • ピンを立てた日時(date_time) ※Unix タイムスタンプ値
  • 緯度(latitude
  • 経度(longtitude

ポイント

冒頭の引用文で説明したとおり、

  • 一旦地図上から消えたピン(Symbol)をデータベースから読み込んだ(緯度・経度・ラベルなどの)情報を使って再配置するケースでは再配置の都度idが変わってしまう
  • 表示中のピン(Symbol)のidをデータベース上の主キー列に変換するMapが必要になる

つまり、Symbolidとテーブルのidは違う値になります。

Symbolidとテーブルのidを変換するためのMap として_symbolInfoMapをメモリ上に保持し、ピンの情報は緯度・経度を含めてテーブルに格納します。

テーブルのピン情報を読み込んで画面にピンを立てる

  // DB から Symbol 情報を読み込んで地図に表示する
  void _addSymbols() async {
    List<SymbolInfoWithLatLng> infoList = await _fetchRecords();
    _controller.future.then((mapboxMap) async {
      List<Symbol> symbolList =
          await mapboxMap.addSymbols(_convertToSymbolOptions(infoList));
      // 全 Symbol 情報(DB 主キーへの変換マップ)を設定する
      _symbolInfoMap.clear();
      for (int i = 0; i < symbolList.length; i++) {
        _symbolInfoMap[symbolList[i].id] = infoList[i].id;
      }
      // 全てのマーク(ピン)を立て終えた
      if (!_symbolAllSet) {
        setState(() {
          _symbolAllSet = true;
        });
      }
    });
  }

そして、すべてのピンを立て終えたところで_symbolAllSettrueにしていますが、これはテーブルから読み込んだピンをすべて立て終える前に別の新しいピンを立てたり、画面上に表示されているピンを削除したりしないための処理(_symbolInfoMapの不整合防止)です。

画面にピンを立てる(ピンを新規に立てる)

ピン情報のタイトル・説明文・日時・緯度・経度を_addRecordでテーブルに保存し、オートインクリメント値として発行されたid_symbolInfoMapに登録しています。

  // DB 行追加
  Future<int> _addRecord(
      Symbol symbol, SymbolInfoWithLatLng symbolInfoWithLatLng) async {
    return await _database.insert(
      'symbol_info',
      {
        'title': symbolInfoWithLatLng.symbolInfo.title,
        'describe': symbolInfoWithLatLng.symbolInfo.describe,
        'date_time':
            symbolInfoWithLatLng.symbolInfo.dateTime.millisecondsSinceEpoch,
        'latitude': symbolInfoWithLatLng.latLng.latitude,
        'longtitude': symbolInfoWithLatLng.latLng.longitude,
      },
    );
  }
  // マーク(ピン)を立ててラベルを付ける
  Future<void> _addMark(LatLng tapPoint) async {
    final SymbolInfo? symbolInfo = await Navigator.of(context).push(
        MaterialPageRoute(builder: (context) => const CreateSymbolInfoPage()));
    if (symbolInfo != null) {
      // 詳細情報が入力されたらマーク(ピン)を立てる
      _controller.future.then((mapboxMap) {
        Future<Symbol> futureSymbol = mapboxMap.addSymbol(SymbolOptions(
          geometry: tapPoint,
          textField: _formatLabel(symbolInfo.title),
          textAnchor: "top",
          textColor: "#000",
          textHaloColor: "#FFF",
          textHaloWidth: 3,
          textSize: 12.0,
          iconImage: "mapbox-marker-icon-blue",
          iconSize: 1,
        ));
        futureSymbol.then((symbol) {
          // DB に行追加
          SymbolInfoWithLatLng symbolInfoWithLatLng =
              SymbolInfoWithLatLng(0, symbolInfo, tapPoint); // id はダミー
          Future<int> futureId = _addRecord(symbol, symbolInfoWithLatLng);
          // Map に DB の id を追加
          futureId.then((id) {
            _symbolInfoMap[symbol.id] = id;
          });
        });
      });
    }
  }

画面のピンをタップしたときにピン情報を表示する

ピン情報のタイトル・説明文・日時を_fetchRecordでテーブルから読み込んで表示します。

 // DB 行取得(詳細情報のみ)
  Future<SymbolInfo> _fetchRecord(Symbol symbol) async {
    int id = _symbolInfoMap[symbol.id]!;
    List<Map<String, Object?>> maps = await _database.query(
      'symbol_info',
      columns: ['title', 'describe', 'date_time'],
      where: 'id = ?',
      whereArgs: [id],
    );
    Map map = maps.first;
    return SymbolInfo(map['title'], map['describe'],
        DateTime.fromMillisecondsSinceEpoch(map['date_time'], isUtc: false));
  }
  // Symbol の情報を表示する
  void _dispSymbolInfo(Symbol symbol) {
    Future<SymbolInfo> futureSymbolInfo = _fetchRecord(symbol);
    futureSymbolInfo.then((symbolInfo) => {
          showDialog(
            context: navigatorKey.currentContext!,
            builder: (BuildContext context) => AlertDialog(
              title: Text(symbolInfo.title),
              content: Column(
                mainAxisSize: MainAxisSize.min,
                children: <Widget>[
                  Text(symbolInfo.dateTime.toString().substring(0, 19)),
                  const Gap(16),
                  Text(symbolInfo.describe),
                ],
              ),
              actions: <Widget>[
                TextButton(
                  child: const Text('削除'),
                  onPressed: () {
                    _removeMark(symbol);
                    Navigator.pop(context);
                  },
                ),
                TextButton(
                  child: const Text('閉じる'),
                  onPressed: () {
                    Navigator.pop(context);
                  },
                ),
              ],
            ),
          )
        });
  }

ピン情報を削除する

テーブルと_symbolInfoMapから削除しています。

  // DB 行削除
  Future<int> _removeRecord(Symbol symbol) async {
    int id = _symbolInfoMap[symbol.id]!;
    return await _database
        .delete('symbol_info', where: 'id = ?', whereArgs: [id]);
  }
  // マーク(ピン)を削除する
  void _removeMark(Symbol symbol) {
    _controller.future.then((mapboxMap) {
      mapboxMap.removeSymbol(symbol);
    });
    _removeRecord(symbol);
    _symbolInfoMap.remove(symbol.id);
  }

画面例

ソースコード

main.dart(変更なし)

import 'package:flutter/material.dart';

import 'map_page.dart';

final navigatorKey = GlobalKey<NavigatorState>();
void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Mapbox',
      home: const MapPage(),
      navigatorKey: navigatorKey,
    );
  }
}

map_page.dart

import 'dart:async';
import 'dart:io';
import 'dart:math';

import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:location/location.dart';
import 'package:mapbox_gl/mapbox_gl.dart';
import 'package:maptool/create_symbol_info_page.dart';
import 'package:maptool/main.dart';
import 'package:sqflite/sqflite.dart';

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

  
  _MapPageState createState() => _MapPageState();
}

// マーク(ピン)の登録情報
class SymbolInfo {
  String title;
  String describe;
  DateTime dateTime;

  SymbolInfo(this.title, this.describe, this.dateTime);
}

// マーク(ピン)の登録情報(DB の id・緯度・経度つき)
class SymbolInfoWithLatLng {
  int id;
  SymbolInfo symbolInfo;
  LatLng latLng;

  SymbolInfoWithLatLng(this.id, this.symbolInfo, this.latLng);
}

class _MapPageState extends State<MapPage> {
  final Completer<MapboxMapController> _controller = Completer();
  final Location _locationService = Location();
  // 地図スタイル用 Mapbox URL
  final String _style = '【地図スタイルのURL】';
  // Location で緯度経度が取れなかったときのデフォルト値
  final double _initialLat = 35.6895014;
  final double _initialLong = 139.6917337;
  // ズームのデフォルト値
  final double _initialZoom = 13.5;
  // 方位のデフォルト値(北)
  final double _initialBearing = 0.0;
  // 全 Symbol 情報(DB 主キーへの変換マップ)
  final Map<String, int> _symbolInfoMap = {};
  // 現在位置
  LocationData? _yourLocation;
  // GPS 追従?
  bool _gpsTracking = false;
  // 画面上に全てのマーク(ピン)を立て終えた?
  bool _symbolAllSet = false;
  // DB
  late Database _database;

  // 現在位置の監視状況
  StreamSubscription? _locationChangedListen;

  
  void initState() {
    super.initState();

    // 現在位置の取得
    _getLocation();

    // 現在位置の変化を監視
    _locationChangedListen =
        _locationService.onLocationChanged.listen((LocationData result) async {
      setState(() {
        _yourLocation = result;
      });
    });
    setState(() {
      _gpsTracking = true;
    });
  }

  
  void dispose() {
    super.dispose();

    // 監視を終了
    _locationChangedListen?.cancel();
    // DB クローズ
    _closeDatabase();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: _makeMapboxMap(),
      floatingActionButton: _makeFloatingIcons(),
    );
  }

  // 地図ウィジェット
  Widget _makeMapboxMap() {
    if (_yourLocation == null) {
      // 現在位置が取れるまではロード中画面を表示
      return const Center(
        child: CircularProgressIndicator(),
      );
    }
    // GPS 追従が ON かつ地図がロードされている→地図の中心を移動
    _moveCameraToGpsPoint();
    // Mapbox ウィジェットを返す
    return MapboxMap(
      // 地図(スタイル)を指定
      styleString: _style,
      // 初期表示される位置情報を現在位置から設定
      initialCameraPosition: CameraPosition(
        target: LatLng(_yourLocation!.latitude ?? _initialLat,
            _yourLocation!.longitude ?? _initialLong),
        zoom: _initialZoom,
      ),
      onMapCreated: (MapboxMapController controller) {
        _controller.complete(controller);
        _createDatabase().then((value) => {_addSymbols()});
        _controller.future.then((mapboxMap) {
          mapboxMap.onSymbolTapped.add(_onSymbolTap);
        });
      },
      compassEnabled: true,
      // 現在位置を表示する
      myLocationEnabled: true,
      // カメラの位置を追跡する
      trackCameraPosition: true,
      // 地図をタップしたとき
      onMapClick: (Point<double> point, LatLng tapPoint) {
        _onTap(point, tapPoint);
      },
      // 地図を長押ししたとき
      onMapLongClick: (Point<double> point, LatLng tapPoint) {
        if (_symbolAllSet) {
          _addMark(tapPoint);
        }
      },
    );
  }

  // フローティングアイコンウィジェット
  Widget _makeFloatingIcons() {
    return Column(mainAxisSize: MainAxisSize.min, children: [
      FloatingActionButton(
        heroTag: 'addMark',
        backgroundColor: Colors.blue,
        onPressed: () {
          // 画面の中心にマーク(ピン)を立てる
          _addSymbolOnCameraPosition();
        },
        child: Icon(
            _symbolAllSet ? Icons.add_location : Icons.add_location_outlined),
      ),
      const Gap(16),
      FloatingActionButton(
        heroTag: 'resetZoom',
        backgroundColor: Colors.blue,
        onPressed: () {
          // ズームを戻す
          _resetZoom();
        },
        child: const Text('±', style: TextStyle(fontSize: 28.0, height: 1.0)),
      ),
      const Gap(16),
      FloatingActionButton(
        heroTag: 'resetBearing',
        backgroundColor: Colors.blue,
        onPressed: () {
          // 北向きに戻す
          _resetBearing();
        },
        child: const Text('N',
            style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
      ),
      const Gap(16),
      FloatingActionButton(
        heroTag: 'gpsToggle',
        backgroundColor: Colors.blue,
        onPressed: () {
          _gpsToggle();
        },
        child: Icon(
          // GPS 追従の ON / OFF に合わせてアイコン表示する
          _gpsTracking ? Icons.gps_fixed : Icons.gps_not_fixed,
        ),
      ),
    ]);
  }

  // DB から Symbol 情報を読み込んで地図に表示する
  void _addSymbols() async {
    List<SymbolInfoWithLatLng> infoList = await _fetchRecords();
    _controller.future.then((mapboxMap) async {
      List<Symbol> symbolList =
          await mapboxMap.addSymbols(_convertToSymbolOptions(infoList));
      // 全 Symbol 情報(DB 主キーへの変換マップ)を設定する
      _symbolInfoMap.clear();
      for (int i = 0; i < symbolList.length; i++) {
        _symbolInfoMap[symbolList[i].id] = infoList[i].id;
      }
      // 全てのマーク(ピン)を立て終えた
      if (!_symbolAllSet) {
        setState(() {
          _symbolAllSet = true;
        });
      }
    });
  }

  // SymbolInfoWithLatLngs のリストから SymbolOptions のリストに変換
  List<SymbolOptions> _convertToSymbolOptions(
      List<SymbolInfoWithLatLng> infoList) {
    List<SymbolOptions> optionsList = [];
    for (SymbolInfoWithLatLng info in infoList) {
      SymbolOptions options = SymbolOptions(
        geometry: LatLng(info.latLng.latitude, info.latLng.longitude),
        textField: _formatLabel(info.symbolInfo.title),
        textAnchor: "top",
        textColor: "#000",
        textHaloColor: "#FFF",
        textHaloWidth: 3,
        textSize: 12.0,
        iconImage: "mapbox-marker-icon-blue",
        iconSize: 1,
      );
      optionsList.add(options);
    }
    return optionsList;
  }

  // DB 作成
  Future<void> _createDatabase() async {
    // DB テーブル作成
    _database = await openDatabase(
      'maptool.db',
      version: 1,
      onCreate: (db, version) async {
        await db.execute(
          'CREATE TABLE symbol_info ('
          '  id INTEGER PRIMARY KEY AUTOINCREMENT,'
          '  title TEXT,'
          '  describe TEXT,'
          '  date_time INTEGER,'
          '  latitude REAL,'
          '  longtitude REAL'
          ')',
        );
      },
    );
  }

  // DB クローズ
  Future<void> _closeDatabase() async {
    _database.close();
  }

  // DB 全行取得
  Future<List<SymbolInfoWithLatLng>> _fetchRecords() async {
    List<Map<String, Object?>> maps = await _database.query(
      'symbol_info',
      columns: [
        'id',
        'title',
        'describe',
        'date_time',
        'latitude',
        'longtitude'
      ],
      orderBy: 'id ASC',
    );
    List<SymbolInfoWithLatLng> symbolInfoWithLatLngs = [];
    for (Map map in maps) {
      SymbolInfo symbolInfo = SymbolInfo(map['title'], map['describe'],
          DateTime.fromMillisecondsSinceEpoch(map['date_time'], isUtc: false));
      LatLng latLng = LatLng(map['latitude'], map['longtitude']);
      SymbolInfoWithLatLng symbolInfoWithLatLng =
          SymbolInfoWithLatLng(map['id'], symbolInfo, latLng);
      symbolInfoWithLatLngs.add(symbolInfoWithLatLng);
    }
    return symbolInfoWithLatLngs;
  }

  // DB 行取得(詳細情報のみ)
  Future<SymbolInfo> _fetchRecord(Symbol symbol) async {
    int id = _symbolInfoMap[symbol.id]!;
    List<Map<String, Object?>> maps = await _database.query(
      'symbol_info',
      columns: ['title', 'describe', 'date_time'],
      where: 'id = ?',
      whereArgs: [id],
    );
    Map map = maps.first;
    return SymbolInfo(map['title'], map['describe'],
        DateTime.fromMillisecondsSinceEpoch(map['date_time'], isUtc: false));
  }

  // DB 行追加
  Future<int> _addRecord(
      Symbol symbol, SymbolInfoWithLatLng symbolInfoWithLatLng) async {
    return await _database.insert(
      'symbol_info',
      {
        'title': symbolInfoWithLatLng.symbolInfo.title,
        'describe': symbolInfoWithLatLng.symbolInfo.describe,
        'date_time':
            symbolInfoWithLatLng.symbolInfo.dateTime.millisecondsSinceEpoch,
        'latitude': symbolInfoWithLatLng.latLng.latitude,
        'longtitude': symbolInfoWithLatLng.latLng.longitude,
      },
    );
  }

  // DB 行削除
  Future<int> _removeRecord(Symbol symbol) async {
    int id = _symbolInfoMap[symbol.id]!;
    return await _database
        .delete('symbol_info', where: 'id = ?', whereArgs: [id]);
  }

  // 現在位置を取得
  void _getLocation() async {
    _yourLocation = await _locationService.getLocation();
  }

  // GPS 追従を ON / OFF
  void _gpsToggle() {
    setState(() {
      _gpsTracking = !_gpsTracking;
    });
    // ここは本来 iOS では不要
    _moveCameraToGpsPoint();
  }

  // GPS 追従が ON なら地図の中心を現在位置へ
  void _moveCameraToGpsPoint() {
    if (_gpsTracking) {
      _controller.future.then((mapboxMap) {
        if (Platform.isAndroid) {
          mapboxMap.moveCamera(CameraUpdate.newLatLng(LatLng(
              _yourLocation!.latitude ?? _initialLat,
              _yourLocation!.longitude ?? _initialLong)));
        } else if (Platform.isIOS) {
          mapboxMap.animateCamera(CameraUpdate.newLatLng(LatLng(
              _yourLocation!.latitude ?? _initialLat,
              _yourLocation!.longitude ?? _initialLong)));
        }
      });
    }
  }

  // 地図をタップしたときの処理
  void _onTap(Point<double> point, LatLng tapPoint) {
    _moveCameraToTapPoint(tapPoint);
    setState(() {
      _gpsTracking = false;
    });
  }

  // 地図の中心をタップした場所へ
  void _moveCameraToTapPoint(LatLng tapPoint) {
    _controller.future.then((mapboxMap) {
      if (Platform.isAndroid) {
        mapboxMap.moveCamera(CameraUpdate.newLatLng(tapPoint));
      } else if (Platform.isIOS) {
        mapboxMap.animateCamera(CameraUpdate.newLatLng(tapPoint));
      }
    });
  }

  // 画面の中心にマーク(ピン)を立てる
  void _addSymbolOnCameraPosition() {
    if (_symbolAllSet) {
      _controller.future.then((mapboxMap) {
        CameraPosition? camera = mapboxMap.cameraPosition;
        LatLng position = camera!.target;
        _addMark(position);
      });
    }
  }

  // マーク(ピン)を立ててラベルを付ける
  Future<void> _addMark(LatLng tapPoint) async {
    final SymbolInfo? symbolInfo = await Navigator.of(context).push(
        MaterialPageRoute(builder: (context) => const CreateSymbolInfoPage()));
    if (symbolInfo != null) {
      // 詳細情報が入力されたらマーク(ピン)を立てる
      _controller.future.then((mapboxMap) {
        Future<Symbol> futureSymbol = mapboxMap.addSymbol(SymbolOptions(
          geometry: tapPoint,
          textField: _formatLabel(symbolInfo.title),
          textAnchor: "top",
          textColor: "#000",
          textHaloColor: "#FFF",
          textHaloWidth: 3,
          textSize: 12.0,
          iconImage: "mapbox-marker-icon-blue",
          iconSize: 1,
        ));
        futureSymbol.then((symbol) {
          // DB に行追加
          SymbolInfoWithLatLng symbolInfoWithLatLng =
              SymbolInfoWithLatLng(0, symbolInfo, tapPoint); // id はダミー
          Future<int> futureId = _addRecord(symbol, symbolInfoWithLatLng);
          // Map に DB の id を追加
          futureId.then((id) {
            _symbolInfoMap[symbol.id] = id;
          });
        });
      });
    }
  }

  // マークをタップしたときに Symbol の情報を表示する
  void _onSymbolTap(Symbol symbol) {
    _dispSymbolInfo(symbol);
  }

  // Symbol の情報を表示する
  void _dispSymbolInfo(Symbol symbol) {
    Future<SymbolInfo> futureSymbolInfo = _fetchRecord(symbol);
    futureSymbolInfo.then((symbolInfo) => {
          showDialog(
            context: navigatorKey.currentContext!,
            builder: (BuildContext context) => AlertDialog(
              title: Text(symbolInfo.title),
              content: Column(
                mainAxisSize: MainAxisSize.min,
                children: <Widget>[
                  Text(symbolInfo.dateTime.toString().substring(0, 19)),
                  const Gap(16),
                  Text(symbolInfo.describe),
                ],
              ),
              actions: <Widget>[
                TextButton(
                  child: const Text('削除'),
                  onPressed: () {
                    _removeMark(symbol);
                    Navigator.pop(context);
                  },
                ),
                TextButton(
                  child: const Text('閉じる'),
                  onPressed: () {
                    Navigator.pop(context);
                  },
                ),
              ],
            ),
          )
        });
  }

  // マーク(ピン)を削除する
  void _removeMark(Symbol symbol) {
    _controller.future.then((mapboxMap) {
      mapboxMap.removeSymbol(symbol);
    });
    _removeRecord(symbol);
    _symbolInfoMap.remove(symbol.id);
  }

  // 先頭 5 文字を取得(5 文字以上なら先頭 4 文字+「…」)
  String _formatLabel(String label) {
    return (label.length < 6 ? label : '${label.substring(0, 4)}…');
  }

  // 地図の上を北に
  void _resetBearing() {
    _controller.future.then((mapboxMap) {
      mapboxMap.animateCamera(CameraUpdate.bearingTo(_initialBearing));
    });
  }

  // 地図のズームを初期状態に
  void _resetZoom() {
    _controller.future.then((mapboxMap) {
      mapboxMap.moveCamera(CameraUpdate.zoomTo(_initialZoom));
    });
  }
}

Discussion