❤️
Flutterでいいねボタンのアニメーションと状態同期を実装する方法
はじめに
SNSアプリでよく見かける「いいねボタン」を実装したいと思ったことはありませんか?
タップするとハートが赤くなって、数が増えるあの機能です。
実は、Flutterではアニメーションと状態同期を使って、このような機能を簡単に実装できます。
この記事では、以下の内容について詳しく説明します:
- いいねボタンのアニメーション実装
- 投稿一覧と詳細ページ間の状態同期
- アニメーション付きとアニメーションなしの使い分け
アニメーションとは?
アニメーションとは、UIの要素が時間とともに変化する効果のことです。
例えば:
- ボタンがタップされた時に少し大きくなる
- 色が変わるときに滑らかに変化する
- 要素が画面に現れる時にフェードインする
FlutterではAnimationController
を使って、このようなアニメーションを制御できます。
状態同期とは?
状態同期とは、複数の画面で同じデータの状態を一致させることです。
例えば:
- 投稿一覧でいいねを押すと、詳細ページでもいいね状態が反映される
- 詳細ページでいいねを押すと、一覧ページに戻った時にも状態が更新される
実装の全体像
今回実装する機能の流れ:
- 投稿一覧ページ: いいねボタンタップ → アニメーション付きで状態更新
- 詳細ページ: いいねボタンタップ → アニメーション付きで状態更新 + 親ページに通知
- 一覧ページに戻る: 詳細ページからの通知を受け取って、アニメーションなしで状態更新
1. データモデルの作成
まず、投稿の情報を管理するデータクラスを作成します:
/// 投稿データのモデルクラス
///
/// 投稿の情報を管理するためのデータクラスです。
/// 各投稿の基本情報(ID、ユーザー名、内容、いいね数、いいね状態)を保持します。
class Post {
/// 投稿の一意のID
///
/// 投稿を識別するための文字列です。
/// いいね状態の更新時に該当する投稿を特定するために使用されます。
final String id;
/// 投稿者のユーザー名
///
/// 投稿を作成したユーザーの名前を表します。
/// UIで表示される名前として使用されます。
final String username;
/// 投稿の内容
///
/// ユーザーが投稿したテキスト内容です。
/// 投稿カードや詳細ページで表示されます。
final String content;
/// いいねの数
///
/// その投稿に付けられたいいねの総数を表します。
/// いいねボタンの横に表示され、リアルタイムで更新されます。
final int likeCount;
/// いいねの状態
///
/// 現在のユーザーがその投稿にいいねを付けているかどうかを表します。
/// true: いいね済み(赤いハート)
/// false: いいね未済み(グレーのハート)
final bool isLiked;
/// コンストラクタ
///
/// [id] 投稿の一意のID
/// [username] 投稿者のユーザー名
/// [content] 投稿の内容
/// [likeCount] いいねの数
/// [isLiked] いいねの状態
Post({
required this.id,
required this.username,
required this.content,
required this.likeCount,
required this.isLiked,
});
}
2. アニメーションの基本概念
AnimationControllerとは?
AnimationController
は、アニメーションの進行を制御するクラスです。
主な機能:
- アニメーションの開始・停止・逆再生
- アニメーションの時間設定
- アニメーションの進行状況の管理
基本的な使い方
class _MyWidgetState extends State<MyWidget>
with SingleTickerProviderStateMixin {
/// アニメーションを制御するコントローラー
late AnimationController _animationController;
/// スケールアニメーション
late Animation<double> _scaleAnimation;
void initState() {
super.initState();
// アニメーションコントローラーを初期化
_animationController = AnimationController(
duration: const Duration(milliseconds: 200), // アニメーション時間
vsync: this, // アニメーションの同期
);
// スケールアニメーションを設定
_scaleAnimation = Tween<double>(
begin: 1.0, // 開始時のスケール
end: 1.2, // 終了時のスケール
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.elasticOut, // アニメーションの曲線
));
}
void dispose() {
// アニメーションコントローラーを破棄
_animationController.dispose();
super.dispose();
}
}
重要なポイント
- SingleTickerProviderStateMixin: アニメーションの同期を提供
- dispose(): メモリリークを防ぐために必ず破棄する
- duration: アニメーションの時間(200-300ミリ秒が適切)
- curve: アニメーションの動き方(elasticOutは弾力のある動き)
3. 投稿一覧ページの実装
状態管理
/// 投稿一覧ページの状態管理クラス
///
/// 投稿データといいねの状態を管理します。
/// 投稿一覧でのいいね操作と、詳細ページからの状態同期を処理します。
class _PostListPageState extends State<PostListPage> {
/// 投稿データのリスト
///
/// 表示する投稿の配列です。
/// 各投稿は[Post]クラスのインスタンスで、いいね状態の変更に応じて更新されます。
final List<Post> _posts = [
Post(
id: '1',
username: '田中太郎',
content: '今日は素晴らしい天気ですね!公園で散歩を楽しみました。',
likeCount: 15,
isLiked: false,
),
// 他の投稿...
];
/// いいねボタンがタップされた時の処理(アニメーション付き)
///
/// 投稿一覧ページでいいねボタンがタップされた時に呼び出されます。
/// アニメーション付きでいいね状態を切り替えます。
///
/// [postId] いいねを切り替える投稿のID
void _onLikePressed(String postId) {
setState(() {
final postIndex = _posts.indexWhere((post) => post.id == postId);
if (postIndex != -1) {
final post = _posts[postIndex];
_posts[postIndex] = Post(
id: post.id,
username: post.username,
content: post.content,
likeCount: post.isLiked ? post.likeCount - 1 : post.likeCount + 1,
isLiked: !post.isLiked,
);
}
});
}
/// 詳細ページから戻ってきた時の処理(アニメーションなし)
///
/// 詳細ページでいいね状態が変更された後、一覧ページに戻ってきた時に呼び出されます。
/// アニメーションなしでUIのみを更新します。
///
/// [postId] 更新する投稿のID
/// [isLiked] 新しいいいね状態
/// [likeCount] 新しいいいね数
void _updateLikeStatus(String postId, bool isLiked, int likeCount) {
setState(() {
final postIndex = _posts.indexWhere((post) => post.id == postId);
if (postIndex != -1) {
final post = _posts[postIndex];
_posts[postIndex] = Post(
id: post.id,
username: post.username,
content: post.content,
likeCount: likeCount,
isLiked: isLiked,
);
}
});
}
}
重要なポイント
- アニメーション付き: ユーザーが直接操作した時はアニメーションで視覚的フィードバック
- アニメーションなし: 他の画面からの状態同期時は静かに更新
- setState(): UIの再描画をトリガー
- indexWhere(): 配列から特定の条件に合う要素を検索
4. 投稿カードのアニメーション実装
アニメーションの設定
/// 投稿カードの状態管理クラス
///
/// いいねボタンのアニメーションを管理します。
/// [SingleTickerProviderStateMixin]を使用してアニメーションコントローラーを提供します。
class _PostCardState extends State<PostCard>
with SingleTickerProviderStateMixin {
/// アニメーションを制御するコントローラー
///
/// いいねボタンのスケールアニメーションを制御します。
/// タップ時にボタンが拡大・縮小するアニメーションを実行します。
late AnimationController _animationController;
/// スケールアニメーション
///
/// いいねボタンの拡大・縮小を制御するアニメーションです。
/// 1.0(通常サイズ)から1.2(拡大サイズ)まで変化します。
late Animation<double> _scaleAnimation;
void initState() {
super.initState();
// アニメーションコントローラーを初期化(200ミリ秒)
_animationController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
// スケールアニメーションを設定(1.0 → 1.2)
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 1.2,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.elasticOut, // 弾力のあるアニメーション
));
}
void dispose() {
// アニメーションコントローラーを破棄
_animationController.dispose();
super.dispose();
}
}
アニメーションの実行
/// いいねボタンがタップされた時の処理
///
/// いいねボタンがタップされた時に呼び出されます。
/// 1. 親ウィジェットにいいね状態の変更を通知
/// 2. アニメーションを実行(拡大 → 縮小)
void _onLikeButtonPressed() {
widget.onLikePressed();
_animationController.forward().then((_) {
_animationController.reverse();
});
}
アニメーションの表示
GestureDetector(
onTap: _onLikeButtonPressed,
child: AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Icon(
widget.post.isLiked
? Icons.favorite
: Icons.favorite_border,
color: widget.post.isLiked ? Colors.red : Colors.grey,
size: 24,
),
);
},
),
)
5. 詳細ページの実装
コールバック関数の受け渡し
/// 投稿詳細ページ
///
/// 投稿の詳細情報を表示するページです。
/// 投稿内容を大きく表示し、いいねボタンも大きく配置されています。
/// いいね状態が変更されると、親ページにコールバックで通知します。
class PostDetailPage extends StatefulWidget {
/// 表示する投稿のデータ
final Post post;
/// いいね状態が変更された時に呼び出されるコールバック関数
///
/// 詳細ページでいいね状態が変更された時に、親ページに新しい状態を通知します。
/// これにより、一覧ページに戻った時に状態が同期されます。
final Function(bool isLiked, int likeCount) onLikeStatusChanged;
/// コンストラクタ
///
/// [post] 表示する投稿のデータ
/// [onLikeStatusChanged] いいね状態変更時のコールバック
const PostDetailPage({
super.key,
required this.post,
required this.onLikeStatusChanged,
});
State<PostDetailPage> createState() => _PostDetailPageState();
}
状態管理とアニメーション
/// 投稿詳細ページの状態管理クラス
///
/// 詳細ページでのいいねの状態を管理します。
/// 投稿一覧ページとは独立した状態を持ち、変更時に親ページに通知します。
class _PostDetailPageState extends State<PostDetailPage>
with SingleTickerProviderStateMixin {
/// いいねの状態
///
/// 現在のユーザーがこの投稿にいいねを付けているかどうかを表します。
/// 初期値は[widget.post.isLiked]から取得されます。
late bool _isLiked;
/// いいねの数
///
/// この投稿に付けられたいいねの総数を表します。
/// 初期値は[widget.post.likeCount]から取得されます。
late int _likeCount;
/// アニメーションを制御するコントローラー
///
/// 詳細ページのいいねボタンのアニメーションを制御します。
/// 投稿一覧ページよりも大きなスケールアニメーションを実行します。
late AnimationController _animationController;
/// スケールアニメーション
///
/// いいねボタンの拡大・縮小を制御するアニメーションです。
/// 1.0(通常サイズ)から1.5(拡大サイズ)まで変化します。
/// 投稿一覧ページ(1.2)よりも大きな拡大率です。
late Animation<double> _scaleAnimation;
void initState() {
super.initState();
// 初期状態を設定
_isLiked = widget.post.isLiked;
_likeCount = widget.post.likeCount;
// アニメーションコントローラーを初期化(300ミリ秒)
_animationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
// スケールアニメーションを設定(1.0 → 1.5)
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 1.5,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.elasticOut, // 弾力のあるアニメーション
));
}
}
いいね処理と親ページへの通知
/// いいねボタンがタップされた時の処理
///
/// 詳細ページでいいねボタンがタップされた時に呼び出されます。
/// 1. いいね状態と数を更新
/// 2. 親ページに状態変更を通知
/// 3. アニメーションを実行
void _onLikePressed() {
setState(() {
_isLiked = !_isLiked;
_likeCount = _isLiked ? _likeCount + 1 : _likeCount - 1;
});
// 親ページに状態変更を通知
widget.onLikeStatusChanged(_isLiked, _likeCount);
// アニメーションを実行
if (_isLiked) {
_animationController.forward();
} else {
_animationController.reverse();
}
}
6. ページ間の遷移と状態同期
詳細ページへの遷移
/// 投稿がタップされた時の処理
///
/// 投稿カードがタップされた時に詳細ページに遷移します。
/// 詳細ページには現在の投稿データと、状態変更時のコールバック関数を渡します。
///
/// [post] タップされた投稿のデータ
void _onPostTapped(Post post) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PostDetailPage(
post: post,
onLikeStatusChanged: (isLiked, likeCount) {
_updateLikeStatus(post.id, isLiked, likeCount);
},
),
),
);
}
状態同期の流れ
-
詳細ページでいいねボタンをタップ
- 詳細ページの状態を更新
- アニメーションを実行
- 親ページにコールバックで通知
-
一覧ページで通知を受け取る
-
_updateLikeStatus
が呼び出される - アニメーションなしで状態を更新
- UIが再描画される
-
7. アニメーションの違い
投稿一覧ページ
- スケール: 1.0 → 1.2
- 時間: 200ミリ秒
- 用途: ユーザーの直接操作
詳細ページ
- スケール: 1.0 → 1.5
- 時間: 300ミリ秒
- 用途: ユーザーの直接操作
状態同期時
- アニメーション: なし
- 用途: 他の画面からの状態更新
8. よくある質問
Q: なぜアニメーションを分けるの?
A: ユーザー体験を向上させるためです。
- ユーザーが直接操作した時は視覚的フィードバック
- 他の画面からの更新時は静かに変更
Q: コールバック関数は必要?
A: はい、ページ間で状態を同期するために必要です。
- 詳細ページから親ページに状態変更を通知
- 一覧ページに戻った時に正しい状態を表示
Q: アニメーションコントローラーは必ず破棄するの?
A: はい、メモリリークを防ぐために必ず破棄してください。
-
dispose()
メソッドで破棄 - 忘れるとメモリが解放されない
9. 完全な実装例
以下は、今回説明した機能の完全な実装例です。
import 'package:flutter/material.dart';
/// アプリケーションのエントリーポイント
void main() {
runApp(const MyApp());
}
/// アプリケーションのルートウィジェット
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'いいねアニメーションデモ',
theme: ThemeData(
primarySwatch: Colors.blue,
appBarTheme: const AppBarTheme(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 1,
),
),
home: const PostListPage(),
);
}
}
/// 投稿データのモデルクラス
class Post {
final String id;
final String username;
final String content;
final int likeCount;
final bool isLiked;
Post({
required this.id,
required this.username,
required this.content,
required this.likeCount,
required this.isLiked,
});
}
/// 投稿一覧ページ
class PostListPage extends StatefulWidget {
const PostListPage({super.key});
State<PostListPage> createState() => _PostListPageState();
}
/// 投稿一覧ページの状態管理クラス
class _PostListPageState extends State<PostListPage> {
final List<Post> _posts = [
Post(
id: '1',
username: '田中太郎',
content: '今日は素晴らしい天気ですね!公園で散歩を楽しみました。',
likeCount: 15,
isLiked: false,
),
Post(
id: '2',
username: '佐藤花子',
content: '新しいレストランで美味しい料理を食べました。おすすめです!',
likeCount: 28,
isLiked: true,
),
Post(
id: '3',
username: '山田次郎',
content: 'プログラミングの勉強を頑張っています。Flutterは楽しいですね!',
likeCount: 42,
isLiked: false,
),
];
void _onLikePressed(String postId) {
setState(() {
final postIndex = _posts.indexWhere((post) => post.id == postId);
if (postIndex != -1) {
final post = _posts[postIndex];
_posts[postIndex] = Post(
id: post.id,
username: post.username,
content: post.content,
likeCount: post.isLiked ? post.likeCount - 1 : post.likeCount + 1,
isLiked: !post.isLiked,
);
}
});
}
void _updateLikeStatus(String postId, bool isLiked, int likeCount) {
setState(() {
final postIndex = _posts.indexWhere((post) => post.id == postId);
if (postIndex != -1) {
final post = _posts[postIndex];
_posts[postIndex] = Post(
id: post.id,
username: post.username,
content: post.content,
likeCount: likeCount,
isLiked: isLiked,
);
}
});
}
void _onPostTapped(Post post) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PostDetailPage(
post: post,
onLikeStatusChanged: (isLiked, likeCount) {
_updateLikeStatus(post.id, isLiked, likeCount);
},
),
),
);
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('投稿一覧'),
centerTitle: true,
),
body: ListView.builder(
itemCount: _posts.length,
itemBuilder: (context, index) {
final post = _posts[index];
return PostCard(
post: post,
onLikePressed: () => _onLikePressed(post.id),
onPostTapped: () => _onPostTapped(post),
);
},
),
);
}
}
/// 投稿カードのウィジェット
class PostCard extends StatefulWidget {
final Post post;
final VoidCallback onLikePressed;
final VoidCallback onPostTapped;
const PostCard({
super.key,
required this.post,
required this.onLikePressed,
required this.onPostTapped,
});
State<PostCard> createState() => _PostCardState();
}
/// 投稿カードの状態管理クラス
class _PostCardState extends State<PostCard>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 1.2,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.elasticOut,
));
}
void dispose() {
_animationController.dispose();
super.dispose();
}
void _onLikeButtonPressed() {
widget.onLikePressed();
_animationController.forward().then((_) {
_animationController.reverse();
});
}
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
elevation: 2,
child: InkWell(
onTap: widget.onPostTapped,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
backgroundColor: Colors.grey[300],
child: Text(
widget.post.username[0],
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.black,
),
),
),
const SizedBox(width: 12),
Text(
widget.post.username,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
],
),
const SizedBox(height: 12),
Text(
widget.post.content,
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 16),
Row(
children: [
GestureDetector(
onTap: _onLikeButtonPressed,
child: AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Icon(
widget.post.isLiked
? Icons.favorite
: Icons.favorite_border,
color: widget.post.isLiked ? Colors.red : Colors.grey,
size: 24,
),
);
},
),
),
const SizedBox(width: 8),
Text(
'${widget.post.likeCount}',
style: TextStyle(
color: Colors.grey[600],
fontSize: 14,
),
),
],
),
],
),
),
),
);
}
}
/// 投稿詳細ページ
class PostDetailPage extends StatefulWidget {
final Post post;
final Function(bool isLiked, int likeCount) onLikeStatusChanged;
const PostDetailPage({
super.key,
required this.post,
required this.onLikeStatusChanged,
});
State<PostDetailPage> createState() => _PostDetailPageState();
}
/// 投稿詳細ページの状態管理クラス
class _PostDetailPageState extends State<PostDetailPage>
with SingleTickerProviderStateMixin {
late bool _isLiked;
late int _likeCount;
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
void initState() {
super.initState();
_isLiked = widget.post.isLiked;
_likeCount = widget.post.likeCount;
_animationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 1.5,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.elasticOut,
));
}
void dispose() {
_animationController.dispose();
super.dispose();
}
void _onLikePressed() {
setState(() {
_isLiked = !_isLiked;
_likeCount = _isLiked ? _likeCount + 1 : _likeCount - 1;
});
widget.onLikeStatusChanged(_isLiked, _likeCount);
if (_isLiked) {
_animationController.forward();
} else {
_animationController.reverse();
}
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('投稿詳細'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
backgroundColor: Colors.grey[300],
radius: 25,
child: Text(
widget.post.username[0],
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.black,
fontSize: 18,
),
),
),
const SizedBox(width: 16),
Text(
widget.post.username,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 20,
),
),
],
),
const SizedBox(height: 24),
Text(
widget.post.content,
style: const TextStyle(fontSize: 18, height: 1.6),
),
const SizedBox(height: 32),
Row(
children: [
GestureDetector(
onTap: _onLikePressed,
child: AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Icon(
_isLiked ? Icons.favorite : Icons.favorite_border,
color: _isLiked ? Colors.red : Colors.grey,
size: 32,
),
);
},
),
),
const SizedBox(width: 12),
Text(
'$_likeCount いいね',
style: TextStyle(
color: Colors.grey[600],
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
),
);
}
}
まとめ
この記事では、Flutterでいいねボタンのアニメーションと状態同期を実装する方法について詳しく説明しました。
学んだこと
- アニメーションの基本: AnimationControllerとTweenの使い方
- 状態管理: setState()を使ったUI更新
- ページ間通信: コールバック関数を使った状態同期
- ユーザー体験: アニメーション付きとアニメーションなしの使い分け
実装のポイント
- アニメーションコントローラーは必ずdisposeする
- ユーザーの直接操作時はアニメーション付き
- 他の画面からの更新時はアニメーションなし
- コールバック関数でページ間の状態を同期
この実装方法を応用すれば、他のUI要素でも同様のアニメーションと状態同期を実装できます。
Discussion