
無限スクロール風の仕組みを作る
この節で学ぶこと
前回の 4-7 では、video_player を使って、DartPad上で動画を再生する方法を学びました。
動画を再生する流れは、次のようなものでした。
VideoPlayerControllerを作る
↓
initializeで動画を準備する
↓
playで再生する
↓
VideoPlayerで画面に表示する
↓
disposeで片付ける
今回の 4-8 では、TikTok風アプリらしく、動画がずっと続いているように見せる「無限スクロール風」の仕組みを作ります。
実際には、動画データが3本しかなくても大丈夫です。
3本の動画を、
PET
FOOD
ROOM
PET
FOOD
ROOM
PET
FOOD
ROOM
...
のように繰り返し表示することで、ずっと続いているように見せます。
この節で大切なのは、次の一文です。
無限スクロール風UIは、pageIndexを動画数で割った余りを使って、同じListを循環させることで作れる。
まず無限スクロール風とは何か
無限スクロール風とは、画面をスクロールしても終わりがないように見えるUIです。
TikTokやInstagramリールのようなショート動画アプリでは、次々に動画が出てきます。
ユーザーから見ると、動画がずっと続いているように感じます。
1本目
↓
2本目
↓
3本目
↓
4本目
↓
5本目
↓
...
しかし、今回の教材では、実際に何百本もの動画を用意するわけではありません。
まずは3本だけ用意します。
videos
├─ videos[0] PET
├─ videos[1] FOOD
└─ videos[2] ROOM
この3本を繰り返し表示します。
PET → FOOD → ROOM → PET → FOOD → ROOM
これにより、無限に続いているような体験を作れます。
前回までのPageView.builder
前回までの PageView.builder は、次のような形でした。
PageView.builder(
scrollDirection: Axis.vertical,
itemCount: videos.length,
itemBuilder: (context, index) {
final video = videos[index];
return ShortVideoPage(
video: video,
index: index,
);
},
)
ここで itemCount: videos.length を指定しています。
videos.length が3なら、ページは3枚だけです。
index 0 → videos[0]
index 1 → videos[1]
index 2 → videos[2]
3ページ目まで行くと、それ以上はスクロールできません。
TikTok風にずっと続くように見せたい場合は、少し工夫が必要です。
無限スクロール風にする考え方
無限スクロール風にするには、PageView.builder の itemCount を指定しない方法があります。
PageView.builder(
scrollDirection: Axis.vertical,
itemBuilder: (context, pageIndex) {
...
},
)
itemCount を書かないと、PageView.builder は必要に応じてページを作り続けます。
ただし、動画データは3本しかありません。
そこで、pageIndex をそのまま使うのではなく、動画データの番号に変換します。
そのときに使うのが % です。
final videoIndex = pageIndex % videos.length;
新しい言葉:%とは何か
% は、割った余りを求める演算子です。
たとえば、次のようになります。
| 式 | 結果 | 理由 |
|---|---|---|
0 % 3 | 0 | 0を3で割った余り |
1 % 3 | 1 | 1を3で割った余り |
2 % 3 | 2 | 2を3で割った余り |
3 % 3 | 0 | 3を3で割ると余り0 |
4 % 3 | 1 | 4を3で割ると余り1 |
5 % 3 | 2 | 5を3で割ると余り2 |
6 % 3 | 0 | 6を3で割ると余り0 |
つまり、pageIndex % 3 は、次のように繰り返されます。
0, 1, 2, 0, 1, 2, 0, 1, 2...
これを動画の番号として使えば、3本の動画を循環できます。
pageIndexとvideoIndexの違い
ここで、2つの番号を分けて考えます。
| 名前 | 意味 |
|---|---|
pageIndex | PageViewが作るページ番号 |
videoIndex | videosから取り出す動画番号 |
pageIndex は、スクロールするたびに増えていきます。
pageIndex
0, 1, 2, 3, 4, 5, 6, 7...
一方、videoIndex は、動画Listの範囲に収める番号です。
videoIndex
0, 1, 2, 0, 1, 2, 0, 1...
コードでは、次のように変換します。
final videoIndex = pageIndex % videos.length;
これで、pageIndex がどれだけ大きくなっても、videoIndex は 0、1、2 のどれかになります。
図で理解する
動画が3本ある場合、対応は次のようになります。
pageIndex 0 → videoIndex 0 → PET
pageIndex 1 → videoIndex 1 → FOOD
pageIndex 2 → videoIndex 2 → ROOM
pageIndex 3 → videoIndex 0 → PET
pageIndex 4 → videoIndex 1 → FOOD
pageIndex 5 → videoIndex 2 → ROOM
pageIndex 6 → videoIndex 0 → PET
つまり、ページはどんどん進みますが、表示する動画データは3本の中で循環します。
これが、無限スクロール風の基本です。
まずは動画なしで無限スクロール風を確認する
いきなり動画再生と組み合わせると難しくなります。
まずは、動画なしで pageIndex と videoIndex の関係を確認しましょう。

DartPadに次のコードを貼り付けてください。
import 'package:flutter/material.dart';
void main() {
runApp(const InfinitePageViewPracticeApp());
}
class InfinitePageViewPracticeApp extends StatelessWidget {
const InfinitePageViewPracticeApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: InfinitePageViewPracticePage(),
);
}
}
class ShortVideo {
const ShortVideo({
required this.userName,
required this.caption,
required this.categoryLabel,
required this.color,
});
final String userName;
final String caption;
final String categoryLabel;
final Color color;
}
const videos = [
ShortVideo(
userName: 'pet_cafe_diary',
caption: '小さな命の動きは、見ているだけで少しやさしい気持ちになる。',
categoryLabel: 'PET',
color: Color(0xFFE91E63),
),
ShortVideo(
userName: 'food_and_nature',
caption: 'おいしいものを探す旅の途中で出会った、自然の小さなリズム。',
categoryLabel: 'FOOD',
color: Color(0xFF2196F3),
),
ShortVideo(
userName: 'daily_pet_room',
caption: 'ペットと過ごす午後。何気ない一瞬が、あとから思い出になる。',
categoryLabel: 'ROOM',
color: Color(0xFFFF9800),
),
];
class InfinitePageViewPracticePage extends StatelessWidget {
const InfinitePageViewPracticePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: PageView.builder(
scrollDirection: Axis.vertical,
itemBuilder: (context, pageIndex) {
final videoIndex = pageIndex % videos.length;
final video = videos[videoIndex];
return ShortVideoMockPage(
video: video,
pageIndex: pageIndex,
videoIndex: videoIndex,
);
},
),
);
}
}
class ShortVideoMockPage extends StatelessWidget {
const ShortVideoMockPage({
super.key,
required this.video,
required this.pageIndex,
required this.videoIndex,
});
final ShortVideo video;
final int pageIndex;
final int videoIndex;
@override
Widget build(BuildContext context) {
return Container(
color: Colors.black,
child: Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
video.color,
Colors.black,
],
),
),
),
),
Center(
child: Container(
width: 250,
height: 360,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.12),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: Colors.white.withOpacity(0.24),
),
),
child: Center(
child: Text(
video.categoryLabel,
style: const TextStyle(
color: Colors.white,
fontSize: 38,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
),
),
),
Positioned(
left: 16,
top: 56,
child: SafeArea(
child: Text(
'pageIndex: $pageIndex / videoIndex: $videoIndex',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
),
),
Positioned(
left: 16,
right: 80,
bottom: 36,
child: SafeArea(
top: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'@${video.userName}',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
video.caption,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
height: 1.4,
),
),
],
),
),
),
],
),
);
}
}
実行して確認すること
上下にスワイプしてみてください。
表示は次のように繰り返されます。
PET
↓
FOOD
↓
ROOM
↓
PET
↓
FOOD
↓
ROOM
画面の左上に、次のような表示があります。
pageIndex: 3 / videoIndex: 0
これは、ページ番号は3だけれど、動画番号は0になっているという意味です。
pageIndex % videos.length によって、動画番号が循環していることを確認できます。
itemCountを書かない理由
今回のコードでは、PageView.builder に itemCount を書いていません。
PageView.builder(
scrollDirection: Axis.vertical,
itemBuilder: (context, pageIndex) {
...
},
)
前回は、次のように書いていました。
itemCount: videos.length,
itemCount を書くと、動画の本数分だけページを作ります。
動画が3本なら3ページだけです。
一方、itemCount を書かないと、PageView.builder は必要なページを作り続けます。
itemCountあり
↓
指定した数で止まる
itemCountなし
↓
ページを作り続ける
そのため、無限スクロール風にできます。
ただし本当に無限ではない
ここで注意があります。
この実装は、厳密には本当に無限ではありません。
PageView.builder がページを作り続けるように見せ、動画データを循環させているだけです。
本当に無限に動画データがある
わけではない
3本の動画を繰り返して
無限に見せている
教材では、このような表現を「無限スクロール風」と呼びます。
上方向にもスクロールできるようにする
ここまでのコードでは、最初のページが pageIndex 0 です。
下方向には進めますが、最初の状態から上方向にはあまり戻れません。
TikTok風に「上下どちらにも続いている」ように見せたい場合は、大きなページ番号から始める方法があります。
final PageController pageController = PageController(
initialPage: videos.length * 1000,
);
たとえば、動画が3本なら、
videos.length * 1000 = 3000
3000ページ目から始めます。
すると、上にも下にもたくさんスクロールできるように見えます。
PageControllerとは何か
PageController は、PageView を操作・管理するためのControllerです。
final PageController pageController = PageController(
initialPage: 3000,
);
初心者向けには、次のように理解してください。
PageController = PageViewの位置を管理するもの
initialPage を指定すると、最初に表示するページ番号を決められます。
initialPage: videos.length * 1000,
これにより、最初からかなり進んだページにいることになります。
currentPageIndexを持つ理由
最終アプリでは、今どのページを見ているかを知る必要があります。
そのために、currentPageIndex を持ちます。
late int currentPageIndex;
そして、今見ている動画番号を計算します。
int get currentVideoIndex {
return currentPageIndex % videos.length;
}
これは、次の意味です。
現在のページ番号を、
動画数で割った余りにして、
現在の動画番号として使う
StatefulWidgetが必要になる理由
currentPageIndex は、スワイプするたびに変わります。
変わる値を持つので、StatefulWidget が必要です。
currentPageIndex
↓
スワイプで変わる
↓
画面の状態
↓
StatefulWidgetで管理する
前半の練習では StatelessWidget で作りましたが、ページ位置を管理するなら StatefulWidget にします。
上下に続いて見える無限スクロール風コード
次に、PageController と currentPageIndex を使った形を確認します。
DartPadに次のコードを貼り付けてください。

import 'package:flutter/material.dart';
void main() {
runApp(const InfiniteLoopPageViewApp());
}
class InfiniteLoopPageViewApp extends StatelessWidget {
const InfiniteLoopPageViewApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: InfiniteLoopPageViewPage(),
);
}
}
class ShortVideo {
const ShortVideo({
required this.userName,
required this.caption,
required this.categoryLabel,
required this.color,
});
final String userName;
final String caption;
final String categoryLabel;
final Color color;
}
const videos = [
ShortVideo(
userName: 'pet_cafe_diary',
caption: '小さな命の動きは、見ているだけで少しやさしい気持ちになる。',
categoryLabel: 'PET',
color: Color(0xFFE91E63),
),
ShortVideo(
userName: 'food_and_nature',
caption: 'おいしいものを探す旅の途中で出会った、自然の小さなリズム。',
categoryLabel: 'FOOD',
color: Color(0xFF2196F3),
),
ShortVideo(
userName: 'daily_pet_room',
caption: 'ペットと過ごす午後。何気ない一瞬が、あとから思い出になる。',
categoryLabel: 'ROOM',
color: Color(0xFFFF9800),
),
];
class InfiniteLoopPageViewPage extends StatefulWidget {
const InfiniteLoopPageViewPage({super.key});
@override
State<InfiniteLoopPageViewPage> createState() =>
_InfiniteLoopPageViewPageState();
}
class _InfiniteLoopPageViewPageState extends State<InfiniteLoopPageViewPage> {
late final PageController pageController;
late int currentPageIndex;
int get currentVideoIndex {
return currentPageIndex % videos.length;
}
@override
void initState() {
super.initState();
currentPageIndex = videos.length * 1000;
pageController = PageController(
initialPage: currentPageIndex,
);
}
@override
void dispose() {
pageController.dispose();
super.dispose();
}
void onPageChanged(int pageIndex) {
setState(() {
currentPageIndex = pageIndex;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: PageView.builder(
controller: pageController,
scrollDirection: Axis.vertical,
onPageChanged: onPageChanged,
itemBuilder: (context, pageIndex) {
final videoIndex = pageIndex % videos.length;
final video = videos[videoIndex];
return ShortVideoMockPage(
video: video,
pageIndex: pageIndex,
videoIndex: videoIndex,
currentVideoIndex: currentVideoIndex,
);
},
),
);
}
}
class ShortVideoMockPage extends StatelessWidget {
const ShortVideoMockPage({
super.key,
required this.video,
required this.pageIndex,
required this.videoIndex,
required this.currentVideoIndex,
});
final ShortVideo video;
final int pageIndex;
final int videoIndex;
final int currentVideoIndex;
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
video.color,
Colors.black,
],
),
),
),
),
Center(
child: Container(
width: 250,
height: 360,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.12),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: Colors.white.withOpacity(0.24),
),
),
child: Center(
child: Text(
video.categoryLabel,
style: const TextStyle(
color: Colors.white,
fontSize: 38,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
),
),
),
Positioned(
top: 56,
left: 16,
child: SafeArea(
child: Text(
'pageIndex: $pageIndex / videoIndex: $videoIndex',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
),
),
Positioned(
right: 16,
top: 56,
child: SafeArea(
child: Text(
'current: ${currentVideoIndex + 1} / ${videos.length}',
style: TextStyle(
color: Colors.white.withOpacity(0.75),
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
),
),
Positioned(
left: 16,
right: 80,
bottom: 36,
child: SafeArea(
top: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'@${video.userName}',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
video.caption,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
height: 1.4,
),
),
],
),
),
),
],
);
}
}
実行して確認すること
このコードを実行すると、最初から pageIndex が3000付近になります。
画面には、次のような表示が出ます。
pageIndex: 3000 / videoIndex: 0
上にも下にもスワイプできます。
表示される動画データは、次のように循環します。
PET
FOOD
ROOM
PET
FOOD
ROOM
これで、3本の動画が無限に続いているように見えます。
最終アプリとのつながり
最終的なTikTok風アプリでは、動画を本当に再生します。
そのときも、無限ループ風の考え方は同じです。
final videoIndex = pageIndex % videos.length;
この videoIndex を使って、動画データと動画controllerを取り出します。
video: videos[videoIndex],
controller: controllers[videoIndex],
つまり、最終アプリでは次のような流れになります。
pageIndex
↓
% videos.length
↓
videoIndex
↓
videos[videoIndex]
↓
controllers[videoIndex]
↓
画面表示・動画再生
動画再生と組み合わせるときの注意
動画再生と無限スクロールを組み合わせる場合、ページが変わったときに次の処理が必要になります。
前の動画を停止する
↓
次の動画を最初に戻す
↓
次の動画を再生する
コードでは、最終的に次のような形になります。
void onPageChanged(int pageIndex) {
final previousVideoIndex = currentVideoIndex;
final nextVideoIndex = pageIndex % videos.length;
controllers[previousVideoIndex].pause();
setState(() {
currentPageIndex = pageIndex;
});
controllers[nextVideoIndex]
..seekTo(Duration.zero)
..play();
}
この処理は、後の完成コードで使います。
ここでは、まず流れだけ理解しておけば大丈夫です。
seekTo(Duration.zero)とは何か
seekTo(Duration.zero) は、動画の再生位置を最初に戻す処理です。
controllers[nextVideoIndex].seekTo(Duration.zero);
Duration.zero は、0秒という意味です。
つまり、次の意味になります。
次の動画を0秒の位置に戻す
TikTok風アプリでは、次の動画に切り替わったとき、最初から再生されるほうが自然です。
そのため、seekTo(Duration.zero) を使います。
手を動かす練習1:動画の数を増やす
videos に4本目を追加してみましょう。
ShortVideo(
userName: 'sweet_table',
caption: '甘いものを囲む時間は、少しだけ日常をやわらかくしてくれる。',
categoryLabel: 'SWEETS',
color: Color(0xFF9C27B0),
),
追加すると、循環が次のようになります。
PET
FOOD
ROOM
SWEETS
PET
FOOD
ROOM
SWEETS
videos.length が4になるため、pageIndex % videos.length の結果も 0,1,2,3 で循環します。
手を動かす練習2:initialPageを変える
次の部分を探してください。
currentPageIndex = videos.length * 1000;
これを次のように変えてみます。
currentPageIndex = videos.length * 10;
開始ページ番号が小さくなります。
さらに、次のようにすると、最初から0ページ目になります。
currentPageIndex = 0;
ただし、0から始めると、上方向には戻りにくくなります。
TikTok風に上下どちらにも続いているように見せたい場合は、大きな値から始めるほうが自然です。
手を動かす練習3:videoIndexの表示を観察する
スワイプしながら、左上の表示を見てください。
pageIndex: 3000 / videoIndex: 0
pageIndex: 3001 / videoIndex: 1
pageIndex: 3002 / videoIndex: 2
pageIndex: 3003 / videoIndex: 0
pageIndex は増えているのに、videoIndex は 0,1,2 を繰り返しています。
これが % の役割です。
手を動かす練習4:%を使わないとどうなるか考える
次のコードがあるとします。
final video = videos[pageIndex];
もし pageIndex が3000なら、どうなるでしょうか。
videos[3000] を取り出そうとしてしまいます。
しかし、動画は3本しかありません。
そのため、エラーになります。
正しくは、次のようにします。
final videoIndex = pageIndex % videos.length;
final video = videos[videoIndex];
% を使うことで、動画Listの範囲内に収められます。
よくあるつまずき1:%の意味が分からない
% は、割り算の余りです。
無限スクロール風では、次のように考えると分かりやすいです。
ページ番号はどんどん増える
でも動画は3本しかない
だから、3で割った余りを使う
余りは0,1,2のどれかになる
これで、videos[0]、videos[1]、videos[2] の範囲に収まります。
よくあるつまずき2:itemCountを書いてしまう
無限スクロール風にしたい場合は、itemCount を書きません。
PageView.builder(
scrollDirection: Axis.vertical,
itemBuilder: ...
)
itemCount: videos.length を書くと、動画の本数分で止まります。
学習段階では、次のように整理してください。
| 作りたいもの | itemCount |
|---|---|
| 動画数だけ表示 | 書く |
| 無限スクロール風 | 書かない |
よくあるつまずき3:上に戻れない
initialPage を0にすると、最初の状態から上方向には戻れません。
TikTok風に上下どちらにも続いているように見せたい場合は、大きいページ番号から始めます。
currentPageIndex = videos.length * 1000;
これは、実際に動画が1000周分あるわけではありません。
最初の位置を大きくしているだけです。
よくあるつまずき4:currentPageIndexを更新し忘れる
ページが変わったときには、currentPageIndex を更新します。
void onPageChanged(int pageIndex) {
setState(() {
currentPageIndex = pageIndex;
});
}
これを忘れると、今どのページにいるかを正しく管理できません。
動画再生と組み合わせるときには、前の動画を止める処理にも関わります。
よくあるつまずき5:PageControllerをdisposeし忘れる
PageController を作ったら、使い終わったときに片付けます。
@override
void dispose() {
pageController.dispose();
super.dispose();
}
Controllerを使ったら、dispose で片付ける。
これはFlutterで大切な習慣です。
この節の確認問題
確認問題1
無限スクロール風とは何ですか。
答え
スクロールしても終わりがないように見えるUIです。
今回の教材では、少数の動画データを繰り返し表示することで、無限に続いているように見せています。
確認問題2
% は何をする演算子ですか。
答え
割った余りを求める演算子です。
たとえば、4 % 3 は 1 です。
確認問題3
final videoIndex = pageIndex % videos.length; は何をしていますか。
答え
ページ番号を動画Listの範囲に収めるために、動画数で割った余りを求めています。
これにより、videoIndex が 0, 1, 2, 0, 1, 2... のように循環します。
確認問題4
無限スクロール風にしたい場合、PageView.builder の itemCount は書きますか。
答え
基本的には書きません。
itemCount を書くと、その数でページが止まります。
確認問題5
PageController は何のために使いますか。
答え
PageView の位置を管理するために使います。
initialPage を指定すると、最初に表示するページ番号を決められます。
確認問題6
なぜ initialPage に大きい数字を指定するのですか。
答え
最初の画面から上方向にも下方向にもたくさんスクロールできるように見せるためです。
この節のまとめ
この節では、TikTok風アプリに欠かせない「無限スクロール風」の仕組みを学びました。
動画データが3本しかなくても、pageIndex % videos.length を使うことで、動画を循環させることができます。
final videoIndex = pageIndex % videos.length;
final video = videos[videoIndex];
この仕組みにより、表示は次のように繰り返されます。
PET
FOOD
ROOM
PET
FOOD
ROOM
...
また、PageController の initialPage に大きな値を指定することで、上下どちらにも続いているように見せられます。
currentPageIndex = videos.length * 1000;
pageController = PageController(
initialPage: currentPageIndex,
);
この節で一番大切なのは、次の一文です。
少ないデータでも、%を使ってListを循環させれば、無限スクロール風のUIを作れる。
次の節では、右側に並ぶいいね・コメント・保存・共有ボタンを、RightActionBar として部品化していきます。