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

いいね・保存・共有の状態を変える

この節で学ぶこと

前回の 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 は、truefalse のどちらかを持つ型です。

意味
falseいいねしていない
trueいいねしている

この値によって、ハートの色を変えます。

color: isLiked ? Colors.pink : Colors.white

これは、次の意味です。

isLiked が true ならピンク
そうでなければ白

新しい言葉:boolとは何か

bool は、真偽値を表す型です。

真偽値とは、truefalse のどちらかです。

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;

そのため、いいねすると likedVideoIndexes0 が入ります。

toggleLikeの解説

void toggleLike(int videoIndex) {
  setState(() {
    if (likedVideoIndexes.contains(videoIndex)) {
      likedVideoIndexes.remove(videoIndex);
    } else {
      likedVideoIndexes.add(videoIndex);
    }
  });
}

この処理は、次の意味です。

もし、すでにいいね済みなら
↓
Setから削除する

まだいいねしていないなら
↓
Setに追加する

containsremoveadd の関係を整理します。

コード意味
contains(videoIndex)その動画番号が含まれているか
remove(videoIndex)その動画番号を削除する
add(videoIndex)その動画番号を追加する

この3つを使うと、押すたびに状態を切り替えられます。

表示用のboolを作る

build の中では、次のように状態を確認しています。

final isLiked = likedVideoIndexes.contains(videoIndex);
final isSaved = savedVideoIndexes.contains(videoIndex);
final isShared = sharedVideoIndexes.contains(videoIndex);

これは、次の意味です。

この動画はいいね済みか
この動画は保存済みか
この動画は共有済みか

この isLikedisSavedisSharedRightActionBar に渡しています。

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です。

isActivetrue になると、少し大きくなります。

通常
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;
}

なぜ元データを直接変えないのか

今回の videosconst で作っています。

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:コメントボタンにも色をつける準備をする

今は、コメントボタンの isActivefalse です。

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

ActionButtononTap を渡す理由は何ですか。

答え

同じ 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風のコメント入力画面を下から表示します。

教材トップへ戻る