はじめに
ライブラリ「XLPagerTabStrip」を使って横スワイプでタブ・ページ切り替えしていた画面を
無限スクロールさせることになりました。
有名なライブラリだしきっと簡単に無限スクロールできるよね。
と思い調査してみたら、意外とループ機能が無かった...ので自作しました。
自分用の備忘録として、実装のポイントをメモします。
デモ
いろんなアプリでよく見かけますね
ページが切り替わるタイミングは3種類。
- ページを左右スワイプしたとき
- タブを左右スワイプしたとき
- 未選択のタブを押下したとき
タブは無限スクロールし、またページの切り替えに合わせて追従します。
選択されたタブは色が変わり、タブのタイトル幅に合わせてバーのサイズも変わります。
環境
- XCode Version 10.1
- Swift 4.2
画面構成
タブはCollectionView、ページはPageViewController
タブ下のBarはUIViewで構成しました。
CollectionViewにはUILabelが乗ったカスタムCellをセットします。
ループ処理
実装するにあたってこちらのサイトにはとてもお世話になりました。
UIPageViewControllerをつかって無限スクロールできるタブUIを実装してOSSとして公開しました
UIPageViewControllerのループ処理
今回のサンプルコードでは画面をA,B,C,Dの4種類用意しました。
ページのループ処理は以下の通り。
pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController)
pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController)
前後のVCを読み込むDelegateでメソッド「nextViewController」を呼び
// 画面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のループ処理
高速スクロールしてもCellが見切れないよう、Cell数を項目の5倍用意しました。
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へ
強制的にスクロール位置を戻すことでループを実現させます。
ページ切り替えに合わせてタブを追従させる
他アプリでは、ページ切り替え後にタブを移動させるアニメーションを割と見かけましたが
指の動きに合わせて動いてくれた方が見た目タイムラグが無さそうだな
と思ったので実装してみました。
// スクロール開始位置
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のタイトルを青く染めます。またタブ下のバーの横幅を変更します。
画面が切り替わったら色が変わるのではなく、中央に来たら変わるのがポイント。
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
})
}
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で本メソッドが何度も無駄に呼ばれるのを防ぐため。
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になりました。
もっとこうした方が良い等アドバイスがあれば、ぜひ (#・ω・)