ChatGPTに頼りながら、スティッキーヘッダーを利用したFlutterアプリのExampleを動かしてみた

2023.03.15

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、CX事業本部 Delivery部の若槻です。

最近Flutterアプリ開発でスティッキーヘッダー実装の要件があったのですが、せっかくなので調査がてらChatGPTに頼りながらExampleアプリを動かすところまでやってみました。

スティッキーヘッダーとは

スティッキーヘッダーとは、スクロール可能なリストビューの上部に固定されたヘッダーのことです。スクロールすると、リストアイテムがスクロールする一方で、スティッキーヘッダーはスクロール可能なビュー内で常に表示され、リストアイテムが画面の上部にスクロールされると、その後に続くヘッダーが表示されます。これにより、ユーザーがリスト内のアイテムをスクロールしているときでも、重要な情報を見逃すことがなくなります。

Flutterでは、sticky_headersパッケージを使用して、スティッキーヘッダーを簡単に作成することができます。

やってみた

環境

  • Flutter:3.7.7
  • ChatGPT:Model GPT-3.5

Exampleのコードを動かす

まずはExampleのコードがそのまま動くか試してみます。

example/lib/main.dart

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

import './images.dart';

void main() => runApp(const ExampleApp());

@immutable
class ExampleApp extends StatelessWidget {
  const ExampleApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sticky Headers Example',
      theme: ThemeData(
        primarySwatch: Colors.blueGrey,
      ),
      home: const MainScreen(),
    );
  }
}

@immutable
class MainScreen extends StatelessWidget {
  const MainScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ScaffoldWrapper(
      title: 'Sticky Headers Example',
      child: ListView(
        children: ListTile.divideTiles(
          context: context,
          tiles: <Widget>[
            ListTile(
              title: const Text('Example 1 - Headers and Content'),
              onTap: () => navigateTo(context, (context) => const Example1()),
            ),
            ListTile(
              title: const Text('Example 2 - Animated Headers with Content'),
              onTap: () => navigateTo(context, (context) => const Example2()),
            ),
            ListTile(
              title: const Text('Example 3 - Headers overlapping the Content'),
              onTap: () => navigateTo(context, (context) => const Example3()),
            ),
            ListTile(
              title: const Text('Example 4 - Example using scroll controller'),
              onTap: () => navigateTo(context, (context) => const Example4()),
            ),
          ],
        ).toList(growable: false),
      ),
    );
  }

  void navigateTo(BuildContext context, WidgetBuilder builder) {
    Navigator.of(context).push(MaterialPageRoute(builder: builder));
  }
}

@immutable
class Example1 extends StatelessWidget {
  const Example1({
    Key? key,
    this.controller,
  }) : super(key: key);

  final ScrollController? controller;

  @override
  Widget build(BuildContext context) {
    return ScaffoldWrapper(
      wrap: controller == null,
      title: 'Example 1',
      child: ListView.builder(
        primary: controller == null,
        controller: controller,
        itemBuilder: (context, index) {
          return StickyHeader(
            controller: controller, // Optional
            header: Container(
              height: 50.0,
              color: Colors.blueGrey[700],
              padding: const EdgeInsets.symmetric(horizontal: 16.0),
              alignment: Alignment.centerLeft,
              child: Text(
                'Header #$index',
                style: const TextStyle(color: Colors.white),
              ),
            ),
            content: Container(
              color: Colors.grey[300],
              child: Image.network(
                imageForIndex(index),
                fit: BoxFit.cover,
                width: double.infinity,
                height: 200.0,
              ),
            ),
          );
        },
      ),
    );
  }

  String imageForIndex(int index) {
    return Images.imageThumbUrls[index % Images.imageThumbUrls.length];
  }
}

@immutable
class Example2 extends StatelessWidget {
  const Example2({
    Key? key,
    this.controller,
  }) : super(key: key);

  final ScrollController? controller;

  @override
  Widget build(BuildContext context) {
    return ScaffoldWrapper(
      wrap: controller == null,
      title: 'Example 2',
      child: ListView.builder(
        primary: controller == null,
        controller: controller,
        itemBuilder: (context, index) {
          return StickyHeaderBuilder(
            controller: controller, // Optional
            builder: (BuildContext context, double stuckAmount) {
              stuckAmount = 1.0 - stuckAmount.clamp(0.0, 1.0);
              return Container(
                height: 50.0,
                color: Color.lerp(Colors.blue[700], Colors.red[700], stuckAmount),
                padding: const EdgeInsets.symmetric(horizontal: 16.0),
                alignment: Alignment.centerLeft,
                child: Row(
                  children: <Widget>[
                    Expanded(
                      child: Text(
                        'Header #$index',
                        style: const TextStyle(color: Colors.white),
                      ),
                    ),
                    Offstage(
                      offstage: stuckAmount <= 0.0,
                      child: Opacity(
                        opacity: stuckAmount,
                        child: IconButton(
                          icon: const Icon(Icons.favorite, color: Colors.white),
                          onPressed: () => ScaffoldMessenger.of(context)
                              .showSnackBar(SnackBar(content: Text('Favorite #$index'))),
                        ),
                      ),
                    ),
                  ],
                ),
              );
            },
            content: Container(
              color: Colors.grey[300],
              child: Image.network(
                imageForIndex(index),
                fit: BoxFit.cover,
                width: double.infinity,
                height: 200.0,
              ),
            ),
          );
        },
      ),
    );
  }

  String imageForIndex(int index) {
    return Images.imageThumbUrls[index % Images.imageThumbUrls.length];
  }
}

@immutable
class Example3 extends StatelessWidget {
  const Example3({
    Key? key,
    this.controller,
  }) : super(key: key);

  final ScrollController? controller;

  @override
  Widget build(BuildContext context) {
    return ScaffoldWrapper(
      wrap: controller == null,
      title: 'Example 3',
      child: ListView.builder(
        primary: controller == null,
        controller: controller,
        itemBuilder: (context, index) {
          return StickyHeaderBuilder(
            overlapHeaders: true,
            controller: controller, // Optional
            builder: (BuildContext context, double stuckAmount) {
              stuckAmount = 1.0 - stuckAmount.clamp(0.0, 1.0);
              return Container(
                height: 50.0,
                color: Colors.grey.shade900.withOpacity(0.6 + stuckAmount * 0.4),
                padding: const EdgeInsets.symmetric(horizontal: 16.0),
                alignment: Alignment.centerLeft,
                child: Text(
                  'Header #$index',
                  style: const TextStyle(color: Colors.white),
                ),
              );
            },
            content: Container(
              color: Colors.grey[300],
              child: Image.network(
                imageForIndex(index),
                fit: BoxFit.cover,
                width: double.infinity,
                height: 200.0,
              ),
            ),
          );
        },
      ),
    );
  }

  String imageForIndex(int index) {
    return Images.imageThumbUrls[index % Images.imageThumbUrls.length];
  }
}

@immutable
class ScaffoldWrapper extends StatelessWidget {
  const ScaffoldWrapper({
    Key? key,
    required this.title,
    required this.child,
    this.wrap = true,
  }) : super(key: key);

  final Widget child;
  final String title;
  final bool wrap;

  @override
  Widget build(BuildContext context) {
    if (wrap) {
      return Scaffold(
        appBar: PreferredSize(
          preferredSize: const Size.fromHeight(kToolbarHeight),
          child: Hero(
            tag: 'app_bar',
            child: AppBar(
              title: Text(title),
              elevation: 0.0,
            ),
          ),
        ),
        body: child,
      );
    } else {
      return Material(
        child: child,
      );
    }
  }
}

@immutable
class Example4 extends StatefulWidget {
  const Example4({Key? key}) : super(key: key);

  @override
  State<Example4> createState() => _Example4State();
}

class _Example4State extends State<Example4> {
  late final _controller = List.generate(4, (_) => ScrollController());

  @override
  Widget build(BuildContext context) {
    return Material(
      child: DefaultTabController(
        length: 3,
        child: NestedScrollView(
          controller: _controller[0],
          headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
            return <Widget>[
              SliverAppBar(
                title: const Text('Example 4'),
                pinned: true,
                forceElevated: innerBoxIsScrolled,
                bottom: const TabBar(
                  tabs: <Tab>[
                    Tab(text: 'Example 1'),
                    Tab(text: 'Example 2'),
                    Tab(text: 'Example 3'),
                  ],
                ),
              ),
            ];
          },
          body: MediaQuery.removePadding(
            context: context,
            removeTop: true,
            child: TabBarView(
              children: <Widget>[
                Example1(controller: _controller[1]),
                Example2(controller: _controller[2]),
                Example3(controller: _controller[3]),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

images.dartが無いと怒られました。exampleのコードをそのままコピペしただけでは動かないのはよくあることです。

images.dartが使われている箇所のコードを示して、images.dartの中身をどのように書けば良いかChatGPTに聞いてみます。

下記のFlutterコードで`images.dart`ファイルの内容はどのように書けばいい?`と尋ねてみます。

すると以下の回答がもらえました。

このコードで使用されているimages.dartファイルは、Imagesという名前のクラスを含んでいると仮定します。このクラスには、サンプル画像のURLリストが含まれているとします。その場合、images.dartファイルは以下のように書けます。

class Images {
  static const List<String> imageThumbUrls = [
    'https://example.com/image1.jpg',
    'https://example.com/image2.jpg',
    'https://example.com/image3.jpg',
    // 他にもURLを追加できます
  ];
}

このファイルには、サンプル画像のURLリストを定義するために静的なimageThumbUrlsリストが含まれています。このリストには、各画像のURLが文字列として含まれています。また、このリストは、Example1ウィジェットで使用されるimageForIndexメソッドでインデックスに応じて呼び出されます。

回答をもとにめそこ壁紙のサムネイルURLを指定したimages.dartを作成。

lib/images.dart

class Images {
  static const List<String> imageThumbUrls = [
    'https://mesoko.jp/wp-content/uploads/2019/07/190711_Mesoko_Jul_eyecatch-260x260.png',
    'https://mesoko.jp/wp-content/uploads/2019/06/190610_Mesoko_Jun_eyecatch-260x260.png',
    'https://mesoko.jp/wp-content/uploads/2019/05/190507_Mesoko_May_eyecatch-260x260.png',
  ];
}

runコマンドを実行しmain.dartをiOS Simulaterで実行すると、きちんとExampleアプリを起動し動かすことができました。スティッキーヘッダーの動作が確認できています。

Example1クラスだけを使用した実装にする

Exmapleコードをそのまま用いると、Exmaple1から4までの画面が実装されます。 Image

Example2では、ヘッダー上のアイコンをクリックするとアクションが発生します。 Image

Example3では、ヘッダーが透過してグループ内のコンテンツと重なるようになります。 Image

Example4では、タブバー(scroll controller)内でスティッキーヘッダーを実装しています。 Image

sticky_headersパッケージのオプション設定による動作の差異が確認できるのは便利ですが、ここではExample1の画面だけを使用した実装に修正してみます。またChatGPTに尋ねてコードを教えてもらいます。

Example1の画面だけを実装した、runAppでアプリを実行できるコードに修正してください

すると以下の回答がもらえました。(ChatGPTで回答が長い時に回答途中で出力が止まってしまうことがよくあるのですが、回答が途中ですよのように尋ねると回答を再開してくれます。)

申し訳ありません、途中までの回答が投稿されてしまいました。

続きを投稿します。

import 'package:flutter/material.dart';
import 'package:sticky_headers/sticky_headers.dart';
import 'images.dart';

void main() {
  runApp(
    MaterialApp(
      title: 'Example1',
      home: Example1(),
    ),
  );
}

class Example1 extends StatelessWidget {
  const Example1({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Example1'),
      ),
      body: ListView.builder(
        itemBuilder: (BuildContext context, int index) {
          return StickyHeader(
            header: Container(
              height: 50.0,
              color: Colors.blueGrey[700],
              padding: EdgeInsets.symmetric(horizontal: 16.0),
              alignment: Alignment.centerLeft,
              child: Text(
                'Header #$index',
                style: TextStyle(color: Colors.white),
              ),
            ),
            content: Container(
              color: Colors.grey[300],
              child: Image.network(
                Images.imageThumbUrls[index % Images.imageThumbUrls.length],
                fit: BoxFit.cover,
                width: double.infinity,
                height: 200.0,
              ),
            ),
          );
        },
      ),
    );
  }
}

このコードでは、main関数でMaterialAppを定義し、Example1をホーム画面として指定しています。Example1では、Scaffoldウィジェットを使用して、AppBarとListViewを表示しています。ListView.builderを使用して、動的なリストアイテムを生成し、StickyHeaderウィジェットを使用してスティッキーヘッダーを実装しています。

また、images.dartファイルもインポートしていますので、正常に動作することが期待されます。

回答のコードでmain.dartの記述を置き換えてiOS Simulaterで実行すると、きちんと動かすことができました。コードの説明文も不自然な点は見受けられませんね。

ちなみにこの回答をもらうにあたり、はじめにExample1クラスのみで動くコードに修正してと尋ねたところ、main()の抜けたExample1クラスのみのコードが返ってきました。

私の方で回答のコードに手を加えれば動くには動くのですが、コピペで動くコードが欲しかったので、Example1の画面だけを実装した、runAppでアプリを実行できるコードに修正してくださいと尋ね直したところ、望んだコードの回答を得ることができました。

おわりに

ChatGPTに頼りながら、スティッキーヘッダーを利用したFlutterアプリのExampleを動かしてみました。

「深く考えずにとりあえず動くコードが欲しい」という場合にもChatGTPは難なく応えてくれます。便利ですね。

以上