無限ループするUIPageViewControllerとタブ


はじめに

ライブラリ「XLPagerTabStrip」を使って横スワイプでタブ・ページ切り替えしていた画面を

無限スクロールさせることになりました。

有名なライブラリだしきっと簡単に無限スクロールできるよね。

と思い調査してみたら、意外とループ機能が無かった...ので自作しました。

自分用の備忘録として、実装のポイントをメモします。


デモ

いろんなアプリでよく見かけますね

スクリーンショット 2019-06-04 8.42.34.png

ページが切り替わるタイミングは3種類。


  • ページを左右スワイプしたとき

  • タブを左右スワイプしたとき

  • 未選択のタブを押下したとき

タブは無限スクロールし、またページの切り替えに合わせて追従します。

選択されたタブは色が変わり、タブのタイトル幅に合わせてバーのサイズも変わります。


環境


  • XCode Version 10.1

  • Swift 4.2


画面構成

タブはCollectionView、ページはPageViewController

タブ下のBarはUIViewで構成しました。

スクリーンショット 2019-06-04 8.42.34.png

CollectionViewにはUILabelが乗ったカスタムCellをセットします。

スクリーンショット 2019-06-04 8.42.34.png


ループ処理

実装するにあたってこちらのサイトにはとてもお世話になりました。

UIPageViewControllerをつかって無限スクロールできるタブUIを実装してOSSとして公開しました


UIPageViewControllerのループ処理

今回のサンプルコードでは画面をA,B,C,Dの4種類用意しました。

スクリーンショット 2019-06-04 8.42.34.png

ページのループ処理は以下の通り。


PageViewController.swift

pageViewController(_ pageViewController: UIPageViewController,  viewControllerBefore viewController: UIViewController)

 pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController)


前後のVCを読み込むDelegateでメソッド「nextViewController」を呼び


PageViewController.swift

    // 画面A,B,C,DのUIViewControllerが格納された配列

private (set) var pageControllergrop = [UIViewController]()

private func nextViewController(viewController: UIViewController, isAfter: Bool) -> UIViewController? {
guard var index = self.pageControllergrop.index(of: viewController) else { return nil }
index = isAfter ? (index + 1) : (index - 1)

if index < 0 {
index = self.pageControllergrop.count - 1
} else if index == self.pageControllergrop.count {
index = 0
}
if index >= 0 && index < self.pageControllergrop.count {
return self.pageControllergrop[index]
}
return nil
}


前のVCが無ければ末尾のVCを、後のVCが無ければ頭のVCを表示させることで、ページをループさせます。


UICollectionViewのループ処理

スクリーンショット 2019-06-04 8.42.34.png

高速スクロールしてもCellが見切れないよう、Cell数を項目の5倍用意しました。


PageViewController.swift


func scrollViewDidScroll(_ scrollView: UIScrollView) {
let isScrollCollectionView: Bool = (scrollView.className == "UICollectionView")
...
// CollectionView スクロール時
if isScrollCollectionView {
// CollectionView 1項目分の横幅
let listWidth = self.collectionView.contentSize.width / 5.0

// スクロールが左側のしきい値を超えたとき、中央に戻す
if (self.collectionView.contentOffset.x <= self.cellWidth) {
self.collectionView.contentOffset.x = (listWidth * 2) + self.cellWidth

// スクロールが右側のしきい値を超えたとき、中央に戻す
} else if (self.collectionView.contentOffset.x) >= (listWidth * 3) + cellWidth {
self.collectionView.contentOffset.x = listWidth + self.cellWidth
}
}
...
}


画面左側に1ループ目/1番目のCellが来たら、3ループ目/1番目のCellへ

画面左側に4ループ目/1番目のCellが来たら、2ループ目/1番目のCellへ

強制的にスクロール位置を戻すことでループを実現させます。


ページ切り替えに合わせてタブを追従させる

他アプリでは、ページ切り替え後にタブを移動させるアニメーションを割と見かけましたが

指の動きに合わせて動いてくれた方が見た目タイムラグが無さそうだな

と思ったので実装してみました。


PageViewController.swift

    // スクロール開始位置

private var startPointPageX:CGFloat = 0

func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
// スワイプ前のスクロール位置を保持
self.startPointPageX = scrollView.contentOffset.x

// 表示中のページにCellの中心値を合わせるため、CollectionViewの初回スクロール位置を設定
self.startPointCollectionX = {
let listWidth = self.collectionView.contentSize.width / CGFloat(self.pagelistsCount)
let contentOffsetX = listWidth * 2 + (self.cellWidth * CGFloat(self.selectedPageNum))
let centerMargin = (App.windowWidth - self.cellWidth) / 2
return contentOffsetX - centerMargin
}()
...
}

func scrollViewDidScroll(_ scrollView: UIScrollView) {
let isScrollCollectionView: Bool = (scrollView.className == "UICollectionView")

// Pageスクロール中はCollectionViewスクロールを禁止
self.collectionView.isScrollEnabled = isScrollCollectionView

if isScrollCollectionView {
...
// PageViewをスクロール時
} else {
// スワイプ前の位置からどれだけ移動したか計算
let changePageX = self.startPointPageX - scrollView.contentOffset.x

// 1枚のページ内で移動した割合をCellの移動割合に
let changeCollectionX = self.cellWidth * (changePageX / App.windowWidth)

// 高速スクロールを封じる
if abs(changePageX) > 0 && abs(changePageX) < App.windowWidth {
self.notScrollView.isHidden = false
}
// 計算した分CollectionViewをスクロール
if changeCollectionX != 0 {
self.collectionView.contentOffset.x = self.startPointCollectionX - changeCollectionX
}
}
...
}


これでページをスワイプ中に、指の動きに合わせてCellも追従します。

欠点として、ページを高速で切り替えると、CollectionViewのループ処理と競合して

CollectionViewの追従がズレてしまう問題が発生しました。

追従時のみUIPageViewControllerの上に透明なUIViewを表示することで

ページの高速スクロールを封じることができましたが、この辺りもっとイケてる実装にできないか..。


タブの色味を追従に合わせる

画面中央に来たCellのタイトルを青く染めます。またタブ下のバーの横幅を変更します。

画面が切り替わったら色が変わるのではなく、中央に来たら変わるのがポイント。


PageViewController.swift


private let pagelist: [String] = ["A", "BBBB", "CCC", "DDDDDD"]
private var selectedCollectionNum:Int!

// Cellの色を更新するメソッド
private func changeCellColor(_ indexPath: IndexPath? = nil) {

// 一旦全てのCell色をリセット
for cell in self.collectionView.visibleCells {
if let cell = cell as? BarCollectionViewCell {
// カスタムCell内に配置しているLabelを灰色に
cell.setSelectedCell(false)
}
}
// 更新するCellが存在しなければreturn
guard let indexPath = indexPath else {
self.selectedCollectionNum = nil
return
}
guard let cell = self.collectionView.cellForItem(at: indexPath) as? BarCollectionViewCell else {
self.selectedCollectionNum = nil
return
}
// 選択したCell番号を保持
self.selectedCollectionNum = indexPath.row
// バーの横幅を更新
self.changeSelectBar(indexPath.row % self.pagelist.count)
// カスタムCell内に配置しているLabelを青色に
cell.setSelectedCell(true)
}

private func changeSelectBar(_ nextPageNum: NSInteger) {
// バーの横幅を、タイトルの文字数に合わせて計算
let titleWidth: CGFloat = (CGFloat(self.pagelist[nextPageNum].count) * 15) + 14

UIView.animate(
withDuration: 0.25,
animations: {
self.selectBarView.frame.size = CGSize(width: titleWidth, height: 3)
self.selectBarView.center.x = App.windowWidth / 2
})
}



BarCollectionViewCell.swift

class BarCollectionViewCell: UICollectionViewCell {

@IBOutlet private weak var categoryTitleLabel: UILabel!

override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
}
func setTitle(_ title: String) {
self.categoryTitleLabel.text = title
setSelectedCell(false)
}
// ラベルの色を切り替え
func setSelectedCell(_ select: Bool) {
self.categoryTitleLabel.textColor = select ? UIColor.blue : UIColor.gray
}
}


selectBarViewは、タブ下に配置しているUIViewです。

本メソッドは、ページスクロール前、スクロール中、スクロール後、タブ選択時に呼ばれます。

具体的には以下の箇所。


  • scrollViewDidScroll

  • scrollViewDidEndDragging

  • scrollViewDidEndDecelerating

  • scrollViewWillBeginDragging // 全Cellの色味をリセットするため

  • collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)

選択したCell番号を、selectedCollectionNumに保持している理由は

scrollViewDidScrollで本メソッドが何度も無駄に呼ばれるのを防ぐため。


PageViewController.swift

    func scrollViewDidScroll(_ scrollView: UIScrollView) {

...
// cellの色味を更新
let center = self.view.convert(self.collectionView.center, to: self.collectionView)
guard let indexPath = self.collectionView.indexPathForItem(at: center) else { return }
if self.selectedCollectionNum != indexPath.row {
self.changeCellColor(indexPath)
}
}

scrollViewDidScroll内で中央のCellを算出後、CellのindexPath.rowとselectedCollectionNumを比較し

異なっている時だけメソッドを呼んでいます。


まとめ

実装の詳細は、Githubのサンプルコードからどうぞ。

LoopPageDemo

ページの高速スクロールはできませんが、ライブラリ不使用でそれなりのUIになりました。

もっとこうした方が良い等アドバイスがあれば、ぜひ (#・ω・)


参考サイト

UIPageViewControllerをつかって無限スクロールできるタブUIを実装してOSSとして公開しました