9
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Posted at

概要

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

タブの無限スクロール

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

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

  2. タブ数*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
    }
}
  1. ループの端に来たら 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 として上げています。

output.gif

9
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?