Flutterでアプリケーションを開発する際、多様なデバイスの画面サイズに対応するレスポンシブなUI設計は避けて通れない課題です。特に、「コンテンツの量に応じて、画面下部にボタンやフッターを配置したい」という要件は頻繁に登場します。コンテンツが少ない場合は画面の最下部に、コンテンツが多い場合はスクロールした先にフッターを配置する、という挙動です。
この一見単純に見えるレイアウトは、Column
やStack
といった基本的なウィジェットだけではうまく実現できず、多くの開発者が頭を悩ませるポイントです。しかし、Flutterに用意されているCustomScrollView
とSliverという概念を理解すれば、この問題を非常にエレガントに解決できます。
この記事では、まず従来のレイアウト方法の限界を解説し、その後CustomScrollView
とSliverFillRemaining
ウィジェットを駆使して、あらゆるコンテンツ量に柔軟に対応できる「スティッキーフッター」レイアウトを実装する方法を、具体的なコード例と共に詳しく解説します。
なぜColumnやStackでは難しいのか?レスポンシブレイアウトの課題
本題に入る前に、なぜ一般的なウィジェットではこの「スティッキーフッター」レイアウトが難しいのかを理解しておきましょう。この課題には、大きく分けて2つのシナリオが存在します。
- シナリオA: 画面に表示するコンテンツが少なく、スクロールが発生しない場合。フッターは画面の最下部に固定されるべきです。
- シナリオB: コンテンツが画面の高さを超えており、スクロールが必要な場合。フッターは全コンテンツの末尾に配置されるべきです。

この2つのシナリオを同時に満たすレイアウトを考えてみましょう。
試み1:ColumnとExpanded/Spacer
Column
ウィジェット内でExpanded
やSpacer
を使えば、シナリオAは簡単に実現できます。しかし、この方法はコンテンツが画面の高さを超えた瞬間に破綻します。
Scaffold(
body: Column(
children: [
// メインコンテンツ
Container(height: 300, color: Colors.blue[100], child: Center(child: Text('短いコンテンツ'))),
const Spacer(), // 残りの空間をすべて埋める
// フッターボタン
Container(
width: double.infinity,
height: 60,
color: Colors.grey[300],
child: Center(child: Text('フッターボタン')),
),
],
),
);
上記のコードは、コンテンツが短い場合は期待通りに動作します。しかし、Container
のheightを900
のように画面より高く設定すると、ウィジェットが描画領域をはみ出してしまい、悪名高い「RenderFlex overflowed」エラーが発生します。これを回避するためにColumn
をListView
やSingleChildScrollView
でラップすると、今度はSpacer
が機能しなくなり、フッターがコンテンツのすぐ下に配置されてしまいます。
試み2:StackとPositioned
次に考えられるのがStack
です。Stack
とPositioned
を使えば、フッターを常に画面下部に固定できます。
Scaffold(
body: Stack(
children: [
// スクロール可能なメインコンテンツ
SingleChildScrollView(
child: Container(
height: 900, // 長いコンテンツ
color: Colors.blue[100],
child: Center(child: Text('長いコンテンツ')),
),
),
// 画面下部に固定されたフッター
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
height: 60,
color: Colors.grey[300],
child: Center(child: Text('フッターボタン')),
),
),
],
),
);
この方法は、コンテンツが長くてもフッターは常に画面下部に表示され続けます。しかし、これはシナリオBの要件を満たしていません。スクロールの最下部まで到達しても、フッターがコンテンツの末尾に重なってしまい、ユーザーは最後のコンテンツを見ることができません。これもまた、理想的な挙動とは言えません。
このように、単純なウィジェットの組み合わせでは、両方のシナリオを満足させる柔軟なレイアウトを構築するのは困難です。ここで登場するのが、CustomScrollView
とSliverです。
解決策: CustomScrollViewとSliverによる柔軟なレイアウト構築
FlutterにおけるSliver(スリヴァー)とは、「スクロール可能な領域の一部」を表現するための特別なウィジェットです。普段私たちが何気なく使っているListView
やGridView
も、内部的にはCustomScrollView
と、それぞれに対応するSliver(SliverList
, SliverGrid
)で構成されています。CustomScrollView
は、これらのSliverを自由に組み合わせて、独自のスクロール効果やレイアウトを作成するための強力なウィジェットです。
今回の課題を解決するために、以下の2つのSliverを主に使用します。
SliverToBoxAdapter
: 通常の非Sliverウィジェット(Boxプロトコルウィジェット、例えばContainer
やColumn
)を、Sliverツリーの中に配置できるように変換するアダプターです。メインコンテンツ部分をこれでラップします。SliverFillRemaining
: ビューポート(画面の表示領域)の残りの空間を埋めるためのSliverです。これが今回のレイアウトの鍵となります。
実装のステップ
それでは、実際にCustomScrollView
を使ってレイアウトを構築していきましょう。全体の構造は非常にシンプルです。
- 全体の親として
CustomScrollView
を配置します。 slivers
プロパティに、ウィジェットのリストを渡します。- 最初の要素として、
SliverToBoxAdapter
を使い、そのchild
にメインコンテンツを配置します。 - 次の要素として、
SliverFillRemaining
を配置し、そのchild
にフッターウィジェットを配置します。
言葉で説明すると少し複雑に聞こえるかもしれませんが、以下のGIFアニメーションとコードを見れば、その挙動が直感的に理解できるはずです。コンテンツが短い場合はフッターが下部に固定され、コンテンツが長くなるとスクロールの終端に追従しているのがわかります。

鍵となるプロパティ: `hasScrollBody: false`
この実装で最も重要なポイントは、SliverFillRemaining
のhasScrollBody
プロパティをfalse
に設定することです。
SliverFillRemaining(
hasScrollBody: false, // <-- これが非常に重要!
child: Align(
alignment: Alignment.bottomCenter,
child: Container(
// フッターのスタイル
),
),
)
hasScrollBody
プロパティは、SliverFillRemaining
がスクロール可能な本体として振る舞うかどうかを決定します。
true
(デフォルト): このSliverがビューポート全体を埋め、それ自体がスクロールコンテンツを持つことを想定します。例えば、中身がListView
の場合などに使用します。false
: このSliverは、あくまで「残りの空間を埋める」役割に徹し、スクロールの本体にはなりません。これにより、前のSliver(この場合はメインコンテンツ)が画面を埋め尽くしていない場合にのみ、残りの可視領域を埋めます。コンテンツが長くて画面外にはみ出している場合は、単純にそのコンテンツの後に配置されるだけです。
今回の要件では、フッター自体がスクロールするわけではなく、あくまで残りの空間を埋めるか、コンテンツの末尾に配置されるかなので、hasScrollBody: false
がまさに最適な設定となります。
実践的なコード例
ここまでの説明をまとめた、完全なサンプルコードを以下に示します。このコードはそのままコピーして実行できます。_isContentLong
という真偽値フラグを切り替えることで、コンテンツの長さが短い場合と長い場合の両方の挙動を確認できます。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Sticky Footer Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: const ResponsiveFooterPage(),
);
}
}
class ResponsiveFooterPage extends StatefulWidget {
const ResponsiveFooterPage({super.key});
@override
State<ResponsiveFooterPage> createState() => _ResponsiveFooterPageState();
}
class _ResponsiveFooterPageState extends State<ResponsiveFooterPage> {
bool _isContentLong = false;
Widget _buildMainContent() {
// このウィジェットがメインのコンテンツ部分
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_isContentLong ? '長いコンテンツの例' : '短いコンテンツの例',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 16),
const Text(
'このレイアウトはCustomScrollViewとSliverFillRemainingを使用して構築されています。'
'下のスイッチを切り替えることで、コンテンツの長さを変更し、フッターの挙動を確認できます。',
),
const SizedBox(height: 20),
// _isContentLongがtrueの場合のみ、追加のコンテンツを表示
if (_isContentLong)
Container(
height: 800, // 画面をはみ出すほどの高さ
decoration: BoxDecoration(
color: Colors.teal[50],
border: Border.all(color: Colors.teal.shade200),
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: Text(
'この部分はコンテンツが長い場合にのみ表示されます。\nスクロールしてフッターを確認してください。',
textAlign: TextAlign.center,
),
),
),
],
),
);
}
Widget _buildFooter() {
// このウィジェットがフッター部分
return Container(
padding: const EdgeInsets.all(16.0),
width: double.infinity,
color: Colors.grey[200],
child: ElevatedButton(
onPressed: () {
// ボタンが押された時の処理
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('ボタンがクリックされました!')),
);
},
child: const Text('確定ボタン'),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('レスポンシブフッター'),
actions: [
// コンテンツの長さを切り替えるスイッチ
Row(
children: [
const Text('コンテンツを長くする'),
Switch(
value: _isContentLong,
onChanged: (value) {
setState(() {
_isContentLong = value;
});
},
),
],
)
],
),
body: CustomScrollView(
slivers: [
// 1. メインコンテンツ部分
SliverToBoxAdapter(
child: _buildMainContent(),
),
// 2. 残りの空間を埋めるフッター部分
SliverFillRemaining(
hasScrollBody: false, // これが重要!
child: Align(
alignment: Alignment.bottomCenter,
child: _buildFooter(),
),
),
],
),
);
}
}
まとめと応用
この記事では、Flutterで多様な画面サイズとコンテンツ量に対応するための、レスポンシブなフッターレイアウトの実装方法を解説しました。Column
やStack
といった基本的なウィジェットの限界を理解し、CustomScrollView
とSliverFillRemaining
を組み合わせることで、この課題をスマートに解決できることを見てきました。
重要なポイントの再確認:
- コンテンツが少ない場合は画面下部に固定、多い場合はスクロールの終端に配置するレイアウトには
CustomScrollView
が最適です。 - メインコンテンツは
SliverToBoxAdapter
でラップしてSliverツリーに含めます。 - フッター部分は
SliverFillRemaining
でラップし、そのchild
に配置します。 - 最も重要なのは
SliverFillRemaining
のプロパティをhasScrollBody: false
に設定することです。これにより、フッターがスクロール本体としてではなく、あくまで「残りの空間を埋める」要素として機能します。
このテクニックをマスターすることで、ユーザー体験を損なうことなく、より洗練されたUIを設計できるようになります。さらに、CustomScrollView
はSliverAppBar
(スクロールに応じて見た目が変わるAppBar)やSliverGrid
(グリッドレイアウト)、SliverList
(リストレイアウト)など、他の多くのSliverと組み合わせることも可能です。ぜひこのパターンを応用して、より複雑でダイナミックなスクロール表現に挑戦してみてください。
0 개의 댓글:
Post a Comment