ページ送り出来る詳細画面

ページ送り出来る詳細画面の実装方法のまとめです
詳細画面でページ送りしたら一覧画面のcellの位置も同期させるように実装します
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/104665/77227007-300d-4778-f08f-5335e30fa157.gif" width="375">

## 前置き
一覧のAPIと詳細のAPIで別れているような場合は実現が難しい気がします
詳細画面は一覧のAPIから大まかな表示が出来るようにして、詳細画面で必要な追加情報は個別のAPIを呼んで表示させるような設計が望ましいでしょう

なお今回は記載しませんが、詳細画面のインタラクティブなトランジション(dismiss)を実装しておくと使いやすくなります

## 実装

詳細画面を管理するPageViewControllerを実装します

一覧画面のリストに更新があった時(Paginateなど)、更新メソッド(`updatePlaylist`)を呼び最新のデータを反映させます
詳細画面をページ送りで遷移させた時、`delegate`で一覧画面に通知することで一覧画面のcellの位置を同期させることが出来ます

細かい実装は省略します(コメント参照)

``` swift:Sample.swift
// 画面に表示するデータ
public struct Sample {
    public let ~~~
}
```
``` swift:SampleNavigatorViewController.swift

internal protocol SampleNavigatorDelegate: class {
    /// PageViewControllerの遷移が完了した時に呼ばれる
    func transitionedToSample(at index: Int)
}

// 詳細画面を管理するPageViewController
class SampleNavigatorViewController: UIPageViewController {

    fileprivate weak var navigatorDelegate: SampleNavigatorDelegate?
    fileprivate let pageDataSource: SampleNavigatorPagesDataSource!

    internal static func configuredWith(
        sample: Sample,
        initialPlaylist: [Sample]? = nil,
        navigatorDelegate: SampleNavigatorDelegate?) -> SampleNavigatorViewController {
        let vc = SampleNavigatorViewController(
            initialSample: sample,
            initialPlaylist: initialPlaylist,
            navigatorDelegate: navigatorDelegate
        )
        vc.setViewControllers(
            [.init()],
            direction: .forward,
            animated: true,
            completion: nil
        )
        return vc
    }
    
    private init(initialSample: Sample,
                 initialPlaylist: [Sample]?,
                 navigatorDelegate: SampleNavigatorDelegate?) {
        
        self.pageDataSource = SampleNavigatorPagesDataSource(initialPlaylist: initialPlaylist,
                                                             initialSample: initialSample)
        self.navigatorDelegate = navigatorDelegate

        super.init(transitionStyle: .scroll,
                   navigationOrientation: .horizontal,
                   options:[UIPageViewController.OptionsKey.interPageSpacing: 6])
    }
    
    internal required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.dataSource = self.pageDataSource
        self.delegate = self
        self.view.backgroundColor = .white

        self.setInitialPagerViewController()
    }

    /// 呼び出し元のViewControllerはリストが更新される度にこのメソッドを呼ぶ必要がある
    internal func updatePlaylist(_ playlist: [Sample]) {
        self.pageDataSource.updatePlaylist(playlist)
    }

    fileprivate func setInitialPagerViewController() {
        guard let navController = self.pageDataSource.initialController() else { return }
        self.setViewControllers([navController], direction: .forward, animated: false, completion: nil)
    }
}

extension SampleNavigatorViewController: UIPageViewControllerDelegate {
    
    internal func pageViewController(_ pageViewController: UIPageViewController,
                                     didFinishAnimating finished: Bool,
                                     previousViewControllers: [UIViewController],
                                     transitionCompleted completed: Bool) {
        // `completed` がtrueなら、
        // SampleNavigatorDelegateの transitionedToSample(at:) を呼び、
        // pageViewController(_:willTransitionTo:) で取得した `newIndex` を渡すように実装する
    }
    
    internal func pageViewController(
        _ pageViewController: UIPageViewController,
        willTransitionTo pendingViewControllers: [UIViewController]) {
        
        guard let nav = pendingViewControllers.first as? UINavigationController else { return }
        let newIndex = self.pageDataSource.indexFor(controller: nav)

        // `newIndex` を変数に保持するように実装する
    }
}
```
DataSourceクラスでページ(詳細画面)の取得・作成・削除を行います
`pageViewController(_:viewControllerBefore)`と`pageViewController(_:viewControllerAfter)`で前後の画面を作成します

``` swift:SampleNavigatorPagesDataSource
import UIKit

internal final class SampleNavigatorPagesDataSource: NSObject, UIPageViewControllerDataSource {
    
    fileprivate let initialSample: Sample
    fileprivate var playlist: [Sample] = []
    fileprivate var viewControllers: [UIViewController?] = []

    init(initialPlaylist: [Sample]?, initialSample: Sample) {
        self.initialSample = initialSample
        self.playlist = initialPlaylist ?? [initialSample]
        
        super.init()
        
        self.padControllers(toLength: self.playlist.count)
    }
    
    internal func updatePlaylist(_ playlist: [Sample]) {
        self.playlist = playlist
    }
    
    internal func initialController() -> UIViewController? {
        return self.playlist.index(of: self.initialSample).flatMap(self.controllerFor(index:))
    }
    
    internal func initialDetailController() -> SampleDetailViewController? {
        return self.playlist.index(of: self.initialSample).flatMap(self.sampleDetailControllerFor(index:))
    }
    
    internal func controllerFor(index: Int) -> UIViewController? {
        guard index >= 0 && index < self.playlist.count else { return nil }
        
        let sample = self.playlist[index]
        self.padControllers(toLength: index)
        
        self.viewControllers[index] = self.viewControllers[index]
            ?? self.createViewController(forSample: sample)
        return self.viewControllers[index]
    }
    
    internal func sampleDetailControllerFor(index: Int) -> SampleDetailViewController? {
        return self.controllerFor(index: index)
            .flatMap { $0 as? UINavigationController }
            .flatMap { $0.viewControllers.first as? SampleDetailViewController }
    }
    
    internal func indexFor(controller: UIViewController) -> Int? {
        return self.viewControllers.index { $0 == controller }
    }
    
    internal func sampleFor(controller: UIViewController) -> Sample? {
        return self.indexFor(controller: controller).map { self.playlist[$0] }
    }
    
    internal func pageViewController(_ pageViewController: UIPageViewController,
                                     viewControllerBefore viewController: UIViewController) -> UIViewController? {
        
        guard let pageIdx = self.viewControllers.index(where: { $0 == viewController }) else {
            fatalError("Couldn't find \(viewController) in \(self.viewControllers)")
        }
        
        let previousPageIdx = pageIdx - 1
        guard previousPageIdx >= 0 else {
            return nil
        }
        
        let sample = self.playlist[previousPageIdx]
        self.padControllers(toLength: previousPageIdx)
        self.viewControllers[previousPageIdx] = self.viewControllers[previousPageIdx]
            ?? self.createViewController(forSample: sample)
        
        self.clearViewControllersFarAway(fromIndex: previousPageIdx)
        
        return self.viewControllers[previousPageIdx]
    }
    
    internal func pageViewController(_ pageViewController: UIPageViewController,
                                     viewControllerAfter viewController: UIViewController) -> UIViewController? {
     
        guard let pageIdx = self.viewControllers.index(where: { $0 == viewController }) else {
            fatalError("Couldn't find \(viewController) in \(self.viewControllers)")
        }
        
        let nextPageIdx = pageIdx + 1
        guard nextPageIdx < self.playlist.count else {
            return nil
        }
        
        let sample = self.playlist[nextPageIdx]
        self.padControllers(toLength: nextPageIdx)
        self.viewControllers[nextPageIdx] = self.viewControllers[nextPageIdx]
            ?? self.createViewController(forSample: sample)
        
        self.clearViewControllersFarAway(fromIndex: nextPageIdx)
        
        return self.viewControllers[nextPageIdx]
    }
    
    fileprivate func createViewController(forSample: Sample) -> UIViewController {
        return UINavigationController(
            rootViewController: SampleDetailViewController.configuredWith(
                sample: forSample
            )
        )
    }
    
    fileprivate func clearViewControllersFarAway(fromIndex index: Int) {
        // 前後のページ以外は必要ないので削除する
        self.viewControllers.indices
            .filter { abs($0 - index) >= 3 }
            .forEach { idx in
                self.viewControllers[idx] = nil
        }
    }
    
    fileprivate func padControllers(toLength length: Int) {
        guard self.viewControllers.count <= length else { return }
        
        (self.viewControllers.count...length).forEach { _ in
            self.viewControllers.append(nil)
        }
    }
}
```

一覧画面
こちらも細かい実装は省略します(コメント参照)

``` swift
class SamplesViewController: UITableViewController {
    // viewDidLoadなどの実装

    // tableView(_:didSelectRowAt)で呼ぶように実装する
    // tapしたSampleとリスト全体のSample配列を渡す
    private func goTo(sample: Sample, initialPlaylist: [Sample]) {
        let vc = SampleNavigatorViewController.configuredWith(sample: sample,
                                                              initialPlaylist: initialPlaylist,
                                                              navigatorDelegate: self)
        self.present(vc, animated: true, completion: nil)
    }

    // 一覧画面のリストに更新があった時(Paginateなど)、このメソッドを呼ぶように実装する
    private func updateSamplePlaylist(_ playlist: [Sample]) {
        guard let navigator = self.presentedViewController as? SampleNavigatorViewController else { return }
        navigator.updatePlaylist(playlist)
    }
}

extension SamplesViewController: SampleNavigatorDelegate {
    func transitionedToSample(at index: Int) {
        self.tableView.scrollToRow(at: self.dataSource.indexPath(forSampleRow: index),
                                   at: .top,
                                   animated: false)
    }
}
```