gspiers / LoopLayout

Example of a Custom UICollectionViewLayout

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Vertical infinity Scroll of CollectionView

rajeshsonu opened this issue · comments

How can I able to add vertical infinity scroll

Vertical Scroll without Arch
import UIKit

class LoopLayoutInvalidationContext: UICollectionViewLayoutInvalidationContext {
var boundsSizeDidChange: Bool = false
var accessibilityDidChange: Bool = false
}

class LoopLayout: UICollectionViewLayout {

// MARK: Private properties
private let notificationCenter = NotificationCenter.default
private var itemCount = 0
private let itemSize = CGSize(width: 80, height: 80)
private let itemYSpacing: CGFloat = 20.0
private var itemAndSpacingHeight: CGFloat {
    return itemSize.height + itemYSpacing
}
private let contentMultiple: CGFloat = 2 // Number of repeating content sections, use double the space of the content so content has room to wrap around.
private var contentHeight: CGFloat {
    let totalItemAndSpacingHeight = (CGFloat(itemCount) * itemAndSpacingHeight)
    return totalItemAndSpacingHeight
}

private var leadingOffsetY: CGFloat {
    guard let cv = collectionView else { return insetHeight }
    return shouldWrap ? insetHeight : cv.frame.height / 2.0
}
private var trailingOffsetY: CGFloat {
    var heightAdjustment = insetHeight
    if !shouldWrap, let cv = collectionView {
        heightAdjustment = cv.frame.height / 2.0
    }

    return collectionViewContentSize.height - heightAdjustment
}

// This needs to be large enough that a fast swipe will naturally come to a stop before bouncing.
private let insetHeight: CGFloat = 16000

private var hasSetInitialContentOffsetOnce = false

private var hasEnoughContentToWrap: Bool {
    guard let cv = collectionView else { return false }
    // Only wrap around if there is enough content to fill the screen.
    return contentHeight > (cv.frame.height + itemAndSpacingHeight)
}

private var shouldWrap: Bool {
    let isAccessibilityRunning = UIAccessibility.isSwitchControlRunning || UIAccessibility.isVoiceOverRunning
    return !isAccessibilityRunning && hasEnoughContentToWrap
}

private var layoutAttributes: [UICollectionViewLayoutAttributes] = []
private var adjustedLayoutAttributes: [UICollectionViewLayoutAttributes] = []

// MARK: Lifecycle
override init() {
    super.init()
    commonInit()
}

required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)
    commonInit()
}

private func commonInit() {
    notificationCenter.addObserver(self, selector: #selector(LoopLayout.accessibilityDidChange), name: UIAccessibility.voiceOverStatusDidChangeNotification, object: nil)
    notificationCenter.addObserver(self, selector: #selector(LoopLayout.accessibilityDidChange), name: UIAccessibility.switchControlStatusDidChangeNotification, object: nil)
}

deinit {
    notificationCenter.removeObserver(self, name: UIAccessibility.voiceOverStatusDidChangeNotification, object: nil)
    notificationCenter.removeObserver(self, name: UIAccessibility.switchControlStatusDidChangeNotification, object: nil)
}

}

// MARK: UICollectionViewLayout static overrides
extension LoopLayout {
override class var invalidationContextClass: AnyClass {
return LoopLayoutInvalidationContext.self
}
}

// MARK: UICollectionViewLayout overrides
extension LoopLayout {
override var collectionViewContentSize: CGSize {
guard let cv = collectionView else { return .zero }

    let totalContentHeight: CGFloat
    if shouldWrap {
        let totalInsetHeight = insetHeight * 2.0
        totalContentHeight = totalInsetHeight + contentHeight
    } else {
        // The contentWidth has one extra full item's width on the end as the content wraps.
        // We need to remove this as we aren't wrapping in this case.
        let extraHeight = itemAndSpacingHeight
        totalContentHeight = contentHeight + cv.frame.height - extraHeight
    }

    return CGSize(width: cv.frame.height, height: totalContentHeight)
}

override func prepare() {
    super.prepare()
    guard let cv = collectionView else { return }

    itemCount = cv.numberOfItems(inSection: 0)

    // These are cached until reloadData, or bounds size change.
    if layoutAttributes.count == 0 {
        var currentY = leadingOffsetY
        layoutAttributes = []
        for item in 0..<itemCount {
            let indexPath = IndexPath(item: item, section: 0)
            let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
            attributes.size = itemSize

            // Determine vertical center
            let xCenter = cv.bounds.maxX / 2.0
            let yCenter = currentY + (itemSize.height / 2.0)
            attributes.center = CGPoint(x: xCenter, y: yCenter)

            layoutAttributes.append(attributes)

            currentY += itemSize.height + itemYSpacing
        }
    }

    // If there aren't any items we don't need to do anything else.
    if itemCount == 0 { return }

    setInitialContentOffsetIfRequired()

    // Normalize the value so that the first item's left edge would be at zero
    let normalizedContentOffsetY = cv.contentOffset.y - leadingOffsetY

    // Find nearest item index (this can be larger than itemCount as we scroll to the right).
    let nearestContentIndex = Int(normalizedContentOffsetY / itemAndSpacingHeight)
    let nearestItemIndex = Int(nearestContentIndex) % itemCount

    // How many full content widths are we offset by.
    let multiple = (nearestContentIndex - nearestItemIndex) / itemCount

    adjustedLayoutAttributes = layoutAttributes.copy().shift(distance: nearestItemIndex)
    let firstAttributes = adjustedLayoutAttributes[0]


    // Find the currentX and then change all attributes after first index to move right along the layout.
    var currentY = firstAttributes.center.y
    currentY += (contentHeight * CGFloat(multiple))

    for attributes in adjustedLayoutAttributes {
        if shouldWrap {
            attributes.center = CGPoint(x: attributes.center.x, y:currentY )
            currentY += itemAndSpacingHeight
        }

        adjustAttribute(attributes)
    }
}

override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
    return adjustedLayoutAttributes.filter { rect.intersects($0.frame) }
}

override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
    for attributes in adjustedLayoutAttributes where attributes.indexPath == indexPath {
        return attributes
    }

    return nil
}

override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
    return true
}

override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
    let context = super.invalidationContext(forBoundsChange: newBounds)
    guard let cv = collectionView else { return context }
    // If we are scrolling off the leading/trailing offsets we need to adjust contentOffset so we can 'wrap' around.
    // This will be seamless for the user as the current momentum is maintained.
    if shouldWrap {
        if cv.contentOffset.y >= trailingOffsetY {
            let offset = CGPoint(x: 0, y: -contentHeight)
            context.contentOffsetAdjustment = offset
        } else if cv.contentOffset.y <= leadingOffsetY {
            let offset = CGPoint(x: 0, y: contentHeight)
            context.contentOffsetAdjustment = offset
        }
    }

    return context
}

override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) {
    guard let loopContext = context as? LoopLayoutInvalidationContext else {
        assertionFailure("Unexpected invalidation context type: \(context)")
        super.invalidateLayout(with: context)
        return
    }

    // Re-ask the delegate for centered indexpath if we ever reload data
    if loopContext.invalidateEverything || loopContext.invalidateDataSourceCounts || loopContext.accessibilityDidChange {
        layoutAttributes = []
        adjustedLayoutAttributes = []
        hasSetInitialContentOffsetOnce = false
    }

    super.invalidateLayout(with: loopContext)
}

}

// MARK: Private methods
extension LoopLayout {
@objc private func accessibilityDidChange() {
let invalidationContext = LoopLayoutInvalidationContext()
invalidationContext.accessibilityDidChange = true
invalidateLayout(with: invalidationContext)
}

private func initialContentOffset() -> CGPoint? {
    guard let cv = collectionView, itemCount > 0 else { return nil }

    let firstIndexPath = IndexPath(item: 0, section: 0)
    let attributes = layoutAttributes[firstIndexPath.item]
    // Start at the end of the content if we are wrapping.
    let initialContentOffsetY: CGFloat
    if shouldWrap {
        initialContentOffsetY = contentHeight
    } else {
        initialContentOffsetY = 0
    }

    let centeredOffsetY = (attributes.center.y + initialContentOffsetY) - (cv.frame.height / 2.0)
    let contentOffsetAdjustment = CGPoint(x: 0, y: centeredOffsetY)

    return contentOffsetAdjustment
}

private func setInitialContentOffsetIfRequired() {
    guard !hasSetInitialContentOffsetOnce, let cv = collectionView, let initialContentOffset = initialContentOffset() else { return }

    // We only do this once, unless the user calls reload data and invalidates everything.
    hasSetInitialContentOffsetOnce = true

    cv.setContentOffset(initialContentOffset, animated: false)
}

private func adjustAttribute(_ attribute: UICollectionViewLayoutAttributes) {
    let transform: CATransform3D = CATransform3DIdentity
    attribute.transform3D = transform
}

}

// MARK: Public methods
extension LoopLayout {

public func closestIndexPathToCenter() -> IndexPath? {
    guard let cv = collectionView else { return nil }
    let viewCenterY = cv.contentOffset.y + (cv.frame.height / 2.0)

    // Find the nearest index path nearest to center of cv frame.
    // We use a rect here so that we don't test a point that lands between cells.
    let centerRect = CGRect(x: 0, y: viewCenterY - (itemAndSpacingHeight / 2.0), width: cv.bounds.width, height: itemAndSpacingHeight)
    if let attributesInRect = layoutAttributesForElements(in: centerRect), let firstAttribute = attributesInRect.first {
        var closestAttribute = firstAttribute
        var closestDistance = abs(closestAttribute.center.y - viewCenterY)
        for attributes in attributesInRect {
            let distance = abs(attributes.center.y - viewCenterY)
            if distance < closestDistance {
                closestAttribute = attributes
                closestDistance = distance
            }
        }
        return closestAttribute.indexPath
    } else {
        // Either no cells or we are looping around.
        return nil
    }
}

}