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