↔️

【Flutter】Column と Row に追加される spacing をみてみる

2024/11/17に公開

初めに

以下の Issue で Column や Row に spacing を導入することが議論されており、Master Channel で確認できるようになっていたので、ざっくり触ってみてみたいと思います。

https://github.com/flutter/flutter/issues/55378

記事の対象者

  • Flutter 学習者

要約

今回の内容を要約すると以下のようになります。

  • spacing を使えば複数の SizedBoxGap を配置することなく、均等な間隔で要素を配置することができるようになる
  • SwiftUI の VStack(spacing: 16) のように各要素の間の指定が可能になる
  • Jetpack Compose の Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { } のように各要素の間の指定が可能になる

実装

基本的には要約の通りですが、2024年11月17日現在の Master Channel で Column, Row に spacing プロパティが追加されており、要素を等間隔に並べたい場合に SizedBoxGap を多用することなく実装できるようになっています。

まずは Flutter の Master Channel を使用するようにします。
以下のコマンドを実行することで Channel の切り替えができます。

flutter channel master

FVM を使用している場合は以下のコマンドで Channel の切り替えができます。

fvm use master

要素を等間隔に並べるUIとしてユーザーの新規登録画面を例にとって実装していきます。
今までの実装では以下のようなコードになります。
同じ高さの SizedBox を間に挟んで実装することが多いかと思います。

Column(
  children: [
    const _CustomTextField(
      hintText: 'メールアドレス',
    ),
    const SizedBox(height: 16),  // ここ
    const _CustomTextField(
      hintText: 'パスワード',
    ),
    const SizedBox(height: 16),  // ここ
    const _CustomTextField(
      hintText: 'パスワード(確認)',
    ),
    const SizedBox(height: 16),  // ここ
    ElevatedButton(onPressed: () {}, child: const Text('登録')),
  ],
),

このようなコードを、追加された spacing を用いて実装すると以下のようになります。
これで複数の SizedBox を記述する必要がなくなります。また、各要素の間隔が統一されるため、「A と B の要素の間隔だけ違う」といった間違いが少なくなるかと思います。

Column(
+ spacing: 16,
  children: [
    const _CustomTextField(
      hintText: 'メールアドレス',
    ),
-   const SizedBox(height: 16),
    const _CustomTextField(
      hintText: 'パスワード',
    ),
-   const SizedBox(height: 16),
    const _CustomTextField(
      hintText: 'パスワード(確認)',
    ),
-   const SizedBox(height: 16),
    ElevatedButton(onPressed: () {}, child: const Text('登録')),
  ],
),

spacing の指定は Row でも同様にできます。

SwiftUI の場合

SwiftUI の場合は VStack に spacing を付与することで同じようなレイアウトになるかと思います。
コードは以下のようなイメージです。

VStack(spacing: 16) {
    TextField(text: $email) {
        Text("メールアドレス")
    }
    TextField(text: $password) {
        Text("パスワード")
    }
    TextField(text: $confirmPassword) {
        Text("パスワード(確認)")
    }
}

Jetpack Compose の場合

Jetpack Compose の場合は ColumnverticalArrangementArrangement.spacedBy(16.dp) を付与することで、各要素の垂直方向に間隔を空けることができ、同じようなレイアウトになるかと思います。
コードは以下のようなイメージです。

Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
    TextField(
        value = email.value,
        onValueChange = {
            email.value = it
        },
    )
    TextField(
        value = password.value,
        onValueChange = {
            password.value = it
        },
    )
    TextField(
        value = confirmPassword.value,
        onValueChange = {
            confirmPassword.value = it
        },
    )
    Button(onClick = {}) {
        Text("登録")
    }
}

詳細

最後に少し内部の実装をみてみたいと思います。

Master Channel に切り替えて Column の内容をのぞいてみると以下のようになっています。
Column, RowFlex を継承するようになっており、 spacing を受け取るようになっています。

basic.dart
class Column extends Flex {
  const Column({
    super.key,
    super.mainAxisAlignment,
    super.mainAxisSize,
    super.crossAxisAlignment,
    super.textDirection,
    super.verticalDirection,
    super.textBaseline,
    super.spacing,  // ここ
    super.children,
  }) : super(
    direction: Axis.vertical,
  );
}

Flex の中身をみてみると、 createRenderObjectspacing を渡していることがわかります。

basic.dart

RenderFlex createRenderObject(BuildContext context) {
  return RenderFlex(
    direction: direction,
    mainAxisAlignment: mainAxisAlignment,
    mainAxisSize: mainAxisSize,
    crossAxisAlignment: crossAxisAlignment,
    textDirection: getEffectiveTextDirection(context),
    verticalDirection: verticalDirection,
    textBaseline: textBaseline,
    clipBehavior: clipBehavior,
    spacing: spacing,  // ここ
  );
}

さらに RenderFlex の中身をみてみると以下のようなコードがあります。(みやすいように省略している部分が多々あります)
RenderFlexperformLayout_distributeSpace というメソッドに spacing が渡されていることがわかります。

flex.dart

void performLayout() {
  final BoxConstraints constraints = this.constraints;
  final (double leadingSpace, double betweenSpace) = mainAxisAlignment._distributeSpace(remainingSpace, childCount, flipMainAxis, spacing);
}

_distributeSpace メソッドは以下のようになっています。
このメソッドで返却しているレコード型の変数はそれぞれ以下のような意味かと思います。

  • leadingSpace : 要素の前に来るスペース(Columnの場合は要素の上のスペース)の大きさ
  • betweenSpace : 要素ごとの間のスペースの大きさ
flex.dart
(double leadingSpace, double betweenSpace) _distributeSpace(double freeSpace, int itemCount, bool flipped, double spacing) {
  assert(itemCount >= 0);
  return switch (this) {
    MainAxisAlignment.start => flipped ? (freeSpace, spacing) : (0.0, spacing),

    MainAxisAlignment.end =>                             MainAxisAlignment.start._distributeSpace(freeSpace, itemCount, !flipped, spacing),
    MainAxisAlignment.spaceBetween when itemCount < 2 => MainAxisAlignment.start._distributeSpace(freeSpace, itemCount, flipped, spacing),
    MainAxisAlignment.spaceAround when itemCount == 0 => MainAxisAlignment.start._distributeSpace(freeSpace, itemCount, flipped, spacing),

    MainAxisAlignment.center =>       (freeSpace / 2.0,             spacing),
    MainAxisAlignment.spaceBetween => (0.0,                         freeSpace / (itemCount - 1) + spacing),
    MainAxisAlignment.spaceAround =>  (freeSpace / itemCount / 2,   freeSpace / itemCount + spacing),
    MainAxisAlignment.spaceEvenly =>  (freeSpace / (itemCount + 1), freeSpace / (itemCount + 1) + spacing),
  };
}

上記のコードについて少し詳しくみてみます。
MainAxisAlignment.start の場合は以下のようになっています。
flipped の値に関わらず、受け取った spacing の値をレコード型の2つ目のフィールドの betweenSpace として返却しています。

MainAxisAlignment.start => flipped ? (freeSpace, spacing) : (0.0, spacing),
fliped の詳細

fliped は辿っていくと以下のようになっています。
Column を例にとると、verticalDirection の値はデフォルトで VerticalDirection.down となっています。つまり、デフォルトではスクロール方向が上から下になっています。これを VerticalDirection.up とするとスクロール方向が下から上に反転するため、 flipedtrue になります。

bool get _flipMainAxis => firstChild != null && switch (direction) {
  Axis.horizontal => switch (textDirection) {
    null || TextDirection.ltr => false,
    TextDirection.rtl => true,
  },
  Axis.vertical => switch (verticalDirection) {
    VerticalDirection.down => false,
    VerticalDirection.up => true,
  },
};

MainAxisAlignment.spaceBetween spaceAround spaceEvenly の場合は以下のようになっています。 freeSpaceitemCount などで分割し、それに spacing の値を足した値を betweenSpace として返却しています。

MainAxisAlignment.spaceBetween => (0.0,                         freeSpace / (itemCount - 1) + spacing),
MainAxisAlignment.spaceAround =>  (freeSpace / itemCount / 2,   freeSpace / itemCount + spacing),
MainAxisAlignment.spaceEvenly =>  (freeSpace / (itemCount + 1), freeSpace / (itemCount + 1) + spacing),

これまでのコードで、 _distributeSpace メソッドの中で変更された betweenSpace は、レイアウトの段階で以下のように使われています。(わかりやすいように他の部分を省略しています。)

void performLayout() {
  final (double leadingSpace, double betweenSpace) = mainAxisAlignment._distributeSpace(remainingSpace, childCount, flipMainAxis, spacing);

  childMainPosition += _getMainSize(child.size) + betweenSpace;
}

childMainPosition は名前の通り、 Flex の子要素のメイン軸における位置を示しています。 _getMainSize(child.size)betweenSpace を足し合わせることで childMainPosition の位置を変更しています。

この部分からも spacing の値がそれぞれの子要素に追加してスペースを設けているというイメージができるかと思います。

また、これまでの SizedBoxGap はこのコードにおける _getMainSize(child.size) の方でサイズが計算されるかと思うので、これまでの実装とは少し異なる実装をしていることがわかります。

以上です。

まとめ

最後まで読んでいただいてありがとうございました。

冒頭でも述べましたが、 spacing は現在 Master Channel にあり、 Stateble Channel では使用できませんが、使用できるようになれば楽にレイアウトができる部分が増えるかと思います。

誤っている点やもっと良い書き方があればご指摘いただければ幸いです。

参考

https://github.com/flutter/flutter/issues/55378

Discussion