iosswiftparallaxuicollectionviewlayout

How to center spinner style card view in collectionView cell after scrolled?


I have a collectionView scrolling like parallax but it's active visible cell is on top of the previous and the next cell. But, the problem is when I scrolled from first cell (black) to left/right it automatically shows the 4th cell (cyan) or from 4th (cyan) cell to the 1st one (black) but what I want is it should scroll 1 cell at a time (cell-by-cell).

Visualized example is:

I'm using the following logic:

extension UIScrollView {
var visibleRect: CGRect {
    CGRect(origin: contentOffset, size: bounds.size)
   }
 }

class ViewController: UIViewController, UICollectionViewDelegateFlowLayout, UICollectionViewDataSource {

@IBOutlet private var collectionView: UICollectionView!

private var images = [
    UIColor.black,
    UIColor.purple,
    UIColor.yellow,
    UIColor.cyan,
    UIColor.red,
    UIColor.blue
]

override func viewDidLoad() {
    super.viewDidLoad()
    collectionView.register(
        UINib(
            nibName: "FooCollectionViewCell",
            bundle: nil),
        forCellWithReuseIdentifier: "FooCollectionViewCell")

    collectionView.delegate = self
    collectionView.dataSource = self

    let layout = CarouselFlowLayout()
    layout.itemSize = CGSize(width: 350, height: 350)
    layout.scrollDirection = .horizontal

    layout.sideItemAlpha = 0.8
    layout.sideItemScale = 0.8
    layout.spacingMode = CarouselFlowLayoutSpacingMode.overlap(visibleOffset: collectionView.frame.width * 0.5)
    collectionView?.setCollectionViewLayout(layout, animated: false)
}

func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int {
    return 1
}

func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
    return images.count
}

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "FooCollectionViewCell", for: indexPath as IndexPath) as? FooCollectionViewCell else { return FooCollectionViewCell() }

    cell.set(item: (images[indexPath.row]))
    cell.layer.cornerRadius = 8.0
    return cell
  }
}

public enum CarouselFlowLayoutSpacingMode {
  case fixed(spacing: CGFloat)
  case overlap(visibleOffset: CGFloat)
}

class CarouselFlowLayout: UICollectionViewFlowLayout {

 struct LayoutState {
    var size: CGSize
    var direction: UICollectionView.ScrollDirection
    func isEqual(otherState: LayoutState) -> Bool {
        return CGSizeEqualToSize(self.size, otherState.size) && self.direction == otherState.direction
    }
}

var sideItemScale: CGFloat = 0.6
var sideItemAlpha: CGFloat = 0.6
var spacingMode = CarouselFlowLayoutSpacingMode.fixed(spacing: 90)

private var state = LayoutState(size: CGSizeZero, direction: .horizontal)

override func prepare() {
    super.prepare()

    let currentState = LayoutState(size: self.collectionView!.bounds.size, direction: self.scrollDirection)

    if !self.state.isEqual(otherState: currentState) {
        self.setupCollectionView()
        self.updateLayout()
        self.state = currentState
    }
}

private func setupCollectionView() {
    guard let collectionView = self.collectionView else { return }
    if collectionView.decelerationRate != .fast {
        collectionView.decelerationRate = .fast
    }
}

private func updateLayout() {
    guard let collectionView = self.collectionView else { return }
    let collectionSize = collectionView.bounds.size
    let yInset = (collectionSize.height - self.itemSize.height) / 2
    let xInset = (collectionSize.width - self.itemSize.width) / 2
    self.sectionInset = UIEdgeInsets(top: yInset, left: xInset, bottom: yInset, right: xInset)

    let side = self.itemSize.width
    let scaledItemOffset =  (side - side * self.sideItemScale) / 2
    switch self.spacingMode {
    case .fixed(let spacing):
        self.minimumLineSpacing = spacing - scaledItemOffset
    case .overlap(let visibleOffset):
        let fullSizeSideItemOverlap = visibleOffset + scaledItemOffset
        let inset = xInset
        self.minimumLineSpacing = inset - fullSizeSideItemOverlap
    }
}

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

override public func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
    guard let superAttributes = super.layoutAttributesForElements(in: rect),
          let attributes = NSArray(array: superAttributes, copyItems: true) as? [UICollectionViewLayoutAttributes]
    else { return nil }
    return attributes.map({ self.transformLayoutAttributes(attributes: $0) })
}

private func transformLayoutAttributes(attributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
    guard let collectionView = self.collectionView else { return attributes }

    let collectionCenter = collectionView.frame.size.width / 2
    let offset = collectionView.contentOffset.x
    let normalizedCenter = attributes.center.x - offset

    let maxDistance = self.itemSize.width + self.minimumLineSpacing
    let distance = min(abs(collectionCenter - normalizedCenter), maxDistance)
    let ratio = (maxDistance - distance) / maxDistance

    let alpha = ratio * (1 - self.sideItemAlpha) + self.sideItemAlpha
    let scale = ratio * (1 - self.sideItemScale) + self.sideItemScale
    attributes.alpha = alpha

    let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size)
    let dist = CGRectGetMidX(attributes.frame) - CGRectGetMidX(visibleRect)
    var transform = CATransform3DScale(CATransform3DIdentity, scale, scale, 1)
    transform = CATransform3DTranslate(transform, 0, 0, -abs(dist / 1000))
    attributes.transform3D = transform
    return attributes
}

override public func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {

    guard let collectionView = self.collectionView else {
            let latestOffset = super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity)
            return latestOffset
        }

        let pageWidth = self.itemSize.width + self.minimumInteritemSpacing
        let approximatePage = collectionView.contentOffset.x/pageWidth
        let currentPage = velocity.x == 0 ? round(approximatePage) : (velocity.x < 0.0 ? floor(approximatePage) : ceil(approximatePage))
        let flickVelocity = velocity.x * 0.3
        let flickedPages = (abs(round(flickVelocity)) <= 1) ? 0 : round(flickVelocity)
        let newHorizontalOffset = ((currentPage + flickedPages) * pageWidth) - collectionView.contentInset.left
        return CGPoint(x: newHorizontalOffset, y: proposedContentOffset.y)
   }
}

Solution

  • I achieved that behavior with the following updates on my questioned code,

    var sideItemScale: CGFloat = 0.8
    var sideItemAlpha: CGFloat = 0.5
    var sideItemShift: CGFloat = 0.8
    var spacingMode = CarouselFlowLayoutSpacingMode.fixed(spacing: 10)
    
    var state = LayoutState(size: CGSize.zero, direction: .horizontal)
    
    override func prepare() {
        super.prepare()
        let currentState = LayoutState(size: collectionView!.bounds.size, direction: scrollDirection)
    
        if !state.isEqual(currentState) {
            setupCollectionView()
            updateLayout()
            state = currentState
        }
    }
    
    func transformLayoutAttributes(_ attributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
        guard let collectionView = collectionView else { return attributes }
        let isHorizontal = (scrollDirection == .horizontal)
    
        let collectionCenter = isHorizontal ? collectionView.frame.size.width / 2 : collectionView.frame.size.height / 2
        let offset = isHorizontal ? collectionView.contentOffset.x : collectionView.contentOffset.y
        let normalizedCenter = (isHorizontal ? attributes.center.x : attributes.center.y) - offset
    
        let maxDistance = (isHorizontal ? itemSize.width : itemSize.height) + minimumLineSpacing
        let distance = min(abs(collectionCenter - normalizedCenter), maxDistance)
        let ratio = (maxDistance - distance) / maxDistance
    
        let alpha = ratio * (1 - sideItemAlpha) + sideItemAlpha
        let scale = ratio * (1 - sideItemScale) + sideItemScale
        let shift = (1 - ratio) * sideItemShift
        attributes.alpha = alpha
        attributes.transform3D = CATransform3DScale(CATransform3DIdentity, scale, scale, 1)
        attributes.zIndex = Int(alpha * 10)
    
        if isHorizontal {
            attributes.center.y += shift
        } else {
            attributes.center.x += shift
        }
        return attributes
    }
    
    override func targetContentOffset(
        forProposedContentOffset proposedContentOffset: CGPoint,
        withScrollingVelocity _: CGPoint
    ) -> CGPoint {
        guard let collectionView = collectionView, !collectionView.isPagingEnabled,
              let layoutAttributes = layoutAttributesForElements(in: collectionView.bounds)
        else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset) }
    
        scrollDirection = .horizontal
    
        let midSide = collectionView.bounds.size.width / 2
        let proposedContentOffsetCenterOrigin = proposedContentOffset.x + midSide
    
        var targetContentOffset: CGPoint
        let closest = layoutAttributes
            .sorted {
                abs($0.center.x - proposedContentOffsetCenterOrigin) <
                    abs($1.center.x - proposedContentOffsetCenterOrigin)
            }
            .first ?? UICollectionViewLayoutAttributes()
        targetContentOffset = CGPoint(x: floor(closest.center.x - midSide), y: proposedContentOffset.y)
        return targetContentOffset
    }