[Flutter] ウィジェットを並べてはみだしたら非表示にしたい

こんにちは。Flutter 勉強中の @knsk765 です。
ちょっとこのタイトルだと何を言っているのかわからないのでイメージを。

目次

やりたいこと

  • 1行分のエリア内にチップを並べる
  • チップはいくつでも並べられる
  • 複数並べてはみ出したチップ以降は非表示にする
  • はみ出して表示できなかった分は +n で数を表示する
  • チップがひとつではみ出す場合は非表示にせず中身を省略(...)表示する

ポイント

  • チップは中の文字によって長さが変わる
  • エリアからはみ出したかどうかを判別するには描画されたサイズの取得が必要

ここでネックになるのが描画されたチップウィジェットのサイズ取得です。
普通にやろうと思うといったんチップウィジェットを並べた後で個々のサイズを取得して、はみ出した部分の描画をしなおさないといけない。
できるんだろうか、そんなこと...

Flow と FlowDelegate

そこで使えそうなのが Flow ウィジェット。 Flow ウィジェットは子ウィジェットの位置を再計算して描画をコントロールできるウィジェットなのです。位置を移動させるアニメーションなどに使われる模様。
FlowDelegate に描画ロジックを記載することで、Flow の子ウィジェットを自由に配置できるとのこと。
これなら今回やりたいことが実現できそう。

やってみよう

main

まずは main.dart のコードを。

main.dart

import 'package:chip_example/widgets/chip_area.dart';
import 'package:flutter/material.dart';

Future main() async {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const MaterialApp(home: MyApp()));
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("チップをならべる"),
      ),
      body: const SafeArea(
        child: Padding(
          padding: EdgeInsets.all(12),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.start,
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              ChipArea(
                  label: 'おさまるの ひとつ',
                  items: ['空条 Q太郎']),
              ChipArea(
                  label: 'ながいの ひとつ',
                  items: ['オラオラオラオラオラオラオラオラオラオラオラオラオラ']),
              ChipArea(
                  label: 'おさまるの いっぱい',
                  items: ['ジョナサソ', 'ヅョセフ', '東方 じょうじょ']),
              ChipArea(
                  label: 'ひとつ はみだす',
                  items: ['ジョナサソ', 'ヅョセフ', 'じょうじょ', 'しおばな']),
              ChipArea(
                  label: 'いっぱい はみだす',
                  items: ['魔少年', 'ゴージャス★', '来訪者', '奇妙な', '動かない', '執行中', '進行中']),
            ],
          ),
        ),
      ),
    );
  }
}

ChipArea がチップを並べる1行分のエリアウィジェット。items に渡した文字列配列をチップ化して並べます。

Chip ウィジェット

標準の Chip を使ってスタイルを定義済みの CustomChip を作っておきます。
テキストは overflow: TextOverflow.ellipsis で、はみ出したら省略(...)表示するようにします。

custom_chip.dart

import 'package:flutter/material.dart';

class CustomChip extends StatelessWidget {
  final String text;
  const CustomChip({super.key, required this.text});

  @override
  Widget build(BuildContext context) {
    return Chip(
      backgroundColor: Colors.blue.shade200,
      side: BorderSide.none,
      shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(12), side: BorderSide.none),
      label: Text(text,
          style: TextStyle(
              fontSize: 16,
              color: Colors.grey.shade800,
              fontWeight: FontWeight.normal,
              overflow: TextOverflow.ellipsis)),
      materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
      labelPadding: const EdgeInsets.symmetric(horizontal: 1),
      visualDensity: const VisualDensity(horizontal: 0.0, vertical: -4),
    );
  }
}

Chip エリア

chip_area.dart

import 'package:chip_example/widgets/custom_chip.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

class ChipArea extends HookWidget {
  final String label;
  final List<String> items;

  const ChipArea({super.key, required this.label, required this.items});

  List<Widget> _buildChips() {
    return items.map((t) => CustomChip(text: t)).toList();
  }

  @override
  Widget build(BuildContext context) {
    final hiddenChipCount = useState(0);

    void onOverflow(remain) {
      WidgetsBinding.instance.addPostFrameCallback((_) {
        hiddenChipCount.value = remain;
      });
    }

    return Column(
      mainAxisAlignment: MainAxisAlignment.start,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          label,
          style: const TextStyle(
            fontWeight: FontWeight.bold,
            fontSize: 16,
            color: Colors.black87,
          ),
        ),
        const SizedBox(height: 8),
        SizedBox(
          height: 40,
          child: Row(
            mainAxisAlignment: MainAxisAlignment.start,
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Expanded(
                child: Align(
                  alignment: Alignment.topLeft,
                  child: Flow(
                    delegate: ChipFlowDelegate(
                      spacing: 8,
                      onOverflow: onOverflow,
                    ),
                    children: _buildChips(),
                  ),
                ),
              ),
              if (hiddenChipCount.value > 0)
                SizedBox(
                  width: 50,
                  child: Align(
                    alignment: Alignment.topRight,
                    child: CustomChip(text: '+${hiddenChipCount.value}'),
                  ),
                ),
            ],
          ),
        ),
        const Divider(height: 0, thickness: 1, color: Colors.grey),
        const SizedBox(height: 24),
      ],
    );
  }
}

class ChipFlowDelegate extends FlowDelegate {
  final int spacing;
  final void Function(int remain)? onOverflow;

  ChipFlowDelegate({required this.spacing, this.onOverflow});

  @override
  void paintChildren(FlowPaintingContext context) {
    double x = 0;
    int painted = 0;

    for (int i = 0; i < context.childCount; i++) {
      final childSize = context.getChildSize(i);

      double childWidth = childSize?.width ?? 0;

      if (x + childWidth > context.size.width) {
        break;
      }

      context.paintChild(i, transform: Matrix4.translationValues(x, 0, 0));
      x += childWidth + spacing;

      painted++;
    }

    if (painted < context.childCount) {
      onOverflow?.call(context.childCount - painted);
    }
  }

  @override
  bool shouldRepaint(covariant FlowDelegate oldDelegate) {
    return false;
  }
}

解説

Flow(
  delegate: ChipFlowDelegate(
    spacing: 8,
    onOverflow: onOverflow,
  ),
  children: _buildChips(),
),

Flow ウィジェットにChipFlowDelegateCustomChipのリストを渡しています。 onOverflow` はチップがはみ出したときに実行したいコールバックです。

描画処理(チップの配置)

void paintChildren(FlowPaintingContext context) {
  double x = 0;
  int painted = 0;

  // Flow の children に渡したウィジェットを順番に処理
  for (int i = 0; i < context.childCount; i++) {
    final childSize = context.getChildSize(i);

    // 描画する子ウィジェットのサイズを取得できる
    double childWidth = childSize?.width ?? 0;

    // 次に子ウィジェットを並べるとはみ出すならば終了(描画しない)
    if (x + childWidth > context.size.width) {
      break;
    }

    // 子ウィジェットを描画
    context.paintChild(i, transform: Matrix4.translationValues(x, 0, 0));
    x += childWidth + spacing;

    painted++;
  }

  // 描画できた子ウィジェット数が少ない(いくつかはみ出した)場合は
  // オーバーフロー時のコールバックを呼ぶ
  // (コールバックの引数は はみ出した数)
  if (painted < context.childCount) {
    onOverflow?.call(context.childCount - painted);
  }
}

これではみ出したチップを描画しないという要件は解決です。
あとは、いくつはみ出したかを(+n)のチップとして右端に配置します。

はみ出し数チップ

final hiddenChipCount = useState(0);

void onOverflow(remain) {
  WidgetsBinding.instance.addPostFrameCallback((_) {
    hiddenChipCount.value = remain;
  });
}

はみ出したときに、いくつはみ出したかカウントを更新します。
今回、ステートは flutter_hooks で管理しています。
ビルド時にステートをそのまま更新するとエラーになるので、WidgetsBinding.instance.addPostFrameCallback 内で更新します。

if (hiddenChipCount.value > 0)
  SizedBox(
    width: 50,
    child: Align(
      alignment: Alignment.topRight,
      child: CustomChip(text: '+${hiddenChipCount.value}'),
    ),
  ),

あとははみ出したチップがあれば(+n)のチップを右端に配置して完成。
もっかいおさらいするとこんなかんじになりました。

どちらかというと私は Windows Forms や WPF などの開発をしていたエンジニアなので Flutter の描画処理ってまた全然違って戸惑うことも多いですが、面白いですね。