18
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Posted at

ニュースアプリとかでよく見るあれです。
成果物: Yaruki00/YKPageViewController

こちらがとても良くできています。ライブラリをお探しならぜひ。
UIPageViewControllerをつかって無限スクロールできるタブUIを実装してOSSとして公開しました

はじめに

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

UIPageViewControllerのセットアップ

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

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のデリゲートセットは必須ではないですが、スクロールに合わせてタブも動かしたいという場合に必要になります。

タブを生成

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

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つのメソッドを実装します。タブを生成でセットしておいたタグを見て、現在のページを判定しています。

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つがタブ表示変更に関わっています。

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にタブとページ情報を突っ込んで、画面遷移します。

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)
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にタブの幅を渡すことで、タブをスクロールさせることができます。
ぶっちゃけ動きが微妙なので、上に挙げたリンクのようにタブも無限ループにしたほうがいいと思います。。。

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を継承しています。

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

タブバーの位置を変えたい

YKPageViewController.xibを弄くればレイアウトを好きに変えられるはずです。

おわりに

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

18
19
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?