👌

FlutterでPageViewを使ったオンボーディング画面を作成する方法

に公開

はじめに

アプリを初めて起動した時に、使い方を説明する画面を見たことはありませんか?
例えば、InstagramやTikTokなどのアプリでは、初回起動時に「写真を共有しよう」「動画を見よう」といった説明画面が表示されます。

このようなオンボーディング画面(アプリの使い方を説明する画面)を作りたいと思ったことはありませんか?

実は、FlutterではPageViewというウィジェットを使うことで、簡単にスワイプでページを切り替えられる画面を作成できます。

PageViewとは?

PageViewは、複数のページを横に並べて、スワイプで切り替えられるウィジェットです。

主な特徴:

  • スワイプでページを切り替え可能
  • アニメーション付きの滑らかな切り替え
  • ページインジケータ(現在のページを表示するドット)と組み合わせやすい
  • オンボーディング画面やチュートリアル画面に最適

実装手順

1. 基本的なPageViewの使い方

まずは、最もシンプルなPageViewの実装例を見てみましょう:

PageView(
  children: [
    // 1ページ目
    Container(
      color: Colors.red,
      child: const Center(
        child: Text('1ページ目'),
      ),
    ),
    // 2ページ目
    Container(
      color: Colors.blue,
      child: const Center(
        child: Text('2ページ目'),
      ),
    ),
    // 3ページ目
    Container(
      color: Colors.green,
      child: const Center(
        child: Text('3ページ目'),
      ),
    ),
  ],
)

このコードで、赤、青、緑の3つのページが作成され、左右にスワイプして切り替えることができます。

2. ページの切り替えを制御する

PageControllerを使って、プログラムでページを切り替えることができます:

class _MyPageState extends State<MyPage> {
  // ページの切り替えを制御するコントローラー
  final PageController _controller = PageController();
  
  // 現在のページ番号
  int _currentPage = 0;

  // 次のページに進む処理
  void _nextPage() {
    _controller.animateToPage(
      _currentPage + 1,
      duration: const Duration(milliseconds: 300), // アニメーション時間
      curve: Curves.easeInOut, // アニメーションの曲線
    );
  }

  
  Widget build(BuildContext context) {
    return PageView(
      controller: _controller,
      onPageChanged: (index) {
        setState(() {
          _currentPage = index; // ページが変わった時に現在のページ番号を更新
        });
      },
      children: [
        // 各ページの内容
      ],
    );
  }
}

3. ページインジケータの追加

現在のページを表示するドット(インジケータ)を追加する方法:

Row(
  mainAxisAlignment: MainAxisAlignment.center,
  children: List.generate(
    3, // ページ数
    (index) => Container(
      margin: const EdgeInsets.symmetric(horizontal: 4),
      width: _currentPage == index ? 16 : 8, // 現在のページは大きく
      height: 8,
      decoration: BoxDecoration(
        color: _currentPage == index ? Colors.black : Colors.grey, // 現在のページは濃い色
        borderRadius: BorderRadius.circular(4),
      ),
    ),
  ),
)

4. 次へボタンの追加

ページを進めるためのボタンを追加する方法:

ElevatedButton(
  onPressed: _nextPage,
  child: Text(
    _currentPage == 2 ? '完了' : '次へ', // 最後のページでは「完了」と表示
  ),
)

よく使うPageViewのバリエーション

PageView.builder

ページ数が多い場合や、動的にページを生成したい場合に使用します:

PageView.builder(
  itemCount: 10, // ページ数
  itemBuilder: (context, index) {
    return Container(
      color: Colors.primaries[index % Colors.primaries.length],
      child: Center(
        child: Text('${index + 1}ページ目'),
      ),
    );
  },
)

PageViewの他のメソッド

  • controller.jumpToPage(index) - アニメーションなしでページを切り替え
  • controller.nextPage() - 次のページに移動
  • controller.previousPage() - 前のページに移動
  • controller.position.page - 現在のページ番号を取得

注意点

  1. PageControllerは必ずdisposeする必要があります
  2. ページ数が多い場合は、PageView.builderを使用することをお勧めします
  3. アニメーションの時間は、ユーザビリティを考慮して300-500ミリ秒程度が適切です

完全な実装例

以下は、オンボーディング画面の完全な実装例です。

// Flutterの基本パッケージをインポート
import 'package:flutter/material.dart';

/// アプリケーションのエントリーポイント
/// アプリが起動した時に最初に実行される関数
void main() {
  runApp(const MyApp());
}

/// アプリケーションのルートウィジェット
/// MaterialAppを設定し、アプリ全体のテーマやナビゲーションを管理
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Page View Demo',
      // デモページをホーム画面として設定
      home: const PageViewDemoPage(),
    );
  }
}

/// オンボーディングページのメインウィジェット
/// ユーザーにアプリの使い方を説明する画面を管理
class PageViewDemoPage extends StatefulWidget {
  const PageViewDemoPage({super.key});

  
  State<PageViewDemoPage> createState() => _PageViewDemoPageState();
}

/// オンボーディングページの状態管理クラス
/// ページの切り替えやユーザーの操作を管理
class _PageViewDemoPageState extends State<PageViewDemoPage> {
  /// ページの切り替えを制御するコントローラー
  /// アニメーションやページの移動を管理
  final PageController _controller = PageController();
  
  /// 現在表示されているページのインデックス(0から開始)
  int _currentPage = 0;

  /// 各ページの情報を格納するリスト
  /// 各ページのタイトル、説明文、背景色を定義
  final List<Map<String, dynamic>> _pages = [
    {
      'title': 'Welcome',           // ページのタイトル
      'description': 'Let\'s get started with our amazing app!', // ページの説明文
      'color': Colors.purpleAccent, // ページの背景色
    },
    {
      'title': 'Discover',
      'description': 'Explore features and enjoy the experience.',
      'color': Colors.orangeAccent,
    },
    {
      'title': 'Get Started',
      'description': 'Sign up and begin your journey!',
      'color': Colors.teal,
    },
  ];

  /// 次のページに進む処理
  /// 現在のページが最後でない場合は次のページに移動し、
  /// 最後のページの場合は完了メッセージを表示
  void _nextPage() {
    if (_currentPage < _pages.length - 1) {
      // まだ次のページがある場合:アニメーション付きで次のページに移動
      _controller.animateToPage(
        _currentPage + 1,                           // 移動先のページインデックス
        duration: const Duration(milliseconds: 300), // アニメーション時間(300ミリ秒)
        curve: Curves.easeInOut,                    // アニメーションの曲線(滑らかな動き)
      );
    } else {
      // 最後のページの場合:完了メッセージを表示
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('完了しました!')),
      );
    }
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: [
          // メインのページビュー(画面の大部分を占める)
          PageView(
            controller: _controller, // ページの制御に使用
            // ページが変更された時の処理(ユーザーがスワイプした時など)
            onPageChanged: (index) {
              setState(() {
                _currentPage = index; // 現在のページインデックスを更新
              });
            },
            children: [
              // 各ページのウィジェットを順番に配置
              OnboardingPage(
                title: _pages[0]['title'],
                description: _pages[0]['description'],
                color: _pages[0]['color'],
              ),
              OnboardingPage(
                title: _pages[1]['title'],
                description: _pages[1]['description'],
                color: _pages[1]['color'],
              ),
              OnboardingPage(
                title: _pages[2]['title'],
                description: _pages[2]['description'],
                color: _pages[2]['color'],
              ),
            ],
          ),
          // 下部のインジケータとボタン(画面の最前面に表示)
          Positioned(
            bottom: 40,  // 画面下部から40ピクセル上
            left: 24,    // 左端から24ピクセル
            right: 24,   // 右端から24ピクセル
            child: Column(
              children: [
                // ページインジケータ(現在のページを表示するドット)
                PageIndicator(
                  currentPage: _currentPage,    // 現在のページ番号
                  totalPages: _pages.length,    // 総ページ数
                ),
                const SizedBox(height: 16),     // 16ピクセルの空白
                // 次へボタン
                NextButton(
                  onPressed: _nextPage,                    // ボタンが押された時の処理
                  isLastPage: _currentPage == _pages.length - 1, // 最後のページかどうか
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

/// 各オンボーディングページのウィジェット
/// タイトル、説明文、背景色を表示する再利用可能なコンポーネント
class OnboardingPage extends StatelessWidget {
  /// ページのタイトル
  final String title;
  
  /// ページの説明文
  final String description;
  
  /// ページの背景色(グラデーションの開始色として使用)
  final Color color;

  /// コンストラクタ
  /// [title] ページのタイトル
  /// [description] ページの説明文
  /// [color] ページの背景色
  const OnboardingPage({
    super.key,
    required this.title,
    required this.description,
    required this.color,
  });

  
  Widget build(BuildContext context) {
    return Container(
      // グラデーション背景を設定(上から下へのグラデーション)
      decoration: BoxDecoration(
        gradient: LinearGradient(
          colors: [color, Colors.white], // 指定された色から白へのグラデーション
          begin: Alignment.topCenter,    // グラデーションの開始位置(上部中央)
          end: Alignment.bottomCenter,   // グラデーションの終了位置(下部中央)
        ),
      ),
      child: Center(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 24.0), // 左右に24ピクセルの余白
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center, // 縦方向の中央揃え
            children: [
              // タイトルテキスト
              Text(
                title,
                style: const TextStyle(
                  fontSize: 36,           // フォントサイズ36
                  fontWeight: FontWeight.bold, // 太字
                  color: Colors.black87,  // 色(黒に近いグレー)
                ),
              ),
              const SizedBox(height: 20), // 20ピクセルの空白
              // 説明テキスト
              Text(
                description,
                style: const TextStyle(
                  fontSize: 18,           // フォントサイズ18
                  color: Colors.black54,  // 色(薄いグレー)
                ),
                textAlign: TextAlign.center, // テキストを中央揃え
              ),
            ],
          ),
        ),
      ),
    );
  }
}

/// ページインジケータのウィジェット
/// 現在のページを表示するドットの集合体
class PageIndicator extends StatelessWidget {
  /// 現在表示されているページのインデックス(0から開始)
  final int currentPage;
  
  /// 総ページ数
  final int totalPages;

  /// コンストラクタ
  /// [currentPage] 現在のページ番号
  /// [totalPages] 総ページ数
  const PageIndicator({
    super.key,
    required this.currentPage,
    required this.totalPages,
  });

  
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center, // 横方向の中央揃え
      children: List.generate(
        totalPages, // 総ページ数分のドットを生成
        (index) => AnimatedContainer(
          duration: const Duration(milliseconds: 300), // アニメーション時間
          margin: const EdgeInsets.symmetric(horizontal: 4), // 左右に4ピクセルの余白
          // 現在のページの場合は大きく(16ピクセル)、そうでなければ小さく(8ピクセル)
          width: currentPage == index ? 16 : 8,
          height: 8, // 高さは常に8ピクセル
          decoration: BoxDecoration(
            // 現在のページの場合は濃い色(黒)、そうでなければ薄い色(グレー)
            color: currentPage == index
                ? Colors.black87
                : Colors.grey,
            borderRadius: BorderRadius.circular(4), // 角を丸くする(4ピクセルの半径)
          ),
        ),
      ),
    );
  }
}

/// 次へボタンのウィジェット
/// ページを進めるためのボタン(最後のページでは「完了」と表示)
class NextButton extends StatelessWidget {
  /// ボタンが押された時に実行される処理
  final VoidCallback onPressed;
  
  /// 最後のページかどうかのフラグ
  final bool isLastPage;

  /// コンストラクタ
  /// [onPressed] ボタンが押された時の処理
  /// [isLastPage] 最後のページかどうか
  const NextButton({
    super.key,
    required this.onPressed,
    required this.isLastPage,
  });

  
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: onPressed, // ボタンが押された時の処理
      style: ElevatedButton.styleFrom(
        backgroundColor: Colors.black87, // ボタンの背景色
        padding: const EdgeInsets.symmetric(
          horizontal: 40, // 左右の余白40ピクセル
          vertical: 14,   // 上下の余白14ピクセル
        ),
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(30), // 角を丸くする(30ピクセルの半径)
        ),
      ),
      child: Text(
        // 最後のページの場合は「完了」、そうでなければ「次へ」
        isLastPage ? '完了' : '次へ',
        style: const TextStyle(fontSize: 16), // フォントサイズ16
      ),
    );
  }
}

まとめ

PageViewを使用することで、簡単に美しいオンボーディング画面を作成できます。
スワイプでのページ切り替え、アニメーション、インジケータなど、ユーザーにとって使いやすい機能を簡単に実装できます。

参考リンク

Discussion