✏️

TableWidgetのIntrinsicColumnWidthの動作について

2024/12/07に公開

これはなに

この記事は、FOLIO Advent Calendar 2024の7日目の記事です。

FlutterのTable Widgetには、Columnの幅をどのように決定するかを指定するためのColumnWidthがあります。
その中の一つにIntrinsicColumnWidthというものがあり、コンテンツの内容に応じて幅を変えてくれるのでとても便利です。(カラムのサイズを決めるには非常に高価な方法と言われていても使ってしまいがち😇)
しかし、子の要素がTextで文字列が数字のみなどの一定の条件下で使用すると、折り返してほしい最大の幅を超えても改行されず、突き抜けてしまうという問題点があり、使用する際に注意が必要です。

そのため本記事では、Table + IntrinsicColumnWidth + Textの場合に、なぜそういった動作になるかの理解と、回避するためのワークアラウンドについて考えていきたいと思います。

動作確認

まずは、実際にどのような動作になるのかについて確認してみましょう。

ラベルと値があるパターンを想定して、ラベルの方はFixedColumnWidth、値の方にIntrisicColumnWidthを使用したパターンで確認してみます。

上記の例では、背景色がグレーの箇所にwidthが400のContainerがあり、そのchildにTableWidgetを入れていますが、数字だけのTextがはみ出してしまっているのがわかります。

IntrinsicColumnWidthを使用したサンプルコード

class IntrinsicColumnWidthCheckWidget extends StatelessWidget {
  const IntrinsicColumnWidthCheck({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: Container(
        width: 400,
        color: Colors.black12,
        child: Table(
          columnWidths: const {
            0: FixedColumnWidth(50),
            1: FixedColumnWidth(16),
            2: IntrinsicColumnWidth()
          },
          children: List.generate(10, (index) {
            return TableRow(
              children: [
                TableCell(
                  child: Text(
                    'Item $index',
                    style: Theme.of(context).textTheme.bodyMedium,
                  ),
                ),
                const TableCell(
                  child: SizedBox(height: 16),
                ),
                TableCell(
                  child: Text(
                    '1234567890' * (index + 1),
                    style: Theme.of(context).textTheme.bodyMedium,
                  ),
                ),
              ],
            );
          }),
        ),
      ),
    );
  }
}

これをIntrisicColumnWidthからFlexColumnWidthに変更すると、以下のように枠内に収まって描画されます。

FlexColumnWidthを使用したサンプルコード
class FlexColumnWidthCheckWidget extends StatelessWidget {
  const IntrinsicColumnWidthCheck({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: Container(
        width: 400,
        color: Colors.black12,
        child: Table(
          columnWidths: const {
            0: FixedColumnWidth(50),
            1: FixedColumnWidth(16),
            2: FlexColumnWidth()
          },
          children: List.generate(10, (index) {
            return TableRow(
              children: [
                TableCell(
                  child: Text(
                    'Item $index',
                    style: Theme.of(context).textTheme.bodyMedium,
                  ),
                ),
                const TableCell(
                  child: SizedBox(height: 16),
                ),
                TableCell(
                  child: Text(
                    '1234567890' * (index + 1),
                    style: Theme.of(context).textTheme.bodyMedium,
                  ),
                ),
              ],
            );
          }),
        ),
      ),
    );
  }
}

上記の例からわかるように、IntrinsicColumnWidthでは、数字のみなどの場合に、親の制約を超えてはみ出してしまう可能性があることがわかったかと思います。

登場人物

実際にIntrinsicColumnWidthの動作の話に入っていく前に、前提として登場する要素について軽く触れておきます。

Table Widget

  • テーブルレイアウトで表示するウィジェット
    • 高さは自動で調整される
    • 幅のサイズを調整するには、columnWidths プロパティを使用する
      • 設定しなかった場合のデフォルトのcolumnWidthはFlexColumnWidth
  • Tableのchildrenが受け付けるのはTableRowのみ
    • TableRowにそのTableに描画したいWidgetを詰めていく

TableColumnWidth

  • Tableの幅のサイズを調整するために使用する
  • FixedColumnWidth
    • 固定サイズ
  • FlexColumnWidth
    • 他の列が描画されサイズが確定した後に残りのサイズを割り当てる
  • FractionColumnWidth
    • TableのConstraintのmaxWidthのn分の一のサイズにする
  • IntrinsicColumnWidth
    • 描画時にアイテムが取りうる最大の値を幅に設定する
  • MaxColumnWidth
    • AとBを比べて大きい方のサイズに合わせる
  • MinColumnWidth
    • AとBを比べて小さい方のサイズに合わせる

IntrinsicColumnWidthの実装について

まずは、IntrinsicColumnWidth自体がどのような実装を持っているのか確認します。
IntrinsicColumnWidthはTableColumnWidthを継承しているので、以下の4つのメソッドの実装を持っています。

  • double minIntrinsicWidth(Iterable<RenderBox> cells, double containerWidth)
  • double maxIntrinsicWidth(Iterable<RenderBox> cells, double containerWidth)
  • double? flex(Iterable<RenderBox> cells) => null
  • String toString() => objectRuntimeType(this, 'TableColumnWidth')

toStringflexに関しては、特に解説すべき内容がないのでスキップして、残り2メソッドがどのような処理をしているかを重点的に見ていこうと思います。

minIntrinsicWidth

引数はmaxIntrinsicWidthと共通で、

  • Iterable<RenderBox> cells
  • double containerWidth

の2つを持っています。

Iterable<RenderBox> cellsは、このIntrinsicColumnWidthが設定されている列のすべてのセルが入ったコレクションです。
double containerWidthは、TableWidget自体(= 親Widget)のConstrintsのmaxWidthです。

中の実装としては、以下のようになっています。


double minIntrinsicWidth(Iterable<RenderBox> cells, double containerWidth) {
  double result = 0.0;
  for (final RenderBox cell in cells) {
    result = math.max(result, cell.getMinIntrinsicWidth(double.infinity));
  }
  return result;
}

やっていることとしては、cellsの中からgetMinIntrinsicWidthの最大値を取得しています。

getMinIntrinsicWidth

getMinIntrinsicWidthは、RenderBoxクラスのメソッドで、以下のように説明されています。

Returns the minimum width that this box could be without failing to correctly paint its contents within itself, without clipping.
このボックスが、クリッピングすることなく、その内容を正しくそれ自身の中に描くことに失敗しない最小の幅を返します。

要するに、要素がはみ出すことなく描画した場合の最小の幅を返します。

「Hello, World」という文字列をTextに描画しようとした際に、範囲が狭かった場合、上記のようにカンマの位置で改行して表示されることを期待すると思います。
このように適当な箇所で改行されてた場合、一行単位で一番大きいサイズになるものを返しています。
なので、上記の例では、「World」が一番大きいサイズになるので、そのサイズが返り値になります。
言い換えると、「Text内に描画する文字列の中で最長の単語の幅を取得することができるメソッド」と言えると思います。

minIntrinsicWidthを計算するRenderBox.computeMinIntrinsicWidthの説明内にも、英単語の途中で改行されないようにするサイズを返すという説明がされています。

ちなみに、日本語の場合は、一文字ごとのサイズになるので、最小の幅で描画する場合は、一文字ごとに改行されることになります。
数字の場合は数字列(「12345」や「85336」などの単位)ごとのサイズになるので、最小の幅で描画する場合は、数字列ごとに改行されることになります。

maxIntrinsicWidth

maxIntrinsicWidthは、引数はminIntrinsicWidthと一緒で、実装もほぼ同じです。
ですが、一部だけ異なっています。


double maxIntrinsicWidth(Iterable<RenderBox> cells, double containerWidth) {
  double result = 0.0;
  for (final RenderBox cell in cells) {
    result = math.max(result, cell.getMaxIntrinsicWidth(double.infinity));
  }
  return result;
}

違いとしては、cell.getMinIntrinsicWidthではなく、cell.getMaxIntrinsicWidthになっている点です。
そして、そのgetMaxIntrinsicWidthの最大値を取得しています。

getMaxIntrinsicWidth

getMaxIntrinsicWidthは以下のように説明されています。

Returns the smallest width beyond which increasing the width never decreases the preferred height.
幅を広げても好ましい高さが減少しなくなる最小の幅を返します。

Textの描画を例にして解説します。
まずは、小さいサイズのBoxに描画することを考えます。そのBoxのサイズと文字列の長さにもよりますが、最初はBoxが小さいため、文字列が改行されて表示される可能性があります。ですが、そのBoxのサイズをだんだん大きくしていくと、いつか文字列全文を一行で表示できるサイズになり、Boxのサイズを大きくしても文字列の高さは変化しなくなります。
なので、その全文が一行で表示できるサイズが
Textを描画しようとした際に、最初小さいサイズのBoxに最初は改行されて表示されている可能性がありますが、幅をどんどん広げていくと、いつか全文を一行で表示することができるようになるので、それ以上広げても高さが変化しなくなります。
なので、全文が一行で収まる幅が、好ましい高さが減少しなくなる最小の幅ということになるため、その値を返します。

IntrinsicColumnWidthの実装まとめ

minIntrinsicWidthmaxIntrinsicWidthの実装から、IntrisicColumnWidthはサイズを決定するために、そのColumn内のセルの最小のサイズと最大のサイズを計算して、その範囲内でセルのサイズを決定して描画をしていそうだということがわかりました。

はみ出してしまう動作について

IntrinsicColumnWidthがどのようにColumnのサイズの幅を決定しているかについてある程度把握したところで、実際にはみ出す場合はどのような動作になっているかについて確認していきます。

例えば、「FlutterFlutterFlutter」という幅が150になる文字列を描画しようとする場合について考えてみます。
この文字列は、一単語としてみなされるため、単語の途中で改行されないように、それぞれ以下のような値になります。

  • minIntrinsicWidth = 150
  • maxIntrinsicWidth = 150

minIntrinsicWidthとmaxIntrinsicWidthどちらも150なので、150 ~ 150の範囲、つまり150固定で描画されることになります。

このように、150未満になることができないため、親の制約の最大幅100だった場合も、150のTextとして描画されてしまい、はみ出してしまうことになります。

これが、IntrinsicColumnWidthではみ出してしまう場合の動作になります。

その他の例

日本語

日本語の場合は、ひらがなのみ、カタカナのみ、漢字のみの場合に発生し、組み合わせた場合は発生しません。
発生する例:「あいうえおあいうえお」、「独立行政法人国立高等専門学校機構」など
発生しない例:「長い言葉」など

数字

数字の場合は数字のみはもちろんですが、「,」との組み合わせでも発生します。
e.g. 「123,456,789」

回避策案

では、この問題の回避策を考えてみます。
問題となっていそうな箇所は、minIntrinsicWidthのサイズが親の制約よりも大きくなってしまう点かと思われます。
なので、これを回避するためにminIntrinsicWidthの実装を変更してみようと思います。

IntrisicColumnWidthを継承したMyIntrinsicColumnWidthを作成します。

class MyIntrinsicColumnWidth extends IntrinsicColumnWidth {
  const MyIntrinsicColumnWidth({super.flex});

  
  double minIntrinsicWidth(Iterable<RenderBox> cells, double containerWidth) {
    return 0;
  }
}

minIntrinsicWidthメソッドだけをOverrideし、常に0を返すように変更します。
このようにすることで、上記の例の「FlutterFlutterFlutter」を描画する際に、サイズが0~150の範囲で描画するようになります。
なので、描画できる最大幅が100だとした場合は、0~150の範囲内で描画できる最大幅となる100のTextとして描画されるので、はみ出すことがなくなります。
テキストのサイズが50だとした場合は、0~50の範囲内で描画できる最大幅の50で描画されるためIntrinsicColumnWidthの元々の動作と同じになります。

まとめ

  • Tableの要素に改行位置が判定しにくそうな文字列を設定するTextがある場合かつIntrinsicColumnWidthを使用したい場合は注意が必要
  • どうしても使用したい場合は、minIntrinsicWidthをOverrideして、最小の幅を返すメソッドにしてしまうのが良さそう

Discussion