7/17追記:動作イメージを追加&ソースコード・記事本文若干手直し
動作イメージは Claude にソースコードを渡して作ってもらいました
Flutter でレスポンシブ対応
といったら MediaQuery
で画面の、もしくは LayoutBuilder
で親要素のサイズ(制限)を取得し その値に応じて条件分岐したり表示サイズを算出したりして配置や表示内容を変化させるのが定石です。
実際対応要件の複雑さがある程度以上だとそれしか方法がないことが大概ですが、そこまで複雑でないときに
サイズの数値や条件分岐まで扱わねばならないのは 内容次第ではあるものの些かオーバーエンジニアリング気味なきらいがあります。
一方 もっとお手軽な方法として、画面(親要素)の全幅(or全高)を埋める Expanded
/Flexible
や
要素が多くなり幅に収まり切らないときは改行(回り込み)して縦に並べる Wrap
等のウィジェットは
それら自体の挙動がレスポンシブなので、それで要件を満たせるなら用いない手はありません。
が、隙間は埋めつつ回り込みもさせたいといったように もう少し気の利いた配置をしたくてもそのようなオプションやウィジェットはなく、
結局その程度でも LayoutBuilder
等を使って表示ロジックをゴリゴリ書かざるを得ないのは微妙に痒いところに手が届かない感があります。
そこで今回はウィジェット単体、それも平易なインタフェースでそれを実現してくれる汎用レスポンシブウィジェットを2種類ほど作ってみましたので、もしお役に立てそうならご参考にするなり まるっとコピるなりご自由にご活用ください。
横幅いっぱいに広がる Wrap
まず一つ目。画面(親要素)の幅は Expanded
のように目一杯埋めつつ、指定された単位当たり最小表示幅を確保できない場合は Wrap
のように回り込む(改行する)ウィジェットです。
単位とは flex = 1
で、例えば minWidthPerFlex: 120
の場合 flex: 3
の子要素は最低360px.幅は確保するように回り込ませます4 。
同一行内に複数個横並びとなる場合、それら各々の flex
により表示幅の比率が決定されます5。
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
(列間/行間の隙間)も欲しくなりますよね。その場合は
こちらのコードを追加してください。
dependencies:
+ gap: ^3.0.1
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
のように全列同じ幅かつ全行同じ列数でありながら、その列数は画面(親要素)幅に合わせて自動で調整するウィジェットも前項の ResponsiveWrap
を extends
することで簡単に実現できます。
具体的には children
の flex
は 1
で固定し、行内要素数が行の最大 flex 未満のときはその不足分の幅の空ウィジェットを付加するよう _filler()
を override します。
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 で内部でしか扱わないので、children
は Widget
で与えるようにしています。
minWidthPerFlex
で指定した幅が確保できない場合回り込むのは ResponsiveWrap
と全く同じですが、
これも flex
の値が必ず 1 であることから minWidthPerFlex
= 子要素自体の最小表示幅 となります。
おまけ:複数画像のギャラリー表示
本題のレスポンシブとは全然関係ないんですが、特定のデータ構造・コンテンツ内容に依存せず複数の子要素を汎用的に扱えるウィジェット(レスポンシブの要否に拘らず)を最近集中的に作っていたので
その一環で作ったギャラリー向け複数画像表示ウィジェット:上部に大画像一枚&下部に全画像サムネ、大画像内のスワイプや左右端の「<」「>」ボタン、ないしサムネクリックで大画像を切り替えながら表示――もついでに載せておきます。
dependencies:
+ gap: ^3.0.1
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 使用の場合
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.」は全て削除
}
}
画像表示の Image
を Widget
に、画像URLの List
もその Widget
の生成に必要なデータの List
に置き換えれば
複数の子要素ウィジェットをあたかもギャラリーのように表示させることもできるかもしれません(未検証)。
使いどころがあるかは分かりませんけど。