Flutterアプリケーション開発概論

無限スクロール風の仕組みを作る

この節で学ぶこと

前回の 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.builderitemCount を指定しない方法があります。

PageView.builder(
  scrollDirection: Axis.vertical,
  itemBuilder: (context, pageIndex) {
    ...
  },
)

itemCount を書かないと、PageView.builder は必要に応じてページを作り続けます。

ただし、動画データは3本しかありません。

そこで、pageIndex をそのまま使うのではなく、動画データの番号に変換します。

そのときに使うのが % です。

final videoIndex = pageIndex % videos.length;

新しい言葉:%とは何か

% は、割った余りを求める演算子です。

たとえば、次のようになります。

結果理由
0 % 300を3で割った余り
1 % 311を3で割った余り
2 % 322を3で割った余り
3 % 303を3で割ると余り0
4 % 314を3で割ると余り1
5 % 325を3で割ると余り2
6 % 306を3で割ると余り0

つまり、pageIndex % 3 は、次のように繰り返されます。

0, 1, 2, 0, 1, 2, 0, 1, 2...

これを動画の番号として使えば、3本の動画を循環できます。

pageIndexとvideoIndexの違い

ここで、2つの番号を分けて考えます。

名前意味
pageIndexPageViewが作るページ番号
videoIndexvideosから取り出す動画番号

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 がどれだけ大きくなっても、videoIndex012 のどれかになります。

図で理解する

動画が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本の中で循環します。

これが、無限スクロール風の基本です。

まずは動画なしで無限スクロール風を確認する

いきなり動画再生と組み合わせると難しくなります。

まずは、動画なしで pageIndexvideoIndex の関係を確認しましょう。

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.builderitemCount を書いていません。

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 にします。

上下に続いて見える無限スクロール風コード

次に、PageControllercurrentPageIndex を使った形を確認します。

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 は増えているのに、videoIndex0,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 % 31 です。

確認問題3

final videoIndex = pageIndex % videos.length; は何をしていますか。

答え

ページ番号を動画Listの範囲に収めるために、動画数で割った余りを求めています。

これにより、videoIndex0, 1, 2, 0, 1, 2... のように循環します。

確認問題4

無限スクロール風にしたい場合、PageView.builderitemCount は書きますか。

答え

基本的には書きません。

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
...

また、PageControllerinitialPage に大きな値を指定することで、上下どちらにも続いているように見せられます。

currentPageIndex = videos.length * 1000;

pageController = PageController(
  initialPage: currentPageIndex,
);

この節で一番大切なのは、次の一文です。

少ないデータでも、%を使ってListを循環させれば、無限スクロール風のUIを作れる。

次の節では、右側に並ぶいいね・コメント・保存・共有ボタンを、RightActionBar として部品化していきます。

教材トップへ戻る