はじめに

こんにちは、エンジニアの遠藤です。
今回は Flutter を使って拡大/縮小できる Map のようなアプリを作成しましたので、その紹介です。

完成イメージ

まずは出来上がったイメージをご覧ください。
それぞれ、iOS と Android のシミュレータです。
(容量的な兼ね合いで、画質が荒い点はご容赦ください...)

iOS

map_all

Android

map

いわゆる Google Map のようなジオメトリを表示するものではなく、例えばオフィスや施設内のフロアマップを表示するようなイメージで作りました。

ソースコード全量

コードの全量は以下の通りです。

lib/main.dart

lib/main.dart
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

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

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const DemoPage(),
    );
  }
}

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

  @override
  State<DemoPage> createState() => _DemoPageState();
}

class PinData {
  num x, y;
  final String message;
  PinData(this.x, this.y, this.message);
}

class _DemoPageState extends State<DemoPage> {
  final _transformationController = TransformationController();
  double scale = 1.0;
  double defaultWidth = 50.0;
  double defaultHeight = 50.0;
  double defFontSize = 20.0;

  double calcWidth(){
    return ((defaultWidth / scale) /2);
  }
  double calcHeight(){
    return ((defaultHeight / scale));
  }

  void tapPin(String message){
    showDialog(
      context: context,
      builder: (_) {
        return AlertDialog(
          title: const Text("この場所は"),
          content: Text(message),
          actions: <Widget>[
            TextButton(
              child: const Text("OK"),
              onPressed: () => Navigator.pop(context),
            ),
          ],
        );
      },
    );
  }

  // ピンのリストを適当に生成
  final List<PinData> pinDataList = [
    PinData(80, 480, "左下の公園"),
    PinData(130, 340, "左上の公園"),
    PinData(190, 480, "橋"),
    PinData(280, 390, "駅"),
    PinData(320, 370, "駅の近くの公園"),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: InteractiveViewer(
          alignPanAxis: false,
          constrained: true,
          panEnabled: true,
          scaleEnabled: true,
          boundaryMargin: const EdgeInsets.all(100.0),
          minScale: 0.1,
          maxScale: 10.0,
          onInteractionUpdate: (details) {
            setState(() {
              // データを更新
              scale = _transformationController.value.getMaxScaleOnAxis();
            });
          },
          transformationController: _transformationController,
          child: Stack(
            fit: StackFit.expand,
            children: <Widget>[
              Image.asset(
                "images/map_open.png",
                fit: BoxFit.fitWidth,
              ),
              for (PinData pinData in pinDataList)
              // 一定の scale よりも小さくなったら非表示にする
                if (scale > 0.9)
                // Positionedで配置
                  Positioned(
                      // 座標を左上にすると、拡大縮小時にピンの位置がズレていくので、ピンの先端がズレないように固定
                      left: pinData.x - calcWidth(),
                      top: pinData.y - calcHeight(),
                      // 画像の拡大率に合わせて、ピン画像のサイズを調整
                      width: defaultWidth / scale,
                      height: defaultHeight / scale,
                      child: GestureDetector(
                        child: Container(
                          alignment: const Alignment(0.0, 0.0),
                          child: Image.asset("images/map_pin_shadow.png"),
                        ),
                        onTap: (){
                          tapPin(pinData.message);
                        },
                      )
                  ),
            ],
          ),
        ));
  }
}

pubspec.yaml

pubspec.yaml
name: flutter_map
description: A new Flutter project.

publish_to: 'none' # Remove this line if you wish to publish to pub.dev

version: 1.0.0+1

environment:
  sdk: ">=2.17.0"

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^1.0.2

dev_dependencies:
  flutter_test:
    sdk: flutter

  flutter_lints: ^2.0.0

flutter:

  uses-material-design: true

  # 以下の2ファイルを追加しただけです
  # ピン画像とマップ画像を追加しておきます。
  assets:
     - images/map_open.png
     - images/map_pin_shadow.png

ちょっと解説

Map 表示部分

それぞれの要素を簡単に解説します。

Map は InteractiveViewer を使って、拡大/縮小できるようにしています。
InteractiveViewer の詳細は以下の公式が分かりやすいです。
https://api.flutter.dev/flutter/widgets/InteractiveViewer-class.html

InteractiveViewer(
  alignPanAxis: false,  // true にすると、pan操作が水平/垂直に限定される
  constrained: true,    // 子 Widget に親のサイズ制約が適用されるか
  panEnabled: true,     // pan操作を許容するか
  scaleEnabled: true,   // 拡大縮小を許容するか
  boundaryMargin: const EdgeInsets.all(100.0),  // pan 可能なマージン
  minScale: 0.1,        // 拡大率の最小値
  maxScale: 10.0,       // 拡大率の最大値
  child: Stack(         // 画像を貼り付け
    fit: StackFit.expand,
    children: <Widget>[
      Image.asset(
        "images/map_open.png",
        fit: BoxFit.fitWidth,
      )
    ]
  )
)

あとは、 InteractiveViewer の child: Stack にピン画像を適切な座標で配置すれば、画像の移動に合わせてピンも動かせます。

Scale の取得と適用

ただ、 Stack で配置するだけではマップの拡大縮小に合わせてピンのサイズもデカくなってしまいます。
なので、 InteractiveViewer の拡大率、縮小率に合わせてピン画像を適切にサイズ変更してあげる必要があります。

scale = 1.0

map_all

拡大時

map_all

処理内容

まず InteractiveViewer の拡大縮小が発生したイベントをトリガーにして、 scale を保存します。

onInteractionUpdate: (details) {
    setState(() {
      // データを更新
      scale = _transformationController.value.getMaxScaleOnAxis();
    });
},

あとは、この scale を使って、表示する画像サイズを計算してあげればOKです。

Positioned(
  // 画像の拡大率に合わせて、ピン画像のサイズを調整
  width: defaultWidth / scale,
  height: defaultHeight / scale,
),

Pin 表示位置の微調整

これで画像の拡大に合わせてピンが大きくなることはなくなりましたが、さらに topleft の位置を調整してあげると、より自然な形で pin を移動させることができます。

座標を左上で指定

map_all

これは何も考えずに画像の左上の座標を指定した場合の表示です。
ピンの先端に注目すると、画像の拡大に合わせて、指定した場所からずれているように感じますね。
左上の位置はずれていないので、矢印が左上を向いているような画像の場合は計算は不要です。

座標を画像の中央下で指定

double calcWidth(){
  return ((defaultWidth / scale) /2);
}
double calcHeight(){
  return ((defaultHeight / scale));
}
// ...
// 座標を左上にすると、拡大縮小時にピンの位置がズレていくので、ピンの先端がズレないように固定
left: pinData.x - calcWidth(),
top: pinData.y - calcHeight(),

map_all

ピンの先端を座標に指定すると、違和感が減ります。

終わりに

調べてもドンピシャな情報が見つからなかったので、試してみたら意外と簡単にできました。
拡大縮小イベントを取得して、サイズ計算して再描画すると、結構カクカクするのかなと思いましたが、想像以上にヌルヌル動きました。
これはただのサンプルなので、まだまだ調整は必要ですがプロジェクトでも使えそうな挙動を実装することができました。

ゲームとか子育てアプリではなく、少し実用的な Flutter の記事もこれから少しづつ書いていきたいと思います。