
下部のユーザー名・キャプション・音源表示を作る
この節で学ぶこと
前回の 4-11 では、コメントボタンを押したときに、下からTikTok風のコメント入力画面を表示する方法を学びました。
コメントボタンを押す
↓
BottomSheetが開く
↓
コメント一覧が表示される
↓
TextFieldでコメントを入力する
↓
送信するとコメントが追加される
今回の 4-12 では、動画の下部に表示される情報を作ります。
TikTok風アプリでは、動画の下に次のような情報が表示されます。
@pet_cafe_diary
小さな命の動きは、見ているだけで少しやさしい気持ちになる。
PET ♪ Healing Cafe Sound - Pet Cafe Diary
この部分は、ただ文字を置いているだけに見えるかもしれません。
しかし、実際にはかなり重要です。
なぜなら、下部情報には「誰の投稿か」「どんな内容か」「どんな音源か」がまとまっているからです。
この節で大切なのは、次の一文です。
下部情報は、動画データをユーザーに伝えるための情報表示エリアである。
まず下部情報を分解する
TikTok風アプリの下部情報は、いくつかの部品に分けられます。
BottomVideoInfo
├─ ユーザー名
├─ キャプション
└─ カテゴリ + 音源情報
それぞれの役割は、次の通りです。
| 表示 | 役割 |
|---|---|
| ユーザー名 | 誰の投稿かを示す |
| キャプション | 動画の説明文を表示する |
| カテゴリ | 動画のテーマを短く示す |
| 音源情報 | 使用されている音源名を表示する |
今回の完成イメージは、次のようなものです。
@pet_cafe_diary
小さな命の動きは、見ているだけで少しやさしい気持ちになる。今日はペットカフェ風の癒し動画。
PET ♪ Healing Cafe Sound - Pet Cafe Diary
動画背景の上に白い文字を重ねるため、文字色や影も工夫します。
下部情報はどのデータから作るのか
前の節までに、動画データを ShortVideo classとして設計しました。
今回使う主なpropertyは、次の4つです。
final String userName;
final String caption;
final String musicTitle;
final String categoryLabel;
それぞれ、画面では次のように使います。
| property | 画面での表示例 | 役割 |
|---|---|---|
userName | @pet_cafe_diary | 投稿者名 |
caption | 小さな命の動きは... | 説明文 |
musicTitle | Healing Cafe Sound... | 音源名 |
categoryLabel | PET | カテゴリラベル |
ここでも、基本の流れは同じです。
ShortVideoデータ
↓
BottomVideoInfoに渡す
↓
TextやRowで表示する
新しい言葉:情報設計とは何か
ここで、少し大切な考え方として「情報設計」という言葉を紹介します。
情報設計とは、どの情報を、どの順番で、どのように見せるかを考えることです。
初心者向けには、次のように理解してください。
情報設計 = ユーザーが分かりやすい順番で情報を並べること
TikTok風の下部情報では、次の順番が自然です。
1. 誰の投稿か
2. どんな内容か
3. どんなカテゴリ・音源か
そのため、上から順に、
ユーザー名
キャプション
カテゴリ + 音源
と並べます。
Columnで縦に並べる
下部情報は、縦方向に並んでいます。
そのため、Column を使います。
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('@pet_cafe_diary'),
Text('小さな命の動きは...'),
Row(
children: [
Text('PET'),
Icon(Icons.music_note_rounded),
Text('Healing Cafe Sound'),
],
),
],
)
構造で見ると、次のようになります。
Column
├─ Text ユーザー名
├─ Text キャプション
└─ Row カテゴリ + 音源
Column は縦に並べるWidgetです。
Row は横に並べるWidgetです。
今回の下部情報では、この2つを組み合わせます。
crossAxisAlignmentとは何か
Column で文字を左揃えにしたい場合は、crossAxisAlignment を使います。
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...
],
)
CrossAxisAlignment.start は、左揃えという意味です。
TikTok風アプリでは、下部情報は左下に配置されるため、左揃えが自然です。
@pet_cafe_diary
小さな命の動きは...
PET ♪ Healing Cafe Sound
中央揃えではなく、左揃えにすることで、投稿情報として読みやすくなります。
まず下部情報だけ作る
最初に、動画や右側ボタンを考えず、下部情報だけを作ってみます。
DartPadに次のコードを貼り付けてください。

import 'package:flutter/material.dart';
void main() {
runApp(const BottomInfoPracticeApp());
}
class BottomInfoPracticeApp extends StatelessWidget {
const BottomInfoPracticeApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: BottomInfoPracticePage(),
);
}
}
class ShortVideo {
const ShortVideo({
required this.userName,
required this.caption,
required this.musicTitle,
required this.categoryLabel,
});
final String userName;
final String caption;
final String musicTitle;
final String categoryLabel;
}
const video = ShortVideo(
userName: 'pet_cafe_diary',
caption: '小さな命の動きは、見ているだけで少しやさしい気持ちになる。今日はペットカフェ風の癒し動画。',
musicTitle: 'Healing Cafe Sound - Pet Cafe Diary',
categoryLabel: 'PET',
);
class BottomInfoPracticePage extends StatelessWidget {
const BottomInfoPracticePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: BottomVideoInfo(video: video),
),
),
);
}
}
class BottomVideoInfo extends StatelessWidget {
const BottomVideoInfo({
super.key,
required this.video,
});
final ShortVideo video;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'@${video.userName}',
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
video.caption,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
height: 1.35,
),
),
const SizedBox(height: 10),
Row(
children: [
Text(
video.categoryLabel,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
const Icon(
Icons.music_note_rounded,
color: Colors.white,
size: 17,
),
const SizedBox(width: 5),
Text(
video.musicTitle,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
),
),
],
),
],
);
}
}
実行して確認すること
実行すると、黒背景の上に下部情報が表示されます。
@pet_cafe_diary
小さな命の動きは、見ているだけで少しやさしい気持ちになる。今日はペットカフェ風の癒し動画。
PET ♪ Healing Cafe Sound - Pet Cafe Diary
この段階では、まだ画面下部に固定していません。
まずは、BottomVideoInfo という部品そのものを理解することが目的です。
@${video.userName} の意味
次のコードを見てください。
Text(
'@${video.userName}',
)
これは、ユーザー名の前に @ をつけて表示しています。
video.userName が、
pet_cafe_diary
なら、画面には次のように表示されます。
@pet_cafe_diary
'@${video.userName}' のような書き方は、文字列の中に変数の値を埋め込む方法です。
新しい言葉:文字列補間とは何か
'@${video.userName}' のように、文字列の中に変数を入れる書き方を、文字列補間と呼びます。
初心者向けには、次のように理解してください。
文字列補間 = 文字の中に変数の値を入れる書き方
例です。
final name = 'pet_cafe_diary';
print('@$name');
結果は次のようになります。
@pet_cafe_diary
@${video.userName} のように {} を使うと、propertyにも対応しやすくなります。
SizedBoxで余白を作る
次のようなコードが出てきます。
const SizedBox(height: 8),
これは、縦方向の余白を作っています。
ユーザー名
↓ 8pxの余白
キャプション
SizedBox は、余白を作るときによく使います。
| 書き方 | 意味 |
|---|---|
SizedBox(height: 8) | 縦方向に8pxの余白 |
SizedBox(width: 8) | 横方向に8pxの余白 |
UIでは、情報同士が詰まりすぎると読みにくくなります。
そのため、適度な余白を入れることが大切です。
height: 1.35とは何か
キャプションの TextStyle には、height があります。
style: const TextStyle(
color: Colors.white,
fontSize: 14,
height: 1.35,
),
この height は、行の高さです。
キャプションが2行以上になったとき、行間を調整できます。
heightが小さい
↓
行間が詰まる
heightが大きい
↓
行間が広がる
文章が読みやすくなるように、1.3 から 1.5 くらいを使うことがあります。
長い文字がはみ出す問題
音源名が長い場合、画面からはみ出すことがあります。
たとえば、次のような音源名です。
Healing Cafe Sound - Pet Cafe Diary Very Long Long Music Title
このまま Row に入れると、横幅が足りなくなり、エラーやはみ出しの原因になることがあります。
そこで使うのが、Expanded と overflow です。
新しい言葉:Expandedとは何か
Expanded は、Row や Column の中で、残りのスペースを使うためのWidgetです。
Expanded(
child: Text(
video.musicTitle,
),
)
Row の中で Expanded を使うと、残っている横幅の中でTextを表示してくれます。
Row
├─ PET
├─ ♪
└─ Expanded 音源名
音源名が長い場合でも、残り幅の中に収めやすくなります。
新しい言葉:overflowとは何か
overflow は、文字が入りきらないときに、どう表示するかを決める設定です。
overflow: TextOverflow.ellipsis,
TextOverflow.ellipsis は、入りきらない文字を ... で省略する設定です。
Healing Cafe Sound - Pet Cafe Diary Very Long...
TikTok風アプリでは、音源名やキャプションが長くなりすぎることがあります。
そのため、maxLines や overflow を使って、見た目を崩さないようにします。
下部情報をより実用的にするコード
次に、Expanded、maxLines、overflow、カテゴリラベルの装飾を追加します。
DartPadに次のコードを貼り付けてください。

import 'package:flutter/material.dart';
void main() {
runApp(const BottomInfoStyledApp());
}
class BottomInfoStyledApp extends StatelessWidget {
const BottomInfoStyledApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: BottomInfoStyledPage(),
);
}
}
class ShortVideo {
const ShortVideo({
required this.userName,
required this.caption,
required this.musicTitle,
required this.categoryLabel,
});
final String userName;
final String caption;
final String musicTitle;
final String categoryLabel;
}
const video = ShortVideo(
userName: 'pet_cafe_diary',
caption: '小さな命の動きは、見ているだけで少しやさしい気持ちになる。今日はペットカフェ風の癒し動画。何気ない日常の一瞬を、ショート動画として残します。',
musicTitle: 'Healing Cafe Sound - Pet Cafe Diary Very Long Music Title',
categoryLabel: 'PET',
);
class BottomInfoStyledPage extends StatelessWidget {
const BottomInfoStyledPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: const [
Positioned.fill(
child: VideoMockBackground(),
),
Positioned(
left: 16,
right: 96,
bottom: 36,
child: BottomVideoInfo(video: video),
),
],
),
);
}
}
class VideoMockBackground extends StatelessWidget {
const VideoMockBackground({super.key});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFFE91E63),
Colors.black,
],
),
),
child: Center(
child: Container(
width: 240,
height: 360,
decoration: BoxDecoration(
color: Colors.white12,
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: Colors.white24,
),
),
child: const Center(
child: Text(
'PET',
style: TextStyle(
color: Colors.white,
fontSize: 40,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
),
),
),
);
}
}
class BottomVideoInfo extends StatelessWidget {
const BottomVideoInfo({
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: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 3,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.16),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: Colors.white.withOpacity(0.18),
),
),
child: Text(
video.categoryLabel,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
const Icon(
Icons.music_note_rounded,
color: Colors.white,
size: 17,
),
const SizedBox(width: 5),
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,
),
],
),
),
),
],
),
],
),
);
}
}
実行して確認すること
実行すると、画面左下に投稿情報が表示されます。
長いキャプションは2行まで表示され、それ以上は ... で省略されます。
長い音源名も1行で省略されます。
@pet_cafe_diary
小さな命の動きは、見ているだけで少しやさしい気持ちになる。今日は...
PET ♪ Healing Cafe Sound - Pet Cafe Diary Very Long...
ここで確認するポイントは、次の3つです。
1. 長い文章が画面からはみ出していない
2. 右側ボタンが入るスペースを空けている
3. 動画背景の上でも文字が読みやすい
Positionedで左下に配置する
今回、下部情報は Stack の中で Positioned を使って配置しています。
Positioned(
left: 16,
right: 96,
bottom: 36,
child: BottomVideoInfo(video: video),
),
これは、次の意味です。
左から16px
右から96px
下から36px
の範囲にBottomVideoInfoを置く
右側を 96px 空けているのは、右側アクションバーと重ならないようにするためです。
下部情報
↓
右側ボタンに重ならないように右側に余白を取る
TikTok風UIでは、右側にいいね・コメント・保存・共有ボタンがあるため、下部テキストを画面いっぱいに広げると重なってしまいます。
そのため、right: 96 のように右側に余白を取ります。
SafeAreaを使う理由
BottomVideoInfo の中では、SafeArea を使っています。
return SafeArea(
top: false,
child: Column(...),
);
SafeArea は、スマホ画面の端やホームインジケーターにUIが重ならないようにするWidgetです。
下部情報は画面下に近いため、端末によっては見切れる可能性があります。
SafeArea を使うと、安全な範囲に収まりやすくなります。
SafeArea
↓
画面端やノッチ、ホームバーに重なりにくくする
top: false は、上方向のSafeAreaは気にしないという意味です。
今回のWidgetは下部に置くため、上方向の余白は不要です。
文字に影をつける理由
動画背景の上に白い文字を置くと、映像によっては見づらくなります。
そのため、文字に影をつけています。
shadows: [
Shadow(
color: Colors.black87,
blurRadius: 8,
),
],
これは、文字の後ろにぼかした黒い影をつける設定です。
白文字だけ
↓
明るい背景では読みにくい
白文字 + 黒い影
↓
明るい背景でも読みやすい
TikTok風UIでは、動画の内容が常に変わるため、文字の可読性を上げる工夫が必要です。
カテゴリラベルをContainerで作る
カテゴリラベルは、Container で小さなバッジ風にしています。
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 3,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.16),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: Colors.white.withOpacity(0.18),
),
),
child: Text(
video.categoryLabel,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
)
Container を使うことで、背景色、余白、角丸、枠線をつけられます。
PET
↓
小さな丸みのあるラベルとして表示
カテゴリは必須ではありませんが、動画のテーマが分かりやすくなります。
BorderRadius.circular(999)とは何か
カテゴリラベルでは、次のように書いています。
borderRadius: BorderRadius.circular(999),
これは、角を大きく丸める指定です。
数字を大きくすると、カプセル型のような丸いラベルになります。
角が少し丸い
↓
角が大きく丸い
↓
カプセル型
小さなラベルやボタンを丸くしたいときによく使います。
Rowでカテゴリと音源を横に並べる
カテゴリと音源情報は、横に並べています。
Row(
children: [
カテゴリラベル,
SizedBox(width: 8),
音符アイコン,
SizedBox(width: 5),
Expanded(
child: Text(video.musicTitle),
),
],
)
構造は次の通りです。
Row
├─ PET
├─ 余白
├─ ♪
├─ 余白
└─ 音源名
このように、横並びの情報には Row を使います。
maxLinesとは何か
Text では、maxLines を使っています。
maxLines: 2,
これは、最大で何行まで表示するかを決める設定です。
キャプションでは2行まで表示しています。
Text(
video.caption,
maxLines: 2,
overflow: TextOverflow.ellipsis,
)
音源名では1行まで表示しています。
Text(
video.musicTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
長い文章を無限に表示すると、画面が崩れます。
そのため、表示行数を制限します。
完成アプリとのつながり
最終的なTikTok風アプリでは、BottomVideoInfo は ShortVideoPage の中で使います。
Positioned(
left: 16,
right: 86,
bottom: 28,
child: BottomVideoInfo(video: video),
),
video は、PageView.builder で取り出した動画データです。
final video = videos[videoIndex];
つまり、ページが切り替わるたびに、下部情報も変わります。
videos[0]
↓
@pet_cafe_diary
videos[1]
↓
@food_and_nature
videos[2]
↓
@daily_pet_room
同じ BottomVideoInfo でも、渡すデータによって表示が変わります。
データとUIの関係をもう一度整理する
今回の下部情報は、固定文字ではなく、ShortVideo データから作っています。
Text('@${video.userName}')
Text(video.caption)
Text(video.musicTitle)
Text(video.categoryLabel)
この考え方がとても重要です。
データを変える
↓
UIの表示が変わる
たとえば、動画データを次のように変えます。
userName: 'food_and_nature',
caption: 'おいしいものを探す旅の途中で出会った、自然の小さなリズム。',
musicTitle: 'Kitchen Walk - Food & Nature',
categoryLabel: 'FOOD',
すると、同じ BottomVideoInfo を使っていても、表示が変わります。
@food_and_nature
おいしいものを探す旅の途中で出会った...
FOOD ♪ Kitchen Walk - Food & Nature
これが、Widgetを部品化し、データを渡すメリットです。
手を動かす練習1:ユーザー名を変える
次の部分を探してください。
userName: 'pet_cafe_diary',
これを次のように変えてみましょう。
userName: 'food_trip_japan',
画面の表示が、
@food_trip_japan
に変わります。
手を動かす練習2:キャプションを短くする
次の caption を短くしてみます。
caption: '今日はペットカフェ風の癒し動画。',
短くすると、省略記号が出なくなります。
この練習で、maxLines と overflow がどのように働くかを確認できます。
手を動かす練習3:キャプションの最大行数を変える
次の部分を探してください。
maxLines: 2,
これを次のように変えてみます。
maxLines: 3,
キャプションが最大3行まで表示されるようになります。
ただし、表示する行数を増やすと、画面下部のスペースを多く使います。
TikTok風UIでは、動画を邪魔しすぎないバランスが大切です。
手を動かす練習4:カテゴリラベルを変える
次の部分を探してください。
categoryLabel: 'PET',
これを次のように変えてみます。
categoryLabel: 'FOOD',
カテゴリラベルの表示が変わります。
手を動かす練習5:右側余白を変える
次の部分を探してください。
right: 96,
これを次のように変えてみます。
right: 40,
下部情報の横幅が広がります。
ただし、右側アクションバーと重なりやすくなります。
TikTok風UIでは、右側にボタンがあることを想定して余白を取る必要があります。
よくあるつまずき1:Rowの中で文字がはみ出す
音源名が長い場合、Row の中で文字がはみ出すことがあります。
その場合は、Expanded で包みます。
Expanded(
child: Text(
video.musicTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
)
Expanded がないと、Textが必要なだけ横幅を取ろうとして、画面からはみ出すことがあります。
よくあるつまずき2:文字が背景に埋もれる
動画背景が明るい場合、白文字が読みにくくなります。
対策として、文字に影をつけます。
shadows: [
Shadow(
color: Colors.black87,
blurRadius: 8,
),
],
また、前の節で作ったような黒いグラデーションを動画の上に重ねるのも有効です。
動画
↓
黒グラデーション
↓
白文字
よくあるつまずき3:下部情報が右側ボタンに重なる
Positioned で下部情報を置くときに、right を指定していないと、右側ボタンと重なることがあります。
Positioned(
left: 16,
right: 96,
bottom: 36,
child: BottomVideoInfo(video: video),
)
right: 96 を入れることで、右側に余白を作れます。
よくあるつまずき4:constが使えない
BottomVideoInfo(video: video) のように、変数を渡す場合は、const をつけられないことがあります。
BottomVideoInfo(video: video)
video は実行中に渡される値なので、固定ではありません。
そのため、const BottomVideoInfo(video: video) でエラーになる場合があります。
エラーになったら、const を外してください。
よくあるつまずき5:SafeAreaの意味が分からない
下部情報は画面下に近いため、端末によってはホームバーや画面端と重なる可能性があります。
SafeArea を使うと、見切れを避けやすくなります。
SafeArea(
top: false,
child: Column(...),
)
top: false は、上側の余白は気にしないという意味です。
この節の確認問題
確認問題1
下部情報には、どのような情報を表示しますか。
答え
ユーザー名、キャプション、カテゴリ、音源情報を表示します。
確認問題2
BottomVideoInfo に ShortVideo データを渡す理由は何ですか。
答え
動画ごとに、ユーザー名やキャプション、音源情報を変えて表示するためです。
確認問題3
Column は何のために使いましたか。
答え
ユーザー名、キャプション、カテゴリ・音源情報を縦に並べるために使いました。
確認問題4
Row は何のために使いましたか。
答え
カテゴリラベル、音符アイコン、音源名を横に並べるために使いました。
確認問題5
Expanded はなぜ必要ですか。
答え
長い音源名が画面からはみ出さないように、残りの横幅の中で表示するためです。
確認問題6
overflow: TextOverflow.ellipsis は何をしますか。
答え
文字が入りきらないときに、末尾を ... で省略します。
確認問題7
下部情報を配置するとき、right: 96 のように右側余白を取る理由は何ですか。
答え
右側アクションバーと下部テキストが重ならないようにするためです。
この節のまとめ
この節では、TikTok風アプリの下部に表示するユーザー名・キャプション・音源情報を作りました。
下部情報は、次のような構造です。
BottomVideoInfo
├─ ユーザー名
├─ キャプション
└─ カテゴリ + 音源情報
Column で縦に並べ、音源情報の部分だけ Row で横に並べました。
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('@${video.userName}'),
Text(video.caption),
Row(
children: [
Text(video.categoryLabel),
Icon(Icons.music_note_rounded),
Expanded(
child: Text(video.musicTitle),
),
],
),
],
)
また、長い文章がはみ出さないように、maxLines、overflow、Expanded を使いました。
maxLines: 1,
overflow: TextOverflow.ellipsis,
この節で一番大切なのは、次の一文です。
下部情報は、ShortVideoデータを受け取り、ユーザーが理解しやすい順番で表示するWidgetである。
次の節では、これまで作ってきた部品を整理し、Widgetを分割して読みやすいコードに整えていきます。