概要
NewsPicks等でよく見かける無限スクロールするタブを実装する機会があったので、 UITabScrollController
としてライブラリ化してみました。せっかくなので少しまとめておこうと思います。
タブの無限スクロール
ループの端の先が見えるようにタブを3周分並べます(タブの数が1画面に収まらないくらい少ない場合は5周分等もっと増やす必要があります)
-
UICollectionView を使ってタブを並べます
-
タブ数*3の場合
// items: [UITabItems]
extension TabScrollHeaderView: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return items.count * 3
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let index = indexPath.item.quotientAndRemainder(dividingBy: items.count).remainder
let item = items[index]
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "identifier", for: indexPath)
// configure cell with item
return cell
}
}
- ループの端に来たら
contentOffset
を更新する
func setupTabScrollPosition() {
let loopWidth = collectionView.contentSize.width / 3
let rightBoundary = loopWidth * 2
let leftBoundary = loopWidth
if collectionView.contentOffset.x > rightBoundary {
collectionView.contentOffset.x = leftBoundary
} else if collectionView.contentOffset.x < leftBoundary {
collectionView.contentOffset.x = rightBoundary
}
}
コンテンツの無限スクロール
リストは UIScrollView をコンテンツ3画面分確保し、中央に現在表示中のコンテンツ、両隣に隣のタブのコンテンツを用意しておきます。スクロール位置は常に真ん中になるようにします。横スクロールで隣に移動し終わったら、次のコンテンツを用意してコンテンツ配置とスクロールを調整します。
// UIScrollViewDelegate
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let pageWidth = scrollView.frame.size.width
if scrollView.contentOffset.x >= pageWidth * 2 {
if let view = delegate?.tabScrollBodyViewNeedRight(self) {
pushToRight(view)
resetContentOffset()
}
} else if scrollView.contentOffset.x <= 0 {
if let view = delegate?.tabScrollBodyViewNeedLeft(self) {
pushToLeft(view)
resetContentOffset()
}
}
let progress = (scrollView.contentOffset.x - pageWidth) / pageWidth
delegate?.tabScrollBodyView(self, progress: progress)
}
コンテンツのスクロールに合わせてヘッダを動かす
func updateProgressView(progress: CGFloat) {
guard currentIndex != TabScrollController.Const.unspecified else { return }
guard items.count > 0 else { return }
isScrollingByMyself = false
guard let attr = collectionView.collectionViewLayout.layoutAttributesForItem(at: IndexPath(item: currentLoopIndex, section: 0)) else { return }
let loopWidth = collectionView.contentSize.width / CGFloat(loopCount)
let offset: CGFloat
let widthDelta: CGFloat
if progress > 0 {
// Moving to right
guard let rightAttr = collectionView.collectionViewLayout.layoutAttributesForItem(at: IndexPath(item: currentLoopIndex + 1, section: 0)) else { return }
offset = (rightAttr.center.x - attr.center.x) * progress
widthDelta = (rightAttr.bounds.size.width - attr.bounds.size.width) * progress
}
else if progress < 0 {
// Moving to left
guard let leftAttr = collectionView.collectionViewLayout.layoutAttributesForItem(at: IndexPath(item: currentLoopIndex - 1, section: 0)) else { return }
offset = (attr.center.x - leftAttr.center.x) * progress
widthDelta = (attr.bounds.size.width - leftAttr.bounds.size.width) * progress
} else {
// Stopped
offset = 0
widthDelta = 0
}
for (index, progressView) in progressViews.enumerated() {
progressView.center.x = loopWidth * CGFloat(index) + attr.center.x + offset
progressView.frame.origin.y = collectionView.bounds.size.height - progressView.frame.size.height
progressView.bounds.size.width = attr.frame.size.width + widthDelta
}
keepCenter(offset: offset)
}
func keepCenter(offset: CGFloat = 0) {
guard let attr = collectionView.collectionViewLayout.layoutAttributesForItem(at: IndexPath(item: currentLoopIndex, section: 0)) else { return }
let loopWidth = collectionView.contentSize.width / CGFloat(loopCount)
var baseCenter = attr.center.x - collectionView.frame.size.width * 0.5
if baseCenter < loopWidth * CGFloat(loopOffset) {
baseCenter += loopWidth
}
collectionView.setContentOffset(CGPoint(x: baseCenter + offset, y: 0), animated: false)
}
タブのタップで横に移動
タブをタップした場合、現在選択中のタブの右側か左側かを判定し、そちらのコンテンツを差し替えてからスクロールします。タブはループしているので、スクロール位置によっては左側にあっても右側の方が近い場合があります。感覚的に自然に見せるのは非常に手間が掛かりそうなので、今回は右側か左側か、近い方へ移動することとしました。
public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let itemIndex = indexPath.item.quotientAndRemainder(dividingBy: items.count).remainder
guard currentIndex != itemIndex else { return }
if checkNextIsRight(indexPath: indexPath) {
delegate?.tabScrollHeaderView(headerView: self, didSelectRight: itemIndex)
} else {
delegate?.tabScrollHeaderView(headerView: self, didSelectLeft: itemIndex)
}
}
// タップしたタブが選択中のタブの右側かどうか
private func checkNextIsRight(indexPath: IndexPath) -> Bool {
let toIndex = indexPath.item.quotientAndRemainder(dividingBy: items.count).remainder
let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size)
let visibleProgressView = progressViews.filter { (progressView) -> Bool in
progressView.frame.intersects(visibleRect)
}
if visibleProgressView.count == 1, let attr = collectionView.layoutAttributesForItem(at: indexPath) {
return visibleProgressView[0].center.x < attr.center.x
} else {
let count = CGFloat(items.count)
let center = count * 0.5
let deltaCenter = center - CGFloat(currentIndex)
let toPosition = (CGFloat(toIndex) + deltaCenter + count).truncatingRemainder(dividingBy: count)
return toPosition > center
}
}
完成
上記を、ユーザが操作中なのか、タブをタップしてアニメーションで移動中なのか等をフラグ管理で制御しています。
そこに以下の機能を追加して、
- コンテンツの縦スクロールに反応してタブを画面上部に隠す
- タブにバッジを表示
- ある程度見た目を調整できるように
GitHub に UITabScrollController として上げています。