
動画データを「ただの値」から「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-2 | ListとMapで動画一覧の元データを作る | List / Map | 複数の動画データを一覧として持つ |
| 3-3 | classでVideoデータを設計する | class / instance | 動画1本分の設計図を作る |
| 3-4 | constructorとpropertyで動画の持ち物を決める | constructor / property | title、channelName、viewsなどを持たせる |
| 3-5 | methodで動画データに表示用のふるまいを持たせる | method | 再生回数や投稿日を表示用テキストに変える |
| 3-6 | カプセル化で安全に使えるVideo部品を作る | final / getter | 間違ったデータ変更を防ぐ |
| 3-7 | List・Map・classを組み合わせて動画一覧を操作する | データ操作 | 人気動画だけ表示、カテゴリ別に分ける |
| 3-8 | YouTube風ミニアプリ用のクラスを設計する | 設計演習 | 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本が持っている情報の名前です。
右側には、実際の値が入っています。
| 名前 | 実際の値 | 意味 |
|---|---|---|
| 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(...) の中には、動画カードを表示するために必要な情報がまとまっています。
いきなり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本になると、管理がかなりつらくなります。
バラバラ管理で起きる問題
| 困ること | 具体例 |
|---|---|
| 変数名が増える | title1、title2、title3 と増えていく |
| 対応を間違えやすい | 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では、キーと値の組み合わせでデータを持ちます。
| キー | 値 |
|---|---|
| title | FlutterでYouTube風UIを作る|ListViewとCardの実践入門 |
| channelName | Code Studio |
| views | 128000 |
| publishedAt | 2日前 |
| duration | 12:34 |
| category | Flutter |
| isLive | false |
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,
};
title は String。
views は int。
isLive は bool。
このように型が混ざるため、アプリが大きくなると管理が難しくなることがあります。
そこで、次に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 | 型 | 意味 |
|---|---|---|
| title | String | 動画タイトル |
| channelName | String | チャンネル名 |
| views | int | 再生回数 |
| publishedAt | String | 投稿日 |
| duration | String | 動画時間 |
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 | 型 | 画面での使い道 |
|---|---|---|
| title | String | 動画タイトル |
| channelName | String | チャンネル名 |
| views | int | 再生回数 |
| publishedAt | String | 投稿日 |
| duration | String | 動画時間 |
| category | String | サムネイル中央の文字 |
| thumbnailColor | Color | サムネイル背景色 |
| channelColor | Color | チャンネルアイコン色 |
| isLive | bool | ライブ表示の切り替え |
このように、完成アプリの見た目は、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も、データをまとめるために使えます。
ただし、役割には違いがあります。
| 比較 | Map | class |
|---|---|---|
| 使いやすさ | すぐ書ける | 少し準備が必要 |
| キーの打ち間違い | 起こりやすい | 起こりにくい |
| 型の分かりやすさ | 弱くなりやすい | はっきりする |
| 大きなアプリ | 管理が難しくなりやすい | 管理しやすい |
| 今回の完成アプリ | 途中理解に使う | 最終的に使う |
最初は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 isLive | true / 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本ではなく、複数の動画を一覧として扱うために、List と Map を学びます。