今回はこういう表示が出来るようになるウィジェットを作ってみたいと思います。
(Floating Action Buttonが半分切れてるが工事中マークが出ていない事に注目、さらに言えばDEBUGの帯もちょっと欠けてる)
はじめに
Flutterのウィジェットとそのレイアウトシステムは基本的にウィンドウ(画面)サイズに合わせてその中身をレイアウトするようになっており、ウィンドウサイズが小さくなれば中身もその分小さくレイアウトしようとします。
例えばこのようなアプリ
のウィンドウサイズを小さくすれば中身もそれに応じてレイアウトされていきますが、
Flutterアプリは中身がウィンドウサイズより大きくならざるを得ない状況になった場合にブラウザでのHTML表示のように自動的に縦または横にスクロールできるようになったりはしないので、限界を越えるとレイアウト計算が破綻し、アプリをデバッグ実行している場合はFlutter開発者おなじみ?の工事中マークが表示されたりします。
もちろん、アプリの作りを工夫することでどれだけ小さくなっても破綻しないようにも出来ると思いますが、常に実用的でない小ささまで考慮しながらUIを組むのは労多くして得られるものが少なく現実的ではありません。
そこで、もうある程度以下のサイズは諦めてしまおうということで「中身は最低でもこのサイズでレイアウトしつつはみ出る分は切り取られる」という動作が出来るウィジェットを、今回はSingleChildRenderObjectWidgetとRenderProxyBoxを使って作ってみようと思います。
環境
執筆時点のstable、v2.8.0を使います。
[✓] Flutter (Channel stable, 2.8.0, on macOS 12.0.1 21A559 darwin-x64, locale ja-JP)
コード全文と概要
今回は OverflowClipping
という名前のウィジェットを作る事にします。
引数はウイジェットのkeyの他に
- required の
child
- 既定値を持ちつつ設定可能な最小の幅/高さの
minWidth
とminHeight
を持つこととし、minWidth
/ minHeight
以上の領域が使える場合は普通にchildをレイアウト、ウィジェット自身には最小サイズ以下の領域しかない場合でもchildは最小サイズでレイアウトしつつ描画がはみ出る分は切り取られるようにします。
まず以下にコード全文を掲載します(「詳細」をクリックすると開きます)
import 'dart:math';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
@immutable
class OverflowClipping extends SingleChildRenderObjectWidget {
const OverflowClipping({
required Widget child,
this.minWidth = 240,
this.minHeight = 240,
Key? key,
}) : super(key: key, child: child);
final double minWidth;
final double minHeight;
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderOverflowClipping(
minWidth: minWidth,
minHeight: minHeight,
);
}
@override
void updateRenderObject(
BuildContext context,
_RenderOverflowClipping renderObject,
) {
renderObject
..minWidth = minWidth
..minHeight = minHeight;
}
}
class _RenderOverflowClipping extends RenderProxyBox {
_RenderOverflowClipping({
required double minWidth,
required double minHeight,
}) : _minWidth = minWidth,
_minHeight = minHeight;
double _minWidth;
double get minWidth => _minWidth;
set minWidth(double newValue) {
if (_minWidth == newValue) {
return;
}
_minWidth = newValue;
markNeedsLayout();
}
double _minHeight;
double get minHeight => _minHeight;
set minHeight(double newValue) {
if (_minHeight == newValue) {
return;
}
_minHeight = newValue;
markNeedsLayout();
}
@override
void performLayout() {
final child = this.child;
if (child == null) {
return super.performLayout();
}
size = constraints.biggest;
child.layout(
BoxConstraints.tightFor(
width: max(minWidth, constraints.maxWidth),
height: max(minHeight, constraints.maxHeight),
),
);
}
final _clipRectLayer = LayerHandle<ClipRectLayer>();
@override
void paint(PaintingContext context, Offset offset) {
final child = this.child;
if (child != null) {
_clipRectLayer.layer = context.pushClipRect(
needsCompositing,
offset,
Offset.zero & size,
super.paint,
oldLayer: _clipRectLayer.layer,
);
}
}
}
解説
以下、おおまかな実装内容を順に解説していきます。
@immutable
class OverflowClipping extends SingleChildRenderObjectWidget {
const OverflowClipping({
required Widget child,
this.minWidth = 240,
this.minHeight = 240,
Key? key,
}) : super(key: key, child: child);
final double minWidth;
final double minHeight;
child
を1つもちRenderObjectレベルの実装が出来るウィジェットを作る、ということで SingleChildRenderObjectWidget
を継承します。
minWidth
と minHeight
の既定値は適当にほどほどの値を設定しました。
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderOverflowClipping(
minWidth: minWidth,
minHeight: minHeight,
);
}
@override
void updateRenderObject(
BuildContext context,
_RenderOverflowClipping renderObject,
) {
renderObject
..minWidth = minWidth
..minHeight = minHeight;
}
}
_RenderOverflowClipping
というRenderObject(この後解説します)を生成したり、RenderObject更新時にそのプロパティを更新する実装をしています。
class _RenderOverflowClipping extends RenderProxyBox {
_RenderOverflowClipping({
required double minWidth,
required double minHeight,
}) : _minWidth = minWidth,
_minHeight = minHeight;
double _minWidth;
double get minWidth => _minWidth;
set minWidth(double newValue) {
if (_minWidth == newValue) {
return;
}
_minWidth = newValue;
markNeedsLayout();
}
double _minHeight;
double get minHeight => _minHeight;
set minHeight(double newValue) {
if (_minHeight == newValue) {
return;
}
_minHeight = newValue;
markNeedsLayout();
}
渡された child
をそのままレンダリングするRenderObjectである RenderProxyBox
を継承し、レンダリング部分に手を入れたものを作ります。
引数として minWidth
minHeight
を持ち、OverflowClipping
に設定された値を受け取る事とします。
それぞれについて値を保持している「 _
」付きのprivateな値 (_minWidth
_minHeight
)とgetter、そして以前と違う値の場合に再レイアウトが必要とマーキングする処理を呼び出しつつ保持している値を更新するsetterを実装しています。
- このパターンはPerform dirty checks in settersということでFlutter SDK内で大量に見かけます
@override
void performLayout() {
final child = this.child;
if (child == null) {
return super.performLayout();
}
size = constraints.biggest;
child.layout(
BoxConstraints.tightFor(
width: max(minWidth, constraints.maxWidth),
height: max(minHeight, constraints.maxHeight),
),
);
}
処理のキモその1です。
まず child
がnullの時は特に何もしない(親クラスに処理をまかせる)、とします。
child
が存在する場合は、まず自身のサイズ( size
)は今自身に与えられているレイアウト制約( constraints
)で許される最大の大きさとします。
そして child
をレイアウトする際、その際のレイアウト制約は幅/高さそれぞれ少なくとも最小値以上とし、またlayout()の parentUsesSize
は省略(=デフォルトのfalse)、すなわち「子のレイアウト情報を親は使わない」としています。
こうすることで、自身のサイズに関係なく中身は最小サイズ以下にならないようにします。
final _clipRectLayer = LayerHandle<ClipRectLayer>();
@override
void paint(PaintingContext context, Offset offset) {
final child = this.child;
if (child != null) {
_clipRectLayer.layer = context.pushClipRect(
needsCompositing,
offset,
Offset.zero & size,
super.paint,
oldLayer: _clipRectLayer.layer,
);
}
}
処理のキモその2です。
「その1」で中身は最小サイズ以下にならないようにしましたが、そのままだと中身の描画がはみ出る事になります。
- さすがにアプリのウィンドウの外へ描画がはみ出す事はありませんが、アプリ内の一部分で同様の事を行った場合は特にエラーになったりもせずガンガン中身がはみ出して描画されます
そこで、child
が描画されようとするタイミングで、context.pushClipRect()
を用いて child
の描画のはみ出した部分を切り取ります。
(似たような実装は例えばClipRectウィジェットの中身などで行われます)
パフォーマンス向上のため、前回の pushClipRect()
の実行結果をキャッシュする _clipRectLayer
を用意し oldLayer
に設定しています。
おわりに
以上で OverflowClipping
ウィジェットが実装できました。
これで、例えば
void main() {
runApp(
const OverflowClipping(
child: MyApp(),
),
);
}
のようにアプリ全体をwrapしてしまえば、このアプリは最小でも縦横240以上でレイアウトされ、ウィンドウサイズがそれ以下になっても...仮に
ほとんど大きさが無くなってしまっても
レイアウトのエラーは発生しなくなります。またアプリ全体に限らず「場合によっては切れたり消えたりしても構わない」というものがアプリの中で部分的に使えると便利かもしれません。
RenderObjectWidgetを使った実装は普段から頻繁に行うことは少ないと思いますが、直接的な描画処理に比較的近い部分をいじる事で通常では行いにくい事も出来たりと、より実装の可能性が広がるかなと思います。