Transform.rotateで回転したウィジェットをドラッグしたら、全然違う方向に動いてしまう。
なぜ?どうしたら正しい方向に動かせるの?
という方向けの記事です。
「GestureDetector」でドラッグ可能にしていたウィジェットを回転させたら、この状況に陥りました。
ネット上で調べても情報が見つからず、解決に苦労したので、その経験を共有したいと思います。
結論は、以下のとおりです。
ドラッグした横・縦の移動量(移動方向)を、ウィジェットを回転させた角度分だけ補正する(高校数学で習う「1次変換」の式を使用)。
以下、詳細になります。
40代からプログラミング(Flutter)を始めて、GooglePlayとAppStoreにアプリを公開しているhalzo appdevです。
作成したアプリはこちら↓ 全てFlutterで開発したアプリです。
Transform.rotateで回転させからドラッグするとどうなるか?
「GestureDetector」の「onPanUpdate:」プロパティでドラッグ可能にしておいた「Container」を、「Transform.rotate」でラップして45度回転させました。
回転の中心は、「origin:」プロパティを使い、「Container」の左上端になるよう設定しました(ウィジェット中心からの相対位置で設定するため、「Container」の横幅の1/2、縦幅の1/2をマイナス値で設定)。
結果、ドラッグすると下記動画のような状況になりました。
右にドラッグしたのに、右上の方に動き、上にドラッグしたのに左斜め上の方に動いてしまいます。。
この状態の「Transform.rotate」部分のコードは以下のとおりです。
// pi を使用するのに必要 import 'dart:math'; import 'package:flutter/material.dart'; ・・・(略)・・・ // ウィジェットの初期の配置位置 Offset offsetO = Offset(100, 100); // 回転角度(45度) double angleO = pi / 4; ・・・(略)・・・ body: Transform.translate( // ウィジェットの配置位置(オフセット)を設定 offset: offsetO, child: Transform.rotate( // 45度右回りに回転 angle: angleO, // Containerの左上端を軸に回転 origin: Offset(- 100 / 2, - 50 / 2), child: GestureDetector( onPanUpdate: (DragUpdateDetails detailsO) { // 配置位置に横方向と縦方向の移動量を加算 double dxO = offsetO.dx + detailsO.delta.dx; double dyO = offsetO.dy + detailsO.delta.dy; // 配置位置を更新 offsetO = Offset(dxO, dyO); // 移動した位置で再描画 setState(() {}); }, child: Container( width: 100, height: 50, color: Colors.green, ), ), ), ),
なぜズレた方向に動いてしまうのか?
上記コード内の変数
- detailsO.delta.dx(横方向の移動量)
- detailsO.delta.dy(縦方向の移動量)
をprint関数でコンソールに表示させ、数値を確認すると、下図のように、ちょうど「Container」を回転させた角度分だけ、ズレた方向に移動量が発生していることがわかりました。
これは、「GestureDetector」クラスを「Transform.rotate」クラスでラップしているため、移動方向自体も「Transform.rotate」により回転させられてしまうためと思われます。
ただ、直感的には、右下方向に移動するのであれば、移動方向も右回りに回転角度分だけズレたことになるので、理解しやすいのですが、なぜか逆方向(左回り)にズレるようです。。
この点は、後述する「Transform」が、変形前(回転前)のウィジェット領域を保持している点と関係しているようです。
右へのドラッグは、回転後のウィジェットから見ると、右上方向へのドラッグになるので、それを回転前のウィジェット領域に対して設定すると、画面上で右上方向への移動になる、ということだと推察します(違っているかもしれませんが、、、)。
回転後のウィジェットにGestureDetectorをつけたらどうなるか?
考えられる対策として、移動方向が回転の影響を受けないよう、回転後の状態に対して、ドラッグ機能を設定すれば良さそうです。
具体的には、「Container」を先に「Transform.rotate」クラスでラップし、それを「GestureDetector」クラスでラップする順番に変更する、という方法です。
これでやると、確かにドラッグした方向に移動できるようになります。
しかし、この方法だと、下記動画のように、回転後のウィジェット(この例では緑の「Container」)内の一部分しかドラッグに反応しない、という問題が生じます。
右上部分はドラッグのために掴むことができますが、左側や右下のゾーンは掴むことができず、ドラッグに反応しません。
理由は、「Transform」クラスの公式説明ページにある、下記内容が影響しています。
このオブジェクトは描画の直前に変形を適用します。つまり、このウィジェットの子ウィジェット(つまりこのウィジェット)が消費する空間の大きさを計算するときに、変形は考慮されないということです。
https://api.flutter.dev/flutter/widgets/Transform-class.html
つまり、「Transform.rotate」で回転しても、ウィジェットの領域はあくまで回転前の状態なので、下図のように、回転後と回転前の領域の重複した部分でないと「GestureDetector」が反応しない、ということです。
回転軸をウィジェットの中心にし、わずかに回転させる程度であれば、この方法でも大きな問題は生じないですが、今回のように回転軸をずらし、かつ大きく回転させる場合は、不都合が大きいです。
三角関数を使って、正しい移動量(移動方向)に修正する(1次変換)
今回、たどり着いた結論になります。
高校の数学で習う「1次変換」の考え方を使い、右上に移動してしまう移動量を、右に移動する移動量に変換します。
イメージは以下のとおりです。
一見難しそうですが、単純な公式がありますので、移動量を下記公式に従って再計算するだけになります。
- 補正後の横方向の移動量
=補正前の横方向移動量 × cos(回転角度) - 補正前の縦方向移動量 × sin(回転角度) - 補正後の縦方向の移動量
=補正前の横方向移動量 × sin(回転角度) + 補正前の縦方向移動量 × cos(回転角度)
※移動量の符号は、横方向は右向きがプラス・左向きがマイナス、縦方向は下向きがプラス・上向きがマイナスになります。
※回転角度の符号は、右回りがプラス・左回りがマイナスになります。
※sinやcosの三角関数を使うには、「math」をインポートする必要がありますが、標準でDartに入っているライブラリなので、Android Studioの場合は、sinやcosを書いたときに出てくる赤い波線のところで、「option+Enter」を押して、項目を選択すれば、簡単にインポートできます(「pi」を使っていれば、既にインポート済のはずです)。
なお、1次変換については、下記サイトのご説明が大変わかりやすかったです(ありがとうございます!)。
上記でご紹介した公式のわかりやすい証明も書かれています。
スマホの画面上は、数学上の座標とは、Y軸の正負、回転角度の正負の方向が逆になりますが、全く同じ公式を適用できます。
結果、この方法で移動量(移動方向)を修正すると、下記動画のように、違和感のないドラッグができるようになります。
サンプルコード
ご参考に、今回作成したサンプルコードを以下に掲載します。
コピペすれば、そのまま挙動を確認いただけます。
移動量の修正部分のイメージは以下のとおりです。
注意点としては、補正するのはオフセット値(ウィジェットのX、Y座標)ではなく、あくまで移動量(X、Yの変化量)である、という点です。
間違って、オフセット値を補正してしまうと、ドラッグした途端、とんでもない方向に動いてしまいますので、ご注意ください(自分はこれでハマりました。。。)。
// クラス名、メソッド名、プロパティ名(変数名)について、筆者が作成したもの(名前変更可のもの) // の名前の末尾には、大文字のオー「O」をつけています // ※ライブラリ(パッケージ)で予め決められているもの(名前の変更不可のもの)と、 // 自分で作成したもの(名前の変更可のもの)の区別をしやすくするため import 'dart:math'; import 'package:flutter/material.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: "Test", theme: ThemeData.light(), home: SampleScreenO(), ); } } class SampleScreenO extends StatefulWidget { @override _SampleScreenOState createState() => _SampleScreenOState(); } class _SampleScreenOState extends State<SampleScreenO> { Offset offsetO = Offset(100, 100); double angleO = pi / 4; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: Transform.translate( offset: offsetO, child: Transform.rotate( angle: angleO, origin: Offset(- 100 / 2, - 50 / 2), child: GestureDetector( onPanUpdate: (DragUpdateDetails detailsO) { // 一次変換の公式で、横方向・縦方向の移動量を修正 double convertedDeltaDxO = detailsO.delta.dx * cos(angleO) - detailsO.delta.dy * sin(angleO); double convertedDeltaDyO = detailsO.delta.dx * sin(angleO) + detailsO.delta.dy * cos(angleO); // 修正後の移動量をオフセット値(表示位置)に加算 double dxO = offsetO.dx + convertedDeltaDxO; double dyO = offsetO.dy + convertedDeltaDyO; offsetO = Offset(dxO, dyO); setState(() {}); }, child: Container( width: 100, height: 50, color: Colors.green, ), ), ), ), ); } }
補足:「RotatedBox」を使う方法
ウィジェットの回転に、「Transform.rotate」ではなく、「RotatedBox」を用いるという方法もあります。
「RotatedBox」は、「Transform.rotate」と異なり、回転後の領域がウィジェットの領域になります。
Transform とは異なり、このオブジェクトはレイアウトの前に回転を適用します。つまり、回転したボックス全体が、回転した子に必要なだけのスペースしか消費しません。
https://api.flutter.dev/flutter/widgets/RotatedBox-class.html
そのため、「Container」を先に「RotatedBox」クラスでラップし、それを「GestureDetector」クラスでラップする順番にすれば、回転後のウィジェット内で掴めない部分は発生せず、違和感のないドラッグが可能になります。
しかし、「RotatedBox」は90度単位での回転しかできないため、任意の角度で回転させたい場合には不都合になります。
そのため、回転角度が90度単位で問題ない場合に限っては、「RotatedBox」を用いた方が、コードがシンプルになると思います。
以上、ご参考になれば幸いです。
最後までお読みいただき、ありがとうございました。
個人アプリ開発で役立ったもの
おすすめの学習教材
\超初心者向けでオススメな元Udemyの講座/
\キャンペーン時を狙えば安価で網羅的な内容が学べる(日本語訳あり)/
\Gitの基礎について無料で学べる/
おすすめの学習書籍
\実用的。image_pickerに関してかなり助けられた/
\Dartの基礎文法を素早くインプットできる/
コメント