【SwiftUI】PageViewをSwiftUIで実現する方法

SwiftUI には UIPageViewController に変わるViewがありません(2020/06/05現在)。

そのため、UIViewControllerRepresentable を継承したラッパーを構造体(ここでは PageViewController と呼ぶ)を作成し、それを保持する SwiftUI 表示用のView(ここでは PageView とする)を作成する必要があります。

基本的なソースコードは、以下のApple公式チュートリアルのコードをほぼそのまま使っています。

https://developer.apple.com/tutorials/swiftui/interfacing-with-uikit

【SwiftUI】PageViewをSwiftUIで実現する方法

PageViewController

先ずは大元の UIPageViewController をラップした PageViewController です。

import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
    var controllers: [UIViewController]
    @Binding var currentPage: Int

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIViewController(context: Context) -> UIPageViewController {
        let pageViewController = UIPageViewController(
            transitionStyle: .scroll,
            navigationOrientation: .horizontal)
        pageViewController.dataSource = context.coordinator
        pageViewController.delegate = context.coordinator

        return pageViewController
    }

    func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
        pageViewController.setViewControllers(
            [controllers[currentPage]], direction: .forward, animated: true)
    }

    class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
        var parent: PageViewController

        init(_ pageViewController: PageViewController) {
            self.parent = pageViewController
        }

        func pageViewController(
            _ pageViewController: UIPageViewController,
            viewControllerBefore viewController: UIViewController) -> UIViewController?
        {
            guard let index = parent.controllers.firstIndex(of: viewController) else {
                return nil
            }
            if index == 0 {
                return parent.controllers.last
            }
            return parent.controllers[index - 1]
        }

        func pageViewController(
            _ pageViewController: UIPageViewController,
            viewControllerAfter viewController: UIViewController) -> UIViewController?
        {
            guard let index = parent.controllers.firstIndex(of: viewController) else {
                return nil
            }
            if index + 1 == parent.controllers.count {
                return parent.controllers.first
            }
            return parent.controllers[index + 1]
        }

        func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
            if completed,
                let visibleViewController = pageViewController.viewControllers?.first,
                let index = parent.controllers.firstIndex(of: visibleViewController)
            {
                parent.currentPage = index
            }
        }
    }
}

現在のページ番号は、@Binding var currentPage: Int で管理しており、後述の ContentView が保持している currentPage が、これまた後述の PageView を経由してバインディングされています。

PageView

続いて、ContentView に組み込む PageView を作成します。

import SwiftUI

struct PageView<Page: View>: View {
    var viewControllers: [UIHostingController<Page>]
    @Binding var currentPage: Int

    init(_ views: [Page], currentPage: Binding<Int>) {
        self.viewControllers = views.map { UIHostingController(rootView: $0) }
        self._currentPage = currentPage
    }

    var body: some View {
        VStack {
            PageViewController(controllers: viewControllers, currentPage: $currentPage)
        }
    }
}

イニシャライザーで、ページ要素の View を配列を受け取り、UIHostingController に変換し保持します。

また、ページ番号を管理する変数を @Binding として受け取ります。

実際の View の中身は、先ほど作成した PageViewController がセットされています。

使用例

今回は、3つの View を PageView に格納しスワイプで切り替えられるようになっています。

import SwiftUI

struct ContentView: View {
    
    @State private var currentPage = 0
    
    var body: some View {
        VStack {
            PageView([
                AnyView(Page1()),
                AnyView(Page2()),
                AnyView(Page3())
            ], currentPage: $currentPage)
        }
    }
}

struct Page1: View {
    var body: some View {
        Text("Page1")
    }
}

struct Page2: View {
    var body: some View {
        Text("Page2")
    }
}

struct Page3: View {
    var body: some View {
        Text("Page3")
    }
}

ページ番号を管理する変数が、@State private var currentPage です。これを PageView にバインドさせ、さらに PageView 側で PageViewController にバインドされて行きます。

PageView に渡す View の配列要素は、同じ型の View でなければなりません。そのため、異なる型の View を渡したい場合(そういった場合の方が多いと思いますが)、AnyView に変換して格納する必要があります。

最後に、今回はサンプルではページングがループするようになっていますが、ループさせたくない場合は、以下のように、次のページが最初・最後の場合に nil を返すようにします。

func pageViewController(
    _ pageViewController: UIPageViewController,
    viewControllerBefore viewController: UIViewController) -> UIViewController?
{
    guard let index = parent.controllers.firstIndex(of: viewController) else {
        return nil
    }
    if index == 0 {
        // ループさせない場合は nil を返す
        return nil
//        return parent.controllers.last
    }
    return parent.controllers[index - 1]
}

func pageViewController(
    _ pageViewController: UIPageViewController,
    viewControllerAfter viewController: UIViewController) -> UIViewController?
{
    guard let index = parent.controllers.firstIndex(of: viewController) else {
        return nil
    }
    if index + 1 == parent.controllers.count {
        // ループさせない場合は nil を返す
        return nil
//        return parent.controllers.first
    }
    return parent.controllers[index + 1]
}

以上