1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Flutter】レスポンシブに子要素を配置してくれるウィジェットを作ってみた

Last updated at Posted at 2025-07-15

7/17追記:動作イメージを追加&ソースコード・記事本文若干手直し
動作イメージは Claude にソースコードを渡して作ってもらいました

Flutter でレスポンシブ対応

といったら MediaQuery で画面の、もしくは LayoutBuilder で親要素のサイズ(制限)を取得し その値に応じて条件分岐したり表示サイズを算出したりして配置や表示内容を変化させるのが定石です。
実際対応要件の複雑さがある程度以上だとそれしか方法がないことが大概ですが、そこまで複雑でないときに
サイズの数値や条件分岐まで扱わねばならないのは 内容次第ではあるものの些かオーバーエンジニアリング気味なきらいがあります。

一方 もっとお手軽な方法として、画面(親要素)の全幅(or全高)を埋める Expanded/Flexible
要素が多くなり幅に収まり切らないときは改行(回り込み)して縦に並べる Wrap 等のウィジェットは
それら自体の挙動がレスポンシブなので、それで要件を満たせるなら用いない手はありません。
が、隙間は埋めつつ回り込みもさせたいといったように もう少し気の利いた配置をしたくてもそのようなオプションやウィジェットはなく、
結局その程度でも LayoutBuilder 等を使って表示ロジックをゴリゴリ書かざるを得ないのは微妙に痒いところに手が届かない感があります。

そこで今回はウィジェット単体、それも平易なインタフェースでそれを実現してくれる汎用レスポンシブウィジェットを2種類ほど作ってみましたので、もしお役に立てそうならご参考にするなり まるっとコピるなりご自由にご活用ください。

いずれも縦方向1にスクロール可能23なウィジェット内での使用が前提です

横幅いっぱいに広がる Wrap

まず一つ目。画面(親要素)の幅は Expanded のように目一杯埋めつつ、指定された単位当たり最小表示幅を確保できない場合は Wrap のように回り込む(改行する)ウィジェットです。

横幅いっぱいに広がるWrap 動作イメージ
動作デモはこちら

単位とは flex = 1 で、例えば minWidthPerFlex: 120 の場合 flex: 3 の子要素は最低360px.幅は確保するように回り込ませます4
同一行内に複数個横並びとなる場合、それら各々の flex により表示幅の比率が決定されます5

responsive_wrap.dart
import 'package:flutter/material.dart';

class ResponsiveWrap extends StatelessWidget {
  const ResponsiveWrap({
    super.key,
    required this.children,  // 子ウィジェットのリスト
    required this.minWidthPerFlex,  // flex=1あたりの最小表示幅:これを下回ったら回り込む
    this.verticalAlignment = CrossAxisAlignment.start,
  });

  // 隙間埋めのため、及び Widget と flex ワンセットでの保持を
  // データ構造で強制するため children は <Expanded>[] にした
  final List<Expanded> children;
  final double minWidthPerFlex;
  final CrossAxisAlignment verticalAlignment;

  // 隙間埋め空ウィジェット:ここでは使っていないが後述の拡張時に使う
  List<Widget> _filler({required int flex}) => [];

  Widget _row(int start, int end, int flexLimit) {
    if (start >= children.length || start >= end) {
      throw ArgumentError('Invalid range: $start to $end');
    }

    final rowChildren = children.sublist(start, end).cast<Widget>()
      + _filler(flex: flexLimit - (end - start));
    return rowChildren.length <= 1 ? children[start].child :
      Row(crossAxisAlignment: verticalAlignment, children: rowChildren);
  }

  List<Widget> _rows(double widthLimit) {
    if (children.isEmpty)  return [];

    // 一行に flex いくつ分が収まるか
    final flexLimit = (widthLimit / minWidthPerFlex).floor();

    final rows = <Widget>[];
    int currRowHeadIdx = 0;
    int currRowFlexSum = children.first.flex;

    // i番目まで進めた際に溢れたらi-1番目までを結果リストに加える
    // 従って0番目では溢れ判定を行わないためi=1からループを回す
    for (int i = 1; i < children.length; ++i) {
      currRowFlexSum += children[i].flex;
      if (currRowFlexSum > flexLimit) {
        rows.add(_row(currRowHeadIdx, i, flexLimit));
        currRowHeadIdx = i;
        currRowFlexSum = children[i].flex;
      }
    }

    rows.add(_row(currRowHeadIdx, children.length, flexLimit));
    return rows;
  }

  @override
  Widget build(BuildContext context) => LayoutBuilder(
    builder: (context, constraints) {
      final rows = _rows(constraints.maxWidth);
      return switch (rows.length) {
        0 => SizedBox.shrink(),
        1 => SizedBox(width: double.infinity, child: rows.first),
        _ => Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: rows,
        )
      };
    }
  );
}

Wrap の親戚(?)なら spacing / runSpacing(列間/行間の隙間)も欲しくなりますよね。その場合は

こちらのコードを追加してください。
pubspec.yaml(抜粋)
  dependencies:
+   gap: ^3.0.1
responsive_wrap.dart
  import 'package:flutter/material.dart';
+ import 'package:gap/gap.dart';
+
+ extension on List<Widget> {
+   List<Widget> joinWithGap(double extent) {
+     if (extent <= 0 || length <= 1)   return this;
+     return expand((e) => [e, Gap(extent)]).toList()..removeLast();
+   }
+ }

  class ResponsiveWrap extends StatelessWidget {
    const ResponsiveWrap({
      // 略(この間変更なし)
+     this.spacing = 0,  // 列間の隙間
+     this.runSpacing = 0,  // 行間の隙間
    });

    // 略(この間変更なし)
+   final double spacing;
+   final double runSpacing;

    List<Widget> _filler({required int flex}) => [];

    Widget _row(int start, int end, int flexLimit) {
      // 略(この間変更なし)

-     final rowChildren = children.sublist(start, end).cast<Widget>()
+     final rowChildren = children.sublist(start, end).cast<Widget>().joinWithGap(spacing)
        + _filler(flex: flexLimit - (end - start));
      return rowChildren.length <= 1 ? children[start].child :
        Row(crossAxisAlignment: verticalAlignment, children: rowChildren);
    }

    List<Widget> _rows(double widthLimit) {
      // 略(この間変更なし)

-     return result;
+     return result.joinWithGap(runSpacing);
    }

    // 略(以下変更なし)
  }

列数自動可変の GridView

GridView のように全列同じ幅かつ全行同じ列数でありながら、その列数は画面(親要素)幅に合わせて自動で調整するウィジェットも前項の ResponsiveWrapextends することで簡単に実現できます。
具体的には childrenflex1 で固定し、行内要素数が行の最大 flex 未満のときはその不足分の幅の空ウィジェットを付加するよう _filler() を override します。

列数自動可変のGridView 動作イメージ
動作デモはこちら

responsive_grid_view.dart
  class ResponsiveGridView extends ResponsiveWrap {
    ResponsiveGridView({
      super.key,
      required List<Widget> children,
      required super.minWidthPerFlex,
      super.verticalAlignment = CrossAxisAlignment.start,
+     super.spacing = 0,
+     super.runSpacing = 0,
    }) : super(
      children: children.map((w) => Expanded(child: w)).toList()
    );

    @override
    List<Widget> _filler({required int flex}) =>
-     flex <= 0 ? [] : [Spacer(flex: flex)];
+     flex <= 0 ? [] : List<Widget>.filled(flex, const Spacer())
+       .expand((e) => [Gap(spacing), e]).toList();
  }

前節末の spacing / runSpacing 対応を行っていない場合は「+」(緑色)箇所は含められません。
-」(赤色)箇所のコードで実装してください。

flex の値は必ず 1 で内部でしか扱わないので、childrenWidget で与えるようにしています。
minWidthPerFlex で指定した幅が確保できない場合回り込むのは ResponsiveWrap と全く同じですが、
これも flex の値が必ず 1 であることから minWidthPerFlex = 子要素自体の最小表示幅 となります。

おまけ:複数画像のギャラリー表示

本題のレスポンシブとは全然関係ないんですが、特定のデータ構造・コンテンツ内容に依存せず複数の子要素を汎用的に扱えるウィジェット(レスポンシブの要否に拘らず)を最近集中的に作っていたので
その一環で作ったギャラリー向け複数画像表示ウィジェット:上部に大画像一枚&下部に全画像サムネ、大画像内のスワイプや左右端の「」「」ボタン、ないしサムネクリックで大画像を切り替えながら表示――もついでに載せておきます。

スクリーンショット 2025-07-17 051829.png
動作デモはこちら

pubspec.yaml(抜粋)
  dependencies:
+   gap: ^3.0.1
gallery_view.dart
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';

class GalleryView extends StatefulWidget {
  GalleryView({
    super.key,
    required List<String> imageUrls,
    this.maxWidth = 600,  // サイズ上限はお好みで
    this.maxHeight = 800,
  }) : urls = imageUrls.where((url) => url.isNotEmpty).toList();

  final List<String> urls;
  final double maxWidth;
  final double maxHeight;

  @override
  State<GalleryView> createState() => _GalleryViewState();
}

class _GalleryViewState extends State<GalleryView> {
  int currIdx = 0;  // n番目を表示

  @override
  Widget build(BuildContext context) {
    void changeImage(int newIdx) {
      if (newIdx >= 0 && newIdx < widget.urls.length) {
        setState(() => currIdx = newIdx);
      }
    }
    void previous() => changeImage(currIdx - 1);
    void next() => changeImage(currIdx + 1);

    IconButton changeBtn({
      required IconData icon,
      VoidCallback? onPressed,
    }) => IconButton(
      icon: Icon(icon, size: 40, color: Colors.black54),
      padding: EdgeInsets.zero,
      onPressed: onPressed,
    );

    if (widget.urls.isEmpty)  return SizedBox.shrink();

    return Column(children: [
      Stack(alignment: Alignment.center, children: [
        GestureDetector(
          // 左右スワイプによる画像切替
          onHorizontalDragEnd: (d) {
            if (d.primaryVelocity! > 0)  previous();
            if (d.primaryVelocity! < 0)  next();
          },
          child: ConstrainedBox(
            constraints: BoxConstraints(
              maxWidth: widget.maxWidth,
              maxHeight: widget.maxHeight,
            ),
            child: Image.network(widget.urls[currIdx])
          ),
        ),

        // 左右端矢印ボタンによる画像切替
        if (currIdx > 0)
          Positioned(left: 0, child: changeBtn(
            icon: Icons.chevron_left,
            onPressed: previous,
          )),
        if (currIdx < widget.urls.length - 1)
          Positioned(right: 0, child: changeBtn(
            icon: Icons.chevron_right,
            onPressed: next,
          )),
      ]),
      const Gap(4),
      SizedBox(height: 60, child: ListView.builder(
        shrinkWrap: true,
        scrollDirection: Axis.horizontal,
        itemCount: widget.urls.length,
        itemBuilder: (context, index) => Padding(
          padding: const EdgeInsets.all(4),
          child: GestureDetector(
            onTap: () => changeImage(index),
            child: Image.network(
              widget.urls[index],
              width: 50,  // サムネサイズもお好みで
              color: Colors.black.withOpacity(index == currIdx ? 0 : 0.4),
              colorBlendMode: BlendMode.darken,
            ),
          ),
        ),
      )),
    ]);
  }
}
hooks 使用の場合
pubspec.yaml(抜粋)
  dependencies:
    gap: ^3.0.1
+   flutter_hooks: ^0.21.2
  import 'package:flutter/material.dart';
+ import 'package:flutter_hooks/flutter_hooks.dart';
  import 'package:gap/gap.dart';

- class GalleryView extends StatefulWidget {
+ class GalleryView extends HookWidget {

    // 略(この間変更なし)

-   @override
-   State<GalleryView> createState() => _GalleryViewState();
- }
-
- class _GalleryViewState extends State<GalleryView> {
-   int currIdx = 0;  // n番目を表示
-
    @override
    Widget build(BuildContext context) {
+     final currIdx = useState(0);
+
      void changeImage(int newIdx) {
-       if (newIdx >= 0 && newIdx < widget.urls.length) {
-         setState(() => currIdx = newIdx);
+       if (newIdx >= 0 && newIdx < urls.length) {
+         currIdx.value = newIdx;
        }
      }
 
      // 以下略(下記2点以外変更なし)
      // ・「currIdx」は全て「currIdx.value」に変更
      // ・「widget.」は全て削除
    }
  }

画像表示の ImageWidget に、画像URLの List もその Widget の生成に必要なデータの List に置き換えれば
複数の子要素ウィジェットをあたかもギャラリーのように表示させることもできるかもしれません(未検証)。
使いどころがあるかは分かりませんけど。

  1. 横方向で使いたい場合 RowColumn などを入れ替えれば使えそうな気がしますが未検証、もし必要でしたら試してみてください

  2. ListViewSingleChildScrollView

  3. もう少し正確に言うと、縦方向にサイズ制約のないウィジェット

  4. 親要素自体が360px.未満の場合は除く

  5. 要は普通に RowchildrenExpanded を与えた場合と同じ

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?