Flutter: InteractiveViewerの拡大率「details.scale」を取得したいが、毎回1.0に戻ってしまう
結論:GlobalKeyを用いて座標を把握しつつ、「onInteractionStart:」で拡大率を補正
2022/6/8 Flutter エラー・バグ日記
(2022.6.14更新)
画像などのWidgetを、ピンチイン・ピンチインアウト(拡大縮小)するのに「InteractiveViewer」は大変便利だが、現在の拡大率(スケール)を取得する方法が分からず、数日間、苦しむ。
結局、すべてGlobalKeyを用いて解決するに至ったので、忘れないために記録(他にもっと良い方法がある気がするが、、、)。
なお、拡大率を取得したかった理由は、画像を拡大縮小した後、同じ画像を、同じ拡大率のまま、他の場所で再表示したかったため。
GestureDetectorとTransform.scaleを使う方法
ピンチイン・ピンチインアウトは、下記記事で分かりやすく解説されているとおり、「GestureDetector」と「Transform.scale」を使っても実装できる。
この場合、「GestureDetector」の「onScaleUpdate:」属性の引数(引数は「ScaleUpdateDetails」型。「details」という表記を用いることが多い模様)を用いて、
「引数名.scale」(例えば「details.scale」)
から、現在の拡大率を取得できる。
しかし、画像の拡大縮小状態を維持できず、再度、ピンチイン・ピンチアウトしようとすると、拡大率が1.0に戻ってしまう。
※「onScaleStart:」という属性があるので、ここで何とか設定できないか試したが、ソースコード内の説明に、初期の拡大率は1.0で固定と書かれており、変更できなかった。
また、拡大縮小と伴に、移動も可能にしようとすると、別途、実装が必要になる点も面倒。
そのため、上記の点(拡大縮小の維持、移動を可能にする実装が不要)を解消できる「InteractiveViewer」を用いることにした。
InteractiveViewerで拡大率を取得すると、値がおかしい
下記記事にあるとおり、「InteractiveViewer」における現在の拡大率の取得方法は、「GestureDetector」の場合と似ており、「onInteractionUpdate:」属性の引数(「ScaleUpdateDetails」型。これも「details」という表記を用いることが多い模様)を用いて、
「引数名.scale」(例えば「details.scale」)
から取得できる。
しかし、拡大縮小後、画像自体の拡大縮小状態は維持されるが、「引数名.scale」の値が必ず1.0に戻ってしまう。。。
そのため、大きく拡大した後、少しだけ縮小すると、実際の拡大率は1.0より大きいはずなのに、「引数名.scale」の値は1.0より小さくなってしまう。
いったんピンチイン・ピンチアウトが終了した時点で、「details.scale」の値を保持しておき、次にピンチイン・ピンチアウトするときに、保持していた拡大率で補正することも試みたが、期待どおりの値にならない。。
「InteractiveViewer」の解説記事は多いが、この部分(拡大率を正しくトレースする方法)に触れている記事は少なく、公式サイトの解説にも説明が見当たらない。。。
検討した対処法① TransformationControllerの利用→解決できず
「InteractiveViewer」には、変形状態をコントロールするための「transformationController:」という属性が用意されている。
そのため、「TextEditingController」からテキスト情報を取得するときと同じように、「TransformationControllerのインスタンス.value」(「Matrix4」型)から、拡大率の情報を取得できないか、試行錯誤したが、結局方法が分からず。。。
検討した対処法② onInteractionStartの設定→解決できず
こちらも「GestureDetector」を用いた場合と似ているが、「InteractiveViewer」には、「onInteractionStart:」という属性があるので、ここで拡大率(.scale)の初期設定ができないか試みるも、方法がわからず。。
※「ScaleUpdateDetails」クラス内の「scale」プロパティの初期値を変更したかったのだが、ソースコードを見ると、「GestureDetector」の場合と同様、「1.0」に固定されていることが理由っぽい。しかし、画像自体の拡大縮小状態は維持してくれるのだが。。
検討した対処法③ GlobalKeyでsizeを取得→解決できず
しかたなく、考え方を根本的に変える。
画像のウィジェットにGlobalKeyを設置し、
// クラス・メソッド(関数)・プロパティ(変数)について、筆者が作成したものには、末尾に大文字のオー「O」をつけて表記 // ※パッケージで予め決められているものと区別しやすくするため RenderBox imageBoxO = imageGlobalKeyO.currentContext!.findRenderObject() as RenderBox; double imageHeightO = imageBoxO.size.height;
のような感じで、画像のサイズ(ここでは縦幅)を取得し、これを拡大縮小の前後で測定すれば、「拡大後のサイズ÷拡大前のサイズ」から拡大率を算出できるのでは、という考え。
しかし、、、残念ながら「InteractiveViewer」で拡大縮小をした場合、この画像サイズの値(上記の例では「imageHeightO」の値)は一定のままで、変化はなかった。。。
検討した対処法④ ダミーのContainerにもGlobalKeyを設置→解決、と思ったが問題あり
そこで、次の手として、「Column」を使って画像ファイルの下側に、ダミーの「Container」を配置して、そこにもう一つの「GlobalKey」を設置し、2つの「GlobalKey」から取得した座標値(例えばY座標の値)の差分から、拡大縮小後のサイズを計算することにした(座標値は、拡大縮小とともに、変化してくれるため)。
結果、この方法で拡大率を取得できた。※但し、後で問題発覚(後述)
「InteractiveViewer」が持つプロパティは一切使わなかった。
以下、作成したサンプルコードを掲載(そのままコピペで動かせます)。
※Androidエミュレーターの場合は、Macだとcommandキーを押しながらタッチパッドをドラッグするとピンチイン・ピンチアウトできる。
なお、自分の場合は、拡大縮小の作業終了後の拡大率が分かれば良かったので、「onInteractionEnd:」属性を用いたが、リアルタイムで取得したい場合は、「onInteractionUpdate:」属性の方を用いれば可能。
// クラス・メソッド(関数)・プロパティ(変数)について、筆者が作成したものには、末尾に大文字のオー「O」をつけて表記 // ※パッケージで予め決められているものと区別しやすくするため import 'package:flutter/material.dart'; void main() => runApp(MyApp()); class MyApp extends StatefulWidget { @override State<MyApp> createState() => _MyAppState(); } class _MyAppState extends State<MyApp> { // 画像ウィジェットのY座標取得用のGlobalKey GlobalKey imageGlobalKeyO = GlobalKey(); // ダミーContainer(画像ウィジェット下側)のY座標取得用のGlobalKey GlobalKey containerGlobalKeyO = GlobalKey(); @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, home: Scaffold( appBar: AppBar( title: Text('InteractiveViewer Test'), ), body: Center( child: InteractiveViewer( // 何も設定しなければ、拡大率は最大2.5、最小0.8 // 但し、縮小を可能にするには、この設定が必要 boundaryMargin: EdgeInsets.all(double.infinity), // ピンチイン・ピンチアウト終了後の処理 onInteractionEnd: (details) { // 画像ウィジェットのGlobalKeyからRenderBox型のインスタンスを作成 RenderBox imageBoxO = imageGlobalKeyO.currentContext!.findRenderObject() as RenderBox; // 画像上端のy座標を取得 double imageBoxPositionYO = imageBoxO.localToGlobal(Offset.zero).dy; // 画像の縦幅を取得 ※これはピンチイン・ピンチアウトしても変化しない double imageHeightO = imageBoxO.size.height; print("画像上端のy座標: $imageBoxPositionYO"); print("画像の縦幅: $imageHeightO"); // ダミーContainerのGlobalKeyからRenderBox型のインスタンスを作成 RenderBox containerBoxO = containerGlobalKeyO.currentContext!.findRenderObject() as RenderBox; // ダミーContainerののy座標を取得 double containerBoxPositionYO = containerBoxO.localToGlobal(Offset.zero).dy; print("画像下部のダミーコンテナのy座標: $containerBoxPositionYO}"); // GlobalKeyから計算した拡大率 print("GlobalKeyから計算した拡大率: ${(containerBoxPositionYO - imageBoxPositionYO) / imageHeightO}"); }, child: Column( children: [ // 画像ウィジェット Image.network( "https://picsum.photos/250?image=9", key: imageGlobalKeyO, ), // 画像の下端位置把握のために設置するダミーContainer Container( key: containerGlobalKeyO, ), ], ), ), ), ), ); } }
(2022.6.14追記)
しかし、後日、この方法だと、縦長の画像で、画面より縦幅の大きい画像を配置すると、下図のようにはみ出しエラーになってしまう事が判明。。
※なぜか横長の画像だと、横幅がオーバーしていても、自動調整されてエラーは起きない。
検討した対処法⑤ 上記④に加え、FittedBoxの設置、「onInteractionStart:」で拡大率の補正計算を導入→解決(のはず)
まず、はみ出しエラーを防ぐため、画像とダミーContainerから成る「Column」を「FittedBox」の「fit: BoxFit.contain」でラップし、さらに「Container」でラップして、範囲制約をつけることにした。
これではみ出しエラーは解消されたが、今度は2つのGlobalKeyから計算する拡大率が、最初に画面表示されるサイズからの相対比率ではなく、実際の画像サイズからの比率で計算されるようになってしまった。
これだと、例えば、「InteractiveViewer」の拡大率の範囲を、デフォルトの0.8〜2.5としていた場合、計算される拡大率は、これよりもかなり小さくなる(Max拡大した場合でも、1より小さい数値になる)。。。
そこで、ピンチイン・ピンチアウトを開始したときにイベントを発動できる「onInteractionStart:」属性を利用し、起動後、初めてピンチイン・ピンチアウトするときに、実際の画像サイズからどれだけ縮小(または拡大)して表示されているか、を補正係数として取得することにした。
上記④の方法で算出した拡大率を、この補正係数で割ることで、拡大率の範囲を0.8〜2.5に補正することができた。
※当初、補正係数を「initState」メソッド内で計算することも試みたが、「WidgetsBinding.instance!.addPostFrameCallback」メソッドを使って、「build」メソッド実行後に「GlobalKey」から画像サイズを測定しているにも拘わらず、画像サイズがゼロになってしまった。そのため、やむを得ず「onInteractionStart:」の方で把握することにした(恐らく「Image.network」を使っていて、画像読み込みにタイムラグがあるから?)。
これでようやく、やりたいことを実現できた、、、はず。
改めて修正版のコード全体像を掲載。
※上記④のバージョンからの変更・追加箇所には、「***」をつけたコメントを記載。
// クラス・メソッド(関数)・プロパティ(変数)について、筆者が作成したものには、末尾に大文字のオー「O」をつけて表記 // ※パッケージで予め決められているものと区別しやすくするため import 'package:flutter/material.dart'; void main() => runApp( // *** MediaQuery を使えるようにするため、冒頭に MaterialApp を設置 MaterialApp( debugShowCheckedModeBanner: false, home:MyApp(), ), ); class MyApp extends StatefulWidget { @override State<MyApp> createState() => _MyAppState(); } class _MyAppState extends State<MyApp> { // 画像ウィジェットのY座標取得用のGlobalKey GlobalKey imageGlobalKeyO = GlobalKey(); // ダミーContainer(画像ウィジェット下側)のY座標取得用のGlobalKey GlobalKey containerGlobalKeyO = GlobalKey(); // *** 初回ピンチイン・ピンチアウトであることを示すフラグ bool firstZoomO = true; // *** 拡大率を最初の表示状態からの倍率にしたい場合の補正係数 double correctionFactorO = 1.0; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('InteractiveViewer Test'), ), body: Center( child: InteractiveViewer( // 何も設定しなければ、拡大率は最大2.5、最小0.8 // 但し、縮小を可能にするには、この設定が必要 boundaryMargin: EdgeInsets.all(double.infinity), // *** 拡大率を最初の表示状態からの倍率にしたい場合は、以下の onInteractionStart を設定 // 起動後初回のピンチイン・ピンチアウト時だけ、 // 実際の画像の縦幅と画面上の画像の縦幅の比から、補正係数を算出する onInteractionStart: (details) { if (firstZoomO) { RenderBox imageBoxO = imageGlobalKeyO.currentContext!.findRenderObject() as RenderBox; RenderBox containerBoxO = containerGlobalKeyO.currentContext!.findRenderObject() as RenderBox; // *** ①実際の画像の縦幅 double imageHeightO = imageBoxO.size.height; // *** ②起動後初回の画面上の画像の縦幅(ダミーContainer上端のy座標と画像上端のy座標の差分から計算) double initialImageHeightO = containerBoxO.localToGlobal(Offset.zero).dy - imageBoxO.localToGlobal(Offset.zero).dy; // *** ②÷①で補正係数を算出 correctionFactorO = initialImageHeightO / imageHeightO; print("補正係数: $correctionFactorO"); // *** 初回ピンチイン・ピンチアウトを示すフラグを変更する firstZoomO = false; } }, // ピンチイン・ピンチアウト終了後の処理 onInteractionEnd: (details) { // 画像ウィジェットのGlobalKeyからRenderBox型のインスタンスを作成 RenderBox imageBoxO = imageGlobalKeyO.currentContext!.findRenderObject() as RenderBox; // 画像上端のy座標を取得 double imageBoxPositionYO = imageBoxO.localToGlobal(Offset.zero).dy; // 画像の縦幅を取得 ※これはピンチイン・ピンチアウトしても変化しない double imageHeightO = imageBoxO.size.height; print("画像上端のy座標: $imageBoxPositionYO"); print("画像の縦幅: $imageHeightO"); // ダミーContainerのGlobalKeyからRenderBox型のインスタンスを作成 RenderBox containerBoxO = containerGlobalKeyO.currentContext!.findRenderObject() as RenderBox; // ダミーContainerののy座標を取得 double containerBoxPositionYO = containerBoxO.localToGlobal(Offset.zero).dy; print("画像下部のダミーコンテナのy座標: $containerBoxPositionYO}"); // GlobalKeyから計算した拡大率 // *** 補正係数で割ることで、拡大率を最初の表示状態からの倍率にする // 上記 onInteractionStart の処理をしなければ、補正係数は1.0なので、実際の画像サイズからの拡大率になる print("GlobalKeyから計算した拡大率: ${((containerBoxPositionYO - imageBoxPositionYO) / imageHeightO) / correctionFactorO}"); }, child: // *** 範囲を制限するため Container でラップ Container( // *** 以下は適宜範囲を決めて設定 // *** ここでは、横幅は、画面いっぱいに設定 // 縦幅は、画面の縦幅からAppBarの縦幅を引いた値に設定 width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height - AppBar().preferredSize.height, // *** 親Containerの範囲内に収まるように BoxFit.contain でラップ child: FittedBox( fit: BoxFit.contain, child: Column( children: [ // 画像ウィジェット Image.network( // *** サイズの大きい画像にした場合 "https://halzoblog.com/wp-content/uploads/2021/06/image001.png", key: imageGlobalKeyO, ), // 画像の下端位置把握のために設置するダミーContainer Container( key: containerGlobalKeyO, ), ], ), ), ), ), ), ); } }
日記のつもりが、かなりの長文記事になってしまった。。
リリースしたアプリ(全てFlutterで開発)
個人アプリ開発で役立ったもの
おすすめの学習教材
\超初心者向けでオススメな元Udemyの講座/
\キャンペーン時を狙えば安価で網羅的な内容が学べる(日本語訳あり)/
\Gitの基礎について無料で学べる/
おすすめの学習書籍
\実用的。image_pickerに関してかなり助けられた/
\Dartの基礎文法を素早くインプットできる/