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

PageView.builderで縦スワイプUIを作る

この節で学ぶこと

前回の 4-5 では、TikTok風アプリで使う動画データを ShortVideo classとして設計しました。

動画1本分の情報を、次のようにまとめました。

class ShortVideo {
  const ShortVideo({
    required this.videoUrl,
    required this.userName,
    required this.caption,
    required this.musicTitle,
    required this.likes,
    required this.comments,
    required this.saves,
    required this.shares,
    required this.avatarColor,
    required this.categoryLabel,
  });

  final String videoUrl;
  final String userName;
  final String caption;
  final String musicTitle;
  final int likes;
  final int comments;
  final int saves;
  final int shares;
  final Color avatarColor;
  final String categoryLabel;
}

今回の 4-6 では、この動画データを使って、TikTokのように上下スワイプで画面を切り替える仕組みを作ります。

TikTok風アプリの中心になる動きは、次のようなものです。

1本目の動画を見る
↓
上にスワイプする
↓
2本目の動画に切り替わる
↓
さらに上にスワイプする
↓
3本目の動画に切り替わる

Flutterでは、このようなページ単位のスワイプUIを PageView.builder で作れます。

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

PageView.builderを使うと、Listのデータを1件ずつページとして表示できる。

まずPageViewとは何か

PageView は、ページを1枚ずつ切り替えるためのWidgetです。

初心者向けには、次のように理解すると分かりやすいです。

PageView = 画面をページ単位でスワイプして切り替えるWidget

たとえば、横にスワイプするチュートリアル画面や、画像スライダーなどにも使えます。

今回作るTikTok風アプリでは、縦方向にスワイプします。

PageView
├─ 1ページ目:動画1
├─ 2ページ目:動画2
└─ 3ページ目:動画3

TikTok風アプリでは、1ページが1本の動画画面になります。

PageView.builderとは何か

PageView.builder は、ページを必要な分だけ作るためのWidgetです。

PageView.builder(
  itemCount: videos.length,
  itemBuilder: (context, index) {
    return Text('ページ $index');
  },
)

ここで大切なのは、itemBuilder です。

itemBuilder は、ページを1枚ずつ作る場所です。

itemBuilder
↓
indexを受け取る
↓
index番目のページを作る

たとえば、index が0なら1ページ目、index が1なら2ページ目を作ります。

index = 0 → 1ページ目
index = 1 → 2ページ目
index = 2 → 3ページ目

新しい言葉:builderとは何か

builder は、「必要になったときに作る仕組み」です。

普通の PageView では、あらかじめすべてのページを並べることもできます。

PageView(
  children: [
    Text('1ページ目'),
    Text('2ページ目'),
    Text('3ページ目'),
  ],
)

これでも動きます。

しかし、動画がたくさんある場合、最初から全部作るのは効率がよくありません。

そこで PageView.builder を使います。

PageView.builder(
  itemBuilder: (context, index) {
    return Text('$indexページ目');
  },
)

初心者向けには、次のように覚えてください。

builder = 必要になったページを、その場で作る仕組み

TikTok風アプリでは、動画ページを1枚ずつ作るために PageView.builder を使います。

新しい言葉:itemCountとは何か

itemCount は、全部で何ページ作るかを表します。

itemCount: videos.length,

これは、次の意味です。

videosの数だけページを作る

たとえば、videos に3本の動画が入っている場合、

videos.length

3 です。

そのため、3ページ作られます。

videos.length = 3
↓
PageViewは3ページ作る

新しい言葉:itemBuilderとは何か

itemBuilder は、1ページ分のWidgetを作る場所です。

itemBuilder: (context, index) {
  return Text('ページ $index');
},

index は、今作っているページ番号です。

index意味
01ページ目
12ページ目
23ページ目

FlutterのListやPageViewでは、番号は0から始まります。

初心者がよく間違えるところなので注意してください。

1ページ目は index 0
2ページ目は index 1
3ページ目は index 2

TikTok風アプリではindexで動画を取り出す

前回、動画データを List<ShortVideo> として持つ考え方を学びました。

const videos = [
  ShortVideo(...),
  ShortVideo(...),
  ShortVideo(...),
];

このListから動画を1本取り出すには、次のように書きます。

videos[index]

index が0なら、1本目の動画です。

videos[0]

index が1なら、2本目の動画です。

videos[1]

つまり、PageView.builderList を組み合わせると、次のような流れになります。

PageView.builderがindexを渡す
↓
videos[index]で動画データを取り出す
↓
その動画データを画面に表示する

これが、TikTok風アプリの縦スワイプの基本です。

まずは動画なしで縦スワイプを作る

この節では、まだ本物の動画再生はしません。

まずは、PageView.builder の仕組みを理解するために、動画カードのような画面を上下スワイプで切り替えます。

DartPadに次のコードを貼り付けてください。

import 'package:flutter/material.dart';

void main() {
  runApp(const TikTokPageViewPracticeApp());
}

class TikTokPageViewPracticeApp extends StatelessWidget {
  const TikTokPageViewPracticeApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: ShortVideoHomePage(),
    );
  }
}

class ShortVideo {
  const ShortVideo({
    required this.userName,
    required this.caption,
    required this.musicTitle,
    required this.likes,
    required this.avatarColor,
    required this.categoryLabel,
  });

  final String userName;
  final String caption;
  final String musicTitle;
  final int likes;
  final Color avatarColor;
  final String categoryLabel;
}

const videos = [
  ShortVideo(
    userName: 'pet_cafe_diary',
    caption: '小さな命の動きは、見ているだけで少しやさしい気持ちになる。',
    musicTitle: 'Healing Cafe Sound - Pet Cafe Diary',
    likes: 12800,
    avatarColor: Color(0xFFE91E63),
    categoryLabel: 'PET',
  ),
  ShortVideo(
    userName: 'food_and_nature',
    caption: 'おいしいものを探す旅の途中で出会った、自然の小さなリズム。',
    musicTitle: 'Kitchen Walk - Food & Nature',
    likes: 8732,
    avatarColor: Color(0xFF2196F3),
    categoryLabel: 'FOOD',
  ),
  ShortVideo(
    userName: 'daily_pet_room',
    caption: 'ペットと過ごす午後。何気ない一瞬が、あとから思い出になる。',
    musicTitle: 'Room Light - Daily Pet Room',
    likes: 24100,
    avatarColor: Color(0xFFFF9800),
    categoryLabel: 'ROOM',
  ),
];

class ShortVideoHomePage extends StatelessWidget {
  const ShortVideoHomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: PageView.builder(
        scrollDirection: Axis.vertical,
        itemCount: videos.length,
        itemBuilder: (context, index) {
          final video = videos[index];

          return ShortVideoPage(
            video: video,
            index: index,
          );
        },
      ),
    );
  }
}

class ShortVideoPage extends StatelessWidget {
  const ShortVideoPage({
    super.key,
    required this.video,
    required this.index,
  });

  final ShortVideo video;
  final int index;

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        Positioned.fill(
          child: VideoPlaceholder(
            video: video,
            index: index,
          ),
        ),
        const Positioned(
          top: 52,
          left: 0,
          right: 0,
          child: TopNavigationPractice(),
        ),
        Positioned(
          right: 16,
          bottom: 112,
          child: RightActionBarPractice(video: video),
        ),
        Positioned(
          left: 16,
          right: 92,
          bottom: 36,
          child: BottomVideoInfoPractice(video: video),
        ),
      ],
    );
  }
}

class VideoPlaceholder extends StatelessWidget {
  const VideoPlaceholder({
    super.key,
    required this.video,
    required this.index,
  });

  final ShortVideo video;
  final int index;

  @override
  Widget build(BuildContext context) {
    final colors = [
      const Color(0xFF2A2A2A),
      const Color(0xFF0D1B2A),
      const Color(0xFF2A160B),
    ];

    return Container(
      decoration: BoxDecoration(
        gradient: LinearGradient(
          begin: Alignment.topCenter,
          end: Alignment.bottomCenter,
          colors: [
            colors[index],
            Colors.black,
          ],
        ),
      ),
      child: Center(
        child: Container(
          width: 230,
          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: 36,
                fontWeight: FontWeight.bold,
                letterSpacing: 2,
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class TopNavigationPractice extends StatelessWidget {
  const TopNavigationPractice({super.key});

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      bottom: false,
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 18),
        child: Row(
          children: [
            const Icon(
              Icons.live_tv_rounded,
              color: Colors.white,
              size: 24,
            ),
            const Spacer(),
            Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                Text(
                  'フォロー中',
                  style: TextStyle(
                    color: Colors.white.withOpacity(0.6),
                    fontSize: 15,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                const SizedBox(width: 18),
                const Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Text(
                      'おすすめ',
                      style: TextStyle(
                        color: Colors.white,
                        fontSize: 16,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    SizedBox(height: 4),
                    SizedBox(
                      width: 28,
                      height: 3,
                      child: DecoratedBox(
                        decoration: BoxDecoration(
                          color: Colors.white,
                          borderRadius: BorderRadius.all(
                            Radius.circular(999),
                          ),
                        ),
                      ),
                    ),
                  ],
                ),
              ],
            ),
            const Spacer(),
            const Icon(
              Icons.search_rounded,
              color: Colors.white,
              size: 28,
            ),
          ],
        ),
      ),
    );
  }
}

class RightActionBarPractice extends StatelessWidget {
  const RightActionBarPractice({
    super.key,
    required this.video,
  });

  final ShortVideo video;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        ProfileButtonPractice(color: video.avatarColor),
        const SizedBox(height: 22),
        ActionButtonPractice(
          icon: Icons.favorite_rounded,
          label: formatCount(video.likes),
        ),
        const SizedBox(height: 20),
        const ActionButtonPractice(
          icon: Icons.mode_comment_rounded,
          label: 'コメント',
        ),
        const SizedBox(height: 20),
        const ActionButtonPractice(
          icon: Icons.bookmark_rounded,
          label: '保存',
        ),
        const SizedBox(height: 20),
        const ActionButtonPractice(
          icon: Icons.reply_rounded,
          label: '共有',
        ),
      ],
    );
  }
}

class ProfileButtonPractice extends StatelessWidget {
  const ProfileButtonPractice({
    super.key,
    required this.color,
  });

  final Color color;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 54,
      height: 64,
      child: Stack(
        alignment: Alignment.topCenter,
        children: [
          Container(
            width: 50,
            height: 50,
            decoration: BoxDecoration(
              color: color,
              shape: BoxShape.circle,
              border: Border.all(
                color: Colors.white,
                width: 2,
              ),
            ),
            child: const Icon(
              Icons.person_rounded,
              color: Colors.white,
              size: 28,
            ),
          ),
          Positioned(
            bottom: 6,
            child: Container(
              width: 22,
              height: 22,
              decoration: const BoxDecoration(
                color: Color(0xFFFF2D55),
                shape: BoxShape.circle,
              ),
              child: const Icon(
                Icons.add_rounded,
                color: Colors.white,
                size: 18,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class ActionButtonPractice extends StatelessWidget {
  const ActionButtonPractice({
    super.key,
    required this.icon,
    required this.label,
  });

  final IconData icon;
  final String label;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Icon(
          icon,
          color: Colors.white,
          size: 34,
          shadows: const [
            Shadow(
              color: Colors.black54,
              blurRadius: 8,
            ),
          ],
        ),
        const SizedBox(height: 4),
        Text(
          label,
          style: const TextStyle(
            color: Colors.white,
            fontSize: 12,
            fontWeight: FontWeight.bold,
            shadows: [
              Shadow(
                color: Colors.black87,
                blurRadius: 8,
              ),
            ],
          ),
        ),
      ],
    );
  }
}

class BottomVideoInfoPractice extends StatelessWidget {
  const BottomVideoInfoPractice({
    super.key,
    required this.video,
  });

  final ShortVideo video;

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      top: false,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            '@${video.userName}',
            style: const TextStyle(
              color: Colors.white,
              fontSize: 16,
              fontWeight: FontWeight.bold,
              shadows: [
                Shadow(
                  color: Colors.black87,
                  blurRadius: 8,
                ),
              ],
            ),
          ),
          const SizedBox(height: 8),
          Text(
            video.caption,
            maxLines: 2,
            overflow: TextOverflow.ellipsis,
            style: const TextStyle(
              color: Colors.white,
              fontSize: 14,
              height: 1.35,
              shadows: [
                Shadow(
                  color: Colors.black87,
                  blurRadius: 8,
                ),
              ],
            ),
          ),
          const SizedBox(height: 10),
          Row(
            children: [
              const Icon(
                Icons.music_note_rounded,
                color: Colors.white,
                size: 17,
              ),
              const SizedBox(width: 6),
              Expanded(
                child: Text(
                  video.musicTitle,
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(
                    color: Colors.white,
                    fontSize: 13,
                    fontWeight: FontWeight.w600,
                    shadows: [
                      Shadow(
                        color: Colors.black87,
                        blurRadius: 8,
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

String formatCount(int value) {
  if (value >= 10000) {
    final result = value / 10000;
    return '${result.toStringAsFixed(1)}万';
  }

  if (value >= 1000) {
    final result = value / 1000;
    return '${result.toStringAsFixed(1)}K';
  }

  return value.toString();
}

実行して確認すること

実行したら、画面を上下にスワイプしてください。

次のように画面が切り替わります。

PET
↓
FOOD
↓
ROOM

それぞれの画面で、ユーザー名、キャプション、いいね数、プロフィール色が変わります。

これは、PageView.buildervideos[index] を使って、動画データを1件ずつ表示しているからです。

final video = videos[index];

そして、その videoShortVideoPage に渡しています。

return ShortVideoPage(
  video: video,
  index: index,
);

今回のコードの全体構造

今回のコードは、次のような構造になっています。

TikTokPageViewPracticeApp
└─ MaterialApp
   └─ ShortVideoHomePage
      └─ Scaffold
         └─ PageView.builder
            └─ ShortVideoPage
               ├─ VideoPlaceholder
               ├─ TopNavigationPractice
               ├─ RightActionBarPractice
               └─ BottomVideoInfoPractice

前回までは、1画面だけを Stack で作っていました。

今回は、その Stack で作った1画面を、PageView.builder の中で複数表示しています。

1画面分のUI
↓
PageView.builderで複数ページにする
↓
縦スワイプできるようになる

ShortVideoHomePageの解説

中心になるのは、ShortVideoHomePage です。

class ShortVideoHomePage extends StatelessWidget {
  const ShortVideoHomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: PageView.builder(
        scrollDirection: Axis.vertical,
        itemCount: videos.length,
        itemBuilder: (context, index) {
          final video = videos[index];

          return ShortVideoPage(
            video: video,
            index: index,
          );
        },
      ),
    );
  }
}

ここでは、ScaffoldbodyPageView.builder を置いています。

body: PageView.builder(...)

これにより、画面全体がスワイプ可能なページになります。

scrollDirectionとは何か

scrollDirection は、スクロールする方向を指定する設定です。

scrollDirection: Axis.vertical,

Axis.vertical は、縦方向という意味です。

書き方意味
Axis.vertical縦方向
Axis.horizontal横方向

TikTok風アプリでは、上下にスワイプしたいので、Axis.vertical を使います。

もし横スワイプにしたい場合は、次のように書きます。

scrollDirection: Axis.horizontal,

ただし、今回はTikTok風なので縦方向です。

itemCount: videos.length の意味

次の部分を見てください。

itemCount: videos.length,

これは、ページ数を動画の数に合わせるという意味です。

今回の videos には3本の動画があります。

const videos = [
  ShortVideo(...),
  ShortVideo(...),
  ShortVideo(...),
];

そのため、videos.length3 です。

つまり、3ページ作ります。

videos.length = 3
↓
PageView.builderは3ページ作る

itemBuilderの中を見る

次の部分を見てください。

itemBuilder: (context, index) {
  final video = videos[index];

  return ShortVideoPage(
    video: video,
    index: index,
  );
},

ここが、ページを作る場所です。

流れは次の通りです。

indexを受け取る
↓
videos[index]で動画データを取り出す
↓
ShortVideoPageに渡す
↓
1ページ分の画面を作る

たとえば、index が0なら、

final video = videos[0];

です。

1本目の動画データを取り出します。

index が1なら、

final video = videos[1];

です。

2本目の動画データを取り出します。

ShortVideoPageにデータを渡す

ShortVideoPage は、動画1本分の画面です。

class ShortVideoPage extends StatelessWidget {
  const ShortVideoPage({
    super.key,
    required this.video,
    required this.index,
  });

  final ShortVideo video;
  final int index;

ここでは、外から videoindex を受け取っています。

ShortVideoPage(
  video: video,
  index: index,
)

この仕組みによって、同じ ShortVideoPage でも、渡す動画データによって表示が変わります。

ShortVideoPage(video: videos[0])
↓
PETの画面

ShortVideoPage(video: videos[1])
↓
FOODの画面

ShortVideoPage(video: videos[2])
↓
ROOMの画面

同じWidgetでもデータが違えば表示が変わる

ここはとても大切です。

Flutterでは、同じWidgetを使っていても、渡すデータが変われば表示が変わります。

たとえば、BottomVideoInfoPractice を見てみます。

BottomVideoInfoPractice(video: video)

このWidgetの中では、次のように表示しています。

Text('@${video.userName}')
Text(video.caption)
Text(video.musicTitle)

そのため、渡された video が変われば、表示される文字も変わります。

video.userName が pet_cafe_diary
↓
@pet_cafe_diary

video.userName が food_and_nature
↓
@food_and_nature

この考え方は、どんなアプリでも使います。

同じカード部品
↓
違うデータを渡す
↓
違う表示になる

PageView.builderとListの関係

ここで、PageView.builderList の関係を整理します。

役割コード意味
動画一覧videos複数の動画データ
ページ数videos.length動画の数だけページを作る
ページ番号index今作っているページ番号
動画を取り出すvideos[index]index番目の動画
画面を作るShortVideoPage(video: video)動画データを画面に渡す

この表を理解できると、TikTok風アプリの縦スワイプの仕組みが分かります。

indexは0から始まる

index は0から始まります。

今回の動画は3本です。

videos[0] → PET
videos[1] → FOOD
videos[2] → ROOM

videos[3] は存在しません。

もし itemCount: videos.length がある場合、Flutterは 0 から videos.length - 1 までのindexを使います。

今回なら、次の3つです。

0
1
2

初心者がよく間違えるポイントです。

3本あるからindexは0, 1, 2

動画データを増やすとページも増える

PageView.builder の便利なところは、videos にデータを追加すると、ページも増えることです。

たとえば、videos に4本目を追加します。

ShortVideo(
  userName: 'sweet_table',
  caption: '甘いものを囲む時間は、少しだけ日常をやわらかくしてくれる。',
  musicTitle: 'Sweet Table Sound',
  likes: 5400,
  avatarColor: Color(0xFF9C27B0),
  categoryLabel: 'SWEETS',
),

すると、videos.length が4になります。

videos.length = 4
↓
PageView.builderは4ページ作る

UI側のコードを増やす必要はありません。

データを増やすだけで、表示ページも増えます。

これが、データとUIを分ける大きなメリットです。

なぜPageView.builderを使うのか

もし、PageView.builder を使わずに書くと、次のようになります。

PageView(
  scrollDirection: Axis.vertical,
  children: [
    ShortVideoPage(video: videos[0], index: 0),
    ShortVideoPage(video: videos[1], index: 1),
    ShortVideoPage(video: videos[2], index: 2),
  ],
)

3本ならまだよいです。

しかし、動画が10本、100本になったら大変です。

PageView.builder なら、次のように書けます。

PageView.builder(
  itemCount: videos.length,
  itemBuilder: (context, index) {
    final video = videos[index];

    return ShortVideoPage(
      video: video,
      index: index,
    );
  },
)

動画が何本になっても、同じ形で表示できます。

動画が増えても、UIコードの形は変わらない。

手を動かす練習1:横スワイプに変えてみる

次の部分を探してください。

scrollDirection: Axis.vertical,

これを次のように変えてみます。

scrollDirection: Axis.horizontal,

すると、上下ではなく左右にスワイプする画面になります。

確認したら、TikTok風に戻すために Axis.vertical に戻してください。

手を動かす練習2:4本目の動画を追加する

videos の最後に、次のデータを追加してみてください。

ShortVideo(
  userName: 'sweet_table',
  caption: '甘いものを囲む時間は、少しだけ日常をやわらかくしてくれる。',
  musicTitle: 'Sweet Table Sound',
  likes: 5400,
  avatarColor: Color(0xFF9C27B0),
  categoryLabel: 'SWEETS',
),

追加すると、4ページ目が表示されるようになります。

データを追加するだけでページが増えることを確認してください。

手を動かす練習3:表示する文字を変更する

1本目のデータを探してください。

userName: 'pet_cafe_diary',

これを次のように変えてみます。

userName: 'cat_room_japan',

1ページ目のユーザー名が変わります。

これは、画面がデータから作られていることを確認する練習です。

手を動かす練習4:いいね数を変える

1本目の likes を変えてみます。

likes: 12800,

これを次のように変更します。

likes: 35000,

表示が 3.5万 に変わります。

これは、formatCount 関数が数字を表示用に変換しているためです。

よくあるつまずき1:itemBuilderのindexが分からない

index は、今作っているページの番号です。

itemBuilder: (context, index) {
  ...
}

index はFlutterが自動で渡してくれます。

自分で数字を入れる必要はありません。

Flutterがindexを渡す
↓
そのindexを使ってvideos[index]を取り出す

よくあるつまずき2:videos[index]でエラーになる

videos[index] でエラーになる場合、存在しない番号を取り出している可能性があります。

たとえば、動画が3本しかないのに、

videos[3]

とするとエラーです。

3本の場合、使えるindexは次の3つです。

0
1
2

itemCount: videos.length を指定しておけば、Flutterは範囲内のindexだけを使ってくれます。

よくあるつまずき3:itemCountを書き忘れる

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

これは後の「無限スクロール風」の節で使います。

しかし、最初に学ぶ段階では、まず itemCount を書いたほうが分かりやすいです。

itemCount: videos.length,

初心者向けには、まず次のように覚えてください。

最初は itemCount: videos.length を書く

よくあるつまずき4:同じ画面しか表示されない

ページを切り替えても同じ画面しか表示されない場合、videos[index] を使っていない可能性があります。

たとえば、常に videos[0] を渡していると、どのページでも1本目が表示されます。

間違い例です。

final video = videos[0];

正しい例です。

final video = videos[index];

index を使うことで、ページごとに違う動画データを表示できます。

よくあるつまずき5:Widgetを増やしすぎて分からなくなる

今回のコードには、たくさんのWidgetが出てきます。

しかし、中心だけを見るとシンプルです。

ShortVideoHomePage
└─ PageView.builder
   └─ ShortVideoPage
      └─ Stack

まずはこの流れを理解してください。

細かいボタンやテキストは、後から少しずつ見れば大丈夫です。

この節の確認問題

確認問題1

PageView は何をするWidgetですか。

答え

ページを1枚ずつスワイプで切り替えるWidgetです。

TikTok風アプリでは、動画ページを上下に切り替えるために使います。

確認問題2

PageView.builderbuilder は何をする仕組みですか。

答え

必要になったページを、その場で作る仕組みです。

動画データが増えても、同じ形でページを作れます。

確認問題3

scrollDirection: Axis.vertical は何を意味しますか。

答え

縦方向にスクロールするという意味です。

TikTok風アプリでは、上下スワイプで動画を切り替えるために使います。

確認問題4

itemCount: videos.length は何をしていますか。

答え

videos に入っている動画の数だけページを作る、という意味です。

確認問題5

videos[index] は何を表しますか。

答え

videos の中から、index 番目の動画データを取り出す書き方です。

index が0なら1本目、1なら2本目の動画です。

確認問題6

同じ ShortVideoPage なのに、ページごとに表示が変わるのはなぜですか。

答え

渡している ShortVideo データがページごとに違うからです。

同じWidgetでも、渡すデータが変われば表示も変わります。

この節のまとめ

この節では、PageView.builder を使って、TikTokのような縦スワイプUIを作りました。

PageView.builder は、Listのデータを1件ずつ取り出し、ページとして表示できます。

PageView.builder(
  scrollDirection: Axis.vertical,
  itemCount: videos.length,
  itemBuilder: (context, index) {
    final video = videos[index];

    return ShortVideoPage(
      video: video,
      index: index,
    );
  },
)

このコードの流れは、次の通りです。

videosに複数の動画データを用意する
↓
PageView.builderがindexを作る
↓
videos[index]で動画を1本取り出す
↓
ShortVideoPageに渡す
↓
1ページ分の画面として表示する

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

PageView.builderは、Listのデータを1件ずつページに変えるWidgetである。

次の節では、3本の動画でもずっと続いているように見せる「無限スクロール風」の仕組みを作ります。

教材トップへ戻る