突然ですが、通常アプリを作成するとUIはこのぐらいの大きさかと思いますが
このように全体を大きくしたくなったことはありませんでしょうか。
これは1つ1つの文字やボタンの大きさを丹念に設定していったわけではなく、昨今のWebブラウザに搭載されているズーム機能
と似たような形で、Flutterの描画全体を大きくする事で実現しています。
今回はこれのやり方をご紹介致します。
注意
本記事で取り上げた内容はおそらくFlutter SDKの本来想定された使い方の範囲を超えており、実装に際して使用した手段もプロダクションコードに採用すべきものではありません。
あくまでFlutterの特性上こういうことも出来る、という一例としてお楽しみ下さい。
環境
執筆時点のstableを使います。
$ flutter --version
Flutter 1.20.3 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 216dee60c0 (5 days ago) • 2020-09-01 12:24:47 -0700
Engine • revision d1bc06f032
Tools • Dart 2.9.2
Flutterの描画と高解像度ディスプレイの取り扱い
最初にFlutterの描画と高解像度ディスプレイの取り扱いについて確認しておきます。
Flutterアプリケーション全体はengineの一部として実装された Window
に描画されます。
- Window class - dart:ui library - Dart API
- engine/window.dart at d1bc06f032f9d6c148ea6b96b48261d6f545004f · flutter/engine(#L579)
その際、高解像度ディスプレイへの対応はWebのCSS pixel/Androidのdp/iOSのpt...のような論理的なピクセルの値と、物理解像度との比(devicePixelRatio)を扱うことでなされています。
devicePixelRatio
property - Window class - dart:ui library - Dart API- engine/window.dart at d1bc06f032f9d6c148ea6b96b48261d6f545004f · flutter/engine(#L607-L608)
Flutterの対応する各プラットフォームでは、プラットフォームごとのAPIを用いてdevicePixelRatioを取得します。
例えばiOSでは [UIScreen mainScreen].scale
を用いて取得した値を使い
- engine/FlutterViewController.mm at d1bc06f032f9d6c148ea6b96b48261d6f545004f · flutter/engine(#L836)
- engine/FlutterViewController.mm at d1bc06f032f9d6c148ea6b96b48261d6f545004f · flutter/engine(#L844)
Androidでは context.getResources().getDisplayMetrics().density
から取得します。
こうして得た window.devicePixelRatio
はFlutter内の様々なところで使われ、高解像度ディスプレイを考慮しつつウィジェットや画面が描画されます。
※ なお、論理的なピクセルの値と物理解像度との比というのは、iOSのUIKitやSwiftUI、android.widget等のプラットフォームごとのSDKの持つUIツールキットで作成するいわゆる「ネイティブUI」でも当然考慮された上でレイアウトや描画が行われ、「高解像度ディスプレイ上だとボタンが小さくなってしまう」ような事にならないようになっています。
この Window
のインスタンスは、Flutter開発者には main()
の中身でおなじみの runApp()
の中で行われたり、必要に応じて明示的に呼ぶ WidgetsFlutterBinding.ensureInitialized()
等で登場する WidgetsFlutterBinding
にmixinされている WidgetsBinding
から取得するようになっています。
There is a single Window instance in the system, which you can obtain from WidgetsBinding.instance.window.
Window class - dart:ui library - Dart APIより
システム内には単一のWindowインスタンスが存在し、WidgetsBinding.instance.windowから取得することができます。
同引用部のDeepL翻訳
devicePixelRatioを上書き
さて、window.devicePixelRatioについて確認しましたがこれは逆に言えばwindowから先、例えばMaterialのウィジェット等はプラットフォームごとのネイティブAPIから得る「物理解像度との比」の値を直接は見ず、window.devicePixelRatioを拠り所としていることになります。
プラットフォームごとのdevicePixelRatio取得に使われるAPIやその値、例えば [UIScreen mainScreen].scale
や getDisplayMetrics().density
は一介のサードパーティ製アプリから干渉できるようなものではなく、プラットフォームの「ネイティブUI」のレイアウトや描画について影響を及ぼせたりはしない(はず...私はそういう事をする方法を知らないです)ですが、間に1枚Flutter側の実装である window
を通るFlutterでは
ここさえちょろまかせばdevicePixelRatioをほぼ上書き出来るのでは?
と思えます。「ネイティブUI」ではなく独自にUIウィジェットを再実装しているFlutterであれば「物理解像度との比」に実際と違う値を使う事でUIの描画に影響を及ぼし、アプリ全体を拡大/縮小出来そうです。
※ 記事冒頭で触れた「おそらくFlutter SDKの本来想定された使い方の範囲を超えたもの」になります。
ではdevicePixelRatioをちょろまかせる「独自window」をどう用意するか。コードを改変して独自にビルドしたengineを使うというのもの1つの手かとは思いますがちょっと大変です。
そこで、window
は WidgetsBinding.instance.window
から取得する...のであれば、アプリに「独自window」が取れる WidgetsBinding
を使わせられないか、という発想でやってみます。
まず、Flutterアプリはよくこのように始まりますが
void main() => runApp(MyApp());
runApp()
の中身を参考にしながら runCustomApp()
を作り、それを使うようにします。
void runCustomApp(Widget app) {
WidgetsFlutterBinding.ensureInitialized()
// Timer.run()を用いて非同期にattachRootWidget()を実行する
// scheduleAttachRootWidget()はprotecedで、
// 本来はscheduleAttachRootWidget()と同等の処理を自作すべきだが
// 同期的にattachRootWidget()でも一応アプリは起動するのでそちらでお茶を濁す
//
// ただし場合によってscheduleAttachRootWidget()導入
// https://github.com/flutter/flutter/pull/39079
// のきっかけとなったこの問題を踏むだろう
// https://github.com/flutter/flutter/issues/31195
..attachRootWidget(app)
..scheduleWarmUpFrame();
}
void main() => runCustomApp(MyApp());
これでアプリを起動(rootWidgetを WidgetsFlutterBinding
にattach)する場所に手を入れられるようになったので、「独自window」を持った WidgetsFlutterBinding
であるCustomWidgetsBinding
作りにとりかかります。
以下のようにして、このアプリの WidgetsFlutterBinding.instance
はCustomWidgetsBinding
のインスタンスになるようにします。
class CustomWidgetsBinding extends WidgetsFlutterBinding {
static CustomWidgetsBinding ensureInitialized() {
if (WidgetsBinding.instance == null) {
CustomWidgetsBinding();
}
return WidgetsBinding.instance as CustomWidgetsBinding;
}
}
void runCustomApp(Widget app) {
CustomWidgetsBinding.ensureInitialized()
〜略〜
}
そして CustomWidgetsBinding
から window
を取得しようとすると「devicePixelRatioをちょろまかされた独自window」が得られるようにすれば良さそうです、ということで独自window作りにとりかかり、
import 'dart:ui' as ui;
class CustomWindow extends ui.Window
...と始めてしばらくすると Window
がうまくサブクラスを作れない事に気づきます。
- publicなコンストラクタが無く、同一ファイル内でトップレベルに置かれたfinalな変数にprivateコンストラクタで作ったインスタンスを入れてあり(同一ファイル内なので
_
から始まるprivateなコンストラクタが呼べる)、外からはシングルトンとしてそれを使うようになっている。
独自window作りに暗雲が立ち込めてきました、どうしましょう 🤔
DartのImplicit interfaces
ところでDartには Implicit interfaces
という仕様があります。
詳しくはggって頂く事としまして、要するに事前にインターフェースが定義されていないクラスを使っている場所でも
辻褄さえあっていれば別のクラスをねじ込める
ということになります。 Window
はインターフェースが定義されているわけではありませんが
Window
のインターフェースを全て実装していて- devicePixelRatioについて任意の値を返すことができ
- 他の値についてはホンモノの
Window
から取った値を返す
ようなものを作れば良さそうです...が手間がかかりそうですね...
flutter_test/lib/src/window.dart
ここで、FlutterのSDKに flutter_test/lib/src/window.dart
というものが存在する事に気づきます。
これはFlutter SDKのユニットテストのために用意されたコードですが、ここに実装されている TestWindow
がまさに丁度
Window
のインターフェースを全て実装していて- devicePixelRatio...に限らず様々な値を任意に設定出来て、
- 他の値については内部でホンモノの
Window
を保持しておけるようになっていてそこから値やインスタンスを返せる
という、やりたかったことを実装したものになっています。そこで...
ユニットテスト用のクラスをアプリの実装に使うという暴挙 に出ます。
※ 記事冒頭で触れた「プロダクションコードに採用すべきものではない手段」です。
// 普通はこんなことをしてはいけません
import 'package:flutter_test/flutter_test.dart' show TestWindow;
class CustomWidgetsBinding extends WidgetsFlutterBinding {
static CustomWidgetsBinding ensureInitialized() {
if (WidgetsBinding.instance == null) {
CustomWidgetsBinding();
}
return WidgetsBinding.instance as CustomWidgetsBinding;
}
// TestWindow を保持しておくところ
TestWindow _customWindow;
@override
TestWindow get window {
// windowを要求されたらTestWindowを(無ければその場で作ってキープしつつ)返す
return _customWindow ??= TestWindow(window: ui.window);
}
}
最後に、 TestWindow
の「 〜TestValue
に値を書き込むと上書きできる」機能を使ってdevicePixelRatioの値を上書きします。
今回はアプリ本体の起動前に値を設定することとして、main内でまず明示的に CustomWidgetsBinding.ensureInitialized()
を呼び出し、 WidgetsBinding.instance
を準備して上書きを行ってからアプリ本体を起動する事にします。
void main() {
CustomWidgetsBinding.ensureInitialized();
final binding = WidgetsBinding.instance as CustomWidgetsBinding;
final originalDpr = binding.window.devicePixelRatio;
binding.window.devicePixelRatioTestValue = originalDpr * 2;
runCustomApp(MyApp());
}
全てが2倍になりました 😏
なお、この後さらに実装していけばアプリの動作中にdevicePixelRatioを変更できるスライダーを作ったりも出来ます。
おわりに
以上、Flutterはその気になればこんなことも出来るという例をご覧頂きました。
ちなみに何故こんな記事を書くネタがあったのか、こんなことをやった事があるかのと申しますと、私はFlutterに関する情報収集や練習を兼ねて自分用のRSSリーダを作って使っているのですが寝起きや調子が悪いと小さい文字が見えづらくなるという...これはもしかしてもうトシなのか...という個人的事情に際し、ただ文字を大きくするのではなくWebブラウザのズームのような事は出来ないか?と思った事がきっかけです。
今後も自身の調子に合わせてUIと文字を拡大しながらFlutterの動向を追いかけていきたいと思います。