こんにちは、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までの画面が実装されます。
Example2では、ヘッダー上のアイコンをクリックするとアクションが発生します。
Example3では、ヘッダーが透過してグループ内のコンテンツと重なるようになります。
Example4では、タブバー(scroll controller)内でスティッキーヘッダーを実装しています。
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は難なく応えてくれます。便利ですね。
以上