Slackっぽい無限スクロールUIの作り方

1.9K Views

June 16, 25

スライド概要

Slackっぽい無限スクロールUIの作り方について説明したスライドです。
SODA Flutter Talk #2に登壇した際の資料共有です。
https://soda-inc.connpass.com/event/352830/

profile-image

Flutterエンジニア

シェア

またはPlayer版

埋め込む »CMSなどでJSが使えない場合

ダウンロード

関連スライド

各ページのテキスト
1.

Slackっぽい 無限スクロールUIの作り方 株式会社SODA 倉橋鉄平

2.

メモアプリ:Lazy Note ● ● ● 個人開発中のメモアプリ SlackやDiscordのようなタイムライン形式の表示 iOS / Android / Windows / MacOS に対応 ○ PC向けはWebビルドでPWA化しています

3.

Slackっぽい無限スクロールとは ● ● ● スクロールすると無限にアイテムが読み込まれていく 他のクライアントの変更がリアルタイムに反映 される 日付を指定 してジャンプできる

4.

前提 ● データはFirebase Realtime Databaseに保存 ○ RTDBの同期機能から Streamで最新のデータを取得

5.

無限スクロール

6.

無限スクロールWidgetの選定 ● ● 使用するWidget ○ CustomScrollView (Flutter標準に付属) ○ PagedSliverList (infinite_scroll_pagination) PagedSliverListとは? ○ スクロールが端まで到達した時に次のページの読み込み処理を走らせることができる Sliver

7.
[beta]
無限スクロール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)),
),
],
);

8.

リアルタイム同期

9.

リアルタイム同期 ● ● 同期データはRTDBから取得できる しかし、watchできる範囲は有限 タイムライン全体 watchしている範囲 →スクロールに応じてwatchする範囲を切り替え る 画面に写っている範囲

10.

リアルタイム同期(順方向にスクロールした時) スクロールに従ってwatchする範 囲を更新

11.
[beta]
リアルタイム同期(順方向にスクロールした時)
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 の機能で普通
に実現できる

12.

リアルタイム同期(戻る方向にスクロールした時) ある程度スクロールしてから戻っ てくるパターン

13.
[beta]
リアルタイム同期(戻る方向にスクロールした時)
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はない
→独自で実装する必要あり

14.
[beta]
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に収
まっているかどうかを判定

15.
[beta]
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に収
まっているかどうかを判定

16.

ViewPortとは タイムライン全体 ViewPort Scrollableの中で画面に表示されている部分 を示す watchしている範囲 画面に写っている範囲

17.
[beta]
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する

18.

日付ジャンプ

19.

日付ジャンプ機能 カレンダーで日付を指定するとその日付の 投稿までジャンプする機能 日付を指定 その日付のメモま でジャンプ

20.

日付ジャンプ機能 タイムライン全体 画面に写っている範囲 Height 未計算 Sliverの仕組みとして、未表示のアイ テムのHeightは計算されない スクロール ➔ ➔ Height 計算済み center いきなりN番目のアイテムに飛 ぶのは不可能 どうすればいいのか?

21.

日付ジャンプ機能 過去側のタイムライン 未来側のタイムライン 画面に写っている範囲 center Sliverを過去側と未来側で2個用意する ジャンプ先 のアイテム centerを指定した日付のWidgetにする

22.
[beta]
日付ジャンプ機能(実際のコード)
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(未来側)
という構造

23.

日付ジャンプ機能(完成) 0:13~

24.

まとめ Slackっぽい無限スクロールの実現方法 ● 無限スクロールウィジェットの構成 ○ ● リアルタイム同期 ○ ● CustomScrollView + PagedSliverList VisibilityAwareWidgetを50アイテムごとに仕込んで watchする範囲を更新 日付ジャンプ ○ PagedSliverListを2個くっつけて間を centerに設定

25.

おまけ SlackっぽいTextFieldを実装する記事もあるのでぜ ひご覧ください 【Flutter】SlackアプリっぽいTextFieldの作り方 https://zenn.dev/tp113/articles/7941f8ddd0b008

26.

宣伝 LazyNoteのβテスターを募集中です! ご興味あればぜひ!