UIPageViewControllerを使って自前のタブ的なものを作る

ニュースアプリとかでよく見るあれです。
成果物: [Yaruki00/YKPageViewController](
https://github.com/Yaruki00/YKPageViewController)

こちらがとても良くできています。ライブラリをお探しならぜひ。
[UIPageViewControllerをつかって無限スクロールできるタブUIを実装してOSSとして公開しました](http://tech.vasily.jp/entry/tab_page_viewcontroller)

# はじめに
本記事でタブと表現しているものが、Githubのコード中ではメニューという表現になっています。
紛らわしくてすみません。。。

# UIPageViewControllerのセットアップ
生成してViewControllerとViewをそれぞれ親にセット、デリゲートとジェスチャーの設定をします。

```swift:YKPageViewController.swift
// ページビューコントローラもろもろ
private func setupPageViewController() {
    
    // 生成
    self.pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
    
    // 親にセット
    self.pageViewController.view.frame.size = self.contentView.frame.size
    self.addChildViewController(self.pageViewController)
    self.contentView.addSubview(self.pageViewController.view)
    self.pageViewController.didMove(toParentViewController: self)
    
    // デリゲート
    self.pageViewController.delegate = self
    self.pageViewController.dataSource = self
    for view in self.pageViewController.view.subviews {
        if let scrollView = view as? UIScrollView {
            scrollView.delegate = self
        }
    }
    
    // ジェスチャー
    self.contentView.gestureRecognizers = self.pageViewController.gestureRecognizers
}
```

途中でやっているUIScrollViewのデリゲートセットは必須ではないですが、スクロールに合わせてタブも動かしたいという場合に必要になります。

# タブを生成
スクロールビューにタブを並べていきます。
後述するメソッドで現在のページがわかるように、タブとページにタグをセットしています。
スクロールモードの場合は超頑張ってスクロール位置を計算します。
最後で、表示されるページの準備をしています。
制約付ける部分が長い。。。

```swift:YKPageViewController.swift
// ページもろもろ
func setupPageList() {
    
    // タブがスクロールモードの場合は幅制約をつけ直す
    /* 省略 */
    
    // タブ作る
    /* 省略 */
    for i in 0..<self.pageInfoList.count {
        
        let menuItemView = self.pageInfoList[i].menuItemView
        /* 省略 */
        self.menuView.addSubview(menuItemView)
        
        // 高さ制約
        /* 省略 */
        
        // 幅制約
        /* 省略 */
        
        // タブのスクロール位置
        if self.menuViewMode == .Scroll, let fixWidth = self.menuItemViewWidth {
            var offsetX = (CGFloat(i) + 0.5) * fixWidth - 0.5 * UIScreen.main.bounds.width
            offsetX = max(0, offsetX)
            offsetX = min(fixWidth * CGFloat(self.pageInfoList.count) - UIScreen.main.bounds.width, offsetX)
            self.menuScrollOffsetXList.append(offsetX)
        }
        
        // タグのセットしておく
        self.pageInfoList[i].vc.view.tag = i + 1
        self.pageInfoList[i].menuItemView.tag = i + 1
    }
    
    // 中身があれば
    if self.pageInfoList.count > 0 {
        
        // 幅制約続き
        /* 省略 */
        
        // 最初のページをセット
        self.pageViewController.setViewControllers([self.pageInfoList[self.currentPageIndex].vc], direction: .forward, animated: false, completion: nil)
        self.pageInfoList[self.currentPageIndex].menuItemView.didSelect()
    }
}
```

# UIPageViewControllerDelegateとUIPageViewControllerDataSourceを実装
以下の3つのメソッドを実装します。タブを生成でセットしておいたタグを見て、現在のページを判定しています。

```swift:YKPageViewController.swift
// 左隣のページ取得
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
    var index = viewController.view.tag - 1
    
    index -= 1
    if index < 0 {
        index = self.pageInfoList.count - 1
    }
    return self.pageInfoList[index].vc
}

// 右隣のページ取得
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
    var index = viewController.view.tag - 1
    
    index += 1
    if index > self.pageInfoList.count - 1 {
        index = 0
    }
    return self.pageInfoList[index].vc
}

// ページ数
func presentationCount(for pageViewController: UIPageViewController) -> Int {
    return self.pageInfoList.count
}
```

上のコードではページが無限ループするようになっていますが、viewControllerBefore/viewControllerAfterでnilを返すことで有限の遷移にすることもできます。

# ページが変わったときにタブの表示も変える
以下の3つがタブ表示変更に関わっています。

```swift:YKPageViewController.swift
// 遷移アニメーションが完了した時
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
    if completed {
        // 前のタブを非選択状態に
        self.pageInfoList[self.currentPageIndex].menuItemView.didDeselect()
        // インデックス更新
        self.currentPageIndex = self.pageViewController.viewControllers!.last!.view.tag - 1
        // 新しいタブを選択状態に
        self.pageInfoList[self.currentPageIndex].menuItemView.didSelect()
        // タブをスクロール
        if self.menuViewMode == .Scroll {
            self.menuScrollView.setContentOffset(CGPoint(x: self.menuScrollOffsetXList[self.currentPageIndex], y: 0), animated: true)
        }
    }
}

// タブがタップされた
func menuDidSelectByTap(index: Int) {
    // 前のタブを非選択状態に
    self.pageInfoList[self.currentPageIndex].menuItemView.didDeselect()
    // インデックス更新
    self.currentPageIndex = index - 1
    // 画面入れ替え
    self.pageViewController.setViewControllers([self.pageInfoList[self.currentPageIndex].vc], direction: .forward, animated: false, completion: nil)
    // 新しいタブを選択状態に
    self.pageInfoList[self.currentPageIndex].menuItemView.didSelect()
    // タブをスクロール
    if self.menuViewMode == .Scroll {
        self.menuScrollView.setContentOffset(CGPoint(x: self.menuScrollOffsetXList[self.currentPageIndex], y: 0), animated: true)
    }
}

// スクロールデリゲート
func scrollViewDidScroll(_ scrollView: UIScrollView) {
    if self.menuViewMode == .Scroll {
        let movedOffsetX = scrollView.contentOffset.x - self.view.frame.size.width
        var offsetXDiff: CGFloat = 0
        if movedOffsetX < 0 && self.currentPageIndex > 0 {
            offsetXDiff = self.menuScrollOffsetXList[self.currentPageIndex] - self.menuScrollOffsetXList[self.currentPageIndex - 1]
        }
        else if movedOffsetX > 0 && self.currentPageIndex < self.pageInfoList.count - 1 {
            offsetXDiff = self.menuScrollOffsetXList[self.currentPageIndex + 1] - self.menuScrollOffsetXList[self.currentPageIndex]
        }
        if offsetXDiff != 0 {
            self.menuScrollView.contentOffset.x = self.menuScrollOffsetXList[self.currentPageIndex] + movedOffsetX / (self.view.frame.size.width / offsetXDiff)
        }
    }
}
```

# 使う
外から渡すなり、YKPageViewControllerを継承したクラスを作るなりして、pageInfoListにタブとページ情報を突っ込んで、画面遷移します。

```swift:SampleViewController(外から渡すver.)
let vc = YKPageViewController()

// ページ情報
vc.pageInfoList = [
    {
        let view = YKSimpleTextMenuItemView()
        view.backgroundColor = .red
        view.title = "1"
        view.parent = vc
        let vc = UIViewController()
        vc.view.backgroundColor = .red
        let body = UILabel()
        body.text = "アユム"
        body.sizeToFit()
        body.autoresizingMask = [.flexibleTopMargin, .flexibleRightMargin, .flexibleBottomMargin, .flexibleLeftMargin]
        body.center = vc.view.center
        vc.view.addSubview(body)
        return YKPageViewController.PageInfo(menuItemView: view, vc: vc)
    }(),
    /* 省略 */
]

self.navigationController?.pushViewController(vc, animated: true)
```

```swift:CustomYKPageViewController.swift(継承ver.)
class CustomYKPageViewController: YKPageViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // ページ情報
        self.pageInfoList = [
            {
                let view = YKSimpleTextMenuItemView()
                view.backgroundColor = .red
                view.title = "1"
                view.selectedTitleColor = .white
                view.parent = self
                let vc = UIViewController()
                vc.view.backgroundColor = .red
                let body = UILabel()
                body.text = "ユウタ"
                body.sizeToFit()
                body.autoresizingMask = [.flexibleTopMargin, .flexibleRightMargin, .flexibleBottomMargin, .flexibleLeftMargin]
                body.center = vc.view.center
                vc.view.addSubview(body)
                return PageInfo(menuItemView: view, vc: vc)
            }(),
            /* 省略 */
        ]
        
        // ページ準備
        self.setupPageList()
    }
}
```

# タブをスクロールさせたい
menuViewModeに.Scrollをセット、menuItemViewWidthにタブの幅を渡すことで、タブをスクロールさせることができます。
ぶっちゃけ動きが微妙なので、上に挙げたリンクのようにタブも無限ループにしたほうがいいと思います。。。

```swift:SampleViewController(スクロールモード)
let vc = YKPageViewController()
vc.menuViewMode = .Scroll
vc.menuItemViewWidth = 120.0

// ページ情報
vc.pageInfoList = [
    {
        let view = YKSimpleTextMenuItemView()
        view.backgroundColor = .red
        view.title = "1"
        view.selectedTitleColor = .white
        view.parent = vc
        let vc = UIViewController()
        vc.view.backgroundColor = .red
        let body = UILabel()
        body.text = "ヨウスケ"
        body.sizeToFit()
        body.autoresizingMask = [.flexibleTopMargin, .flexibleRightMargin, .flexibleBottomMargin, .flexibleLeftMargin]
        body.center = vc.view.center
        vc.view.addSubview(body)
        return YKPageViewController.PageInfo(menuItemView: view, vc: vc)
    }(),
    /* 省略 */
]

self.navigationController?.pushViewController(vc, animated: true)
```

# タブをカスタマイズしたい
YKPageViewControllerMenuItemViewを継承したクラスを作ることで、タブをカスタマイズできるようになっています。
実際、サンプルで使っているYKSimpleTextMenuItemViewというクラスは、YKPageViewControllerMenuItemViewを継承しています。

```swift:YKPageViewControllerMenuItemView.swift
// このクラスを直接使わないこと！
// 継承したクラスを使ってね
class YKPageViewControllerMenuItemView: UIView, YKPageViewControllerMenuItemViewDelegate {
    
    var parent: YKPageViewController!
    var tapRecognizer: UITapGestureRecognizer!
    
    required override init(frame: CGRect) {
        super.init(frame: frame)
        self.commonInit()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        self.commonInit()
    }
    
    func commonInit() {
        self.tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(YKPageViewControllerMenuItemView.tellTapEventToParent))
        self.addGestureRecognizer(self.tapRecognizer)
    }
    
    func didSelect() {}
    func didDeselect() {}
    
    func tellTapEventToParent() {
        parent.menuDidSelectByTap(index: self.tag)
    }
}
```

```swift:YKPageViewController.swift
protocol YKPageViewControllerMenuItemViewDelegate {
    var parent: YKPageViewController! { set get }
    func didSelect() // タブ(ページ)選択時に呼ばれる
    func didDeselect() // タブ(ページ)選択状態が解除されたときに呼ばれる
}
```

# タブバーの位置を変えたい
YKPageViewController.xibを弄くればレイアウトを好きに変えられるはずです。

# おわりに
ところどころ力技があって、これでいいのか感がとても漂ってはいますが、一応やりたいことは実現できました。
標準のUITabBarControllerは使いやすいですが、カスタマイズしにくい部分があり、自作しなければならない場合がちょいちょいあると思います。
そんなとき、UIPageViewControllerとかContainerViewとか知っていると捗るかもしれません。
まあ、自作する前にライブラリ探す方が無難ですけど。