
いいね・保存・共有の状態を変える
この節で学ぶこと
前回の 4-9 では、TikTok風アプリの右側に並ぶボタンを RightActionBar として部品化しました。
RightActionBar
├─ ProfileButton
├─ ActionButton いいね
├─ ActionButton コメント
├─ ActionButton 保存
├─ ActionButton 共有
└─ SpinningDisc
前回の段階では、ボタンは表示されるだけでした。
つまり、ハートを押しても色は変わりません。
保存ボタンを押しても、保存済みになりません。
共有ボタンを押しても、見た目は変わりません。
今回の 4-10 では、ボタンを押したときに画面の状態を変えます。
具体的には、次のような動きを作ります。
いいねを押す
↓
ハートがピンクになる
↓
いいね数が1増える
もう一度押す
↓
ハートが白に戻る
↓
いいね数も元に戻る
保存と共有も、同じように押した状態を記録して、色を変えます。
この節で大切なのは、次の一文です。
ユーザー操作で画面を変えるには、状態を持ち、setStateで画面を更新する。
まず「状態」とは何か
状態とは、画面に影響する現在の値です。
たとえば、TikTok風アプリでは、次のようなものが状態です。
| 状態 | 例 |
|---|---|
| いいねしているか | ハートが白かピンクか |
| 保存しているか | 保存アイコンが白か黄色か |
| 共有したか | 共有アイコンが白か緑か |
| 今見ている動画 | 1本目か2本目か3本目か |
| コメント一覧 | コメントが何件あるか |
今回扱うのは、いいね・保存・共有の状態です。
押していない状態
↓
白いアイコン
押した状態
↓
色つきアイコン
画面の見た目が変わるため、Flutterに「状態が変わったよ」と知らせる必要があります。
そのために使うのが setState です。
新しい言葉:setStateとは何か
setState は、状態が変わったことをFlutterに知らせるための関数です。
setState(() {
isLiked = true;
});
初心者向けには、次のように理解してください。
setState = 値が変わったので、画面を作り直してくださいとFlutterに伝える命令
Flutterでは、変数の値を変えただけでは、画面が自動で変わらないことがあります。
画面に反映したい場合は、setState の中で値を変えます。
値を変える
↓
setStateでFlutterに知らせる
↓
buildがもう一度呼ばれる
↓
画面が更新される
StatelessWidgetではなくStatefulWidgetが必要
前回の右側ボタンは、状態が変わらない部品として作りました。
そのため、多くのWidgetは StatelessWidget でした。
しかし、今回のようにボタンを押して画面を変える場合は、状態を持つ必要があります。
状態を持つ画面には StatefulWidget を使います。
StatelessWidget
↓
表示が変わらない部品
StatefulWidget
↓
ユーザー操作などで表示が変わる部品
今回の練習では、画面全体を StatefulWidget にして、いいね・保存・共有の状態を管理します。
まずは1つのハートだけで考える
いきなり右側アクションバー全体を作る前に、まずはハート1つで考えます。
必要なのは、次の状態です。
bool isLiked = false;
bool は、true か false のどちらかを持つ型です。
| 値 | 意味 |
|---|---|
false | いいねしていない |
true | いいねしている |
この値によって、ハートの色を変えます。
color: isLiked ? Colors.pink : Colors.white
これは、次の意味です。
isLiked が true ならピンク
そうでなければ白
新しい言葉:boolとは何か
bool は、真偽値を表す型です。
真偽値とは、true か false のどちらかです。
bool isLiked = false;
この場合、isLiked は最初 false です。
つまり、まだいいねしていない状態です。
isLiked = false
↓
いいねしていない
isLiked = true
↓
いいねしている
isLiked のように、is から始まる名前は、true / false を表す変数によく使われます。
まずはハートだけを動かすコード
DartPadに次のコードを貼り付けてください。


import 'package:flutter/material.dart';
void main() {
runApp(const LikeButtonPracticeApp());
}
class LikeButtonPracticeApp extends StatelessWidget {
const LikeButtonPracticeApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: LikeButtonPracticePage(),
);
}
}
class LikeButtonPracticePage extends StatefulWidget {
const LikeButtonPracticePage({super.key});
@override
State<LikeButtonPracticePage> createState() => _LikeButtonPracticePageState();
}
class _LikeButtonPracticePageState extends State<LikeButtonPracticePage> {
bool isLiked = false;
int likeCount = 12800;
void toggleLike() {
setState(() {
if (isLiked) {
isLiked = false;
likeCount = likeCount - 1;
} else {
isLiked = true;
likeCount = likeCount + 1;
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: GestureDetector(
onTap: toggleLike,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.favorite_rounded,
color: isLiked ? const Color(0xFFFF2D55) : Colors.white,
size: 72,
),
const SizedBox(height: 8),
Text(
formatCount(likeCount),
style: TextStyle(
color: isLiked ? const Color(0xFFFF2D55) : Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 20),
const Text(
'ハートをタップしてください',
style: TextStyle(
color: Colors.white54,
fontSize: 14,
),
),
],
),
),
),
);
}
}
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();
}
実行して確認すること
実行したら、ハートをタップしてみてください。
タップ前
白いハート
1.3万
タップ後
ピンクのハート
1.3万から少し増える
もう一度押すと、白いハートに戻ります。
この小さなコードで、次の流れを確認できます。
GestureDetectorでタップを受け取る
↓
toggleLikeが呼ばれる
↓
setStateでisLikedとlikeCountを変える
↓
画面が更新される
toggleLikeの解説
重要なのは、この部分です。
void toggleLike() {
setState(() {
if (isLiked) {
isLiked = false;
likeCount = likeCount - 1;
} else {
isLiked = true;
likeCount = likeCount + 1;
}
});
}
toggle は、切り替えるという意味です。
つまり、toggleLike は、いいね状態を切り替える処理です。
いいねしている
↓
いいねを外す
いいねしていない
↓
いいねする
if (isLiked) は、次の意味です。
もし isLiked が true なら
つまり、すでにいいねしている場合です。
その場合は、いいねを外します。
isLiked = false;
likeCount = likeCount - 1;
逆に、まだいいねしていない場合は、いいねします。
isLiked = true;
likeCount = likeCount + 1;
GestureDetectorとは何か
ハートをタップできるようにしているのが、GestureDetector です。
GestureDetector(
onTap: toggleLike,
child: Column(...),
)
GestureDetector は、タップなどの操作を検知するWidgetです。
| 書き方 | 意味 |
|---|---|
GestureDetector | 操作を検知する |
onTap | タップされたときの処理 |
child | タップ対象になるWidget |
今回の場合、ハートと数字を含む Column をタップ対象にしています。
色を状態によって変える
ハートの色は、次のコードで変えています。
color: isLiked ? const Color(0xFFFF2D55) : Colors.white,
これは、三項演算子です。
条件 ? trueのとき : falseのとき
今回の場合は、次の意味です。
isLiked が true ならピンク
false なら白
数字の色も同じように変えています。
color: isLiked ? const Color(0xFFFF2D55) : Colors.white,
状態に応じて見た目を切り替えるときによく使います。
次に右側アクションバーへ広げる
ここまでで、1つのハートの状態変更が分かりました。
次に、TikTok風の右側アクションバーに広げます。
必要なのは、次の3つの状態です。
bool isLiked = false;
bool isSaved = false;
bool isShared = false;
それぞれの意味は、次の通りです。
| 変数 | 意味 |
|---|---|
isLiked | いいねしているか |
isSaved | 保存しているか |
isShared | 共有したか |
ただし、最終アプリでは動画が複数あります。
そのため、1つの bool だけでは足りません。
動画1はいいね済み
動画2は未いいね
動画3は保存済み
このように、動画ごとに状態を持つ必要があります。
そこで、Set<int> を使います。
新しい言葉:Setとは何か
Set は、重複しない値の集まりです。
final Set<int> likedVideoIndexes = {};
これは、いいねした動画番号を入れるための箱です。
たとえば、0番目と2番目の動画にいいねした場合は、次のようなイメージです。
likedVideoIndexes
{0, 2}
Set は、同じ値を重複して持ちません。
{0, 2, 2}
にはならない
{0, 2}
になる
いいね済みかどうかを判断するには、contains を使います。
likedVideoIndexes.contains(videoIndex)
これは、次の意味です。
likedVideoIndexesの中にvideoIndexが含まれているか
Setで状態を管理する考え方
動画が3本あるとします。
videos[0] PET
videos[1] FOOD
videos[2] ROOM
0番目の動画にいいねした場合、
likedVideoIndexes.add(0);
となります。
1番目の動画を保存した場合、
savedVideoIndexes.add(1);
となります。
2番目の動画を共有した場合、
sharedVideoIndexes.add(2);
となります。
状態を確認するときは、次のようにします。
likedVideoIndexes.contains(videoIndex)
savedVideoIndexes.contains(videoIndex)
sharedVideoIndexes.contains(videoIndex)
新しい言葉:add / remove / contains
Set では、次の操作をよく使います。
| 操作 | 意味 |
|---|---|
add | 値を追加する |
remove | 値を削除する |
contains | 値が含まれているか確認する |
たとえば、いいねする場合です。
likedVideoIndexes.add(videoIndex);
いいねを外す場合です。
likedVideoIndexes.remove(videoIndex);
いいね済みか確認する場合です。
likedVideoIndexes.contains(videoIndex);
この3つが分かると、いいね・保存・共有の状態管理ができます。
右側アクションバーで状態を変えるコード
ここから、TikTok風の右側アクションバーで、いいね・保存・共有の状態が変わるコードを作ります。
DartPadに次のコードを貼り付けてください。


import 'package:flutter/material.dart';
void main() {
runApp(const ActionStatePracticeApp());
}
class ActionStatePracticeApp extends StatelessWidget {
const ActionStatePracticeApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: ActionStatePracticePage(),
);
}
}
class ShortVideo {
const ShortVideo({
required this.userName,
required this.caption,
required this.likes,
required this.comments,
required this.saves,
required this.shares,
required this.avatarColor,
required this.categoryLabel,
});
final String userName;
final String caption;
final int likes;
final int comments;
final int saves;
final int shares;
final Color avatarColor;
final String categoryLabel;
}
const videos = [
ShortVideo(
userName: 'pet_cafe_diary',
caption: '小さな命の動きは、見ているだけで少しやさしい気持ちになる。',
likes: 12800,
comments: 324,
saves: 1204,
shares: 856,
avatarColor: Color(0xFFE91E63),
categoryLabel: 'PET',
),
ShortVideo(
userName: 'food_and_nature',
caption: 'おいしいものを探す旅の途中で出会った、自然の小さなリズム。',
likes: 8732,
comments: 128,
saves: 902,
shares: 411,
avatarColor: Color(0xFF2196F3),
categoryLabel: 'FOOD',
),
ShortVideo(
userName: 'daily_pet_room',
caption: 'ペットと過ごす午後。何気ない一瞬が、あとから思い出になる。',
likes: 24100,
comments: 642,
saves: 3010,
shares: 1112,
avatarColor: Color(0xFFFF9800),
categoryLabel: 'ROOM',
),
];
class ActionStatePracticePage extends StatefulWidget {
const ActionStatePracticePage({super.key});
@override
State<ActionStatePracticePage> createState() =>
_ActionStatePracticePageState();
}
class _ActionStatePracticePageState extends State<ActionStatePracticePage> {
final Set<int> likedVideoIndexes = {};
final Set<int> savedVideoIndexes = {};
final Set<int> sharedVideoIndexes = {};
void toggleLike(int videoIndex) {
setState(() {
if (likedVideoIndexes.contains(videoIndex)) {
likedVideoIndexes.remove(videoIndex);
} else {
likedVideoIndexes.add(videoIndex);
}
});
}
void toggleSave(int videoIndex) {
setState(() {
if (savedVideoIndexes.contains(videoIndex)) {
savedVideoIndexes.remove(videoIndex);
} else {
savedVideoIndexes.add(videoIndex);
}
});
}
void toggleShare(int videoIndex) {
setState(() {
if (sharedVideoIndexes.contains(videoIndex)) {
sharedVideoIndexes.remove(videoIndex);
} else {
sharedVideoIndexes.add(videoIndex);
}
});
}
int displayLikeCount(int videoIndex) {
final base = videos[videoIndex].likes;
if (likedVideoIndexes.contains(videoIndex)) {
return base + 1;
}
return base;
}
int displaySaveCount(int videoIndex) {
final base = videos[videoIndex].saves;
if (savedVideoIndexes.contains(videoIndex)) {
return base + 1;
}
return base;
}
int displayShareCount(int videoIndex) {
final base = videos[videoIndex].shares;
if (sharedVideoIndexes.contains(videoIndex)) {
return base + 1;
}
return base;
}
@override
Widget build(BuildContext context) {
const videoIndex = 0;
final video = videos[videoIndex];
final isLiked = likedVideoIndexes.contains(videoIndex);
final isSaved = savedVideoIndexes.contains(videoIndex);
final isShared = sharedVideoIndexes.contains(videoIndex);
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(
child: VideoMockBackground(video: video),
),
Positioned(
right: 16,
bottom: 90,
child: RightActionBar(
video: video,
isLiked: isLiked,
isSaved: isSaved,
isShared: isShared,
likeCount: displayLikeCount(videoIndex),
commentCount: video.comments,
saveCount: displaySaveCount(videoIndex),
shareCount: displayShareCount(videoIndex),
onTapLike: () => toggleLike(videoIndex),
onTapSave: () => toggleSave(videoIndex),
onTapShare: () => toggleShare(videoIndex),
),
),
Positioned(
left: 16,
right: 96,
bottom: 36,
child: BottomVideoInfo(video: video),
),
],
),
);
}
}
class VideoMockBackground extends StatelessWidget {
const VideoMockBackground({
super.key,
required this.video,
});
final ShortVideo video;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
video.avatarColor,
Colors.black,
],
),
),
child: Center(
child: Container(
width: 240,
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: 40,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
),
),
),
);
}
}
class RightActionBar extends StatelessWidget {
const RightActionBar({
super.key,
required this.video,
required this.isLiked,
required this.isSaved,
required this.isShared,
required this.likeCount,
required this.commentCount,
required this.saveCount,
required this.shareCount,
required this.onTapLike,
required this.onTapSave,
required this.onTapShare,
});
final ShortVideo video;
final bool isLiked;
final bool isSaved;
final bool isShared;
final int likeCount;
final int commentCount;
final int saveCount;
final int shareCount;
final VoidCallback onTapLike;
final VoidCallback onTapSave;
final VoidCallback onTapShare;
@override
Widget build(BuildContext context) {
return Column(
children: [
ProfileButton(color: video.avatarColor),
const SizedBox(height: 22),
ActionButton(
icon: Icons.favorite_rounded,
label: formatCount(likeCount),
activeColor: const Color(0xFFFF2D55),
isActive: isLiked,
onTap: onTapLike,
),
const SizedBox(height: 20),
ActionButton(
icon: Icons.mode_comment_rounded,
label: formatCount(commentCount),
activeColor: const Color(0xFF00D4FF),
isActive: false,
onTap: () {},
),
const SizedBox(height: 20),
ActionButton(
icon: Icons.bookmark_rounded,
label: formatCount(saveCount),
activeColor: const Color(0xFFFFD400),
isActive: isSaved,
onTap: onTapSave,
),
const SizedBox(height: 20),
ActionButton(
icon: Icons.reply_rounded,
label: formatCount(shareCount),
activeColor: const Color(0xFF35F2A2),
isActive: isShared,
rotate: true,
onTap: onTapShare,
),
const SizedBox(height: 24),
SpinningDisc(color: video.avatarColor),
],
);
}
}
class ProfileButton extends StatelessWidget {
const ProfileButton({
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 ActionButton extends StatelessWidget {
const ActionButton({
super.key,
required this.icon,
required this.label,
required this.activeColor,
required this.isActive,
required this.onTap,
this.rotate = false,
});
final IconData icon;
final String label;
final Color activeColor;
final bool isActive;
final VoidCallback onTap;
final bool rotate;
@override
Widget build(BuildContext context) {
final iconWidget = AnimatedScale(
duration: const Duration(milliseconds: 140),
scale: isActive ? 1.18 : 1.0,
curve: Curves.easeOutBack,
child: Icon(
icon,
color: isActive ? activeColor : Colors.white,
size: 34,
shadows: const [
Shadow(
color: Colors.black54,
blurRadius: 8,
),
],
),
);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Column(
children: [
rotate
? Transform.rotate(
angle: 3.14,
child: iconWidget,
)
: iconWidget,
const SizedBox(height: 4),
Text(
label,
style: TextStyle(
color: isActive ? activeColor : Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
shadows: const [
Shadow(
color: Colors.black87,
blurRadius: 8,
),
],
),
),
],
),
);
}
}
class SpinningDisc extends StatelessWidget {
const SpinningDisc({
super.key,
required this.color,
});
final Color color;
@override
Widget build(BuildContext context) {
return Container(
width: 48,
height: 48,
padding: const EdgeInsets.all(9),
decoration: const BoxDecoration(
color: Colors.black,
shape: BoxShape.circle,
),
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
color,
Colors.black,
],
),
),
child: const Icon(
Icons.music_note_rounded,
color: Colors.white,
size: 18,
),
),
);
}
}
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,
),
],
),
),
],
),
);
}
}
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();
}
実行して確認すること
実行したら、右側のボタンを押してみてください。
ハートを押す
↓
ピンクになる
↓
数字が1増える
保存を押す
↓
黄色になる
↓
数字が1増える
共有を押す
↓
緑になる
↓
数字が1増える
もう一度押すと、元に戻ります。
今回のポイントは、ボタンそのものではなく、押した状態をどこで管理しているかです。
状態を管理している場所
状態を管理しているのは、_ActionStatePracticePageState です。
final Set<int> likedVideoIndexes = {};
final Set<int> savedVideoIndexes = {};
final Set<int> sharedVideoIndexes = {};
この3つのSetで、どの動画がいいね済み・保存済み・共有済みかを記録しています。
likedVideoIndexes
↓
いいね済みの動画番号
savedVideoIndexes
↓
保存済みの動画番号
sharedVideoIndexes
↓
共有済みの動画番号
今回の練習コードでは、表示している動画は0番目だけです。
const videoIndex = 0;
そのため、いいねすると likedVideoIndexes に 0 が入ります。
toggleLikeの解説
void toggleLike(int videoIndex) {
setState(() {
if (likedVideoIndexes.contains(videoIndex)) {
likedVideoIndexes.remove(videoIndex);
} else {
likedVideoIndexes.add(videoIndex);
}
});
}
この処理は、次の意味です。
もし、すでにいいね済みなら
↓
Setから削除する
まだいいねしていないなら
↓
Setに追加する
contains、remove、add の関係を整理します。
| コード | 意味 |
|---|---|
contains(videoIndex) | その動画番号が含まれているか |
remove(videoIndex) | その動画番号を削除する |
add(videoIndex) | その動画番号を追加する |
この3つを使うと、押すたびに状態を切り替えられます。
表示用のboolを作る
build の中では、次のように状態を確認しています。
final isLiked = likedVideoIndexes.contains(videoIndex);
final isSaved = savedVideoIndexes.contains(videoIndex);
final isShared = sharedVideoIndexes.contains(videoIndex);
これは、次の意味です。
この動画はいいね済みか
この動画は保存済みか
この動画は共有済みか
この isLiked、isSaved、isShared を RightActionBar に渡しています。
RightActionBar(
isLiked: isLiked,
isSaved: isSaved,
isShared: isShared,
...
)
そして、ActionButton の色を変えるために使います。
ActionButtonで色を変える
ActionButton では、isActive によって色を変えています。
color: isActive ? activeColor : Colors.white,
これは、次の意味です。
isActive が true なら activeColor
false なら白
いいねボタンでは、activeColor にピンクを渡しています。
activeColor: const Color(0xFFFF2D55),
保存ボタンでは、黄色を渡しています。
activeColor: const Color(0xFFFFD400),
共有ボタンでは、緑を渡しています。
activeColor: const Color(0xFF35F2A2),
同じ ActionButton でも、渡す色を変えることで、違う見た目にできます。
AnimatedScaleとは何か
今回の ActionButton では、AnimatedScale を使っています。
AnimatedScale(
duration: const Duration(milliseconds: 140),
scale: isActive ? 1.18 : 1.0,
curve: Curves.easeOutBack,
child: Icon(...),
)
AnimatedScale は、大きさの変化をアニメーションにするWidgetです。
isActive が true になると、少し大きくなります。
通常
scale: 1.0
押した状態
scale: 1.18
これにより、ボタンを押したときに、少しポンと反応するように見えます。
初心者向けには、次のように覚えてください。
AnimatedScale = 大きさの変化をなめらかに見せるWidget
VoidCallbackとは何か
ActionButton には、onTap というpropertyがあります。
final VoidCallback onTap;
VoidCallback は、引数なし・戻り値なしの関数を表す型です。
難しく感じる場合は、次のように理解してください。
VoidCallback = あとで実行する処理を渡すための型
ActionButton の中では、タップされたときに onTap を呼びます。
GestureDetector(
onTap: onTap,
child: Column(...),
)
つまり、ActionButton 自体は、何をするボタンなのかを知りません。
外から渡された処理を実行します。
いいねボタン
↓
onTapLikeを渡す
保存ボタン
↓
onTapSaveを渡す
共有ボタン
↓
onTapShareを渡す
このようにすると、同じ部品をいろいろな用途で使えます。
数字を増やす仕組み
いいね数は、次の関数で表示用に計算しています。
int displayLikeCount(int videoIndex) {
final base = videos[videoIndex].likes;
if (likedVideoIndexes.contains(videoIndex)) {
return base + 1;
}
return base;
}
これは、次の意味です。
元のいいね数を取り出す
↓
もしこの動画がいいね済みなら +1
↓
そうでなければ元の数字のまま
保存数と共有数も同じです。
int displaySaveCount(int videoIndex) {
final base = videos[videoIndex].saves;
if (savedVideoIndexes.contains(videoIndex)) {
return base + 1;
}
return base;
}
int displayShareCount(int videoIndex) {
final base = videos[videoIndex].shares;
if (sharedVideoIndexes.contains(videoIndex)) {
return base + 1;
}
return base;
}
なぜ元データを直接変えないのか
今回の videos は const で作っています。
const videos = [
ShortVideo(...),
ShortVideo(...),
ShortVideo(...),
];
つまり、元データ自体は固定です。
いいねを押したからといって、videos[0].likes を直接書き換えるのではありません。
代わりに、
元のいいね数
+
いいね済みなら1
として表示しています。
この考え方は大切です。
元データ
↓
変えない
状態
↓
別で持つ
表示
↓
元データ + 状態から作る
完成アプリとのつながり
最終的なTikTok風アプリでは、動画が無限スクロール風に切り替わります。
そのため、動画ごとに状態を管理する必要があります。
videos[0] はいいね済み
videos[1] は未いいね
videos[2] は保存済み
だから、Set<int> で動画番号を記録します。
final Set<int> likedVideoIndexes = {};
final Set<int> savedVideoIndexes = {};
final Set<int> sharedVideoIndexes = {};
そして、videoIndex ごとに状態を確認します。
likedVideoIndexes.contains(videoIndex)
この考え方を使うと、動画が増えても対応できます。
手を動かす練習1:いいねの色を変える
次の部分を探してください。
activeColor: const Color(0xFFFF2D55),
これを次のように変えてみてください。
activeColor: Colors.purpleAccent,
いいねしたときのハートの色が変わります。
手を動かす練習2:保存ボタンの色を変える
次の部分を探してください。
activeColor: const Color(0xFFFFD400),
これを次のように変えてみてください。
activeColor: Colors.orange,
保存したときのアイコン色が変わります。
手を動かす練習3:押したときの大きさを変える
次の部分を探してください。
scale: isActive ? 1.18 : 1.0,
これを次のように変えてみてください。
scale: isActive ? 1.35 : 1.0,
押した状態のアイコンが、さらに大きく表示されます。
手を動かす練習4:いいね数の増え方を変える
次の部分を探してください。
return base + 1;
これを次のように変えてみます。
return base + 10;
いいねしたときに、数字が10増えます。
通常は1増えるのが自然ですが、表示の仕組みを理解する練習として試してみてください。
手を動かす練習5:コメントボタンにも色をつける準備をする
今は、コメントボタンの isActive は false です。
isActive: false,
これを true に変えると、コメントボタンが水色になります。
isActive: true,
次の節では、コメントボタンを押したときに、下からコメント入力画面を表示します。
よくあるつまずき1:setStateを書いたのに変わらない
setState の中で、画面に関係する値を変えているか確認しましょう。
正しい例です。
setState(() {
likedVideoIndexes.add(videoIndex);
});
画面では、likedVideoIndexes.contains(videoIndex) を見て色を変えています。
つまり、状態と表示がつながっている必要があります。
状態を変える
↓
表示でその状態を使う
↓
画面が変わる
よくあるつまずき2:Setに追加しても色が変わらない
Set に追加しても色が変わらない場合、isActive に正しい値を渡しているか確認してください。
isActive: isLiked,
もし、次のように固定していると、色は変わりません。
isActive: false,
状態を使いたい場合は、固定値ではなく、変数を渡します。
よくあるつまずき3:constが邪魔をする
状態によって表示が変わるWidgetには、const をつけられないことがあります。
たとえば、次のように状態を渡す場合です。
ActionButton(
isActive: isLiked,
...
)
isLiked は変数なので、const ActionButton(...) にはできません。
エラーが出たら、const を外しましょう。
よくあるつまずき4:boolとSetの使い分けが分からない
1つのボタンだけなら、bool で十分です。
bool isLiked = false;
しかし、動画が複数ある場合は、動画ごとに状態が必要です。
その場合は、Set<int> が便利です。
final Set<int> likedVideoIndexes = {};
整理すると、次のようになります。
| 場面 | 使いやすい型 |
|---|---|
| 1つのハートだけ | bool |
| 複数動画のいいね状態 | Set<int> |
よくあるつまずき5:数字を直接増やそうとする
今回のようなサンプルでは、元データを直接変えず、表示用の関数で調整しています。
int displayLikeCount(int videoIndex) {
final base = videos[videoIndex].likes;
if (likedVideoIndexes.contains(videoIndex)) {
return base + 1;
}
return base;
}
初心者のうちは、次のように覚えると分かりやすいです。
元データは固定
操作状態は別で持つ
表示するときに合成する
この節の確認問題
確認問題1
状態とは何ですか。
答え
画面に影響する現在の値です。
たとえば、いいねしているか、保存しているか、共有したかなどが状態です。
確認問題2
setState は何をするために使いますか。
答え
状態が変わったことをFlutterに知らせ、画面を更新するために使います。
確認問題3
bool isLiked は何を表しますか。
答え
いいねしているかどうかを表します。
true ならいいね済み、false なら未いいねです。
確認問題4
複数動画のいいね状態を管理するために、今回使った型は何ですか。
答え
Set<int> を使いました。
いいね済みの動画番号を記録します。
確認問題5
likedVideoIndexes.contains(videoIndex) は何をしていますか。
答え
その動画番号が、いいね済みのSetに含まれているかを確認しています。
確認問題6
ActionButton に onTap を渡す理由は何ですか。
答え
同じ ActionButton を、いいね・保存・共有など別々の処理に使えるようにするためです。
確認問題7
AnimatedScale は何をするWidgetですか。
答え
大きさの変化をアニメーションで表示するWidgetです。
押した状態のアイコンを少し大きく見せるために使いました。
この節のまとめ
この節では、いいね・保存・共有ボタンを押したときに、状態を変えて画面を更新する方法を学びました。
まず、1つのハートを bool で切り替えました。
その後、複数動画に対応するために、Set<int> を使いました。
final Set<int> likedVideoIndexes = {};
final Set<int> savedVideoIndexes = {};
final Set<int> sharedVideoIndexes = {};
ボタンが押されたら、setState の中でSetに追加・削除します。
setState(() {
if (likedVideoIndexes.contains(videoIndex)) {
likedVideoIndexes.remove(videoIndex);
} else {
likedVideoIndexes.add(videoIndex);
}
});
そして、contains の結果を使って、アイコンの色を変えました。
color: isActive ? activeColor : Colors.white
この節で一番大切なのは、次の一文です。
画面の変化は、状態を変え、setStateでFlutterに知らせることで実現する。
次の節では、コメントボタンを押したときに、TikTok風のコメント入力画面を下から表示します。