1.9K Views
June 16, 25
スライド概要
Slackっぽい無限スクロールUIの作り方について説明したスライドです。
SODA Flutter Talk #2に登壇した際の資料共有です。
https://soda-inc.connpass.com/event/352830/
Slackっぽい 無限スクロールUIの作り方 株式会社SODA 倉橋鉄平
メモアプリ:Lazy Note ● ● ● 個人開発中のメモアプリ SlackやDiscordのようなタイムライン形式の表示 iOS / Android / Windows / MacOS に対応 ○ PC向けはWebビルドでPWA化しています
Slackっぽい無限スクロールとは ● ● ● スクロールすると無限にアイテムが読み込まれていく 他のクライアントの変更がリアルタイムに反映 される 日付を指定 してジャンプできる
前提 ● データはFirebase Realtime Databaseに保存 ○ RTDBの同期機能から Streamで最新のデータを取得
無限スクロール
無限スクロールWidgetの選定 ● ● 使用するWidget ○ CustomScrollView (Flutter標準に付属) ○ PagedSliverList (infinite_scroll_pagination) PagedSliverListとは? ○ スクロールが端まで到達した時に次のページの読み込み処理を走らせることができる Sliver
無限スクロールWidgetの構成
return CustomScrollView(
center: _centerKey,
primary: true,
reverse: true,
slivers: [
if (center != null) ...[
SliverToBoxAdapter(
●
child: SizedBox( height: 48),
CustomScrollViewの中に各
),
if (_pagingNewerController.itemList?.isNotEmpty ?? false)
PagedSliverList(
Sliverを配置
pagingController: _pagingNewerController,
builderDelegate: PagedChildBuilderDelegate<TimelineUIElement>(
itemBuilder: (_, item, index) => TimelineItemWidget(item: item)),
●
日付ジャンプのために2つの
),
],
SliverToBoxAdapter(
key: _centerKey,
PagedSliverListを使用
child: SizedBox(height: center == null ? 48 + widget.bottomSpace : 0),
),
○
if (_pagingOlderController.itemList?.isNotEmpty ?? false)
※詳しくは後述します!
PagedSliverList(
pagingController: _pagingOlderController,
builderDelegate: PagedChildBuilderDelegate<TimelineUIElement>(
itemBuilder: (_, item, index) => TimelineItemWidget(item: item)),
),
],
);
リアルタイム同期
リアルタイム同期 ● ● 同期データはRTDBから取得できる しかし、watchできる範囲は有限 タイムライン全体 watchしている範囲 →スクロールに応じてwatchする範囲を切り替え る 画面に写っている範囲
リアルタイム同期(順方向にスクロールした時) スクロールに従ってwatchする範 囲を更新
リアルタイム同期(順方向にスクロールした時)
class ListViewScreen extends StatefulWidget {
const ListViewScreen({super.key});
@override
State<ListViewScreen> createState() => _ListViewScreenState();
}
class _ListViewScreenState extends State<ListViewScreen> {
late final _pagingController = PagingController<int, Photo>(
getNextPageKey: (state) => (state.keys?.last ?? 0) + 1,
fetchPage: (pageKey) => database.watch(pageKey), //ここでFirebaseのwatch範囲を更新
);
@override
void dispose() {
_pagingController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => PagingListener(
controller: _pagingController,
builder: (context, state, fetchNextPage) => PagedListView<int, Photo>(
state: state,
fetchNextPage: fetchNextPage,
builderDelegate: PagedChildBuilderDelegate(
itemBuilder: (context, item, index) => ImageListTile(item: item),
),
),
);
}
infinite_scroll_pagination の機能で普通
に実現できる
リアルタイム同期(戻る方向にスクロールした時) ある程度スクロールしてから戻っ てくるパターン
リアルタイム同期(戻る方向にスクロールした時)
class ListViewScreen extends StatefulWidget {
const ListViewScreen({super.key});
@override
State<ListViewScreen> createState() => _ListViewScreenState();
}
class _ListViewScreenState extends State<ListViewScreen> {
late final _pagingController = PagingController<int, Photo>(
getNextPageKey: (state) => (state.keys?.last ?? 0) + 1,
fetchPage: (pageKey) => database.watch(pageKey), //ここでFirebaseのwatch範囲を更新
);
@override
void dispose() {
_pagingController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => PagingListener(
controller: _pagingController,
builder: (context, state, fetchNextPage) => PagedListView<int, Photo>(
state: state,
fetchNextPage: fetchNextPage,
builderDelegate: PagedChildBuilderDelegate(
itemBuilder: (context, item, index) => ImageListTile(item: item),
),
),
);
}
infinite_scroll_pagination には、要素の位
置を返すAPIはない
→独自で実装する必要あり
VisibilityAwareWidget
class VisibilityAwareWidget extends StatefulWidget {
const VisibilityAwareWidget({
required this.child,
required this.onVisibilityChanged,
required this.scrollController,
super.key,
});
void _updateVisibility() {
final RenderBox? renderBox =
_key.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null) return;
final RenderBox? viewport =
Scrollable.maybeOf(context)?.context.findRenderObject() as RenderBox?;
if (viewport == null) return;
final Widget child;
final void Function(bool isVisible) onVisibilityChanged;
final ScrollController scrollController;
final offset = renderBox.localToGlobal(Offset.zero);
final viewportOffset = viewport.localToGlobal(Offset.zero);
final viewportHeight = viewport.size.height;
@override
State<VisibilityAwareWidget> createState() => _VisibilityAwareWidgetState();
final itemHeight = renderBox.size.height;
final itemTop = offset.dy;
final itemBottom = itemTop + itemHeight;
class _VisibilityAwareWidgetState extends State<VisibilityAwareWidget> {
final _key = GlobalKey();
bool _isVisible = false;
final isVisible = itemBottom > viewportOffset.dy &&
itemTop < (viewportOffset.dy + viewportHeight);
}
if (_isVisible != isVisible) {
_isVisible = isVisible;
widget.onVisibilityChanged(isVisible);
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_updateVisibility();
widget.scrollController.addListener(() {
_updateVisibility();
});
});
}
}
@override
Widget build(BuildContext context) {
return Container(
key: _key,
child: widget.child,
);
}
}
WidgetのRenderBoxがViewPortに収
まっているかどうかを判定
if (renderBox == null) return;
final RenderBox? viewport =
Scrollable.maybeOf(context)?.context.findRenderObject() as RenderBox?;
if (viewport == null) return;
final offset = renderBox.localToGlobal(Offset.zero);
final viewportOffset = viewport.localToGlobal(Offset.zero);
final viewportHeight = viewport.size.height;
final itemHeight = renderBox.size.height;
final itemTop = offset.dy;
final itemBottom = itemTop + itemHeight;
final isVisible = itemBottom > viewportOffset.dy &&
itemTop < (viewportOffset.dy + viewportHeight);
if (_isVisible != isVisible) {
_isVisible = isVisible;
widget.onVisibilityChanged(isVisible);
}
}
@override
Widget build(BuildContext context) {
return Container(
key: _key,
child: widget.child,
);
WidgetのRenderBoxがViewPortに収
まっているかどうかを判定
ViewPortとは タイムライン全体 ViewPort Scrollableの中で画面に表示されている部分 を示す watchしている範囲 画面に写っている範囲
VisibilityAwareWidgetの使い方
PagedSliverList(
pagingController: _pagingNewerController,
builderDelegate: PagedChildBuilderDelegate<TimelineUIElement>(
itemBuilder: (_, item, index) => index % 50 == 0
? VisibilityAwareWidget(
scrollController: PrimaryScrollController.of(context),
onVisibilityChanged: (isVisible) {
if (isVisible) {
final repository =
投稿50個ごとにVisibilityAwareWidgetを仕
込む
ref.read(timelineRepositoryProvider);
repository.value
?.listenAround(item.createdDateTime); //watch範囲を更新
}
},
child: TimelineItemWidget(item: item),
)
: TimelineItemWidget(item: item)),
),
その投稿が画面に表示されていたら、その
Widgetを中心に前後100個をwatchする
日付ジャンプ
日付ジャンプ機能 カレンダーで日付を指定するとその日付の 投稿までジャンプする機能 日付を指定 その日付のメモま でジャンプ
日付ジャンプ機能 タイムライン全体 画面に写っている範囲 Height 未計算 Sliverの仕組みとして、未表示のアイ テムのHeightは計算されない スクロール ➔ ➔ Height 計算済み center いきなりN番目のアイテムに飛 ぶのは不可能 どうすればいいのか?
日付ジャンプ機能 過去側のタイムライン 未来側のタイムライン 画面に写っている範囲 center Sliverを過去側と未来側で2個用意する ジャンプ先 のアイテム centerを指定した日付のWidgetにする
日付ジャンプ機能(実際のコード)
CustomScrollView(
center: _centerKey,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
cacheExtent: RenderAbstractViewport.defaultCacheExtent * 4,
primary: true,
reverse: true,
slivers: [
PagedSliverList(
pagingController: _pagingNewerController,
builderDelegate: PagedChildBuilderDelegate<TimelineUIElement>(
itemBuilder: (_, item, index) => index % 50 == 0
? VisibilityAwareWidget(
scrollController: PrimaryScrollController.of(context),
onVisibilityChanged: (isVisible) {
if (isVisible) {
final repository =
ref.read(timelineRepositoryProvider);
repository.value
?.listenAround(item.createdDateTime);
}
},
child: TimelineItemWidget(item: item),
)
: TimelineItemWidget(item: item)),
),
SliverToBoxAdapter(
key: _centerKey,
child: SizedBox(
height: center == null ? 48 + widget.bottomSpace : 0,
),
),
PagedSliverList(
pagingController: _pagingOlderController,
builderDelegate: PagedChildBuilderDelegate<TimelineUIElement>(
itemBuilder: (_, item, index) => index % 50 == 0
? VisibilityAwareWidget(
scrollController: PrimaryScrollController.of(context),
onVisibilityChanged: (isVisible) {
if (isVisible) {
final repository =
ref.read(timelineRepositoryProvider);
repository.value
?.listenAround(item.createdDateTime);
}
},
child: TimelineItemWidget(item: item),
)
: TimelineItemWidget(item: item)),
),
],
)
PagedSliverList(過去側)
SliverToBoxAdapter (centerとして設定)
PagedSliverList(未来側)
という構造
日付ジャンプ機能(完成) 0:13~
まとめ Slackっぽい無限スクロールの実現方法 ● 無限スクロールウィジェットの構成 ○ ● リアルタイム同期 ○ ● CustomScrollView + PagedSliverList VisibilityAwareWidgetを50アイテムごとに仕込んで watchする範囲を更新 日付ジャンプ ○ PagedSliverListを2個くっつけて間を centerに設定
おまけ SlackっぽいTextFieldを実装する記事もあるのでぜ ひご覧ください 【Flutter】SlackアプリっぽいTextFieldの作り方 https://zenn.dev/tp113/articles/7941f8ddd0b008
宣伝 LazyNoteのβテスターを募集中です! ご興味あればぜひ!