👌

[Flutter] InteractiveViewerを自在に操るための技術

2024/07/09に公開

これは何?

FlutterにはInteractiveViewerというWidgetがございます。
今回はこれをプログラム上から自由に操るための術をまとめていきます。

TL;DR

  • InteractiveViewer はアフィン変換の変形行列を基にchildを変形する
  • TransformationController に任意の変形行列を与えることで、プログラム上から任意の変形ができる
    • 元の状態を使いたかったら新しい変形行列を左側から掛ける

InteractiveViewerができること

InteractiveViewerは一言でいうと、指定したWidgetを縦横斜めと自由に移動する事ができたり、拡大縮小を施す事ができます。
言い換えると、お持ちのスマホに入ってるマップアプリのような挙動好きなWidgetで再現することができるわけです。

詳しくは下記ドキュメントや、その中にあるWidget of the Weekの動画を見るとわかります。

https://api.flutter.dev/flutter/widgets/InteractiveViewer-class.html

さて、それらの操作はユーザーからのジェスチャーを起点に行う場合であれば、何も考えずに任意のWidgetをラップすればOKです。
ですが、もしそれ以外の方法で操作したいことがあったら……?

「このリストから指定した地点までカメラを動かしたい!」
「拡大縮小ボタンはやっぱり必要だよね〜」

……まぁそういう要望はありますよね。
これらを叶えるための方法を考えましょう。

TransformationController

InteractiveViewerにはListViewPageViewなどのようにコントローラーを指定する事ができます。
この人の場合は、TransformationControllerというものを使います。

あらゆるコントローラーの実態は大体ChangeNotifierValueNotifierのラッパーであることが多いですが、このTransformationControllerの実態はと言うとMatrix4を扱うValueNotifierのラッパーとなっています。

Matrix4 #とは

Matrix4とは皆さん大好き、高校数学で登場する4次元行列のモデルとなるオブジェクトです。こんな感じのやつ(例は単位行列)↓

\begin{pmatrix} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \\ \end{pmatrix}

なぜこれがコントローラーで扱われているのかというと、何を隠そうInteractiveViewerアフィン変換を使った変形をするためのWidgetだからです。

アフィン変換 #とは

https://qiita.com/koshian2/items/c133e2e10c261b8646bf

アフィン変換については上記の記事がよくまとまっているので流し見だけでもしておくと理解が深まります。
この記事に関連する部分でいうと、

平面のアフィン変換とは三角形の移動(写像)を与えることで決まる変換のこと

アフィン変換の合成=行列の積

このあたりでしょうか。

つまり今回は、

  • アフィン変換という行列を与えて画像を変形する方法があること
  • 変換の種類はいくつかあり、それらの組み合わせが可能であること

と覚えておけばまずは大丈夫です。
また、シンプルな変形行列は形が決まっています。今回ここで使うのは移動と拡大縮小ですが、それぞれ行列で表すと以下になります。

移動 \begin{pmatrix} 0 & 0 & 0 & dx \\ 0 & 0 & 0 & dy \\ 0 & 0 & 0 & dz \\ 0 & 0 & 0 & 1 \\ \end{pmatrix} \\
拡大縮小 \begin{pmatrix} s_x & 0 & 0 & 0 \\ 0 & s_y & 0 & 0 \\ 0 & 0 & s_z & 0 \\ 0 & 0 & 0 & 1 \\ \end{pmatrix}

TransformationControllerの扱い方

ではTransformationController上でどのように行列を扱えばいいか、というところが気になります。
しかし話は単純。TransformationControllerで管理している行列は、実はWidgetをどのように変形しているか、を表す行列(=現在の変形行列)そのものになります。

つまり、任意のMatrix4のインスタンスをTransformationController.value に渡してやることで、即座に任意の変形を与えてやることができます。

実際にInteractiveViewerを操作する

文章ばかりではピンとこないと思われるので、実際に動く例を提示します。

DartPad: https://dartpad.dev/?id=297609e398dd27651738bc8e36d1d2fa

基本と応用を兼ねた例をいきなり貼ってみました(コード自体はChatGPT製に修正を加えたものです)。まずは基本的なところから解説していきます。

基本的な変形

InteractiveViewerExampleinitStateにてTransformationControllerの初期化を行っています。

  
  void initState() {
    super.initState();
    _transformationController.value = Matrix4.translationValues(
      -_childSize / 2 + widget.size.width / 2,
      -_childSize / 2 + widget.size.height / 2,
      0
    );
  }

今回は前提として、InteractiveViewerconstrainedをfalseに指定してchildには縦横3000(_childSize)のサイズを持つWidgetを指定しています。

TransformationControllerの初期値は単位行列で、この場合だとchildの左上にカメラが向いているような状態になります。

Initial image
こんな感じ

このカメラを画面中央に持ってくるためには、右下方向にchildのサイズ/2 - 表示領域/2の移動をしてやる必要があります。
ただし、TransformationController経由の移動は負の方向を指定する必要があります。

Initialized image
こうしたい

というわけで、最終的に Matrix4.translationValuesコンストラクタを使って移動変形のための行列を作り、上記のようにTransformationController.valueに指定すればOKです。

応用的な変形

基本的な操作、特にただカメラを移動するだけの操作はわかりました。
では今度は、 「拡大縮小ボタンはやっぱり必要だよね〜」 という声に応えていきましょう。

先に挙げたサンプルの中で、以下の部分に注目します。

  void _zoomIn() {
    setState(() {
      _transformationController.value = _scaleMatrix(_scaleFactor);
    });
  }

  void _zoomOut() {
    setState(() {
      _transformationController.value = _scaleMatrix(1 / _scaleFactor);
    });
  }

  Matrix4 _scaleMatrix(double scale) {
    final center = MediaQuery.of(context).size.center(Offset.zero);
    final translationToCenter = Matrix4.translationValues(center.dx, center.dy, 0);
    final scaleMatrix = Matrix4.diagonal3Values(scale, scale, scale);
    final translationBack = Matrix4.translationValues(-center.dx, -center.dy, 0);

    return translationToCenter * scaleMatrix * translationBack * _transformationController.value;
  }

拡大ボタンには _zoomIn()_zoomOutが割り振られており、拡大縮小をするためのメソッドが共通化されています。
_scaleFactor は常に1.5の値を持つので、要するに

  • 拡大ボタンを押したら1.5倍に拡大する
  • 縮小ボタンを押したら0.666...倍に縮小する

という挙動をすることになっています。

さて、_scaleMatrix() の中を除くと、何やら行列が大量に作られています。
基本的な変形の流れで行くと、Matrix4.diagonal3Values(scale, scale, scale)の部分だけ使えば良さそうですが、これは……?

というのも、何も考えずただスケールを掛けるだけだと、InteractiveViewerとしては左上を原点に考えているので、画面の左上から愚直に拡大/縮小するという挙動になってしまいます

Scaling failed
こうなる

このような挙動を意図する状況はおそらくあまり無いので、画面中央を原点として拡大縮小をするようにしています。

Scaling succeeded
こうしたい

実際にこれがどうやって実現されているかというと、以下のステップを踏んでいます。

  1. 原点をずらしたい位置に一度カメラを移動させる
  2. 拡大/縮小をする
  3. 1で移動した分だけカメラを元の位置に戻すように移動させる

そして、これらの操作は一つ一つTransformationControllerに適用しても動きますが、この変形がアフィン変換であることを活用すると、それぞれの変形の行列を掛け算することで畳み込む(一つの変形行列として定義できる)ことが可能になります。

つまるところ、コードをコメントで補足すると以下のようになります。

  Matrix4 _scaleMatrix(double scale) {
    final center = MediaQuery.of(context).size.center(Offset.zero);
    // ① 画面中央までの距離を移動する行列を定義
    final translationToCenter = Matrix4.translationValues(center.dx, center.dy, 0);
    // ② scaleを適用した拡大/縮小のための行列を定義
    final scaleMatrix = Matrix4.diagonal3Values(scale, scale, scale);
    // ③ ①で移動した分をもとに戻す行列を定義
    final translationBack = Matrix4.translationValues(-center.dx, -center.dy, 0);

    // ①、②、③を順番に掛け算し、それを現在の変形行列の左側から掛ける
    return translationToCenter * scaleMatrix * translationBack * _transformationController.value;
  }

この変形行列を組み合わせる方法を使えば、任意の動きを InteractiveViewer上で再現することが可能になります。
(まだこの記事を書いている時点では回転はサポートされていないようですが……)

おわりに

というわけでInteractiveViewerを自在に操る術をまとめました。
また、ここからの応用テクとしてはAnimationControllerを組み合わせることで移動/拡大/縮小をアニメーション付きで行う、といったこともできます。
ぜひInteractiveViewerで遊んでみてください🫰

Sun* Developers

Discussion