Tuesday, September 5, 2023

Flutter: CustomScrollViewで実現するレスポンシブなフッターレイアウト

Flutterでアプリケーションを開発する際、多様なデバイスの画面サイズに対応するレスポンシブなUI設計は避けて通れない課題です。特に、「コンテンツの量に応じて、画面下部にボタンやフッターを配置したい」という要件は頻繁に登場します。コンテンツが少ない場合は画面の最下部に、コンテンツが多い場合はスクロールした先にフッターを配置する、という挙動です。

この一見単純に見えるレイアウトは、ColumnStackといった基本的なウィジェットだけではうまく実現できず、多くの開発者が頭を悩ませるポイントです。しかし、Flutterに用意されているCustomScrollViewSliverという概念を理解すれば、この問題を非常にエレガントに解決できます。

この記事では、まず従来のレイアウト方法の限界を解説し、その後CustomScrollViewSliverFillRemainingウィジェットを駆使して、あらゆるコンテンツ量に柔軟に対応できる「スティッキーフッター」レイアウトを実装する方法を、具体的なコード例と共に詳しく解説します。

本題に入る前に、なぜ一般的なウィジェットではこの「スティッキーフッター」レイアウトが難しいのかを理解しておきましょう。この課題には、大きく分けて2つのシナリオが存在します。

  • シナリオA: 画面に表示するコンテンツが少なく、スクロールが発生しない場合。フッターは画面の最下部に固定されるべきです。
  • シナリオB: コンテンツが画面の高さを超えており、スクロールが必要な場合。フッターは全コンテンツの末尾に配置されるべきです。
コンテンツの量に応じたフッターの配置例

この2つのシナリオを同時に満たすレイアウトを考えてみましょう。

試み1:ColumnとExpanded/Spacer

Columnウィジェット内でExpandedSpacerを使えば、シナリオ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」エラーが発生します。これを回避するためにColumnListViewSingleChildScrollViewでラップすると、今度はSpacerが機能しなくなり、フッターがコンテンツのすぐ下に配置されてしまいます。

試み2:StackとPositioned

次に考えられるのがStackです。StackPositionedを使えば、フッターを常に画面下部に固定できます。


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(スリヴァー)とは、「スクロール可能な領域の一部」を表現するための特別なウィジェットです。普段私たちが何気なく使っているListViewGridViewも、内部的にはCustomScrollViewと、それぞれに対応するSliver(SliverList, SliverGrid)で構成されています。CustomScrollViewは、これらのSliverを自由に組み合わせて、独自のスクロール効果やレイアウトを作成するための強力なウィジェットです。

今回の課題を解決するために、以下の2つのSliverを主に使用します。

  • SliverToBoxAdapter: 通常の非Sliverウィジェット(Boxプロトコルウィジェット、例えばContainerColumn)を、Sliverツリーの中に配置できるように変換するアダプターです。メインコンテンツ部分をこれでラップします。
  • SliverFillRemaining: ビューポート(画面の表示領域)の残りの空間を埋めるためのSliverです。これが今回のレイアウトの鍵となります。

実装のステップ

それでは、実際にCustomScrollViewを使ってレイアウトを構築していきましょう。全体の構造は非常にシンプルです。

  1. 全体の親としてCustomScrollViewを配置します。
  2. sliversプロパティに、ウィジェットのリストを渡します。
  3. 最初の要素として、SliverToBoxAdapterを使い、そのchildにメインコンテンツを配置します。
  4. 次の要素として、SliverFillRemainingを配置し、そのchildにフッターウィジェットを配置します。

言葉で説明すると少し複雑に聞こえるかもしれませんが、以下のGIFアニメーションとコードを見れば、その挙動が直感的に理解できるはずです。コンテンツが短い場合はフッターが下部に固定され、コンテンツが長くなるとスクロールの終端に追従しているのがわかります。

CustomScrollViewとSliverを使ったレイアウトの動作例

鍵となるプロパティ: `hasScrollBody: false`

この実装で最も重要なポイントは、SliverFillRemaininghasScrollBodyプロパティを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で多様な画面サイズとコンテンツ量に対応するための、レスポンシブなフッターレイアウトの実装方法を解説しました。ColumnStackといった基本的なウィジェットの限界を理解し、CustomScrollViewSliverFillRemainingを組み合わせることで、この課題をスマートに解決できることを見てきました。

重要なポイントの再確認:

  • コンテンツが少ない場合は画面下部に固定、多い場合はスクロールの終端に配置するレイアウトにはCustomScrollViewが最適です。
  • メインコンテンツはSliverToBoxAdapterでラップしてSliverツリーに含めます。
  • フッター部分はSliverFillRemainingでラップし、そのchildに配置します。
  • 最も重要なのはSliverFillRemainingのプロパティをhasScrollBody: falseに設定することです。これにより、フッターがスクロール本体としてではなく、あくまで「残りの空間を埋める」要素として機能します。

このテクニックをマスターすることで、ユーザー体験を損なうことなく、より洗練されたUIを設計できるようになります。さらに、CustomScrollViewSliverAppBar(スクロールに応じて見た目が変わるAppBar)やSliverGrid(グリッドレイアウト)、SliverList(リストレイアウト)など、他の多くのSliverと組み合わせることも可能です。ぜひこのパターンを応用して、より複雑でダイナミックなスクロール表現に挑戦してみてください。


0 개의 댓글:

Post a Comment