🌎

【Flutter】画面をモダンなフルスクリーンの縦カルーセルの作り方

に公開

要約

本記事では、Flutterで縦方向のフルスクリーンのカルーセルの実装方法を紹介します。パッケージは carousel_slider_plusを使います。さらに、画面右中央にページインジケータを重ねて表示する手順も示します。

対象

本ブログの対象者は以下の方々に向けて執筆しています。

  • TikTokやInstagramのリール動画風に縦スワイプでページを切り替える画面を実装したい人
  • 画面いっぱいに1ページを見せ、右側に縦インジケータを出したい人

課題

このブログでは以下を実現できる実装の解説をします。

  • 縦方向のページ送りを1ページで見せたい
  • 完全フルスクリーンで表示したい
  • 現在のページ位置がわかるように、右中央に縦並びのドットを表示したい

解決アプローチ

Step1. 縦方向のフルスクリーンのカルーセルの実装

ポイントは大きく4点あります

  • CarouselSliderにCarouselOptions(scrollDirection: Axis.vertical)を設定。
  • 表示しているカードの前後をどの程度見せるかの指標viewportFraction: 1.0を指定する。
  • 表示する高さにデバイスの高さを指定する
  • AppBarを透過させるため、extendBodyBehindAppBar: trueを指定する

サンプルコードは以下です

  
  Widget build(BuildContext context) {
    final size = MediaQuery.sizeOf(context);

    return Scaffold(
      // SafeArea を無視してフルスクリーン
      extendBodyBehindAppBar: true,
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        elevation: 0,
        title: const Text('Fullscreen Vertical Carousel'),
      ),
      body: Stack(
        children: [
          // 画面いっぱい
          SizedBox.expand(
            child: CarouselSlider.builder(
              itemCount: items.length,
              itemBuilder: (context, index, realIndex) {
                return Container(
                  width: size.width,
                  height: size.height,
                  decoration: BoxDecoration(
                    gradient: LinearGradient(
                      colors: [Colors.indigo.shade800, Colors.indigo.shade400],
                      begin: Alignment.topCenter,
                      end: Alignment.bottomCenter,
                    ),
                  ),
                  child: Center(
                    child: Text(
                      items[index],
                      style: const TextStyle(
                        fontSize: 36,
                        color: Colors.white,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                  ),
                );
              },
              options: CarouselOptions(
                height: size.height, // ← PageViewのため高さ制約が必須
                scrollDirection: Axis.vertical, // ← 縦スクロール
                viewportFraction: 1.0, // ← 1ページを画面いっぱいに
                enableInfiniteScroll: true,
                autoPlay: false,
                enlargeCenterPage: false,
                onPageChanged: (index, reason) {
                  setState(() => currentIndex = index);
                },
              ),
            ),
          ),

Step2. 画面右中央にページインジケータを重ねて表示する

ポイントは大きく3点あります

  • Stackでカルーセルの上に重ねる
  • ドットはColumnで縦一列に並べ、アクティブな位置だけ高さを強調して視認性を向上させる
  • カルーセルのonPageChangedでcurrentIndexを更新してインジケータへ反映させる

完成系コードは以下です。以下コピペで動作確認までできます。

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

  
  State<FullscreenVerticalCarouselRightIndicator> createState() =>
      _FullscreenVerticalCarouselRightIndicatorState();
}

class _FullscreenVerticalCarouselRightIndicatorState
    extends State<FullscreenVerticalCarouselRightIndicator> {
  final items = List.generate(6, (i) => 'Page ${i + 1}');
  int currentIndex = 0;

  
  Widget build(BuildContext context) {
    final size = MediaQuery.sizeOf(context);

    return Scaffold(
      // SafeArea を無視してフルスクリーン
      extendBodyBehindAppBar: true,
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        elevation: 0,
        title: const Text('Fullscreen Vertical Carousel'),
      ),
      body: Stack(
        children: [
          // 画面いっぱい
          SizedBox.expand(
            child: CarouselSlider.builder(
              itemCount: items.length,
              itemBuilder: (context, index, realIndex) {
                return Container(
                  width: size.width,
                  height: size.height,
                  decoration: BoxDecoration(
                    gradient: LinearGradient(
                      colors: [Colors.indigo.shade800, Colors.indigo.shade400],
                      begin: Alignment.topCenter,
                      end: Alignment.bottomCenter,
                    ),
                  ),
                  child: Center(
                    child: Text(
                      items[index],
                      style: const TextStyle(
                        fontSize: 36,
                        color: Colors.white,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                  ),
                );
              },
              options: CarouselOptions(
                height: size.height, // ← PageViewのため高さ制約が必須
                scrollDirection: Axis.vertical, // ← 縦スクロール
                viewportFraction: 1.0, // ← 1ページを画面いっぱいに
                enableInfiniteScroll: true,
                autoPlay: false,
                enlargeCenterPage: false,
                onPageChanged: (index, reason) {
                  setState(() => currentIndex = index);
                },
              ),
            ),
          ),

          // 右中央に縦インジケータ(ドット + 枚数バッジ)
          Align(
            alignment: Alignment.centerRight,
            child: Padding(
              padding: const EdgeInsets.only(right: 16),
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  // 枚数バッジ(例: 3 / 6 )
                  Container(
                    padding: const EdgeInsets.symmetric(
                      horizontal: 10,
                      vertical: 6,
                    ),
                    decoration: BoxDecoration(
                      color: Colors.black.withOpacity(0.35),
                      borderRadius: BorderRadius.circular(12),
                    ),
                    child: Text(
                      '${currentIndex + 1} / ${items.length}',
                      style: const TextStyle(color: Colors.white, fontSize: 13),
                    ),
                  ),
                  const SizedBox(height: 10),

                  // 縦向きドット
                  _VerticalDotsIndicator(
                    count: items.length,
                    index: currentIndex,
                    activeColor: Colors.white,
                    inactiveColor: Colors.white.withOpacity(0.35),
                    size: 8,
                    spacing: 8,
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class _VerticalDotsIndicator extends StatelessWidget {
  const _VerticalDotsIndicator({
    required this.count,
    required this.index,
    this.activeColor = Colors.white,
    this.inactiveColor = Colors.white54,
    this.size = 8,
    this.spacing = 8,
    super.key,
  });

  final int count;
  final int index;
  final Color activeColor;
  final Color inactiveColor;
  final double size;
  final double spacing;

  
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: List.generate(count, (i) {
        final isActive = i == index;
        return AnimatedContainer(
          duration: const Duration(milliseconds: 250),
          curve: Curves.easeOut,
          margin: EdgeInsets.symmetric(vertical: spacing / 2),
          width: size,
          height: isActive ? size * 2.2 : size, // 縦向きなので高さで強調
          decoration: BoxDecoration(
            color: isActive ? activeColor : inactiveColor,
            borderRadius: BorderRadius.circular(size),
          ),
        );
      }),
    );
  }
}

よくあるハマりどころ

  • 高さ指定忘れ
    • CarouselSliderは内部でPageViewを使うため、高さ指定が必須です。高さが未確定だと、例外が出るので要注意です。今回の例だと、MediaQuery.sizeOf(context).height を使って高さを指定してます
  • 親も縦スクロールで競合
    • 親Widgetが縦のListView等で子カルーセルも縦だとジェスチャが奪い合いになるので要注意です
    • その際は、親のListViewにphysics: const NeverScrollableScrollPhysics()を指定し、スクロール不可にする方法で解消できます
  • 次のカードを少し見せたい
    • 次のカードを少し見せたい場合は、viewportFractionの値を0.8〜0.95に調整すると良いでしょう

代替案

より細かい制御がしたい時は、PageViewを使う方法もあるようです。

まとめ

CarouselSliderはスクロール方向を指定できるので、scrollDirectionの指定値で縦スクロールを実現できました。また、モダンな見た目にするため、高さを画面いっぱいにしAppBarを意識させないUI、また、ページインジケータも表示することでユーザビリティも向上させてみました。今回の実装を参考により良いUI・UXの実現に挑戦してみてください。
今回の参考コードについてご質問・ご意見ございましたらコメントで連絡いただけると幸いです。
本ブログを読んでくださりありがとうございました。

参考リンク

Discussion