🎉

【Flutter】FractionallySizedBox で本当にいいのか、サイズ比率を変える3つのウィジェットで考える

2024/04/20に公開

ウィジェットの比率を変える方法

ウィジェットのサイズの、例えば 70% を表示したいとき、Flutter の公式動画では、FcactionallySizedBox が紹介されています。
https://youtu.be/PEsY654EGZ0

ウィジェットのサイズ比率を変える3つのウィジェット

以下がざっくりとした特徴です。

ウィジェット サイズ調整方法 RenderObject 注意点
FractionallySizedBox 親からの BoxConstraints の最大値から計算して調整 RenderFractionallySizedOverflowBox BoxConstraints の最大値が double.infinity だとエラー
Align 子から受け取ったサイズから計算して調整 RenderPositionedBox ClipRect で囲まないと、はみ出てしまう
Transform.scale サイズはそのまま、子の要素のみのサイズなどの表示を調整 RenderTransform 変更されたサイズによって、レイアウトが調整されない

RenderObject とは、ウィジェットのサイズ調整などの描画に関する部分が実際に記述されているクラスです。
描画に関する役割を持つウィジェットは、すべて何かしらの RenderObject をそれぞれ持っています(RenderObjectWidget

FractionallySizedBox

使い方と仕組み

一つずつ見ていきます。
https://api.flutter.dev/flutter/widgets/FractionallySizedBox-class.html

FractionallySizedBox のサイズ調整方法には、実際に描画されいる Size ではなく、祖先から来た BoxConstraints を利用します。

やっていることとしては、「BoxConstraints go down」の要領で祖先から来た BoxConstraints の最大値を捻じ曲げて子孫に送ります。

たとえば、height: 100 の中で、FractionallySizedBox(heightFactor: 0.5) のウィジェットを設置すると、その子孫の高さは 50 になります。

SizedBox(
  height: 100,
  child: Row(
    children: [
      _SizedBox(color: Colors.indigo),
      FractionallySizedBox(
        heightFactor: 0.5,
        child: _SizedBox(
          color: Colors.green,
        ),
      ),
    ]
  )
)

_SizedBox ウィジェットは、なんとなく察してください)

_SizedBox ウィジェットの中身
class _SizedBox extends StatelessWidget {
  const _SizedBox({required this.color});

  final Color color;

  
  Widget build(BuildContext context) {
    return SizedBox.square(
      dimension: 100,
      child: ColoredBox(
        color: color,
        child: const Center(
          child: Text('ABC'),
        ),
      ),
    );
  }
}

この時、子孫に送る BoxConstraintsminHeight == maxHeight となり、いわゆる tight な制約になります。

ソースコードを見るとそれがよくわかります。

https://github.com/flutter/flutter/blob/54e66469a933b60ddf175f858f82eaeb97e48c8d/packages/flutter/lib/src/rendering/shifted_box.dart#L1087

rendering/shifted_box.dart
/// FractionarySizedBox の RenderObject
class RenderFractionallySizedOverflowBox extends RenderAligningShiftedBox {
    // ....
    /// performLayout() で呼ばれる関数
    BoxConstraints _getInnerConstraints(BoxConstraints constraints) {
        double maxHeight = constraints.maxHeight;
        // ...
        if (_heightFactor != null) {
          // * 最大値から計算
          final double height = maxHeight * _heightFactor!;
          // * 計算結果を、minHeight, maxHeight 両方に代入している
          minHeight = height;
          maxHeight = height;
        }
        return BoxConstraints(
          minWidth: minWidth,
          maxWidth: maxWidth,
          minHeight: minHeight,
          maxHeight: maxHeight,
        );
}

そして、BoxConstraints を捻じ曲げるという強い言葉を使った理由は、以下のように祖先の制約を完全に無視したサイズ調整も可能です。

heightFactor に 2 を設定することで、2倍の大きさにしている

RenderFractionallySizedOverflowBox の名の通り、OverflowBox に似た挙動をします。
RenderBox は、基本的に「隣に何のウィジェットがあるか」みたいなことは考えず、渡された Offset や BoxConstraints に従ってとりあえず描画します。

もし、ウィジェットの境界のハミ出しによって描画をさせないようにしたい場合は、Clip の力を借ります。

SizedBox(
  height: 100,
  child: Row(
    children: [
      _SizedBox(color: Colors.indigo),
      FractionallySizedBox(
        heightFactor: 0.5,
        child: _SizedBox(
          color: Colors.green,
        ),
      ),
      ClipRect(
        child: FractionallySizedBox(
            heightFactor: 2,
            child: _SizedBox(
              color: Colors.green,
            ),
          ),
      ),
    ]
  )
)

注意点・適していないパターン

Column/Row で使う場合

たとえば、このような場合にはエラーになってしまいます。

Column(
  children: [
    // ここのウィジェットの高さだけ2分の1にしたい
    FractionarySizedBox(
      heightFactor: 0.5,
      child: SizedBox(
        height: 100,
      ),
    ),
  ],
)

Column の children の BoxConstraints は、すべて 0.0 < h < double.infinity で扱われます。

そして、FractionarySizedBox は、祖先の最大値をもとに子孫に tight な制約を与えるウィジェット。
これでは double.infinity が与えられ[1]Column の children に高さ無限のものが置かれると、Column のなかに ListView を置いた時のようにRenderBox was not laid out と怒られてしまいます。

~OverflowBox なんて名乗っておきながらいざ無限の値を与えられるとエラーになってしまう、根性のないやつですね。

そういう場合は、Expanded/Flexible を充てて最大値の制約を与えててやるのが定石ですが、それだと目一杯広がってしまいます。

FractionallySizedBox はあくまで制約をいじるだけで、実際の Size を * 0.5 するわけではありません。

こういう場合は、次の Align/Transform.scale を使いましょう。

Align

https://api.flutter.dev/flutter/widgets/Align-class.html

子ウィジェットの配置を定めるウィジェットですが、実はこいつも高さと幅を定めるパラメータが存在sます。
変数名は FractionallySizedBox と同じです。

Align(
  heighFactor: 0.5,
  widthFactor: 0.25,
  chid: // ...
)

しかし、こいつは少し奇妙な挙動をします。確認してみましょう。

まず、横に並んだ2つの矩形を用意します。

Row(
  children: [
    _SizedBox(color: Colors.indigo),
    _SizedBox(color: Colors.amber),
  ],
)


では、右のウィジェットに、Align を設けて、widthFactor: 0.5 で半分の幅にしてみましょう。

const Row(
  children: [
    _SizedBox(color: Colors.indigo),
    Align(
      widthFactor: 0.5,
      child: _SizedBox(color: Colors.amber),
    ),
  ],
),

結果

....
なんか変な見た目になりましたね。

試しに、右の黄色い矩形に透明度を与えてみると、重なっていることがわかります。

どういうことかというと、この Align ウィジェットのサイズ調整のキモは、子ウィジェットの制約に影響を与えない という点です。
FractionallySizedBox とは正反対な感じしますね。

Align が持つ RenderObject サイズを決定する部分、 performLayout() 関数を見てみましょう

https://github.com/flutter/flutter/blob/54e66469a933b60ddf175f858f82eaeb97e48c8d/packages/flutter/lib/src/rendering/shifted_box.dart#L450

rendering/shifted_box.dart
class RenderPositionedBox extends RenderAligningShiftedBox {
   
  void performLayout() {
    final BoxConstraints constraints = this.constraints;
    final bool shrinkWrapWidth = _widthFactor != null || constraints.maxWidth == double.infinity;
    final bool shrinkWrapHeight = _heightFactor != null || constraints.maxHeight == double.infinity;

    if (child != null) {
      // 子ウィジェットのレイアウト
      child!.layout(constraints.loosen(), parentUsesSize: true);

      // 子ウィジェットのサイズをもとに、自身のサイズを決める
      // その時、widthFactor/heightFactor の掛け算を行う
      size = constraints.constrain(Size(
        shrinkWrapWidth ? child!.size.width * (_widthFactor ?? 1.0) : double.infinity,
        shrinkWrapHeight ? child!.size.height * (_heightFactor ?? 1.0) : double.infinity,
      ));
      alignChild();
    } else {
      size = constraints.constrain(Size(
        shrinkWrapWidth ? 0.0 : double.infinity,
        shrinkWrapHeight ? 0.0 : double.infinity,
      ));
    }
  }

FractionaySizedBoxwidthFactor/heightFactor との大きな違いは、子ウィジェットレイアウト計算には関わらない点です。

子ウィジェットのレイアウトをした後に、その親である自身のサイズ決めるのは 「Size go up」 なので普通なのですが、その時 widthFactor/heightFactor でさらにサイズに変更を与える計算を加えています。
変なヤツですね。

そして、さっき書いた通り、RenderBox は基本的に「隣に何のウィジェットがあるか」みたいなことは知らないので、ハミ出るとか考えずに、与えられた制約の通りに描画します。
なので、これもさっき書いた通り、Clip を使えばはみ出さずに描画することが可能です。

const Row(
  children: [
    _SizedBox(color: Colors.indigo),
    ClipRect(
      chid: Align(
        widthFactor: 0.5,
        child: _SizedBox(color: Colors.amber),
      ),
    ),
  ],
),

SizeTransition ウィジェットでも、この仕組みが使われています。

Widget buid(BuildContext context) {
    return ClipRect(
      child: Align(
        alignment: alignment,
        heightFactor: axis == Axis.vertical ? math.max(sizeFactor.value, 0.0) : fixedCrossAxisSizeFactor,
        widthFactor: axis == Axis.horizontal ? math.max(sizeFactor.value, 0.0) : fixedCrossAxisSizeFactor,
        child: child,
      ),
    );
}

Align x ClipRect で、実質的に子ウィジェットのサイズの比率をいじることができます。
これは、子ウィジェットをとりあえず表示して、かつその一部を比率で隠したい時に使用されます。
一定の比率で隠すことは少ないかもしれませんが、アニメーションで 0.0 => 1.0 にする場合はよくあるかもしれません。
そういう場合に SizeTransition 採用する形でこの Align x ClipRect を使うことがあると思います。

Transform.scale

最後は Transform ウィジェットです。

https://api.flutter.dev/flutter/widgets/Transform-class.html

このウィジェットは、子ウィジェットの表示を拡大/縮小します。
この時、Transform ウィジェット自体の大きさには影響しない のがポイントです。

これはスクリーンショットを見るのがわかりやすいと思います。

Transform.scale(
scaleX: 0.5
) を与えている

scaleX のみを渡しているので、その分縮小して縦に延びた見た目になっています。
ですが、子ウィジェットが小さくなった分Transformウィジェットが小さくなることはなく、隙間ができています。

これは、RenderTransform が、サイズを決定する performLayout() を override しておらず、描画する関数である paint() でやってる体と思います多分。

まとめ

使い分けはこんな感じになると思います。

  • 「ここの範囲で、ある比率のサイズのウィジェットを表示したい」
    • BoxConstraints の最大値を利用する FractionarySizedBox を選択。
  • ウィジェットの一部を隠して、その分サイズを小さく(大きく)したい場合。
    • Align x ClipRect、もしくは SizeTransition
  • 「一部を隠す」ような場合や、アニメーションで子ウィジェットで出したり隠したりしたい。
    • 非表示の場合は、サイズを 0 にしたい。
      • Align x ClipRect、もしくは SizeTransition
    • 非表示の場合も表示領域を確保、与えた比率の分 拡大/縮小したい。
      • Transform.scale、もしくは ScaleTransition

少し抽象的な話になりましたが、ウィジェットのサイズ比率調節をしたくなった時の助けになれば幸いです。

脚注
  1. print(double.infinity * 0.5) // double.infinity ↩︎

Discussion