ページ送り出来る詳細画面の実装方法のまとめです
詳細画面でページ送りしたら一覧画面のcellの位置も同期させるように実装します
前置き
一覧のAPIと詳細のAPIで別れているような場合は実現が難しい気がします
詳細画面は一覧のAPIから大まかな表示が出来るようにして、詳細画面で必要な追加情報は個別のAPIを呼んで表示させるような設計が望ましいでしょう
なお今回は記載しませんが、詳細画面のインタラクティブなトランジション(dismiss)を実装しておくと使いやすくなります
実装
詳細画面を管理するPageViewControllerを実装します
一覧画面のリストに更新があった時(Paginateなど)、更新メソッド(updatePlaylist
)を呼び最新のデータを反映させます
詳細画面をページ送りで遷移させた時、delegate
で一覧画面に通知することで一覧画面のcellの位置を同期させることが出来ます
細かい実装は省略します(コメント参照)
// 画面に表示するデータ
public struct Sample {
public let ~~~
}
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)
で前後の画面を作成します
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)
}
}
}
一覧画面
こちらも細かい実装は省略します(コメント参照)
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)
}
}