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

完成コードを読み解く

最終バージョン

import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';

void main() {
  runApp(const TikTokLikeVideoApp());
}

class TikTokLikeVideoApp extends StatelessWidget {
  const TikTokLikeVideoApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: ShortVideoHomePage(),
    );
  }
}

class ShortVideo {
  const ShortVideo({
    required this.videoUrl,
    required this.userName,
    required this.caption,
    required this.musicTitle,
    required this.likes,
    required this.comments,
    required this.saves,
    required this.shares,
    required this.avatarColor,
    required this.categoryLabel,
  });

  final String videoUrl;
  final String userName;
  final String caption;
  final String musicTitle;
  final int likes;
  final int comments;
  final int saves;
  final int shares;
  final Color avatarColor;
  final String categoryLabel;
}

const videos = [
  ShortVideo(
    videoUrl:
        'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4',
    userName: 'pet_cafe_diary',
    caption: '小さな命の動きは、見ているだけで少しやさしい気持ちになる。今日はペットカフェ風の癒し動画。',
    musicTitle: 'Healing Cafe Sound - Pet Cafe Diary',
    likes: 12800,
    comments: 324,
    saves: 1204,
    shares: 856,
    avatarColor: Color(0xFFE91E63),
    categoryLabel: 'PET',
  ),
  ShortVideo(
    videoUrl: 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4',
    userName: 'food_and_nature',
    caption: 'おいしいものを探す旅の途中で出会った、自然の小さなリズム。食と暮らしの余白を感じる一本。',
    musicTitle: 'Kitchen Walk - Food & Nature',
    likes: 8732,
    comments: 128,
    saves: 902,
    shares: 411,
    avatarColor: Color(0xFF2196F3),
    categoryLabel: 'FOOD',
  ),
  ShortVideo(
    videoUrl:
        'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4',
    userName: 'daily_pet_room',
    caption: 'ペットと過ごす午後。何気ない一瞬が、あとから思い出になる。',
    musicTitle: 'Room Light - Daily Pet Room',
    likes: 24100,
    comments: 642,
    saves: 3010,
    shares: 1112,
    avatarColor: Color(0xFFFF9800),
    categoryLabel: 'ROOM',
  ),
];

class ShortVideoHomePage extends StatefulWidget {
  const ShortVideoHomePage({super.key});

  @override
  State<ShortVideoHomePage> createState() => _ShortVideoHomePageState();
}

class _ShortVideoHomePageState extends State<ShortVideoHomePage> {
  late final PageController pageController;

  final List<VideoPlayerController> controllers = [];

  final Set<int> likedVideoIndexes = {};
  final Set<int> savedVideoIndexes = {};
  final Set<int> sharedVideoIndexes = {};

  final Map<int, List<String>> commentMap = {
    0: ['かわいい…ずっと見ていられます', 'この雰囲気すごく好きです'],
    1: ['食と自然の組み合わせ、いいですね', '音も落ち着きます'],
    2: ['午後の空気感が素敵', 'ペット動画は癒されます'],
  };

  late int currentPageIndex;

  bool isReady = false;

  int get currentVideoIndex {
    return currentPageIndex % videos.length;
  }

  @override
  void initState() {
    super.initState();

    currentPageIndex = videos.length * 1000;

    pageController = PageController(
      initialPage: currentPageIndex,
    );

    initializeVideos();
  }

  Future<void> initializeVideos() async {
    for (final video in videos) {
      final controller = VideoPlayerController.networkUrl(
        Uri.parse(video.videoUrl),
      );

      await controller.initialize();

      controller
        ..setLooping(true)
        ..setVolume(0)
        ..pause();

      controllers.add(controller);
    }

    await controllers[currentVideoIndex].play();

    setState(() {
      isReady = true;
    });
  }

  @override
  void dispose() {
    pageController.dispose();

    for (final controller in controllers) {
      controller.dispose();
    }

    super.dispose();
  }

  void onPageChanged(int pageIndex) {
    final previousVideoIndex = currentVideoIndex;
    final nextVideoIndex = pageIndex % videos.length;

    controllers[previousVideoIndex].pause();

    setState(() {
      currentPageIndex = pageIndex;
    });

    controllers[nextVideoIndex]
      ..seekTo(Duration.zero)
      ..play();
  }

  void togglePlay() {
    final controller = controllers[currentVideoIndex];

    setState(() {
      if (controller.value.isPlaying) {
        controller.pause();
      } else {
        controller.play();
      }
    });
  }

  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);
      }
    });

    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(
          sharedVideoIndexes.contains(videoIndex)
              ? '共有メニューを開きました'
              : '共有を解除しました',
        ),
        duration: const Duration(milliseconds: 900),
        backgroundColor: Colors.black87,
      ),
    );
  }

  int displayLikeCount(int videoIndex) {
    final base = videos[videoIndex].likes;
    return likedVideoIndexes.contains(videoIndex) ? base + 1 : base;
  }

  int displaySaveCount(int videoIndex) {
    final base = videos[videoIndex].saves;
    return savedVideoIndexes.contains(videoIndex) ? base + 1 : base;
  }

  int displayShareCount(int videoIndex) {
    final base = videos[videoIndex].shares;
    return sharedVideoIndexes.contains(videoIndex) ? base + 1 : base;
  }

  int displayCommentCount(int videoIndex) {
    return commentMap[videoIndex]?.length ?? videos[videoIndex].comments;
  }

  void openCommentSheet(int videoIndex) {
    showModalBottomSheet<void>(
      context: context,
      isScrollControlled: true,
      useSafeArea: true,
      backgroundColor: Colors.transparent,
      builder: (context) {
        return CommentSheet(
          video: videos[videoIndex],
          comments: commentMap[videoIndex] ?? [],
          onSubmit: (text) {
            final trimmedText = text.trim();

            if (trimmedText.isEmpty) {
              return;
            }

            setState(() {
              commentMap.putIfAbsent(videoIndex, () => []);
              commentMap[videoIndex]!.insert(0, trimmedText);
            });
          },
        );
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    if (!isReady) {
      return const Scaffold(
        backgroundColor: Colors.black,
        body: Center(
          child: CircularProgressIndicator(
            color: Colors.white,
          ),
        ),
      );
    }

    return Scaffold(
      backgroundColor: Colors.black,
      body: PageView.builder(
        controller: pageController,
        scrollDirection: Axis.vertical,
        onPageChanged: onPageChanged,
        itemBuilder: (context, pageIndex) {
          final videoIndex = pageIndex % videos.length;

          return ShortVideoPage(
            video: videos[videoIndex],
            controller: controllers[videoIndex],
            videoIndex: videoIndex,
            pageIndex: pageIndex,
            isLiked: likedVideoIndexes.contains(videoIndex),
            isSaved: savedVideoIndexes.contains(videoIndex),
            isShared: sharedVideoIndexes.contains(videoIndex),
            likeCount: displayLikeCount(videoIndex),
            commentCount: displayCommentCount(videoIndex),
            saveCount: displaySaveCount(videoIndex),
            shareCount: displayShareCount(videoIndex),
            onTapVideo: togglePlay,
            onTapLike: () => toggleLike(videoIndex),
            onTapComment: () => openCommentSheet(videoIndex),
            onTapSave: () => toggleSave(videoIndex),
            onTapShare: () => toggleShare(videoIndex),
          );
        },
      ),
    );
  }
}

class ShortVideoPage extends StatelessWidget {
  const ShortVideoPage({
    super.key,
    required this.video,
    required this.controller,
    required this.videoIndex,
    required this.pageIndex,
    required this.isLiked,
    required this.isSaved,
    required this.isShared,
    required this.likeCount,
    required this.commentCount,
    required this.saveCount,
    required this.shareCount,
    required this.onTapVideo,
    required this.onTapLike,
    required this.onTapComment,
    required this.onTapSave,
    required this.onTapShare,
  });

  final ShortVideo video;
  final VideoPlayerController controller;
  final int videoIndex;
  final int pageIndex;
  final bool isLiked;
  final bool isSaved;
  final bool isShared;
  final int likeCount;
  final int commentCount;
  final int saveCount;
  final int shareCount;
  final VoidCallback onTapVideo;
  final VoidCallback onTapLike;
  final VoidCallback onTapComment;
  final VoidCallback onTapSave;
  final VoidCallback onTapShare;

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        Positioned.fill(
          child: GestureDetector(
            onTap: onTapVideo,
            child: VideoBackground(controller: controller),
          ),
        ),
        const Positioned.fill(
          child: VideoGradientOverlay(),
        ),
        const Positioned(
          top: 44,
          left: 0,
          right: 0,
          child: TopNavigation(),
        ),
        Positioned(
          left: 18,
          top: 92,
          child: PageIndicator(
            pageIndex: pageIndex,
            videoIndex: videoIndex,
          ),
        ),
        Positioned(
          right: 12,
          bottom: 92,
          child: RightActionBar(
            video: video,
            isLiked: isLiked,
            isSaved: isSaved,
            isShared: isShared,
            likeCount: likeCount,
            commentCount: commentCount,
            saveCount: saveCount,
            shareCount: shareCount,
            onTapLike: onTapLike,
            onTapComment: onTapComment,
            onTapSave: onTapSave,
            onTapShare: onTapShare,
          ),
        ),
        Positioned(
          left: 16,
          right: 86,
          bottom: 28,
          child: BottomVideoInfo(video: video),
        ),
      ],
    );
  }
}

class VideoBackground extends StatelessWidget {
  const VideoBackground({
    super.key,
    required this.controller,
  });

  final VideoPlayerController controller;

  @override
  Widget build(BuildContext context) {
    final size = controller.value.size;

    return Container(
      color: Colors.black,
      child: FittedBox(
        fit: BoxFit.cover,
        child: SizedBox(
          width: size.width,
          height: size.height,
          child: VideoPlayer(controller),
        ),
      ),
    );
  }
}

class VideoGradientOverlay extends StatelessWidget {
  const VideoGradientOverlay({super.key});

  @override
  Widget build(BuildContext context) {
    return IgnorePointer(
      child: DecoratedBox(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            colors: [
              Colors.black.withOpacity(0.45),
              Colors.transparent,
              Colors.transparent,
              Colors.black.withOpacity(0.86),
            ],
            stops: const [0.0, 0.24, 0.58, 1.0],
          ),
        ),
      ),
    );
  }
}

class TopNavigation extends StatelessWidget {
  const TopNavigation({super.key});

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      bottom: false,
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 18),
        child: Row(
          children: [
            const Icon(
              Icons.live_tv_rounded,
              color: Colors.white,
              size: 24,
            ),
            const Spacer(),
            Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                Text(
                  'フォロー中',
                  style: TextStyle(
                    color: Colors.white.withOpacity(0.65),
                    fontSize: 15,
                    fontWeight: FontWeight.w700,
                  ),
                ),
                const SizedBox(width: 18),
                const Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Text(
                      'おすすめ',
                      style: TextStyle(
                        color: Colors.white,
                        fontSize: 16,
                        fontWeight: FontWeight.w800,
                      ),
                    ),
                    SizedBox(height: 4),
                    SizedBox(
                      width: 28,
                      height: 3,
                      child: DecoratedBox(
                        decoration: BoxDecoration(
                          color: Colors.white,
                          borderRadius: BorderRadius.all(
                            Radius.circular(999),
                          ),
                        ),
                      ),
                    ),
                  ],
                ),
              ],
            ),
            const Spacer(),
            const Icon(
              Icons.search_rounded,
              color: Colors.white,
              size: 28,
            ),
          ],
        ),
      ),
    );
  }
}

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.onTapComment,
    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 onTapComment;
  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: onTapComment,
        ),
        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: Colors.white,
              shape: BoxShape.circle,
              border: Border.all(
                color: Colors.white,
                width: 2,
              ),
            ),
            child: Container(
              margin: const EdgeInsets.all(3),
              decoration: BoxDecoration(
                color: color,
                shape: BoxShape.circle,
              ),
              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.w800,
              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 TweenAnimationBuilder<double>(
      tween: Tween(begin: 0, end: 1),
      duration: const Duration(seconds: 8),
      curve: Curves.linear,
      builder: (context, value, child) {
        return Transform.rotate(
          angle: value * 6.28,
          child: child,
        );
      },
      child: 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.w800,
              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,
              fontWeight: FontWeight.w500,
              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.w800,
                  ),
                ),
              ),
              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,
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

class PageIndicator extends StatelessWidget {
  const PageIndicator({
    super.key,
    required this.pageIndex,
    required this.videoIndex,
  });

  final int pageIndex;
  final int videoIndex;

  @override
  Widget build(BuildContext context) {
    return Text(
      'LOOP ${videoIndex + 1} / ${videos.length}',
      style: TextStyle(
        color: Colors.white.withOpacity(0.75),
        fontSize: 12,
        fontWeight: FontWeight.w700,
        shadows: const [
          Shadow(
            color: Colors.black87,
            blurRadius: 8,
          ),
        ],
      ),
    );
  }
}

class CommentSheet extends StatefulWidget {
  const CommentSheet({
    super.key,
    required this.video,
    required this.comments,
    required this.onSubmit,
  });

  final ShortVideo video;
  final List<String> comments;
  final ValueChanged<String> onSubmit;

  @override
  State<CommentSheet> createState() => _CommentSheetState();
}

class _CommentSheetState extends State<CommentSheet> {
  final TextEditingController controller = TextEditingController();

  late List<String> localComments;

  @override
  void initState() {
    super.initState();
    localComments = [...widget.comments];
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  void submitComment() {
    final text = controller.text.trim();

    if (text.isEmpty) {
      return;
    }

    widget.onSubmit(text);

    setState(() {
      localComments.insert(0, text);
      controller.clear();
    });
  }

  @override
  Widget build(BuildContext context) {
    final bottomInset = MediaQuery.of(context).viewInsets.bottom;

    return AnimatedPadding(
      duration: const Duration(milliseconds: 180),
      curve: Curves.easeOut,
      padding: EdgeInsets.only(bottom: bottomInset),
      child: Container(
        height: MediaQuery.of(context).size.height * 0.72,
        decoration: const BoxDecoration(
          color: Color(0xFF111111),
          borderRadius: BorderRadius.vertical(
            top: Radius.circular(22),
          ),
        ),
        child: Column(
          children: [
            const SizedBox(height: 10),
            Container(
              width: 42,
              height: 4,
              decoration: BoxDecoration(
                color: Colors.white24,
                borderRadius: BorderRadius.circular(999),
              ),
            ),
            const SizedBox(height: 12),
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: Row(
                children: [
                  const Spacer(),
                  Text(
                    '${localComments.length}件のコメント',
                    style: const TextStyle(
                      color: Colors.white,
                      fontSize: 15,
                      fontWeight: FontWeight.w800,
                    ),
                  ),
                  const Spacer(),
                  GestureDetector(
                    onTap: () => Navigator.of(context).pop(),
                    child: const Icon(
                      Icons.close_rounded,
                      color: Colors.white,
                      size: 26,
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(height: 10),
            Expanded(
              child: ListView.separated(
                padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
                itemCount: localComments.length,
                separatorBuilder: (context, index) {
                  return const SizedBox(height: 16);
                },
                itemBuilder: (context, index) {
                  final comment = localComments[index];

                  return CommentTile(
                    index: index,
                    text: comment,
                  );
                },
              ),
            ),
            CommentInputBar(
              controller: controller,
              onSubmit: submitComment,
            ),
          ],
        ),
      ),
    );
  }
}

class CommentTile extends StatelessWidget {
  const CommentTile({
    super.key,
    required this.index,
    required this.text,
  });

  final int index;
  final String text;

  @override
  Widget build(BuildContext context) {
    final colors = [
      const Color(0xFFE91E63),
      const Color(0xFF2196F3),
      const Color(0xFFFF9800),
      const Color(0xFF4CAF50),
    ];

    final color = colors[index % colors.length];

    return Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        CircleAvatar(
          radius: 18,
          backgroundColor: color,
          child: const Icon(
            Icons.person_rounded,
            color: Colors.white,
            size: 20,
          ),
        ),
        const SizedBox(width: 10),
        Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                'user_${index + 1}',
                style: TextStyle(
                  color: Colors.white.withOpacity(0.62),
                  fontSize: 12,
                  fontWeight: FontWeight.w700,
                ),
              ),
              const SizedBox(height: 3),
              Text(
                text,
                style: const TextStyle(
                  color: Colors.white,
                  fontSize: 14,
                  height: 1.35,
                  fontWeight: FontWeight.w500,
                ),
              ),
              const SizedBox(height: 5),
              Row(
                children: [
                  Text(
                    '返信',
                    style: TextStyle(
                      color: Colors.white.withOpacity(0.42),
                      fontSize: 12,
                      fontWeight: FontWeight.w700,
                    ),
                  ),
                  const SizedBox(width: 14),
                  Text(
                    'たった今',
                    style: TextStyle(
                      color: Colors.white.withOpacity(0.36),
                      fontSize: 12,
                      fontWeight: FontWeight.w600,
                    ),
                  ),
                ],
              ),
            ],
          ),
        ),
        const SizedBox(width: 8),
        Column(
          children: [
            Icon(
              Icons.favorite_border_rounded,
              color: Colors.white.withOpacity(0.62),
              size: 20,
            ),
            const SizedBox(height: 2),
            Text(
              '${index + 2}',
              style: TextStyle(
                color: Colors.white.withOpacity(0.45),
                fontSize: 11,
                fontWeight: FontWeight.w700,
              ),
            ),
          ],
        ),
      ],
    );
  }
}

class CommentInputBar extends StatelessWidget {
  const CommentInputBar({
    super.key,
    required this.controller,
    required this.onSubmit,
  });

  final TextEditingController controller;
  final VoidCallback onSubmit;

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      top: false,
      child: Container(
        padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
        decoration: BoxDecoration(
          color: const Color(0xFF111111),
          border: Border(
            top: BorderSide(
              color: Colors.white.withOpacity(0.08),
            ),
          ),
        ),
        child: Row(
          children: [
            const CircleAvatar(
              radius: 18,
              backgroundColor: Color(0xFF444444),
              child: Icon(
                Icons.person_rounded,
                color: Colors.white,
                size: 20,
              ),
            ),
            const SizedBox(width: 10),
            Expanded(
              child: Container(
                height: 42,
                padding: const EdgeInsets.symmetric(horizontal: 14),
                decoration: BoxDecoration(
                  color: Colors.white.withOpacity(0.09),
                  borderRadius: BorderRadius.circular(999),
                ),
                alignment: Alignment.center,
                child: TextField(
                  controller: controller,
                  style: const TextStyle(
                    color: Colors.white,
                    fontSize: 14,
                  ),
                  cursorColor: Colors.white,
                  decoration: InputDecoration(
                    isCollapsed: true,
                    border: InputBorder.none,
                    hintText: 'コメントを追加...',
                    hintStyle: TextStyle(
                      color: Colors.white.withOpacity(0.42),
                      fontSize: 14,
                    ),
                  ),
                  onSubmitted: (_) => onSubmit(),
                ),
              ),
            ),
            const SizedBox(width: 8),
            GestureDetector(
              onTap: onSubmit,
              child: Container(
                height: 42,
                padding: const EdgeInsets.symmetric(horizontal: 14),
                decoration: BoxDecoration(
                  color: const Color(0xFFFF2D55),
                  borderRadius: BorderRadius.circular(999),
                ),
                alignment: Alignment.center,
                child: const Text(
                  '送信',
                  style: TextStyle(
                    color: Colors.white,
                    fontSize: 13,
                    fontWeight: FontWeight.w800,
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

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();
}

この節で学ぶこと

前回の 4-13 では、TikTok風アプリのコードを読みやすくするために、Widgetを役割ごとに分割する考え方を学びました。

ShortVideoPage
├─ VideoBackground
├─ VideoGradientOverlay
├─ TopNavigation
├─ RightActionBar
├─ BottomVideoInfo
└─ CommentSheet

今回の 4-14 では、これまで作ってきた部品が、どのようにつながって1つのアプリになるのかを読み解きます。

ここで大切なのは、完成コードを丸暗記することではありません。

完成コードを見たときに、

これはアプリの入口
これは動画データ
これは縦スクロール
これは動画背景
これは右側ボタン
これはコメント画面
これは下部情報

と、構造で読めるようになることが目的です。

この節で大切なのは、次の一文です。

完成コードは、部品・データ・状態・処理がつながって1つのアプリになったものである。

まず完成アプリの全体像を見る

今回の完成アプリは、TikTok風のショート動画アプリです。

機能としては、次のようなものがあります。

TikTok風ショート動画アプリ
├─ 動画を全画面背景で再生する
├─ 上下スワイプで動画を切り替える
├─ 少ない動画を無限に続いているように見せる
├─ 右側にいいね・コメント・保存・共有ボタンを表示する
├─ いいねを押すとハートに色がつき、数字が増える
├─ 保存を押すと保存アイコンに色がつき、数字が増える
├─ 共有を押すと共有アイコンに色がつき、数字が増える
├─ コメントを押すとBottomSheetでコメント欄が開く
├─ コメントを入力して送信できる
└─ 下部にユーザー名・キャプション・音源情報を表示する

見た目は複雑に見えますが、ここまで学んできた部品の組み合わせです。

アプリの土台
+ 動画データ
+ PageView
+ video_player
+ Stack
+ RightActionBar
+ CommentSheet
+ BottomVideoInfo

つまり、新しいことを急にしているわけではありません。

これまでの節で作った小さな部品が、1つにつながっているだけです。

完成コードを読む順番

完成コードは長いので、上から順番にすべてを理解しようとすると混乱しやすいです。

おすすめの読み方は、次の順番です。

1. main
2. TikTokLikeVideoApp
3. ShortVideo class
4. videos
5. ShortVideoHomePage
6. PageView.builder
7. ShortVideoPage
8. VideoBackground
9. RightActionBar
10. CommentSheet
11. BottomVideoInfo
12. formatCount

この順番で読むと、アプリの流れが見えます。

アプリが起動する
↓
動画データを用意する
↓
縦スクロールで動画ページを作る
↓
1ページの中に動画・ボタン・情報を配置する
↓
ユーザー操作で状態を変える

完成コード全体の構造

完成コードの大きな構造は、次のようになります。

main
└─ TikTokLikeVideoApp
   └─ MaterialApp
      └─ ShortVideoHomePage
         ├─ 動画Controllerを管理する
         ├─ いいね・保存・共有状態を管理する
         ├─ コメント一覧を管理する
         └─ PageView.builder
            └─ ShortVideoPage
               ├─ VideoBackground
               ├─ VideoGradientOverlay
               ├─ TopNavigation
               ├─ PageIndicator
               ├─ RightActionBar
               │  ├─ ProfileButton
               │  ├─ ActionButton
               │  ├─ ActionButton
               │  ├─ ActionButton
               │  ├─ ActionButton
               │  └─ SpinningDisc
               └─ BottomVideoInfo

CommentSheet
├─ CommentTile
└─ CommentInputBar

この図が理解できると、完成コードを読みやすくなります。

1. mainを読む

まず、アプリの入口です。

void main() {
  runApp(const TikTokLikeVideoApp());
}

main は、Dartプログラムのスタート地点です。

Flutterアプリでは、ここで runApp を呼び出します。

main
↓
runApp
↓
TikTokLikeVideoAppをアプリとして表示する

つまり、アプリは TikTokLikeVideoApp から始まります。

2. TikTokLikeVideoAppを読む

次に、アプリ全体の入口になるWidgetを見ます。

class TikTokLikeVideoApp extends StatelessWidget {
  const TikTokLikeVideoApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: ShortVideoHomePage(),
    );
  }
}

ここでは、MaterialApp を返しています。

MaterialApp は、Flutterアプリ全体の基本設定をするWidgetです。

TikTokLikeVideoApp
└─ MaterialApp
   └─ home: ShortVideoHomePage

homeShortVideoHomePage が指定されているため、最初に表示される画面は ShortVideoHomePage です。

3. ShortVideo classを読む

次に、動画1本分のデータ設計を見ます。

class ShortVideo {
  const ShortVideo({
    required this.videoUrl,
    required this.userName,
    required this.caption,
    required this.musicTitle,
    required this.likes,
    required this.comments,
    required this.saves,
    required this.shares,
    required this.avatarColor,
    required this.categoryLabel,
  });

  final String videoUrl;
  final String userName;
  final String caption;
  final String musicTitle;
  final int likes;
  final int comments;
  final int saves;
  final int shares;
  final Color avatarColor;
  final String categoryLabel;
}

ShortVideo は、動画1本分の情報をまとめるclassです。

property役割
videoUrl再生する動画URL
userName投稿者名
caption動画の説明文
musicTitle音源名
likes元のいいね数
comments元のコメント数
saves元の保存数
shares元の共有数
avatarColorプロフィール色
categoryLabel動画カテゴリ

ここで重要なのは、画面に表示する情報を、先にデータとして整理していることです。

データを設計する
↓
Widgetに渡す
↓
画面に表示する

4. videosを読む

次に、複数の動画データを見ます。

const videos = [
  ShortVideo(
    videoUrl:
        'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4',
    userName: 'pet_cafe_diary',
    caption: '小さな命の動きは、見ているだけで少しやさしい気持ちになる。今日はペットカフェ風の癒し動画。',
    musicTitle: 'Healing Cafe Sound - Pet Cafe Diary',
    likes: 12800,
    comments: 324,
    saves: 1204,
    shares: 856,
    avatarColor: Color(0xFFE91E63),
    categoryLabel: 'PET',
  ),
  ShortVideo(
    videoUrl:
        'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4',
    userName: 'food_and_nature',
    caption: 'おいしいものを探す旅の途中で出会った、自然の小さなリズム。食と暮らしの余白を感じる一本。',
    musicTitle: 'Kitchen Walk - Food & Nature',
    likes: 8732,
    comments: 128,
    saves: 902,
    shares: 411,
    avatarColor: Color(0xFF2196F3),
    categoryLabel: 'FOOD',
  ),
  ShortVideo(
    videoUrl:
        'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4',
    userName: 'daily_pet_room',
    caption: 'ペットと過ごす午後。何気ない一瞬が、あとから思い出になる。',
    musicTitle: 'Room Light - Daily Pet Room',
    likes: 24100,
    comments: 642,
    saves: 3010,
    shares: 1112,
    avatarColor: Color(0xFFFF9800),
    categoryLabel: 'ROOM',
  ),
];

videos は、ShortVideo のListです。

videos
├─ videos[0] PET
├─ videos[1] FOOD
└─ videos[2] ROOM

TikTok風アプリでは、この videos をもとに、1ページずつ動画を表示します。

videos[0]
↓
1ページ目の動画

videos[1]
↓
2ページ目の動画

videos[2]
↓
3ページ目の動画

ただし、完成コードでは無限スクロール風にするため、pageIndex % videos.length を使って循環させます。

5. ShortVideoHomePageを読む

完成コードの中心になるのが、ShortVideoHomePage です。

class ShortVideoHomePage extends StatefulWidget {
  const ShortVideoHomePage({super.key});

  @override
  State<ShortVideoHomePage> createState() => _ShortVideoHomePageState();
}

これは StatefulWidget です。

なぜなら、この画面には状態がたくさんあるからです。

現在のページ
動画Controller
いいね済み動画
保存済み動画
共有済み動画
コメント一覧
読み込み完了しているか

状態を持つ画面なので、StatefulWidget にします。

6. Stateの中の変数を読む

_ShortVideoHomePageState の中には、アプリの状態があります。

late final PageController pageController;

final List<VideoPlayerController> controllers = [];

final Set<int> likedVideoIndexes = {};
final Set<int> savedVideoIndexes = {};
final Set<int> sharedVideoIndexes = {};

late int currentPageIndex;

bool isReady = false;

それぞれの役割を整理します。

変数役割
pageControllerPageViewの開始位置などを管理する
controllers各動画のVideoPlayerControllerを持つ
likedVideoIndexesいいね済みの動画番号を記録する
savedVideoIndexes保存済みの動画番号を記録する
sharedVideoIndexes共有済みの動画番号を記録する
currentPageIndex現在のページ番号
isReady動画の準備が終わったか

ここで大切なのは、状態を親Widgetにまとめていることです。

ShortVideoHomePage
↓
アプリ全体に関係する状態を管理する

右側ボタンやコメント欄は、この親Widgetから値や処理を受け取ります。

7. currentVideoIndexを読む

完成コードには、次のgetterがあります。

int get currentVideoIndex {
  return currentPageIndex % videos.length;
}

これは、今表示している動画番号を計算しています。

currentPageIndex は、PageView上のページ番号です。

無限スクロール風にしているため、ページ番号は大きくなります。

currentPageIndex
3000, 3001, 3002, 3003...

しかし、動画データは3本しかありません。

そこで、% videos.length を使います。

3000 % 3 = 0
3001 % 3 = 1
3002 % 3 = 2
3003 % 3 = 0

つまり、動画番号は次のように循環します。

0, 1, 2, 0, 1, 2...

8. initStateを読む

initState は、画面が最初に作られたときに一度だけ呼ばれます。

@override
void initState() {
  super.initState();

  currentPageIndex = videos.length * 1000;

  pageController = PageController(initialPage: currentPageIndex);

  initializeVideos();
}

ここでは、3つの準備をしています。

1. currentPageIndexを大きい値にする
2. PageControllerを作る
3. 動画を初期化する

videos.length * 1000 にしているのは、最初から上にも下にもスクロールできるように見せるためです。

0ページ目から始める
↓
上方向には戻りにくい

3000ページ目から始める
↓
上下どちらにもたくさんスクロールできるように見える

9. initializeVideosを読む

動画の準備をしているのが、initializeVideos です。

Future<void> initializeVideos() async {
  for (final video in videos) {
    final controller = VideoPlayerController.networkUrl(
      Uri.parse(video.videoUrl),
    );

    await controller.initialize();

    controller
      ..setLooping(true)
      ..setVolume(0)
      ..pause();

    controllers.add(controller);
  }

  await controllers[currentVideoIndex].play();

  setState(() {
    isReady = true;
  });
}

流れは次の通りです。

videosを1本ずつ見る
↓
video.videoUrlからVideoPlayerControllerを作る
↓
initializeで動画を準備する
↓
ループ再生・音量0・一時停止にする
↓
controllersに追加する
↓
現在表示する動画だけ再生する
↓
isReadyをtrueにして画面を表示する

controllers は、動画ごとのcontrollerを入れるListです。

controllers[0] → videos[0]用のcontroller
controllers[1] → videos[1]用のcontroller
controllers[2] → videos[2]用のcontroller

動画を切り替えるときは、このcontrollerを使って、前の動画を止め、次の動画を再生します。

10. disposeを読む

controllerを作ったら、使い終わったときに片付けます。

@override
void dispose() {
  pageController.dispose();

  for (final controller in controllers) {
    controller.dispose();
  }

  super.dispose();
}

ここでは、PageController と、すべての VideoPlayerController を片付けています。

Controllerを作る
↓
使い終わったらdisposeする

これはFlutterで大切な習慣です。

11. onPageChangedを読む

動画ページが切り替わったときに呼ばれるのが、onPageChanged です。

void onPageChanged(int pageIndex) {
  final previousVideoIndex = currentVideoIndex;
  final nextVideoIndex = pageIndex % videos.length;

  controllers[previousVideoIndex].pause();

  setState(() {
    currentPageIndex = pageIndex;
  });

  controllers[nextVideoIndex]
    ..seekTo(Duration.zero)
    ..play();
}

この処理はとても重要です。

流れは次の通りです。

今まで見ていた動画番号を覚える
↓
次に表示する動画番号を計算する
↓
前の動画を停止する
↓
currentPageIndexを更新する
↓
次の動画を最初に戻して再生する

ここで、previousVideoIndexnextVideoIndex を分けているのがポイントです。

previousVideoIndex
↓
今までの動画

nextVideoIndex
↓
次に表示する動画

動画アプリでは、見えていない動画が裏で再生され続けると困ります。

そのため、前の動画を止めて、次の動画だけ再生します。

12. toggleLike / toggleSave / toggleShareを読む

いいね・保存・共有の状態を切り替える処理です。

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);
    }
  });
}

ここで使っているのは、Set<int> です。

likedVideoIndexes
↓
いいね済みの動画番号を記録する

savedVideoIndexes
↓
保存済みの動画番号を記録する

sharedVideoIndexes
↓
共有済みの動画番号を記録する

13. 表示用の数字を読む

表示用の数字は、次の関数で計算しています。

int displayLikeCount(int videoIndex) {
  final base = videos[videoIndex].likes;
  return likedVideoIndexes.contains(videoIndex) ? base + 1 : base;
}

これは、次の意味です。

元のいいね数を取り出す
↓
いいね済みなら +1
↓
そうでなければ元の数字のまま

保存数と共有数も同じです。

int displaySaveCount(int videoIndex) {
  final base = videos[videoIndex].saves;
  return savedVideoIndexes.contains(videoIndex) ? base + 1 : base;
}
int displayShareCount(int videoIndex) {
  final base = videos[videoIndex].shares;
  return sharedVideoIndexes.contains(videoIndex) ? base + 1 : base;
}

ここで大切なのは、元データを直接書き換えていないことです。

元データ
↓
videos[videoIndex].likes

操作状態
↓
likedVideoIndexes

表示
↓
元データ + 状態

14. openCommentSheetを読む

コメント欄を開く処理です。

void openCommentSheet(int videoIndex) {
  showModalBottomSheet<void>(
    context: context,
    isScrollControlled: true,
    useSafeArea: true,
    backgroundColor: Colors.transparent,
    builder: (context) {
      return CommentSheet(
        video: videos[videoIndex],
        comments: commentMap[videoIndex] ?? [],
        onSubmit: (text) {
          final trimmedText = text.trim();

          if (trimmedText.isEmpty) {
            return;
          }

          setState(() {
            commentMap.putIfAbsent(videoIndex, () => []);
            commentMap[videoIndex]!.insert(0, trimmedText);
          });
        },
      );
    },
  );
}

ここでは、showModalBottomSheet でコメント欄を表示しています。

重要なのは、videoIndex を受け取っていることです。

videoIndex
↓
どの動画のコメント欄を開くか

コメント一覧は、commentMap から取り出します。

comments: commentMap[videoIndex] ?? [],

?? [] は、もしコメントがなければ空のListを使う、という意味です。

コメント送信時には、対象動画のコメント一覧に追加します。

commentMap.putIfAbsent(videoIndex, () => []);
commentMap[videoIndex]!.insert(0, trimmedText);

つまり、動画ごとにコメントを分けて管理できます。

commentMap[0]
↓
0番動画のコメント

commentMap[1]
↓
1番動画のコメント

commentMap[2]
↓
2番動画のコメント

15. buildを読む

ShortVideoHomePagebuild では、準備中かどうかを見ています。

if (!isReady) {
  return const Scaffold(
    backgroundColor: Colors.black,
    body: Center(child: CircularProgressIndicator(color: Colors.white)),
  );
}

動画の準備が終わるまでは、ローディングを表示します。

isReady = false
↓
読み込み中

isReady = true
↓
動画画面を表示

準備が終わったら、PageView.builder を表示します。

return Scaffold(
  backgroundColor: Colors.black,
  body: PageView.builder(
    controller: pageController,
    scrollDirection: Axis.vertical,
    onPageChanged: onPageChanged,
    itemBuilder: (context, pageIndex) {
      final videoIndex = pageIndex % videos.length;

      return ShortVideoPage(...);
    },
  ),
);

ここが、TikTok風縦スワイプの中心です。

16. PageView.builderを読む

PageView.builder の中で、ページごとに動画を表示します。

itemBuilder: (context, pageIndex) {
  final videoIndex = pageIndex % videos.length;

  return ShortVideoPage(
    video: videos[videoIndex],
    controller: controllers[videoIndex],
    videoIndex: videoIndex,
    pageIndex: pageIndex,
    ...
  );
}

流れは次の通りです。

pageIndexを受け取る
↓
%でvideoIndexに変換する
↓
videos[videoIndex]を取り出す
↓
controllers[videoIndex]を取り出す
↓
ShortVideoPageに渡す

ここで、これまで学んだ内容がつながります。

PageView.builder
+ 無限スクロール風
+ ShortVideoデータ
+ VideoPlayerController

17. ShortVideoPageを読む

ShortVideoPage は、動画1本分の画面です。

class ShortVideoPage extends StatelessWidget {
  const ShortVideoPage({
    super.key,
    required this.video,
    required this.controller,
    required this.videoIndex,
    required this.pageIndex,
    required this.isLiked,
    required this.isSaved,
    required this.isShared,
    required this.likeCount,
    required this.commentCount,
    required this.saveCount,
    required this.shareCount,
    required this.onTapVideo,
    required this.onTapLike,
    required this.onTapComment,
    required this.onTapSave,
    required this.onTapShare,
  });

たくさんの値を受け取っていますが、分類すると分かりやすいです。

分類
表示するデータvideo
動画再生controller
何番目かvideoIndex, pageIndex
状態isLiked, isSaved, isShared
表示数likeCount, commentCount, saveCount, shareCount
操作onTapVideo, onTapLike, onTapComment, onTapSave, onTapShare

つまり、ShortVideoPage は自分で状態を管理するのではなく、親から必要なものを受け取って表示しています。

18. ShortVideoPageのbuildを読む

ShortVideoPage の中身は Stack です。

return Stack(
  children: [
    Positioned.fill(
      child: GestureDetector(
        onTap: onTapVideo,
        child: VideoBackground(controller: controller),
      ),
    ),
    const Positioned.fill(child: VideoGradientOverlay()),
    const Positioned(top: 44, left: 0, right: 0, child: TopNavigation()),
    Positioned(
      left: 18,
      top: 92,
      child: PageIndicator(pageIndex: pageIndex, videoIndex: videoIndex),
    ),
    Positioned(
      right: 12,
      bottom: 92,
      child: RightActionBar(...),
    ),
    Positioned(
      left: 16,
      right: 86,
      bottom: 28,
      child: BottomVideoInfo(video: video),
    ),
  ],
);

構造で見ると、次のようになります。

ShortVideoPage
├─ VideoBackground
├─ VideoGradientOverlay
├─ TopNavigation
├─ PageIndicator
├─ RightActionBar
└─ BottomVideoInfo

これは、4-4で学んだ Stack の考え方そのものです。

背景動画
↓
黒グラデーション
↓
上部ナビ
↓
右側ボタン
↓
下部情報

19. VideoBackgroundを読む

VideoBackground は、動画を背景として表示するWidgetです。

class VideoBackground extends StatelessWidget {
  const VideoBackground({super.key, required this.controller});

  final VideoPlayerController controller;

  @override
  Widget build(BuildContext context) {
    final size = controller.value.size;

    return Container(
      color: Colors.black,
      child: FittedBox(
        fit: BoxFit.cover,
        child: SizedBox(
          width: size.width,
          height: size.height,
          child: VideoPlayer(controller),
        ),
      ),
    );
  }
}

ここでは、VideoPlayer(controller)FittedBox で包み、BoxFit.cover で画面全体に広げています。

VideoPlayer
↓
SizedBoxで動画サイズを指定
↓
FittedBoxで画面いっぱいに広げる
↓
背景動画になる

20. VideoGradientOverlayを読む

動画の上に重ねる黒いグラデーションです。

class VideoGradientOverlay extends StatelessWidget {
  const VideoGradientOverlay({super.key});

  @override
  Widget build(BuildContext context) {
    return IgnorePointer(
      child: DecoratedBox(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            colors: [
              Colors.black.withOpacity(0.45),
              Colors.transparent,
              Colors.transparent,
              Colors.black.withOpacity(0.86),
            ],
            stops: const [0.0, 0.24, 0.58, 1.0],
          ),
        ),
      ),
    );
  }
}

これは、文字を読みやすくするための部品です。

動画だけ
↓
文字が読みにくいことがある

動画 + 黒グラデーション
↓
白文字が読みやすくなる

IgnorePointer を使っているため、タップ操作の邪魔をしません。

21. RightActionBarを読む

RightActionBar は、右側のボタン一覧です。

RightActionBar(
  video: video,
  isLiked: isLiked,
  isSaved: isSaved,
  isShared: isShared,
  likeCount: likeCount,
  commentCount: commentCount,
  saveCount: saveCount,
  shareCount: shareCount,
  onTapLike: onTapLike,
  onTapComment: onTapComment,
  onTapSave: onTapSave,
  onTapShare: onTapShare,
)

ここで渡しているものは、表示に必要な値と、タップされたときの処理です。

表示に必要な値
↓
isLiked, likeCount など

操作時の処理
↓
onTapLike, onTapComment など

RightActionBar の中では、ActionButton を複数並べています。

RightActionBar
├─ ProfileButton
├─ ActionButton いいね
├─ ActionButton コメント
├─ ActionButton 保存
├─ ActionButton 共有
└─ SpinningDisc

22. ActionButtonを読む

ActionButton は、いいね・コメント・保存・共有の共通部品です。

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;

このWidgetは、外から受け取った値によって表示を変えます。

icon
↓
表示するアイコン

label
↓
表示する数字

isActive
↓
色をつけるかどうか

onTap
↓
押されたときの処理

ActionButton は、いいね専用ではありません。

コメント、保存、共有にも使える共通ボタンです。

23. CommentSheetを読む

CommentSheet は、コメント欄全体です。

class CommentSheet extends StatefulWidget {
  const CommentSheet({
    super.key,
    required this.video,
    required this.comments,
    required this.onSubmit,
  });

  final ShortVideo video;
  final List<String> comments;
  final ValueChanged<String> onSubmit;

コメント入力欄があり、内部で TextEditingController を使うため、StatefulWidget です。

CommentSheet
├─ localComments
├─ TextEditingController
├─ CommentTile一覧
└─ CommentInputBar

コメントを送信すると、親Widgetに onSubmit で伝え、自分の中の localComments も更新します。

widget.onSubmit(text);

setState(() {
  localComments.insert(0, text);
  controller.clear();
});

24. BottomVideoInfoを読む

BottomVideoInfo は、下部の投稿情報です。

BottomVideoInfo(video: video)

中では、video から情報を取り出して表示しています。

Text('@${video.userName}')
Text(video.caption)
Text(video.musicTitle)
Text(video.categoryLabel)

このWidgetは、表示するだけなので StatelessWidget です。

受け取ったvideoを表示する
↓
自分で状態は持たない
↓
StatelessWidget

25. formatCountを読む

最後に、数字を表示用に変換する関数です。

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();
}

これは、次のように数字を変換します。

元の数字表示
128001.3万
12041.2K
856856

この関数を使うことで、ボタンの数字表示がTikTok風になります。

label: formatCount(likeCount)

データの流れを整理する

完成コードでは、データは次のように流れます。

videos
↓
PageView.builder
↓
videoIndexで1本取り出す
↓
ShortVideoPageに渡す
↓
RightActionBarやBottomVideoInfoに渡す
↓
画面に表示する

具体的には、こうです。

final videoIndex = pageIndex % videos.length;
video: videos[videoIndex],
BottomVideoInfo(video: video)
Text('@${video.userName}')

この流れが分かると、完成コードがかなり読みやすくなります。

操作の流れを整理する

ユーザー操作の流れも見てみます。

いいねを押した場合

ActionButtonをタップ
↓
onTapLikeが呼ばれる
↓
toggleLike(videoIndex)が実行される
↓
likedVideoIndexesが更新される
↓
setStateで画面が更新される
↓
ハートの色と数字が変わる

コメントを押した場合

コメントActionButtonをタップ
↓
onTapCommentが呼ばれる
↓
openCommentSheet(videoIndex)が実行される
↓
showModalBottomSheetでCommentSheetを表示する
↓
コメントを入力する
↓
onSubmitで親にコメントを渡す
↓
commentMapが更新される

動画をスワイプした場合

PageViewをスワイプ
↓
onPageChangedが呼ばれる
↓
前の動画controllerをpause
↓
currentPageIndexを更新
↓
次の動画controllerをseekTo(Duration.zero)してplay

この3つの流れを理解できると、完成アプリの動きが見えてきます。

完成コードを読むときのコツ

完成コードを読むときは、全部を一気に理解しようとしなくて大丈夫です。

次のように、役割ごとに見ると読みやすくなります。

アプリ起動を見る
↓
main / MaterialApp

データを見る
↓
ShortVideo / videos

動画再生を見る
↓
initializeVideos / VideoBackground

縦スワイプを見る
↓
PageView.builder / onPageChanged

右側ボタンを見る
↓
RightActionBar / ActionButton

コメントを見る
↓
openCommentSheet / CommentSheet

下部情報を見る
↓
BottomVideoInfo

コードは、文章のように上から全部読むよりも、役割ごとに読むほうが理解しやすいです。

手を動かす練習1:動画データを追加する

videos に新しい ShortVideo を追加してみましょう。

ShortVideo(
  videoUrl:
      'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4',
  userName: 'sweet_table',
  caption: '甘いものを囲む時間は、少しだけ日常をやわらかくしてくれる。',
  musicTitle: 'Sweet Table Sound',
  likes: 5400,
  comments: 88,
  saves: 420,
  shares: 120,
  avatarColor: Color(0xFF9C27B0),
  categoryLabel: 'SWEETS',
),

動画データを追加すると、無限スクロール風の循環に新しい動画が加わります。

PET
FOOD
ROOM
SWEETS
PET
FOOD
ROOM
SWEETS

手を動かす練習2:いいね色を変える

RightActionBar のいいねボタンを探してください。

activeColor: const Color(0xFFFF2D55),

これを次のように変えてみます。

activeColor: Colors.purpleAccent,

いいね済みのハート色が変わります。

手を動かす練習3:コメント欄の高さを変える

CommentSheet の中にある次の部分を探します。

height: MediaQuery.of(context).size.height * 0.72,

これを次のように変えてみます。

height: MediaQuery.of(context).size.height * 0.60,

BottomSheetの高さが変わります。

手を動かす練習4:下部情報の表示行数を変える

BottomVideoInfo のキャプション部分を探します。

maxLines: 2,

これを次のように変えてみます。

maxLines: 3,

キャプションが最大3行まで表示されます。

手を動かす練習5:動画切り替え時に最初から再生しないようにする

onPageChanged の中にある次の部分を探します。

controllers[nextVideoIndex]
  ..seekTo(Duration.zero)
  ..play();

これを次のように変えると、次の動画を途中位置から再生する可能性があります。

controllers[nextVideoIndex].play();

通常のTikTok風UIでは、切り替え時に最初から再生するほうが分かりやすいため、確認したら元に戻してください。

よくあるつまずき1:完成コードが長すぎて読めない

完成コードは長いです。

しかし、全体を一度に理解する必要はありません。

まずは、次の5つだけ見てください。

main
ShortVideo
videos
ShortVideoHomePage
ShortVideoPage

この5つが分かると、アプリの中心が見えます。

細かいUI部品は、その後で見れば大丈夫です。

よくあるつまずき2:親と子の関係が分からない

Flutterでは、親Widgetから子Widgetへデータや関数を渡します。

ShortVideoPage(
  video: videos[videoIndex],
  onTapLike: () => toggleLike(videoIndex),
)

子Widgetは、それを受け取って使います。

final ShortVideo video;
final VoidCallback onTapLike;

この流れを覚えてください。

親がデータと処理を持つ
↓
子に渡す
↓
子が表示・実行する

よくあるつまずき3:videoIndexとpageIndexが混乱する

pageIndex は、PageView上のページ番号です。

videoIndex は、videosから取り出す番号です。

final videoIndex = pageIndex % videos.length;

無限スクロール風では、pageIndex はどんどん増えます。

しかし、videoIndex0, 1, 2 のように循環します。

pageIndex: 3003
videoIndex: 0

よくあるつまずき4:Controllerが多くて分からない

完成コードには、2種類のControllerが出てきます。

Controller役割
PageControllerPageViewの開始位置を管理する
VideoPlayerController動画の再生・停止を管理する
TextEditingControllerコメント入力欄の文字を管理する

それぞれ、管理しているものが違います。

PageController
↓
ページ

VideoPlayerController
↓
動画

TextEditingController
↓
文字入力

よくあるつまずき5:setStateの場所が分からない

setState は、状態を持っているWidgetの中で使います。

たとえば、いいね状態は ShortVideoHomePage が持っています。

そのため、toggleLike の中で setState を使います。

setState(() {
  likedVideoIndexes.add(videoIndex);
});

コメント欄の中の localComments は、CommentSheet が持っています。

そのため、CommentSheet の中でも setState を使います。

setState(() {
  localComments.insert(0, text);
});

状態を持っている場所で setState を呼ぶ、と考えてください。

この節の確認問題

確認問題1

完成コードを読むとき、最初に見るべき流れは何ですか。

答え

mainTikTokLikeVideoAppShortVideovideosShortVideoHomePagePageView.builderShortVideoPage の順番で見ると理解しやすいです。

確認問題2

ShortVideo classは何のためにありますか。

答え

動画1本分のデータをまとめるためです。

動画URL、ユーザー名、キャプション、いいね数などを持ちます。

確認問題3

currentVideoIndex は何を計算していますか。

答え

現在のページ番号 currentPageIndex を動画数で割った余りにして、現在表示する動画番号を計算しています。

確認問題4

initializeVideos は何をしていますか。

答え

動画ごとに VideoPlayerController を作り、動画を初期化して、再生準備をしています。

最後に現在の動画だけを再生し、準備完了として isReadytrue にします。

確認問題5

onPageChanged は何のためにありますか。

答え

動画ページが切り替わったときに、前の動画を停止し、次の動画を最初から再生するためです。

確認問題6

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

答え

RightActionBarActionButton は表示部品であり、いいね状態の変更処理は親Widgetが持っているためです。

子Widgetは、タップされたときに親から渡された関数を呼びます。

確認問題7

CommentSheet はなぜ StatefulWidget なのですか。

答え

コメント入力欄のcontrollerを持ち、送信時に localComments を更新して表示を変えるためです。

この節のまとめ

この節では、完成コードを上から丸暗記するのではなく、役割ごとに読み解く方法を学びました。

完成アプリは、次のような部品の組み合わせです。

データ
├─ ShortVideo
└─ videos

画面
├─ ShortVideoHomePage
├─ PageView.builder
└─ ShortVideoPage

表示部品
├─ VideoBackground
├─ RightActionBar
├─ CommentSheet
└─ BottomVideoInfo

状態
├─ currentPageIndex
├─ likedVideoIndexes
├─ savedVideoIndexes
├─ sharedVideoIndexes
└─ commentMap

処理
├─ initializeVideos
├─ onPageChanged
├─ toggleLike
├─ toggleSave
├─ toggleShare
└─ openCommentSheet

この節で一番大切なのは、次の一文です。

完成コードは、データ・状態・処理・Widgetが役割ごとにつながってできている。

次の節では、第4章全体のまとめとして、TikTok風アプリで学んだ考え方を、他のアプリ開発にも応用できる形で整理します。

教材トップへ戻る