[Flutter] InteractiveViewerを自在に操るための技術
これは何?
FlutterにはInteractiveViewerというWidgetがございます。
今回はこれをプログラム上から自由に操るための術をまとめていきます。
TL;DR
-
InteractiveViewer
はアフィン変換の変形行列を基にchildを変形する -
TransformationController
に任意の変形行列を与えることで、プログラム上から任意の変形ができる- 元の状態を使いたかったら新しい変形行列を左側から掛ける
InteractiveViewerができること
InteractiveViewer
は一言でいうと、指定したWidgetを縦横斜めと自由に移動する事ができたり、拡大縮小を施す事ができます。
言い換えると、お持ちのスマホに入ってるマップアプリのような挙動を好きなWidgetで再現することができるわけです。
詳しくは下記ドキュメントや、その中にあるWidget of the Weekの動画を見るとわかります。
さて、それらの操作はユーザーからのジェスチャーを起点に行う場合であれば、何も考えずに任意のWidgetをラップすればOKです。
ですが、もしそれ以外の方法で操作したいことがあったら……?
「このリストから指定した地点までカメラを動かしたい!」
「拡大縮小ボタンはやっぱり必要だよね〜」
……まぁそういう要望はありますよね。
これらを叶えるための方法を考えましょう。
TransformationController
InteractiveViewer
にはListView
やPageView
などのようにコントローラーを指定する事ができます。
この人の場合は、TransformationControllerというものを使います。
あらゆるコントローラーの実態は大体ChangeNotifier
やValueNotifier
のラッパーであることが多いですが、このTransformationController
の実態はと言うとMatrix4を扱うValueNotifier
のラッパーとなっています。
Matrix4 #とは
Matrix4
とは皆さん大好き、高校数学で登場する4次元行列のモデルとなるオブジェクトです。こんな感じのやつ(例は単位行列)↓
なぜこれがコントローラーで扱われているのかというと、何を隠そうInteractiveViewer
はアフィン変換を使った変形をするためのWidgetだからです。
アフィン変換 #とは
アフィン変換については上記の記事がよくまとまっているので流し見だけでもしておくと理解が深まります。
この記事に関連する部分でいうと、
平面のアフィン変換とは三角形の移動(写像)を与えることで決まる変換のこと
このあたりでしょうか。
つまり今回は、
- アフィン変換という行列を与えて画像を変形する方法があること
- 変換の種類はいくつかあり、それらの組み合わせが可能であること
と覚えておけばまずは大丈夫です。
また、シンプルな変形行列は形が決まっています。今回ここで使うのは移動と拡大縮小ですが、それぞれ行列で表すと以下になります。
TransformationControllerの扱い方
ではTransformationController
上でどのように行列を扱えばいいか、というところが気になります。
しかし話は単純。TransformationController
で管理している行列は、実はWidgetをどのように変形しているか、を表す行列(=現在の変形行列)そのものになります。
つまり、任意のMatrix4
のインスタンスをTransformationController.value
に渡してやることで、即座に任意の変形を与えてやることができます。
実際にInteractiveViewerを操作する
文章ばかりではピンとこないと思われるので、実際に動く例を提示します。
DartPad: https://dartpad.dev/?id=297609e398dd27651738bc8e36d1d2fa
基本と応用を兼ねた例をいきなり貼ってみました(コード自体はChatGPT製に修正を加えたものです)。まずは基本的なところから解説していきます。
基本的な変形
InteractiveViewerExample
のinitState
にてTransformationController
の初期化を行っています。
void initState() {
super.initState();
_transformationController.value = Matrix4.translationValues(
-_childSize / 2 + widget.size.width / 2,
-_childSize / 2 + widget.size.height / 2,
0
);
}
今回は前提として、InteractiveViewer
のconstrained
をfalseに指定してchild
には縦横3000(_childSize
)のサイズを持つWidgetを指定しています。
TransformationController
の初期値は単位行列で、この場合だとchild
の左上にカメラが向いているような状態になります。
こんな感じ
このカメラを画面中央に持ってくるためには、右下方向にchildのサイズ/2 - 表示領域/2
の移動をしてやる必要があります。
ただし、TransformationController
経由の移動は負の方向を指定する必要があります。
こうしたい
というわけで、最終的に 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
としては左上を原点に考えているので、画面の左上から愚直に拡大/縮小するという挙動になってしまいます。
こうなる
このような挙動を意図する状況はおそらくあまり無いので、画面中央を原点として拡大縮小をするようにしています。
こうしたい
実際にこれがどうやって実現されているかというと、以下のステップを踏んでいます。
- 原点をずらしたい位置に一度カメラを移動させる
- 拡大/縮小をする
- 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
で遊んでみてください🫰
Discussion