【Flutter】自由に平行移動・回転・拡大縮小できるUIの実現

この記事は約12分で読めます。

やりたかったこと

↓の動画のようにユーザが自由に移動・回転・拡大縮小できるようなUIを実現したいと考えました。
(タッチ位置の描画はAndroidの録画機能によるものです)

実動作するデモも載せておきます。

GestureDetectorとTransformの組み合わせで簡単に実現できるだろうと思ったのですが、思いのほか苦戦してしまったので自分のためも含めて残しておきます。

実現したコード

冒頭の動画およびデモを実現するためのコードは最終的に下のようになりました。

import 'package:flutter/material.dart';
import 'package:vector_math/vector_math_64.dart' as vm;

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

class MainView extends StatefulWidget {
  const MainView({super.key});

  @override
  State<MainView> createState() => _MainViewState();
}

class _MainViewState extends State<MainView> {
  // 最後の操作時におけるfocalPoint、回転、スケール
  var _lastRotation = 0.0, _lastScale = 1.0;
  var _lastFocalPoint = Offset.zero;

  // Transformに適用する4x4マトリクス
  final Matrix4 _transform = Matrix4.identity();

  @override
  Widget build(BuildContext context) {
    final size = MediaQuery.of(context).size;

    return MaterialApp(
        home: Scaffold(
            appBar: AppBar(actions: [
              IconButton(
                  icon: const Icon(Icons.restore),
                  onPressed: () => setState(() {
                        _transform.setFrom(Matrix4.identity());
                      })),
            ]),
            body: Stack(children: [
              Column(children: [
                Expanded(child: Container(color: Colors.red)),
                Expanded(child: Container(color: Colors.green)),
                Expanded(child: Container(color: Colors.blue)),
              ]),
              Builder(builder: (context) {
                return Transform(
                    transform: _transform,
                    child: GestureDetector(
                        onScaleStart: (details) {
                          _lastScale = 1.0;
                          _lastRotation = 0.0;
                          _lastFocalPoint = details.focalPoint;
                        },
                        onScaleUpdate: (details) {
                          // 差分
                          final scaleDelta = details.scale / _lastScale;
                          final rotationDelta =
                              details.rotation - _lastRotation;
                          final txDelta =
                                  details.focalPoint.dx - _lastFocalPoint.dx,
                              tyDelta =
                                  details.focalPoint.dy - _lastFocalPoint.dy;

                          // ピンチ操作の中心
                          final fp = vm.Matrix4.inverted(_transform) *
                              vm.Vector4(
                                  details.focalPoint.dx,
                                  details.focalPoint.dy -
                                      (Scaffold.of(context).appBarMaxHeight ??
                                          0.0),
                                  0.0,
                                  1.0);

                          setState(() {
                            // ピンチ操作中心を基準とした回転・拡大縮小の反映
                            _transform.translate(fp.x, fp.y);
                            _transform.rotateZ(rotationDelta);
                            _transform.scale(scaleDelta, scaleDelta);
                            _transform.translate(-fp.x, -fp.y);

                            // 平行移動
                            _transform.leftTranslate(txDelta, tyDelta);

                            // 次回の差分計算のため記憶
                            _lastScale = details.scale;
                            _lastRotation = details.rotation;
                            _lastFocalPoint = details.focalPoint;
                          });
                        },
                        child: SizedBox(
                            width: size.width * 0.2,
                            height: size.height * 0.2,
                            child: Container(
                                color: Colors.black45,
                                child: const Center(
                                    child: Text('ABC',
                                        style: TextStyle(
                                            color: Colors.white)))))));
              }),
            ])));
  }
}

考え方は下記のとおりです。

  1. Matrix4型の_transformによってTransformの変形状態を管理
  2. GestureDetectorによりユーザ操作の平行移動・回転・拡大縮小を検知して_transformに反映
  3. focalPointに対応するウィジェット座標系の座標を中心に回転・拡大縮小を行う
  4. 平行移動はfocalPointとleftTranslate関数により行う

3、4で少し躓いたので、それぞれについて書いていきます。

躓いたポイント

focalPointに対応するウィジェット座標系の座標を中心に回転・拡大縮小を行う

前述のコードでは、主に62~69、73~76行目にあたる部分になります。

回転・拡大縮小はそのまま行うとウィジェットの原点中心で行われることになりますが、ユーザのピンチ操作に応じた回転・拡大縮小を実現するためにはウィジェットを基準とした座標系(以下、ウィジェット座標系)におけるピンチ操作の中心を求め、原点をずらすような平行移動と組み合わせる必要があります。今回のコードでは、onScaleUpdateのコールバックで得ることの出来る画面全体を基準とした座標系(以下、グローバル座標系)におけるピンチ操作中心であるfocalPointからウィジェット座標系におけるピンチ操作中心を求めています。これを表したのが下の図1です。

グローバル座標系、ウィジェット座標系の関係と_transformによる変形を表す図
図1 座標系と_transformによる変形

図1における赤の座標系が前述のグローバル座標系、緑の座標系が前述のウィジェット座標系であり、AppBarの分だけy座標がずれています。focalPointとして取得できるのはグローバル座標系の座標であるため、AppBarの分だけy座標をずらしてウィジェット座標系に変換したあと_transformの逆変換を適用することで_transformによる変換前のピンチ操作中心を求めることができます。また、別の捉え方をすると、赤のfocalPointがシアンで示す座標系でどの座標になるかを求めているとも言えます。

変換前のピンチ操作中心を求めることができたら、73~76行目のように前後に平行移動操作を入れることでピンチ操作中心を基準とした回転・拡大縮小を行うことができます。

平行移動はleftTranslate関数により行う

前述のコードでは主に56~59、79行目にあたります。

前節でも書いたようにfocalPointはグローバル座標系の座標として取得できるため、_transformの回転・拡大縮小の影響を受けません。それをそのまま_transformに反映するため、tranlate関数ではなくleftTranslate関数を使用します。誤ってtranslate関数を使用してしまった場合は下の動画のような動きになってしまいます。

図1を再掲しますが、簡単には水色の座標系で平行移動するのがtranslate、緑色の座標系で平行移動するのがleftTranslateと捉えればよいと思います。

グローバル座標系、ウィジェット座標系の関係と_transformによる変形を表す図
【再掲】図1 座標系と_transformによる変形

ユーザ操作による平行移動をそのまま反映する場合には緑色の座標系での平行移動としたいので、今回のコードではfocalPointとleftTranslate関数を使用しました。

また、focalPointとは別にfocalPointDeltaというfocalPointの差分を予め計算してくれているものも取得出来ますが、下の動画のように1本指の操作と2本指の操作を混ぜたときに不自然な動きになってしまったので使用しないことにしました。(座標系の考え方も少し異なりますが、ここでは省略します)

まとめ

GestureDetectorとTransformを組み合わせてユーザが自由にウィジェットの平行移動・回転・拡大縮小をできるUIを実現する方法について書きました。私が実装した際に躓いたポイントは下記の2点でした。

  • focalPointに対応するウィジェット座標系の座標を中心に回転・拡大縮小を行う
  • 平行移動はfocalPointとleftTranslate関数により行う

できてしまうと簡単かもしれませんが、同じようなことをしようと思っている方の参考になれば幸いです。

コメント

タイトルとURLをコピーしました