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

動画データを「ただの値」から「1本の情報」へ

本章を理解すると次のプログラムの作り方がわかります。最初に、こちらのコードをDartPadのFlutter環境で実行してみましょう。

理解すると作り方がわかります。

import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Video Mock',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.red),
        scaffoldBackgroundColor: Colors.white,
        useMaterial3: true,
        fontFamily: 'Roboto',
      ),
      home: const VideoHomePage(),
    );
  }
}

class Video {
  const Video({
    required this.title,
    required this.channelName,
    required this.views,
    required this.publishedAt,
    required this.duration,
    required this.category,
    required this.thumbnailColor,
    required this.channelColor,
    required this.isLive,
  });

  final String title;
  final String channelName;
  final int views;
  final String publishedAt;
  final String duration;
  final String category;
  final Color thumbnailColor;
  final Color channelColor;
  final bool isLive;

  String get viewLabel {
    if (views >= 100000000) {
      return '${(views / 100000000).toStringAsFixed(1)}億回視聴';
    }

    if (views >= 10000) {
      return '${(views / 10000).toStringAsFixed(1)}万回視聴';
    }

    return '$views回視聴';
  }

  String get metaLabel {
    if (isLive) {
      return 'ライブ配信中';
    }

    return '$viewLabel・$publishedAt';
  }
}

const videos = [
  Video(
    title: 'FlutterでYouTube風UIを作る|ListViewとCardの実践入門',
    channelName: 'Code Studio',
    views: 128000,
    publishedAt: '2日前',
    duration: '12:34',
    category: 'Flutter',
    thumbnailColor: Color(0xFF121212),
    channelColor: Color(0xFFE53935),
    isLive: false,
  ),
  Video(
    title: 'DartのListとMapをアプリ画面で使う方法をやさしく解説',
    channelName: 'App School',
    views: 8500,
    publishedAt: '5日前',
    duration: '08:21',
    category: 'Dart',
    thumbnailColor: Color(0xFF1E3A8A),
    channelColor: Color(0xFF1565C0),
    isLive: false,
  ),
  Video(
    title: 'UIデザインの基本|スマホ画面を見やすく整える考え方',
    channelName: 'Design Lab',
    views: 24000,
    publishedAt: '1週間前',
    duration: '15:10',
    category: 'UI Design',
    thumbnailColor: Color(0xFF263238),
    channelColor: Color(0xFF2E7D32),
    isLive: false,
  ),
  Video(
    title: 'ライブ:Flutter質問会|Widget・ListView・classの疑問を解決',
    channelName: 'Mobile Dev Live',
    views: 5600,
    publishedAt: '現在',
    duration: 'LIVE',
    category: 'LIVE',
    thumbnailColor: Color(0xFFB71C1C),
    channelColor: Color(0xFF8E24AA),
    isLive: true,
  ),
];

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: const YouTubeLikeAppBar(),
      body: Column(
        children: [
          const CategoryBar(),
          const Divider(height: 1, thickness: 0.6, color: Color(0xFFE5E5E5)),
          Expanded(
            child: ListView.builder(
              padding: EdgeInsets.zero,
              itemCount: videos.length,
              itemBuilder: (context, index) {
                return VideoCard(video: videos[index]);
              },
            ),
          ),
        ],
      ),
      bottomNavigationBar: const YouTubeLikeBottomNavigation(),
    );
  }
}

class YouTubeLikeAppBar extends StatelessWidget implements PreferredSizeWidget {
  const YouTubeLikeAppBar({super.key});

  @override
  Size get preferredSize => const Size.fromHeight(58);

  @override
  Widget build(BuildContext context) {
    return AppBar(
      elevation: 0,
      scrolledUnderElevation: 0,
      backgroundColor: Colors.white,
      surfaceTintColor: Colors.white,
      titleSpacing: 12,
      title: Row(
        children: [
          Container(
            width: 30,
            height: 22,
            decoration: BoxDecoration(
              color: const Color(0xFFFF0000),
              borderRadius: BorderRadius.circular(6),
            ),
            child: const Icon(
              Icons.play_arrow,
              color: Colors.white,
              size: 20,
            ),
          ),
          const SizedBox(width: 6),
          const Text(
            'VideoTube',
            style: TextStyle(
              color: Colors.black,
              fontSize: 21,
              fontWeight: FontWeight.w800,
              letterSpacing: -0.8,
            ),
          ),
        ],
      ),
      actions: [
        IconButton(
          onPressed: () {},
          icon: const Icon(Icons.cast, color: Colors.black87),
        ),
        IconButton(
          onPressed: () {},
          icon: const Icon(Icons.notifications_none, color: Colors.black87),
        ),
        IconButton(
          onPressed: () {},
          icon: const Icon(Icons.search, color: Colors.black87),
        ),
        const Padding(
          padding: EdgeInsets.only(right: 12),
          child: CircleAvatar(
            radius: 14,
            backgroundColor: Color(0xFF1565C0),
            child: Text(
              'A',
              style: TextStyle(
                color: Colors.white,
                fontSize: 13,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
        ),
      ],
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    final categories = [
      'すべて',
      'Flutter',
      'Dart',
      'UI',
      'ライブ',
      '最近アップロード',
      '視聴済み',
    ];

    return SizedBox(
      height: 48,
      child: ListView.separated(
        padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
        scrollDirection: Axis.horizontal,
        itemCount: categories.length,
        separatorBuilder: (context, index) => const SizedBox(width: 8),
        itemBuilder: (context, index) {
          final isSelected = index == 0;

          return AnimatedContainer(
            duration: const Duration(milliseconds: 160),
            padding: const EdgeInsets.symmetric(horizontal: 13),
            alignment: Alignment.center,
            decoration: BoxDecoration(
              color: isSelected ? Colors.black : const Color(0xFFF2F2F2),
              borderRadius: BorderRadius.circular(9),
            ),
            child: Text(
              categories[index],
              style: TextStyle(
                color: isSelected ? Colors.white : Colors.black87,
                fontSize: 14,
                fontWeight: FontWeight.w600,
              ),
            ),
          );
        },
      ),
    );
  }
}

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

  final Video video;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 18),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Thumbnail(video: video),
          Padding(
            padding: const EdgeInsets.fromLTRB(12, 12, 8, 0),
            child: Row(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                ChannelAvatar(video: video),
                const SizedBox(width: 12),
                Expanded(
                  child: VideoInfo(video: video),
                ),
                IconButton(
                  visualDensity: VisualDensity.compact,
                  onPressed: () {},
                  icon: const Icon(
                    Icons.more_vert,
                    size: 22,
                    color: Colors.black87,
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

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

  final Video video;

  @override
  Widget build(BuildContext context) {
    return AspectRatio(
      aspectRatio: 16 / 9,
      child: Stack(
        fit: StackFit.expand,
        children: [
          Container(
            decoration: BoxDecoration(
              gradient: LinearGradient(
                begin: Alignment.topLeft,
                end: Alignment.bottomRight,
                colors: [
                  video.thumbnailColor,
                  Color.lerp(video.thumbnailColor, Colors.black, 0.32)!,
                ],
              ),
            ),
          ),
          Positioned.fill(
            child: CustomPaint(
              painter: ThumbnailPatternPainter(),
            ),
          ),
          Center(
            child: Container(
              padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
              decoration: BoxDecoration(
                color: Colors.black.withOpacity(0.22),
                borderRadius: BorderRadius.circular(14),
                border: Border.all(
                  color: Colors.white.withOpacity(0.18),
                ),
              ),
              child: Text(
                video.category,
                style: const TextStyle(
                  color: Colors.white,
                  fontSize: 28,
                  fontWeight: FontWeight.w900,
                  letterSpacing: -0.6,
                ),
              ),
            ),
          ),
          Positioned(
            right: 8,
            bottom: 8,
            child: video.isLive
                ? Container(
                    padding:
                        const EdgeInsets.symmetric(horizontal: 7, vertical: 4),
                    decoration: BoxDecoration(
                      color: const Color(0xFFE62117),
                      borderRadius: BorderRadius.circular(3),
                    ),
                    child: const Text(
                      'ライブ',
                      style: TextStyle(
                        color: Colors.white,
                        fontSize: 12,
                        fontWeight: FontWeight.w800,
                      ),
                    ),
                  )
                : Container(
                    padding:
                        const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
                    decoration: BoxDecoration(
                      color: Colors.black.withOpacity(0.82),
                      borderRadius: BorderRadius.circular(3),
                    ),
                    child: Text(
                      video.duration,
                      style: const TextStyle(
                        color: Colors.white,
                        fontSize: 12,
                        fontWeight: FontWeight.w700,
                      ),
                    ),
                  ),
          ),
          Positioned(
            left: 10,
            top: 10,
            child: Container(
              padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4),
              decoration: BoxDecoration(
                color: Colors.black.withOpacity(0.48),
                borderRadius: BorderRadius.circular(4),
              ),
              child: const Text(
                'DartPad教材',
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 11,
                  fontWeight: FontWeight.w600,
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class ThumbnailPatternPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final softPaint = Paint()
      ..color = Colors.white.withOpacity(0.08)
      ..style = PaintingStyle.stroke
      ..strokeWidth = 1.2;

    final boldPaint = Paint()
      ..color = Colors.white.withOpacity(0.12)
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2.0;

    for (double x = -size.width; x < size.width * 2; x += 42) {
      canvas.drawLine(
        Offset(x, size.height),
        Offset(x + size.height, 0),
        softPaint,
      );
    }

    final playPath = Path()
      ..moveTo(size.width * 0.46, size.height * 0.40)
      ..lineTo(size.width * 0.46, size.height * 0.60)
      ..lineTo(size.width * 0.61, size.height * 0.50)
      ..close();

    canvas.drawPath(playPath, boldPaint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return false;
  }
}

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

  final Video video;

  @override
  Widget build(BuildContext context) {
    return CircleAvatar(
      radius: 20,
      backgroundColor: video.channelColor,
      child: Text(
        video.channelName.characters.first,
        style: const TextStyle(
          color: Colors.white,
          fontWeight: FontWeight.w800,
          fontSize: 16,
        ),
      ),
    );
  }
}

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

  final Video video;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          video.title,
          maxLines: 2,
          overflow: TextOverflow.ellipsis,
          style: const TextStyle(
            fontSize: 15.5,
            fontWeight: FontWeight.w700,
            height: 1.28,
            color: Colors.black,
          ),
        ),
        const SizedBox(height: 5),
        Text(
          video.channelName,
          maxLines: 1,
          overflow: TextOverflow.ellipsis,
          style: const TextStyle(
            color: Color(0xFF606060),
            fontSize: 13,
            height: 1.25,
          ),
        ),
        const SizedBox(height: 1),
        Row(
          children: [
            Flexible(
              child: Text(
                video.metaLabel,
                maxLines: 1,
                overflow: TextOverflow.ellipsis,
                style: TextStyle(
                  color:
                      video.isLive ? const Color(0xFFE62117) : const Color(0xFF606060),
                  fontSize: 13,
                  fontWeight: video.isLive ? FontWeight.w700 : FontWeight.w400,
                  height: 1.25,
                ),
              ),
            ),
          ],
        ),
      ],
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return NavigationBarTheme(
      data: NavigationBarThemeData(
        height: 64,
        backgroundColor: Colors.white,
        indicatorColor: Colors.transparent,
        labelTextStyle: WidgetStateProperty.resolveWith((states) {
          return TextStyle(
            fontSize: 11,
            color: states.contains(WidgetState.selected)
                ? Colors.black
                : const Color(0xFF606060),
            fontWeight: states.contains(WidgetState.selected)
                ? FontWeight.w700
                : FontWeight.w500,
          );
        }),
      ),
      child: NavigationBar(
        selectedIndex: 0,
        onDestinationSelected: (index) {},
        destinations: const [
          NavigationDestination(
            icon: Icon(Icons.home_outlined),
            selectedIcon: Icon(Icons.home),
            label: 'ホーム',
          ),
          NavigationDestination(
            icon: Icon(Icons.play_circle_outline),
            selectedIcon: Icon(Icons.play_circle),
            label: 'ショート',
          ),
          NavigationDestination(
            icon: Icon(Icons.add_circle_outline),
            selectedIcon: Icon(Icons.add_circle),
            label: '作成',
          ),
          NavigationDestination(
            icon: Icon(Icons.subscriptions_outlined),
            selectedIcon: Icon(Icons.subscriptions),
            label: '登録',
          ),
          NavigationDestination(
            icon: Icon(Icons.person_outline),
            selectedIcon: Icon(Icons.person),
            label: 'マイページ',
          ),
        ],
      ),
    );
  }
}

3章の位置づけ

第3章では、最終的に上のYouTube風の動画一覧アプリ を作れる状態を目指します。

このアプリでは、上部にアプリ名、カテゴリ、動画一覧、下部ナビゲーションがあり、動画カードにはサムネイル、動画タイトル、チャンネル名、再生回数、投稿日、動画時間などが表示されます。

今回のゴールになる完成形では、次のようなデータを使います。

const videos = [
  Video(
    title: 'FlutterでYouTube風UIを作る|ListViewとCardの実践入門',
    channelName: 'Code Studio',
    views: 128000,
    publishedAt: '2日前',
    duration: '12:34',
    category: 'Flutter',
    thumbnailColor: Color(0xFF121212),
    channelColor: Color(0xFFE53935),
    isLive: false,
  ),
];

このコードを見ると、少し難しく感じるかもしれません。

しかし、分解するとやっていることはとてもシンプルです。

「動画1本分の情報を、Video というまとまりで持っている」

それだけです。

第3章では、この完成形に向かって、次の順番で学びます。

新しい見出し学ぶことYouTube風UIとのつながり
3-1動画データを「ただの値」から「1本の情報」へ変数とデータのまとまり動画タイトル、再生回数、チャンネル名をまとめる
3-2ListとMapで動画一覧の元データを作るList / Map複数の動画データを一覧として持つ
3-3classでVideoデータを設計するclass / instance動画1本分の設計図を作る
3-4constructorとpropertyで動画の持ち物を決めるconstructor / propertytitle、channelName、viewsなどを持たせる
3-5methodで動画データに表示用のふるまいを持たせるmethod再生回数や投稿日を表示用テキストに変える
3-6カプセル化で安全に使えるVideo部品を作るfinal / getter間違ったデータ変更を防ぐ
3-7List・Map・classを組み合わせて動画一覧を操作するデータ操作人気動画だけ表示、カテゴリ別に分ける
3-8YouTube風ミニアプリ用のクラスを設計する設計演習Video、Channel、Categoryを考える
3-9次章へ:動画データをFlutter UIに表示するUI接続ListViewとカードUIへつなげる

この章のゴール

この章のゴールは、YouTube風アプリの見た目をいきなり作ることではありません。

その前に、画面に表示するデータをDartでどう持つかを理解することです。

アプリ画面には、必ず裏側のデータがあります。たとえば、画面に次のような情報が表示されているとします。

FlutterでYouTube風UIを作る|ListViewとCardの実践入門
Code Studio
12.8万回視聴・2日前
12:34

これは、画面上では1つの動画カードです。

しかし、裏側では次のようなデータが必要です。

画面に出したいものDartで持つデータ
動画タイトルtitle
チャンネル名channelName
再生回数views
投稿日publishedAt
動画時間duration
カテゴリcategory
サムネイル色thumbnailColor
チャンネルアイコン色channelColor
ライブ中かどうかisLive

完成形のアプリでは、これらを Video というclassにまとめます。

ただし、最初からclassを完全に理解しようとすると詰まりやすいです。

そこで、この節ではまず「動画データは、複数の値がまとまったものなんだ」と理解するところから始めます。

この節で学ぶこと

この節では、動画データを「ただの値」ではなく「動画1本分の情報」として考える練習をします。

この節で理解する内容は、次の4つです。

学ぶこと内容
ただの値文字や数字を単体で見る
バラバラの変数title、viewsなどを別々に持つ
1本分の情報複数の値を動画データとして見る
classにつながる考え方Video というまとまりで扱う準備をする

この節の段階では、まだ完成アプリのすべてを作る必要はありません。

まずは、完成形に出てくる Video の中身を、1つずつ理解していきます。

まず完成形のVideoを見てみる

完成アプリでは、動画1本分のデータを次のように書きます。

Video(
  title: 'FlutterでYouTube風UIを作る|ListViewとCardの実践入門',
  channelName: 'Code Studio',
  views: 128000,
  publishedAt: '2日前',
  duration: '12:34',
  category: 'Flutter',
  thumbnailColor: Color(0xFF121212),
  channelColor: Color(0xFFE53935),
  isLive: false,
)

このコードを見て、最初に全部理解できなくても大丈夫です。

まず注目するのは、左側の名前です。

title
channelName
views
publishedAt
duration
category
thumbnailColor
channelColor
isLive

これは、動画1本が持っている情報の名前です。

右側には、実際の値が入っています。

名前実際の値意味
titleFlutterでYouTube風UIを作る|ListViewとCardの実践入門動画タイトル
channelNameCode Studioチャンネル名
views128000再生回数
publishedAt2日前投稿日
duration12:34動画時間
categoryFlutterカテゴリ
thumbnailColorColor(0xFF121212)サムネイルの色
channelColorColor(0xFFE53935)チャンネルアイコンの色
isLivefalseライブ配信中かどうか

つまり、Video(...) の中には、動画カードを表示するために必要な情報がまとまっています。

いきなりVideoを書かず、まずはただの値として見る

最初は、動画タイトルだけを見てみます。

void main() {
  String title = 'FlutterでYouTube風UIを作る|ListViewとCardの実践入門';

  print(title);
}

これは、Dartの基本です。

String は文字列を表す型です。

title は変数名です。

'FlutterでYouTube風UIを作る|ListViewとCardの実践入門' が実際の値です。

部分意味
String文字列の型
title変数名
‘Flutterで…’実際に入っている文字

実行すると、動画タイトルが表示されます。

FlutterでYouTube風UIを作る|ListViewとCardの実践入門

この時点では、ただの文字列です。

まだ「動画1本分の情報」ではありません。

再生回数もただの数字として見てみる

次に、再生回数を見てみます。

void main() {
  int views = 128000;

  print(views);
}

int は整数を表す型です。

views は再生回数を入れる変数名です。

128000 が実際の再生回数です。

部分意味
int整数の型
views再生回数を表す変数名
128000実際の再生回数

実行すると、次のように表示されます。

128000

これも、まだただの数字です。

画面では「12.8万回視聴」のように表示したくなるかもしれませんが、この段階ではまず数字として持ちます。

ライブ中かどうかはboolで持つ

完成形の Video には、isLive という値もあります。

isLive: false

これは、ライブ配信中かどうかを表す値です。

Dartでは、正しい / 正しくない、はい / いいえ のような値を bool で表します。

void main() {
  bool isLive = false;

  print(isLive);
}

false は「ライブ配信中ではない」という意味です。

もしライブ配信中なら、次のようにできます。

void main() {
  bool isLive = true;

  print(isLive);
}
意味
trueはい、そうである
falseいいえ、そうではない

完成アプリでは、この isLive を使って、通常動画なら動画時間を表示し、ライブ配信なら「ライブ」と表示します。

色もデータとして持てる

完成形には、次のような値もあります。

thumbnailColor: Color(0xFF121212),
channelColor: Color(0xFFE53935),

これは、サムネイルやチャンネルアイコンの色を表しています。

Flutterでは、色もデータとして扱えます。

const Color(0xFF121212)

このような書き方は、最初は少し読みにくいかもしれません。

今は、次の理解で十分です。

書き方意味
Color(0xFF121212)黒に近い色
Color(0xFFE53935)赤系の色
Color(0xFF1565C0)青系の色

この章の中心はDartのデータ設計なので、色の細かい仕組みは深追いしなくて大丈夫です。

ここでは、「動画データの中に、見た目に使う色も入れられる」と理解してください。

1本の動画には複数の値がある

ここまで見たように、動画1本には複数の値があります。

String title = 'FlutterでYouTube風UIを作る|ListViewとCardの実践入門';
String channelName = 'Code Studio';
int views = 128000;
String publishedAt = '2日前';
String duration = '12:34';
String category = 'Flutter';
bool isLive = false;

このように書くと、1本の動画に必要な情報はそろいます。

ただし、まだ全部がバラバラです。

title
channelName
views
publishedAt
duration
category
isLive

人間は「これは同じ動画の情報だ」と分かります。

でも、コード上ではまだ1つにまとまっていません。

ここが大事です。

バラバラの変数で書いた例

まず、動画1本分をバラバラの変数として書いてみます。

void main() {
  String title = 'FlutterでYouTube風UIを作る|ListViewとCardの実践入門';
  String channelName = 'Code Studio';
  int views = 128000;
  String publishedAt = '2日前';
  String duration = '12:34';
  String category = 'Flutter';
  bool isLive = false;

  print(title);
  print(channelName);
  print(views);
  print(publishedAt);
  print(duration);
  print(category);
  print(isLive);
}

このコードは動きます。

出力結果は次のようになります。

FlutterでYouTube風UIを作る|ListViewとCardの実践入門
Code Studio
128000
2日前
12:34
Flutter
false

問題なく表示されています。

ただし、この状態では、データがただ並んでいるだけです。

バラバラの変数のままだと何が困るか

動画が1本だけなら、まだ大きな問題にはなりません。

しかし、動画が増えると一気に大変になります。

たとえば、2本目の動画を追加すると、次のようになります。

void main() {
  String title1 = 'FlutterでYouTube風UIを作る|ListViewとCardの実践入門';
  String channelName1 = 'Code Studio';
  int views1 = 128000;
  String publishedAt1 = '2日前';
  String duration1 = '12:34';

  String title2 = 'DartのListとMapをアプリ画面で使う方法をやさしく解説';
  String channelName2 = 'App School';
  int views2 = 8500;
  String publishedAt2 = '5日前';
  String duration2 = '08:21';

  print('$title1 / $channelName1 / $views1');
  print('$title2 / $channelName2 / $views2');
}

動画が2本になるだけで、変数名に番号がつき始めます。

title1
title2
channelName1
channelName2
views1
views2

これが4本、10本、100本になると、管理がかなりつらくなります。

バラバラ管理で起きる問題

困ること具体例
変数名が増えるtitle1title2title3 と増えていく
対応を間違えやすいtitle1にviews2を組み合わせてしまう
一覧表示しにくいすべての動画を順番に表示する処理が書きにくい
条件で探しにくい再生回数が10000以上の動画だけを探しにくい
画面に渡しにくいVideoCard に1本分の情報を渡しにくい

YouTube風アプリでは、動画を1本だけ表示するのではありません。

複数の動画を一覧表示します。

だからこそ、動画1本分のデータを「まとまり」として扱う必要があります。

動画1本分の情報として見る

次のように考えます。

動画1本分の情報
├─ title
├─ channelName
├─ views
├─ publishedAt
├─ duration
├─ category
├─ thumbnailColor
├─ channelColor
└─ isLive

これが、完成形の Video classにつながります。

完成形では、1本分の動画を次のように書きます。

Video(
  title: 'FlutterでYouTube風UIを作る|ListViewとCardの実践入門',
  channelName: 'Code Studio',
  views: 128000,
  publishedAt: '2日前',
  duration: '12:34',
  category: 'Flutter',
  thumbnailColor: Color(0xFF121212),
  channelColor: Color(0xFFE53935),
  isLive: false,
)

これは、次のように読めます。

Videoという動画データを作る。
その中に、タイトル、チャンネル名、再生回数、投稿日、動画時間などを入れる。

この読み方ができれば、まずは十分です。

Mapで1本分をまとめる

classに進む前に、まずはMapで「まとまり」を作ってみます。

void main() {
  final video = {
    'title': 'FlutterでYouTube風UIを作る|ListViewとCardの実践入門',
    'channelName': 'Code Studio',
    'views': 128000,
    'publishedAt': '2日前',
    'duration': '12:34',
    'category': 'Flutter',
    'isLive': false,
  };

  print(video['title']);
  print(video['channelName']);
  print(video['views']);
}

Mapでは、キーと値の組み合わせでデータを持ちます。

キー
titleFlutterでYouTube風UIを作る|ListViewとCardの実践入門
channelNameCode Studio
views128000
publishedAt2日前
duration12:34
categoryFlutter
isLivefalse

Mapを使うと、バラバラだった値を video という1つのまとまりで扱えるようになります。

Mapの読み方

次のコードを見てください。

print(video['title']);

これは、次のように読みます。

videoの中から、titleというキーの値を取り出す

同じように、次のコードは、

print(video['views']);

こう読みます。

videoの中から、viewsというキーの値を取り出す

Mapは、データに名前をつけて取り出せる仕組みです。

ただの値が、少しずつ「意味のあるデータ」になってきました。

ただしMapには注意点がある

Mapは便利ですが、キーを文字で書くため、打ち間違いに注意が必要です。

void main() {
  final video = {
    'title': 'FlutterでYouTube風UIを作る|ListViewとCardの実践入門',
    'views': 128000,
  };

  print(video['titel']);
}

本当は title と書くべきところを、titel と間違えています。

このようなミスが起きても、コードを見ただけでは気づきにくいことがあります。

また、Mapでは文字、数字、真偽値などが混ざります。

final video = {
  'title': 'FlutterでYouTube風UIを作る',
  'views': 128000,
  'isLive': false,
};

titleString

viewsint

isLivebool

このように型が混ざるため、アプリが大きくなると管理が難しくなることがあります。

そこで、次にclassが必要になります。

classでVideoの形を決める

classは、データの設計図です。

動画1本分のデータを安全に扱うために、Video classを作ります。

まずは、完成形より少し小さくした Video を見てみます。

class Video {
  const Video({
    required this.title,
    required this.channelName,
    required this.views,
    required this.publishedAt,
    required this.duration,
  });

  final String title;
  final String channelName;
  final int views;
  final String publishedAt;
  final String duration;
}

これは、次のように読めます。

Videoは、動画1本分のデータである。
Videoは、title、channelName、views、publishedAt、durationを持つ。

それぞれの型も決まっています。

property意味
titleString動画タイトル
channelNameStringチャンネル名
viewsint再生回数
publishedAtString投稿日
durationString動画時間

Videoデータを作る

Video classを使うと、動画1本分のデータを次のように作れます。

void main() {
  final video = Video(
    title: 'FlutterでYouTube風UIを作る|ListViewとCardの実践入門',
    channelName: 'Code Studio',
    views: 128000,
    publishedAt: '2日前',
    duration: '12:34',
  );

  print(video.title);
  print(video.channelName);
  print(video.views);
  print(video.publishedAt);
  print(video.duration);
}

class Video {
  const Video({
    required this.title,
    required this.channelName,
    required this.views,
    required this.publishedAt,
    required this.duration,
  });

  final String title;
  final String channelName;
  final int views;
  final String publishedAt;
  final String duration;
}

Mapでは、次のように取り出しました。

video['title']

classでは、次のように取り出します。

video.title

こちらのほうが、「videoのtitleを取り出している」と読みやすくなります。

完成形のVideo classに近づける

完成アプリでは、さらに情報が増えます。

class Video {
  const Video({
    required this.title,
    required this.channelName,
    required this.views,
    required this.publishedAt,
    required this.duration,
    required this.category,
    required this.thumbnailColor,
    required this.channelColor,
    required this.isLive,
  });

  final String title;
  final String channelName;
  final int views;
  final String publishedAt;
  final String duration;
  final String category;
  final Color thumbnailColor;
  final Color channelColor;
  final bool isLive;
}

このclassでは、動画カードに必要な情報をまとめて持てるようになっています。

property画面での使い道
titleString動画タイトル
channelNameStringチャンネル名
viewsint再生回数
publishedAtString投稿日
durationString動画時間
categoryStringサムネイル中央の文字
thumbnailColorColorサムネイル背景色
channelColorColorチャンネルアイコン色
isLiveboolライブ表示の切り替え

このように、完成アプリの見た目は、Video の中にあるデータを使って作られています。

完成アプリではVideoCardにVideoを渡す

完成アプリの中には、次のようなコードがあります。

VideoCard(video: videos[index])

これは、次のように読めます。

videosという動画一覧の中から、index番目のVideoを取り出す。
それをVideoCardに渡す。

そして、VideoCard の中では、動画データを受け取っています。

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

  final Video video;
}

この final Video video; は、次の意味です。

VideoCardは、Video型のvideoを受け取る。

つまり、画面の動画カードは、Video データをもとに作られています。

ここが、第3章と最終アプリの大事な接続点です。

完成アプリの全体像をデータ目線で見る

完成アプリの一部を、データの流れとして見ると次のようになります。

videos
↓
ListView.builder
↓
videos[index]
↓
VideoCard(video: videos[index])
↓
Thumbnail(video: video)
↓
VideoInfo(video: video)
↓
画面に表示

今はまだ全部分からなくても大丈夫です。

3-1で大切なのは、最初の部分です。

動画1本分の情報をVideoとしてまとめる

これが分かれば、次の節で List を学ぶ準備ができます。

DartPadで試す最小コード

まずはFlutterではなく、Dartだけで動かせる最小コードを試します。

void main() {
  final video = Video(
    title: 'FlutterでYouTube風UIを作る|ListViewとCardの実践入門',
    channelName: 'Code Studio',
    views: 128000,
    publishedAt: '2日前',
    duration: '12:34',
  );

  print('タイトル: ${video.title}');
  print('チャンネル: ${video.channelName}');
  print('再生回数: ${video.views}');
  print('投稿日: ${video.publishedAt}');
  print('動画時間: ${video.duration}');
}

class Video {
  const Video({
    required this.title,
    required this.channelName,
    required this.views,
    required this.publishedAt,
    required this.duration,
  });

  final String title;
  final String channelName;
  final int views;
  final String publishedAt;
  final String duration;
}

実行結果は次のようになります。

タイトル: FlutterでYouTube風UIを作る|ListViewとCardの実践入門
チャンネル: Code Studio
再生回数: 128000
投稿日: 2日前
動画時間: 12:34

ここでは、まだ画面は作りません。

まずは、動画1本分のデータを作れるようになることが目的です。

ここまでの理解チェック

ここで一度整理します。

書き方何をしているか
String title動画タイトルを文字として持つ
int views再生回数を数字として持つ
bool isLiveライブ中かどうかを持つ
Mapキーと値で動画情報をまとめる
class Video動画1本分のデータ設計図を作る
Video(...)実際の動画データを作る
video.title動画タイトルを取り出す

手を動かす練習1:動画カードの中身を見つける

次の動画カードを見て、どんなデータが含まれているかを書き出してください。

DartのListとMapをアプリ画面で使う方法をやさしく解説
App School
8500回視聴・5日前
08:21

解答例

表示されているものデータとしての意味
DartのListとMapをアプリ画面で使う方法をやさしく解説動画タイトル
App Schoolチャンネル名
8500回視聴再生回数
5日前投稿日
08:21動画時間

手を動かす練習2:バラバラの変数で書く

次の動画情報を、バラバラの変数として書いてください。

タイトル:DartのListとMapをアプリ画面で使う方法をやさしく解説
チャンネル名:App School
再生回数:8500
投稿日:5日前
動画時間:08:21

解答例

void main() {
  String title = 'DartのListとMapをアプリ画面で使う方法をやさしく解説';
  String channelName = 'App School';
  int views = 8500;
  String publishedAt = '5日前';
  String duration = '08:21';

  print(title);
  print(channelName);
  print(views);
  print(publishedAt);
  print(duration);
}

手を動かす練習3:Mapでまとめる

同じ情報を、Mapでまとめてください。

解答例

void main() {
  final video = {
    'title': 'DartのListとMapをアプリ画面で使う方法をやさしく解説',
    'channelName': 'App School',
    'views': 8500,
    'publishedAt': '5日前',
    'duration': '08:21',
  };

  print(video['title']);
  print(video['channelName']);
  print(video['views']);
  print(video['publishedAt']);
  print(video['duration']);
}

Mapを使うと、動画1本分の情報を video としてまとめられます。

手を動かす練習4:Video classでまとめる

同じ情報を、Video classでまとめてください。

解答例

void main() {
  final video = Video(
    title: 'DartのListとMapをアプリ画面で使う方法をやさしく解説',
    channelName: 'App School',
    views: 8500,
    publishedAt: '5日前',
    duration: '08:21',
  );

  print(video.title);
  print(video.channelName);
  print(video.views);
  print(video.publishedAt);
  print(video.duration);
}

class Video {
  const Video({
    required this.title,
    required this.channelName,
    required this.views,
    required this.publishedAt,
    required this.duration,
  });

  final String title;
  final String channelName;
  final int views;
  final String publishedAt;
  final String duration;
}

この形にすると、「動画とは何の情報を持つデータなのか」が分かりやすくなります。

手を動かす練習5:完成形のVideoに必要な情報を確認する

完成アプリの Video classには、次のpropertyがあります。

final String title;
final String channelName;
final int views;
final String publishedAt;
final String duration;
final String category;
final Color thumbnailColor;
final Color channelColor;
final bool isLive;

それぞれの役割を表にすると、次のようになります。

property役割
title動画タイトル
channelNameチャンネル名
views再生回数
publishedAt投稿日
duration動画時間
categoryサムネイル中央に表示するカテゴリ
thumbnailColorサムネイル背景色
channelColorチャンネルアイコン色
isLiveライブ配信中かどうか

この表を見て、「1本の動画カードを作るには、いろいろなデータが必要なんだ」と分かれば十分です。

よくあるつまずき1:Stringとintの違いがあいまいになる

初心者がつまずきやすいのは、文字と数字の違いです。

String title = 'Flutter入門';
int views = 128000;

title は文字です。

views は数字です。

データ理由
動画タイトルString文字だから
チャンネル名String文字だから
再生回数int数字として扱いたいから
投稿日String「2日前」のような文字だから
動画時間String「12:34」のように表示用の文字で扱うから

12:34 は数字のように見えますが、計算する目的ではなく表示する目的なので、この教材では String として扱います。

よくあるつまずき2:Mapとclassの違いが分からない

Mapもclassも、データをまとめるために使えます。

ただし、役割には違いがあります。

比較Mapclass
使いやすさすぐ書ける少し準備が必要
キーの打ち間違い起こりやすい起こりにくい
型の分かりやすさ弱くなりやすいはっきりする
大きなアプリ管理が難しくなりやすい管理しやすい
今回の完成アプリ途中理解に使う最終的に使う

最初はMapで「まとまり」を理解します。

そのあと、classで「安全なまとまり」にします。

よくあるつまずき3:classが急に難しく見える

classは、最初は難しく見えます。

しかし、この章では次のように考えれば大丈夫です。

class Video は、動画1本分の設計図

たとえば、次のclassは、

class Video {
  final String title;
  final String channelName;
  final int views;
}

こういう意味です。

Videoは、
titleという文字
channelNameという文字
viewsという数字
を持つデータである。

まだこの時点では、完璧に書けなくても構いません。

「classはデータの形を決めるもの」と分かることが大切です。

よくあるつまずき4:Colorが出てきて混乱する

完成アプリの Video には、Color も出てきます。

final Color thumbnailColor;
final Color channelColor;

これはFlutterの画面で使う色のデータです。

この節では、Colorの詳しい仕組みは覚えなくて大丈夫です。

今は次の理解で十分です。

thumbnailColor:
サムネイルの背景色に使う

channelColor:
チャンネルアイコンの色に使う

第3章の中心は、色の仕組みではなく、動画データをまとめる考え方です。

よくあるつまずき5:isLiveの意味が分からない

isLive は、ライブ配信中かどうかを表します。

bool isLive = false;

bool は、true または false のどちらかを持つ型です。

isLive意味画面での表示
trueライブ配信中ライブ配信中
false通常動画再生回数・投稿日

完成アプリの中では、次のように使われています。

String get metaLabel {
  if (isLive) {
    return 'ライブ配信中';
  }

  return '$viewLabel・$publishedAt';
}

これは次のように読めます。

もしライブ中なら「ライブ配信中」と表示する。
そうでなければ、再生回数と投稿日を表示する。

このように、bool は表示を切り替える条件としてよく使われます。

この節で覚える対応表

完成アプリの要素Dartでの表現初心者向けの意味
動画タイトルString title文字として持つ
チャンネル名String channelName文字として持つ
再生回数int views数字として持つ
投稿日String publishedAt表示用の文字として持つ
動画時間String duration表示用の文字として持つ
カテゴリString categoryサムネイルに出す文字
サムネイル色Color thumbnailColor見た目に使う色
チャンネル色Color channelColorアイコンに使う色
ライブ中かbool isLivetrue / falseで切り替える
動画1本分Video複数の値をまとめたデータ
動画一覧List<Video>複数の動画データ

確認問題1

次の値は、完成アプリでは何を表していますか。

title: 'FlutterでYouTube風UIを作る|ListViewとCardの実践入門'

答え

動画タイトルです。

確認問題2

次の値は、どの型で持つのが自然ですか。

views: 128000

答え

int です。

再生回数は数字として扱うためです。

確認問題3

次の値は、何を表していますか。

isLive: false

答え

ライブ配信中ではない、という意味です。

false は「いいえ」を表します。

確認問題4

動画1本分の情報をまとめるために、完成アプリでは何というclassを使っていますか。

答え

Video classです。

確認問題5

なぜ動画データをバラバラの変数ではなく、Video としてまとめる必要がありますか。

答え

動画が増えたときに管理しやすくするためです。

また、動画タイトル、チャンネル名、再生回数などを「動画1本分の情報」として安全に扱えるようにするためです。

この節のまとめ

この節では、YouTube風アプリの完成形に向けて、動画データを「ただの値」から「1本分の情報」として見る考え方を学びました。

最初は、動画タイトルや再生回数を単独の変数として見ました。

次に、それらが集まると動画1本分のデータになることを確認しました。

そして、最終的には Video classを使って、動画1本分の情報をまとめることを学びました。

この節のポイント:
- アプリ画面には、裏側のデータがある
- 動画カードには複数の情報が含まれている
- バラバラの変数では、動画が増えたときに管理しにくい
- Mapを使うと、キーと値でデータをまとめられる
- classを使うと、動画1本分の形を安全に決められる
- 完成アプリでは、Video classが動画カードの元データになる

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

YouTube風の動画カードは、Videoというデータを画面に表示したものである。

次の 3-2 では、動画1本ではなく、複数の動画を一覧として扱うために、ListMap を学びます。

教材トップへ戻る