[swift] NewsPicks風の無限スクロールタブ

# 概要

NewsPicks等でよく見かける無限スクロールするタブを実装する機会があったので、 `UITabScrollController` としてライブラリ化してみました。せっかくなので少しまとめておこうと思います。

# タブの無限スクロール

ループの端の先が見えるようにタブを3周分並べます（タブの数が1画面に収まらないくらい少ない場合は5周分等もっと増やす必要があります）

1. UICollectionView を使ってタブを並べます

2. タブ数*3の場合

``` swift
// 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
    }
}
```

3. ループの端に来たら `contentOffset` を更新する

``` swift
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 をコンテンツ３画面分確保し、中央に現在表示中のコンテンツ、両隣に隣のタブのコンテンツを用意しておきます。スクロール位置は常に真ん中になるようにします。横スクロールで隣に移動し終わったら、次のコンテンツを用意してコンテンツ配置とスクロールを調整します。

``` swift
// 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)
}
```

# コンテンツのスクロールに合わせてヘッダを動かす

``` swift
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)
}
```

# タブのタップで横に移動
タブをタップした場合、現在選択中のタブの右側か左側かを判定し、そちらのコンテンツを差し替えてからスクロールします。タブはループしているので、スクロール位置によっては左側にあっても右側の方が近い場合があります。感覚的に自然に見せるのは非常に手間が掛かりそうなので、今回は右側か左側か、近い方へ移動することとしました。

``` swift
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](https://github.com/Matzo/UITabScrollController) として上げています。

![output.gif](https://qiita-image-store.s3.amazonaws.com/0/25206/d38fe0a7-8d16-3c3d-55a8-d23f1b063e1e.gif)