ニュースアプリとかでよく見るあれです。
成果物: Yaruki00/YKPageViewController
こちらがとても良くできています。ライブラリをお探しならぜひ。
UIPageViewControllerをつかって無限スクロールできるタブUIを実装してOSSとして公開しました
はじめに
本記事でタブと表現しているものが、Githubのコード中ではメニューという表現になっています。
紛らわしくてすみません。。。
UIPageViewControllerのセットアップ
生成してViewControllerとViewをそれぞれ親にセット、デリゲートとジェスチャーの設定をします。
// ページビューコントローラもろもろ
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のデリゲートセットは必須ではないですが、スクロールに合わせてタブも動かしたいという場合に必要になります。
タブを生成
スクロールビューにタブを並べていきます。
後述するメソッドで現在のページがわかるように、タブとページにタグをセットしています。
スクロールモードの場合は超頑張ってスクロール位置を計算します。
最後で、表示されるページの準備をしています。
制約付ける部分が長い。。。
// ページもろもろ
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つのメソッドを実装します。タブを生成でセットしておいたタグを見て、現在のページを判定しています。
// 左隣のページ取得
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つがタブ表示変更に関わっています。
// 遷移アニメーションが完了した時
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にタブとページ情報を突っ込んで、画面遷移します。
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)
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にタブの幅を渡すことで、タブをスクロールさせることができます。
ぶっちゃけ動きが微妙なので、上に挙げたリンクのようにタブも無限ループにしたほうがいいと思います。。。
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を継承しています。
// このクラスを直接使わないこと!
// 継承したクラスを使ってね
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)
}
}
protocol YKPageViewControllerMenuItemViewDelegate {
var parent: YKPageViewController! { set get }
func didSelect() // タブ(ページ)選択時に呼ばれる
func didDeselect() // タブ(ページ)選択状態が解除されたときに呼ばれる
}
タブバーの位置を変えたい
YKPageViewController.xibを弄くればレイアウトを好きに変えられるはずです。
おわりに
ところどころ力技があって、これでいいのか感がとても漂ってはいますが、一応やりたいことは実現できました。
標準のUITabBarControllerは使いやすいですが、カスタマイズしにくい部分があり、自作しなければならない場合がちょいちょいあると思います。
そんなとき、UIPageViewControllerとかContainerViewとか知っていると捗るかもしれません。
まあ、自作する前にライブラリ探す方が無難ですけど。