【Swift】UIPageViewControllerで作るスワイプ式オンボーディング画面

# 1.はじめに
自作の買い物リストアプリにスワイプ式のオンボーディング機能を実装したので、この記事にまとめます。

この記事では、以下のようなUIをUIKit（Swift）で実現する手順を紹介します。
- 横スワイプでページ切り替え
- 最終ページに「はじめる」ボタン
- 「スキップ」ボタン付き
- 初回起動時のみ表示

UIKitでオンボーディングを実装したい方の参考になれば嬉しいです。

# 2.完成イメージ
完成イメージは以下です。スキップボタンを押すか、最後の画面ではじめるボタンを押すとアプリのメイン画面が表示されます。
![オンボーディング紹介動画.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1066888/e51c69f4-cff8-4fa6-a23a-d75db410265e.gif)

# 3.オンボーディングとは
実装の前に、簡単にオンボーディングについて調べました。

オンボーディング（Onboarding）とは、アプリやサービスを初めて使うユーザーに対して、使い方や魅力をわかりやすく案内するための導入フローのこと

## オンボーディングの目的
- アプリの使い方を説明する
- 初期設定を手助けする
- ユーザーの離脱を防ぐ
- アプリの価値を伝える

## よくあるオンボーディングの形式
|種類|内容|
|:-----------|:------------|
|スワイプ形式（UIPageViewControllerなど）|ページを横にスワイプして読む|
|モーダル形式|最初に表示される説明画面（スキップ可能）|
|ツアー形式|実際の画面に説明バルーンが出る（コーチマーク）|
|スライダー＋「はじめる」ボタン|最後のページで本編に遷移できる|

# 4.実装ステップ
### 4.1 OnboardingContentViewControllerの作成
3ページ分のコンテンツを表示するUIViewControllerです。
```Swift:OnboardingContentViewController.swift
import UIKit

class OnboardingContentViewController: UIViewController {

    // 各ページで表示する情報
    private let imageName: String
    private let titleText: String
    private let subtitleText: String
    private let isLastPage: Bool // 最後のページかどうかを判定

    // 初期化（画像名・タイトル・サブタイトル・最終ページ判定を渡す）
    init(imageName: String, title: String, subtitle: String, isLastPage: Bool = false) {
        self.imageName = imageName
        self.titleText = title
        self.subtitleText = subtitle
        self.isLastPage = isLastPage
        super.init(nibName: nil, bundle: nil)
    }

    // ストーリーボード未使用なので未対応
    required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        setupLayout()
    }

    // UIパーツをレイアウト
    private func setupLayout() {
        // 画像表示
        let imageView = UIImageView(image: UIImage(named: imageName))
        imageView.contentMode = .scaleAspectFit
        imageView.translatesAutoresizingMaskIntoConstraints = false

        // タイトルラベル
        let titleLabel = UILabel()
        titleLabel.text = titleText
        titleLabel.font = .boldSystemFont(ofSize: 24)
        titleLabel.textAlignment = .center
        titleLabel.numberOfLines = 0

        // サブタイトルラベル
        let subtitleLabel = UILabel()
        subtitleLabel.text = subtitleText
        subtitleLabel.font = .systemFont(ofSize: 16)
        subtitleLabel.textAlignment = .center
        subtitleLabel.textColor = .gray
        subtitleLabel.numberOfLines = 0

        // スタックで縦に並べる
        let stack = UIStackView(arrangedSubviews: [imageView, titleLabel, subtitleLabel])
        stack.axis = .vertical
        stack.spacing = 20
        stack.translatesAutoresizingMaskIntoConstraints = false

        view.addSubview(stack)

        // 最後のページのみ「はじめる」ボタンを追加
        if isLastPage {
            let startButton = UIButton(type: .system)
            startButton.setTitle("はじめる", for: .normal)
            startButton.titleLabel?.font = .systemFont(ofSize: 18, weight: .medium)
            startButton.addTarget(self, action: #selector(dismissOnboarding), for: .touchUpInside)
            stack.addArrangedSubview(startButton)
        }

        // レイアウト制約
        NSLayoutConstraint.activate([
            stack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            stack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
            stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
            imageView.heightAnchor.constraint(equalToConstant: 200)
        ])
    }

    // 「はじめる」ボタン or スキップで閉じる
    @objc private func dismissOnboarding() {
        dismiss(animated: true)
    }
}
```

### 4.2 OnboardingViewController（UIPageViewController）の作成
ページを切り替えるメインのクラスです。
```Swift:OnboardingViewController.swift
import UIKit

// UIPageViewController を使ってオンボーディングをスワイプで表示
class OnboardingViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate {

    // 表示する3ページ分のコンテンツ
    private lazy var pages: [UIViewController] = [
        OnboardingContentViewController(
            imageName: "onboarding1",
            title: "🛒 アイテムを追加しよう",
            subtitle: "買い物リストにアイテムを追加できます。"
        ),
        OnboardingContentViewController(
            imageName: "onboarding2",
            title: "✅ チェックで管理",
            subtitle: "チェックすると購入済みに移動します。"
        ),
        OnboardingContentViewController(
            imageName: "onboarding3",
            title: "📅 日付・カテゴリで並び替え",
            subtitle: "予定に合わせて整理整頓しましょう。",
            isLastPage: true
        )
    ]

    // ページインジケーターとスキップボタン
    private let pageControl = UIPageControl()
    private let skipButton = UIButton(type: .system)

    override func viewDidLoad() {
        super.viewDidLoad()

        // スワイプ操作のための設定
        dataSource = self
        delegate = self
        view.backgroundColor = .systemBackground

        // 最初のページを表示
        if let first = pages.first {
            setViewControllers([first], direction: .forward, animated: true)
        }

        setupPageControl()
        setupSkipButton()
    }

    // ページインジケータの設定
    private func setupPageControl() {
        pageControl.numberOfPages = pages.count
        pageControl.currentPage = 0
        pageControl.currentPageIndicatorTintColor = .systemBlue
        pageControl.pageIndicatorTintColor = .lightGray
        pageControl.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(pageControl)

        NSLayoutConstraint.activate([
            pageControl.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20),
            pageControl.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])
    }

    // スキップボタンの設定
    private func setupSkipButton() {
        skipButton.setTitle("スキップ", for: .normal)
        skipButton.addTarget(self, action: #selector(skipTapped), for: .touchUpInside)
        skipButton.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(skipButton)

        NSLayoutConstraint.activate([
            skipButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
            skipButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20)
        ])
    }

    // スキップボタン押下時に閉じる
    @objc private func skipTapped() {
        dismiss(animated: true)
    }

    // 前のページへ戻る処理
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        guard let index = pages.firstIndex(of: viewController), index > 0 else { return nil }
        return pages[index - 1]
    }

    // 次のページへ進む処理
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        guard let index = pages.firstIndex(of: viewController), index < pages.count - 1 else { return nil }
        return pages[index + 1]
    }

    // ページが切り替わったら pageControl を更新
    func pageViewController(_ pageViewController: UIPageViewController,
                            didFinishAnimating finished: Bool,
                            previousViewControllers: [UIViewController],
                            transitionCompleted completed: Bool) {
        guard let visibleVC = viewControllers?.first,
              let index = pages.firstIndex(of: visibleVC) else { return }
        pageControl.currentPage = index
    }
}
```

### 4.3 TableViewControllerに追加した起動判定（初回起動時のみ表示）
UserDefaultsに起動済みフラグを追加
```Swift:TableViewController.swift
// UserDefaults に初回起動フラグを追加する拡張
extension UserDefaults {
    // hasLaunchedBefore が true なら2回目以降の起動
    var hasLaunchedBefore: Bool {
        get { bool(forKey: "hasLaunchedBefore") }
        set { set(newValue, forKey: "hasLaunchedBefore") }
    }
}
```

TableViewControllerで起動判定して表示
```Swift:TableViewController.swift
// 画面が表示されたときに初回起動かどうかを判定
override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)

    // 初回起動ならオンボーディングを表示
    if !UserDefaults.standard.hasLaunchedBefore {
        UserDefaults.standard.hasLaunchedBefore = true // 起動済みに設定

        // オンボーディング画面をモーダル表示
        let onboardingVC = OnboardingViewController(
            transitionStyle: .scroll,
            navigationOrientation: .horizontal
        )
        onboardingVC.modalPresentationStyle = .fullScreen
        present(onboardingVC, animated: true)
    }
}
```

# おわりに
UIPageViewControllerを使ったシンプルなオンボーディングを実装することで、簡単に新規ユーザーに機能説明をすることができます。

現在は最終ページで「はじめる」ボタンを追加していますが、これに加えて「ログイン」や「プランを選択」などの操作を続けられるUIに拡張していくのも良いかもしれません。

この記事が、UIKitでオンボーディングを実装しようとしている方の参考になれば幸いです。