FlutterとNCMBで地図メモアプリを作る(その1: 画面の仕様とSDKの初期化)
スマートフォンと地図は相性が良いです。スマホは持ち歩いて使うのが基本ですし、位置情報などの情報も取得できます。
今回はNCMBとFlutterを使って地図上にメモできる地図メモアプリを作ります。地図はOpenStreetMapのものを利用し、タップした場所にメモと写真を残しておけるアプリです。
まず最初の記事では画面の説明とSDKの導入までを進めます。
コードについて
今回のコードは flutter_map_note_handson にアップロードしてあります。実装時の参考にしてください。
利用技術について
今回は次のような組み合わせになっています。OpenLayersは地図ライブラリです。国土地理院APIは位置情報を住所に変換する、逆ジオコーディングに利用しています。
- Monaca
- Framework7
- OpenLayers
- 国土地理院API
仕様について
地図はOpenLayersを使い、OpenStreetMapを表示します。
利用する機能について
地図メモアプリで利用するNCMBの機能は次の通りです。
- データストア
- データ登録
- データ取得
- ファイルストア
- アップロード
- ダウンロード
画面について
今回は以下の3つのウィジェットがあります。
MainPage
地図と一覧、二つのタブを読み込むウィジェットです。
MapPage
mapを使って、OpenStreetMapを表示します。デフォルトは東京タワーの位置情報としていますので、位置情報取得のダイアログは使いません。
NotePage
地図上のタップされた場所にメモおよび画像を記録するための画面です。
ListPage
地図画面で表示しているメモを一覧表示する画面です。
RowPage
一覧画面で利用する、各行で利用するウィジェットです。
BubbleBorder
地図で吹き出し表示を行うウィジェットです。
SDKのインストール
flutterコマンドを使って各種ライブラリ・SDKをインストールします。
# 以下を追加済み
# flutter pub add ncmb
# flutter pub add map
# flutter pub add flutter_dotenv
# flutter pub add cached_network_image
# flutter pub add image_picker
# flutter pub add uuid
# flutter pub add http
# flutter pub add geolocator
NCMBのAPIキーを取得
mBaaSでサーバー開発不要! | ニフクラ mobile backendにてアプリを作成し、アプリケーションキーとクライアントキーを作成します。
アセットの追加
.env
をpubspec.yamlに追加します。
flutter:
assets:
- .env
.envファイルの作成
assets/.env
を作成し、アプリケーションキーとクライアントキーを定義します。
APPLICATION_KEY=YOUR_APPLICATION_KEY
CLIENT_KEY=YOUR_CLIENT_KEY
main.dartの修正
main.dartファイルを開いて、NCMB SDKの読み込みと初期化を行います。
// 記述してください
// NCMB SDKをインポート
import 'package:flutter/material.dart';
import 'package:ncmb/ncmb.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
Future main() async {
await dotenv.load(fileName: '.env');
// 記述(ここから)
var applicationKey =
dotenv.get('APPLICATION_KEY', fallback: 'No application key found.');
var clientKey = dotenv.get('CLIENT_KEY', fallback: 'No client key found.');
// 記述(ここまで)
await initializeDateFormatting("ja");
runApp(const MyApp());
}
これでNCMBの初期化が完了します。
メイン画面を表示する
アプリの MyApp
ウィジェットで、 MainPage
ウィジェットを読み込みます。これは地図と一覧のタブを表示しています。
// 記述済み
class MyApp extends StatelessWidget {
final mapboxAccessToken = '';
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.lightBlue,
),
home: const MainPage(),
);
}
}
MainPage
ウィジェットは以下の通りです。メモの一覧を管理する _notes
と、地図を中心点 _location
の2つは画面間で共有するために MainPage
にて管理しています。
// 一部記述を修正してください
import 'package:flutter/material.dart';
import 'package:latlng/latlng.dart';
// インポートを追加してください
import 'package:ncmb/ncmb.dart';
import './map_page.dart';
import './list_page.dart';
// 最初の画面用のStatefulWidget
class MainPage extends StatefulWidget {
const MainPage({Key? key}) : super(key: key);
@override
State<MainPage> createState() => _MainPageState();
}
class _MainPageState extends State<MainPage> {
// タイトル
final title = '地図アプリ';
// 表示するタブ
final _tab = <Tab>[
const Tab(text: '地図', icon: Icon(Icons.map_outlined)),
const Tab(text: 'リスト', icon: Icon(Icons.list_outlined)),
];
// List<dynamic> _notes = [];
// を、
// List<NCMBObject> _notes = [];
// に修正してください
List<dynamic> _notes = [];
final LatLng _location = const LatLng(35.6585805, 139.7454329);
// List<NCMBObject>に修正してください
void _setNotes(List<dynamic> notes) {
setState(() {
_notes = notes;
});
}
// AppBarとタブを表示
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: _tab.length,
child: Scaffold(
appBar: AppBar(
title: Text(title),
bottom: TabBar(
tabs: _tab,
),
),
body: TabBarView(children: [
MapPage(setNotes: _setNotes, location: _location),
ListPage(notes: _notes, location: _location),
]),
),
);
}
}
地図を表示する
以下は MapPage
のコードです。map | Flutter Package用のコードがほとんどです。
// 一部修正してください
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:latlng/latlng.dart';
import 'package:map/map.dart';
import 'package:ncmb/ncmb.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'dart:math';
import './bubble_border.dart';
import './note_page.dart';
// 地図画面用のStatefulWidget
class MapPage extends StatefulWidget {
const MapPage({Key? key, required this.setNotes, required this.location})
: super(key: key);
// List<NCMBObject>に修正します
final Function(List<dynamic>) setNotes;
final LatLng location;
@override
State<MapPage> createState() => _MapPageState();
}
// 地図画面
class _MapPageState extends State<MapPage> {
// 地図コントローラーの初期化
MapController? controller;
// ドラッグ操作用
Offset? _dragStart;
double _scaleStart = 1.0;
// 表示するマーカー
List<Widget> _markers = [];
Widget? _tooltip;
@override
void initState() {
super.initState();
controller = MapController(
location: widget.location,
zoom: 15,
);
}
// 初期のスケール情報
void _onScaleStart(ScaleStartDetails details) {
_dragStart = details.focalPoint;
_scaleStart = 1.0;
}
// センターが変わったときの処理
void _onScaleUpdate(MapTransformer transformer, ScaleUpdateDetails details) {
final scaleDiff = details.scale - _scaleStart;
_scaleStart = details.scale;
if (scaleDiff > 0) {
controller!.zoom += 0.02;
setState(() {});
} else if (scaleDiff < 0) {
controller!.zoom -= 0.02;
if (controller!.zoom < 1) {
controller!.zoom = 1;
}
setState(() {});
} else {
final now = details.focalPoint;
var diff = now - _dragStart!;
_dragStart = now;
final h = transformer.constraints.maxHeight;
final vp = transformer.getViewport();
if (diff.dy < 0 && vp.bottom - diff.dy < h) {
diff = Offset(diff.dx, 0);
}
if (diff.dy > 0 && vp.top - diff.dy > 0) {
diff = Offset(diff.dx, 0);
}
transformer.drag(diff.dx, diff.dy);
setState(() {});
}
}
void _onScaleEnd(MapTransformer transformer) {
// マーカーを表示する
showNotes(transformer);
}
// マーカーウィジェットを作成する
Future<Widget> _buildMarkerWidget(
NCMBObject note, MapTransformer transformer) async {
// 後述
}
Future<void> _onTap(double top, double left, NCMBObject note) async {
// 後述
}
Future<void> showNotes(MapTransformer transformer) async {
// 後述
}
Future<List<NCMBObject>> getNotes(LatLng location) async {
// 後述
}
// 地図をタップした際のイベント
void _onTapUp(MapTransformer transformer, TapUpDetails details) async {
// 後述
}
double clamp(double x, double min, double max) {
if (x < min) x = min;
if (x > max) x = max;
return x;
}
void _onDoubleTapDown(MapTransformer transformer, TapDownDetails details) {
const delta = 0.5;
final zoom = clamp(controller!.zoom + delta, 2, 18);
transformer.setZoomInPlace(zoom, details.localPosition);
setState(() {});
}
void _onPointerSignal(MapTransformer transformer, PointerSignalEvent event) {
if (event is! PointerScrollEvent) return;
final delta = event.scrollDelta.dy / -1000.0;
final zoom = clamp(controller!.zoom + delta, 2, 18);
transformer.setZoomInPlace(zoom, event.localPosition);
setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: null,
body: MapLayout(
controller: controller!,
builder: (context, transformer) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
// タップした際のズーム処理
onDoubleTapDown: (details) =>
_onDoubleTapDown(transformer, details),
// ピンチ/パン処理
onScaleStart: _onScaleStart,
onScaleUpdate: (details) => _onScaleUpdate(transformer, details),
onScaleEnd: (details) => _onScaleEnd(transformer),
// タップした際の処理
onTapUp: (details) async {
_onTapUp(transformer, details);
},
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerSignal: (event) => _onPointerSignal(transformer, event),
child: Stack(children: [
TileLayer(
builder: (context, x, y, z) {
final tilesInZoom = pow(2.0, z).floor();
while (x < 0) {
x += tilesInZoom;
}
while (y < 0) {
y += tilesInZoom;
}
x %= tilesInZoom;
y %= tilesInZoom;
return CachedNetworkImage(
imageUrl: 'https://tile.openstreetmap.org/$z/$x/$y.png',
fit: BoxFit.cover,
);
},
),
..._markers,
_tooltip != null ? _tooltip! : Container(),
]),
),
);
},
),
);
}
}
地図をタップした際の処理
地図をタップした際には _onTapUp
関数が呼ばれます。この関数では、地図上のマーカーがタップされたのか、それ以外の部分なのかを判定して処理分けしています。
// 記述済み
// 地図をタップした際のイベント
void _onTapUp(MapTransformer transformer, TapUpDetails details) async {
// ツールチップが表示されている場合は非表示にして終了
if (_tooltip != null) {
setState(() {
_tooltip = null;
});
return;
}
// 地図上のXY
final location = transformer.toLatLng(details.localPosition);
// メモ追加画面を表示
Navigator.push(
context,
MaterialPageRoute(builder: (context) => NotePage(location: location)),
);
}
上記関数での NotePage
がノート画面になります。
ノート画面での表示
ノート画面 NotePage
は以下のようにフォームを表示しています。
// 記述済み
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('メモ'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Spacer(),
const Text(
'写真を選択して、メモを入力してください',
),
_image != null
? GestureDetector(
child: SizedBox(
child: Image.memory(_image!),
height: 200,
),
onTap: _selectPhoto,
)
: IconButton(
iconSize: 200,
icon: const Icon(
Icons.photo,
color: Colors.blue,
),
onPressed: _selectPhoto,
),
Spacer(),
_address != null
? Text(
"$_address付近のメモ",
style: const TextStyle(fontSize: 20),
)
: const SizedBox(),
const Spacer(),
SizedBox(
width: 300,
child: TextFormField(
controller: _textEditingController,
enabled: true,
style: const TextStyle(color: Colors.black),
maxLines: 5,
onChanged: (text) {
setState(() {
_text = text;
});
},
),
),
const Spacer(),
ElevatedButton(onPressed: _onPressed, child: const Text('保存')),
const Spacer(),
],
),
),
);
}
初期化時に処理
初期化時には、以下のように国土地理院APIを使って位置情報を住所に変換します。
// 記述済み
@override
void initState() {
super.initState();
_textEditingController = TextEditingController();
_getAddress();
}
Future<void> _getAddress() async {
if (widget.location == null) return;
final lat = widget.location!.latitude;
final lng = widget.location!.longitude;
final uri = Uri.parse(
"https://mreversegeocoder.gsi.go.jp/reverse-geocoder/LonLatToAddress?lat=$lat&lon=$lng");
final response = await http.get(uri);
final json = jsonDecode(response.body);
final num = json['results']['muniCd'];
String loadData = await rootBundle.loadString('csv/gsi.csv');
final ary = loadData.split('\n');
final line = ary.firstWhere((line) => line.split(',')[0] == num);
final params = line.split(',');
setState(() {
_address = "${params[2]}${params[4]}${json['results']['lv01Nm']}";
});
}
csv/gsi.csv
は [https://maps.gsi.go.jp/js/muni.js](https://maps.gsi.go.jp/js/muni.js)
のデータをCSVに変換したものです。
1100,1,北海道,1100,札幌市
1101,1,北海道,1101,札幌市 中央区
1102,1,北海道,1102,札幌市 北区
:
47375,47,沖縄県,47375,多良間村
47381,47,沖縄県,47381,竹富町
47382,47,沖縄県,47382,与那国町
写真選択時の処理
写真を選択する際には、写真アイコンをタップします。そして写真が選択されたら、プレビューを表示します。
// 記述済み
Future<void> _selectPhoto() async {
final pickedFile = await picker.pickImage(source: ImageSource.gallery);
if (pickedFile == null) return;
var image = await pickedFile.readAsBytes();
setState(() {
_extension = pickedFile.mimeType!.split('/')[1];
_image = image;
});
}
ノートを保存
入力が終わったら、保存処理を実行します。選択されている位置情報はNCMBの位置情報オブジェクトに変換します。そして住所、メモと一緒に保存します。
// 記述してください
Future<void> _onPressed() async {
final fileName = "${const Uuid().v4()}.$_extension";
final geo =
NCMBGeoPoint(widget.location!.latitude, widget.location!.longitude);
final obj = NCMBObject('Note');
obj.sets({'text': _text, 'address': _address, 'geo': geo});
if (_image != null) {
await NCMBFile.upload(fileName, _image);
obj.set('image', fileName);
}
await obj.save();
Navigator.pop(context);
}
これでメモの保存処理が完成しました。
地図上へのマーカー表示
この処理は MapPage にて実装します。地図の初期化タイミングでは transfer
がないため、中心点を移動し終わったタイミングで処理を行っています。
// 一部修正してください
void _onScaleEnd(MapTransformer transformer) {
// マーカーを表示する
showNotes(transformer);
}
Future<void> showNotes(MapTransformer transformer) async {
final notes = await getNotes(transformer.controller.center);
final markers = await Future.wait(notes.map((note) async {
return await _buildMarkerWidget(note, transformer);
}));
setState(() {
widget.setNotes(notes);
_markers = markers;
});
}
// 関数の内容を修正してください
Future<List<NCMBObject>> getNotes(LatLng location) async {
// NCMBGeoPointに変換
var geo = NCMBGeoPoint(location.latitude, location.longitude);
// 検索用のクエリークラス
var query = NCMBQuery('Note');
// 位置情報を中心に3km範囲で検索
query.withinKilometers('geo', geo, 3);
// レスポンスを取得
var ary = await query.fetchAll();
// List<NCMBObject>に変換
return ary.map((obj) => obj as NCMBObject).toList();
}
_buildMarkerWidget
はNCMBObjectをウィジェットに変換する関数です。
// 関数の内容を修正してください
// マーカーウィジェットを作成する
Future<Widget> _buildMarkerWidget(
NCMBObject note, MapTransformer transformer) async {
final geo = note.get("geo") as NCMBGeoPoint;
final pos = transformer.toOffset(LatLng(geo.latitude!, geo.longitude!));
final image = await NCMBFile.download(note.getString("image"));
return Positioned(
left: pos.dx - 16,
top: pos.dy - 16,
width: 40,
height: 40,
child: GestureDetector(
child: SizedBox(
child: Image.memory(image.data),
height: 200,
),
onTap: () => _onTap(pos.dy, pos.dx, note),
),
);
}
表示したマーカーをタップした際には _onTap
を呼びます。この関数では、画像付きのツールチップを表示します。
// 修正してください
Future<void> _onTap(double top, double left, NCMBObject note) async {
final image = await NCMBFile.download(note.getString('image'));
final tooltip = Container(
margin: const EdgeInsets.only(left: 15.0),
padding: const EdgeInsets.symmetric(
vertical: 5.0,
horizontal: 10.0,
),
child: Column(children: [
Text(note.getString('text')),
Text(note.getString('address', defaultValue: "不明") + "付近のメモ"),
SizedBox(
child: Image.memory(image.data),
height: 200,
)
]),
decoration: const ShapeDecoration(
color: Colors.white,
shape: BubbleBorder(),
),
);
setState(() {
_tooltip = Positioned(
left: left - 170,
top: top - 280,
child: tooltip,
);
});
}
メモの一覧表示
一覧画面 ListPage
では MainPage
から送られてきたメモデータを一覧表示します。メモを RowPage
に送っているだけです。
// 記述済み
class _ListPageState extends State<ListPage> {
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: ListView.builder(
itemBuilder: (BuildContext context, int index) =>
RowPage(note: widget.notes[index], location: widget.location),
itemCount: widget.notes.length),
)
],
));
}
}
RowPage
では受け取ったデータを表示します。画像データをダウンロードし、それも合わせて一覧に利用しています。また、Geolocatorを使って2点間の距離をメートルで算出しています。
// 記述済み
// 設定画面
class _RowPageState extends State<RowPage> {
Uint8List? _image;
@override
void initState() {
super.initState();
_getImage();
}
Future<void> _getImage() async {
// 後述
}
String distance() {
// 後述
}
@override
Widget build(BuildContext context) {
return Row(children: [
_image != null
? SizedBox(
child: Image.memory(_image!),
width: 150,
)
: const SizedBox(
child: Icon(
Icons.photo,
size: 100,
color: Colors.grey,
)),
const Padding(padding: EdgeInsets.only(left: 8)),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// 後述
Text('${distance()}m')
])
]);
}
}
画面表示の追加
画面にコメントや住所を追加表示します。
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// 以下を追加してください(ここから)
Text(widget.note.getString('text')),
Text(widget.note.getString('address', defaultValue: '不明')),
// 以下を追加してください(ここまで)
Text('${distance()}m')
]);
画像取得処理の追加
画像はファイルストアの download メソッドで取得します。
// 関数の内容を修正してください
Future<void> _getImage() async {
if (widget.note.get('image') == null) return;
final fileName = widget.note.getString('image');
final image = await NCMBFile.download(fileName);
setState(() {
_image = image.data;
});
}
距離の算出
地図の中心点との距離は以下のように算出します。
// 関数の内容を修正してください
String distance() {
final geo = widget.note.get('geo') as NCMBGeoPoint;
final dist = Geolocator.distanceBetween(widget.location.latitude,
widget.location.longitude, geo.latitude!, geo.longitude!);
return dist.toStringAsFixed(0);
}
今回利用したNCMBの機能
この地図メモアプリでは、NCMBの以下の機能を利用しました。
- データストア
- データ登録
- データ取得
- ファイルストア
- アップロード
- ダウンロード
NCMBには他にも認証、スクリプト、プッシュ通知などの機能があります。ぜひそれらの機能も利用してください。
まとめ
今回はOpenStreetMapとFlutterを組み合わせて、位置情報を利用したメモアプリを作成しました。位置情報検索はNCMBの売り機能でもあるので、ぜひ利用してください。