UICollectionView horizontal paging layout - ios

Hy, I'm trying to achieve this UI element, that it seems (to me) like an horizontal UIPickerView. Here is an example GIF from when creating a "memoji" on iOS:
Example GIF
I have been trying to accomplish this with UICollectionView and a custom UICollectionViewFlowLayout. But without much luck.
What I tried so far is using
func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint
To stop the scrolling on each cell, thus giving it a sense of paging. But in order to do that I actually have to make the
var collectionViewContentSize: CGSize
Return a way higher content size than it actually exists, otherwise it would just bounce the collectionView and nothing would snap into place no matter what I returned on the previous function.
I also tried using
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>)
To set the collectionView.contentOffset but that was causing weird jumps in the animation and again it was not changing this properly.
Besides the paging per cell I would like to achieve whats on that UI element, a small Haptic Feedback on each scroll when passing trough the elements and the fade in and out of left and right elements on the border. If anyone could point me in the right direction, maybe UICollectionView is not the way to go? I would appreciate a lot. Thank you

I was able to achieve this using a custom UICollectionViewFlowLayout
final class PaginatedCollectionViewFlow: UICollectionViewFlowLayout {
/* Distance from the midle to the other side of the screen */
var availableDistance: CGFloat = 0.0
var midX: CGFloat = 0
var lastElementIndex = 0
let maxAngle = CGFloat(-60.0.degree2Rad)
override func prepare () {
minimumInteritemSpacing = 40.0
scrollDirection = .horizontal
}
/* This should be cached */
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard
let layoutAttributes = super.layoutAttributesForElements(in: rect),
let cv = collectionView else { return nil }
/* Size of the collectionView */
let visibleRect = CGRect(origin: cv.contentOffset, size: cv.bounds.size)
let attributes: [UICollectionViewLayoutAttributes] = layoutAttributes.compactMap { attribute in
guard let copy = attribute.copy() as? UICollectionViewLayoutAttributes else { return nil }
/* Distance from the middle of the screen to the middle of the cell attributes */
let distance = visibleRect.midX - attribute.center.x
/* Normalize the distance between [0, 1] */
let normalizedDistance = abs(distance / availableDistance)
/* Rotate the cell and apply alpha accordingly to the maximum distance from the center */
copy.alpha = 1.0 - normalizedDistance
copy.transform3D = CATransform3DMakeRotation(maxAngle * normalizedDistance, 0, 1, 0)
return copy
}
return attributes
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
}
Make sure to set the UICollectionViewFlowLayout parameters after adding the UICollectionView:
guard let flow = collectionView.collectionViewLayout as? PaginatedCollectionViewFlow else { return }
/* Distance from the middle to the other side of the screen */
flow.availableDistance = floor(view.bounds.width / 2.0)
/* Middle of the screen */
flow.midX = ceil(view.bounds.midX)
/* Index of the last element in the collectionView */
flow.lastElementIndex = vm.numberOfItems - 1
/* Left and Right Insets */
flow.sectionInset.left = flow.midX - 30.0
flow.sectionInset.right = flow.midX - 30.0
And finally after conforming to UICollectionViewDelegate to get the UIScrollView delegate methods:
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
scrollToPosition(scrollView: scrollView)
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
guard !decelerate else { return }
scrollToPosition(scrollView: scrollView)
}
internal func scrollToPosition(scrollView: UIScrollView) {
guard let ip = indexPathForCenterCell else { return }
scrollToIndex(ip.row, animated: true)
}
internal func scrollToIndex(_ index: Int, animated: Bool) {
let ip = IndexPath(item: index, section: 0)
guard let attributes = collectionView.layoutAttributesForItem(at: ip) else { return }
let halfWidth = collectionView.frame.width / CGFloat(2.0)
let offset = CGPoint(x: attributes.frame.midX - halfWidth, y: 0)
collectionView.setContentOffset(offset, animated: animated)
guard let cell = collectionView.cellForItem(at: ip) else { return }
feedbackGenerator.selectionChanged()
cell.isHighlighted = true
collectionView.visibleCells.filter { $0 != cell }.forEach { $0.isHighlighted = false }
}
internal var indexPathForCenterCell: IndexPath? {
let point = collectionView.convert(collectionView.center, from: collectionView.superview)
guard let indexPath = collectionView.indexPathForItem(at: point) else { return collectionView.indexPathsForVisibleItems.first }
return indexPath
}
/* Gets the CGSize based of a maximum size available for the provided String */
func sizeFor(text: String) -> CGSize {
guard let font = UIFont(font: .sanFranciscoSemiBold, size: 15.0) else { return .zero }
let textNS = text as NSString
let maxSize = CGSize(width: collectionView.frame.width / 2, height: collectionView.frame.height)
let frame = textNS.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font : font], context: nil)
return frame.size
}
This provided Pagination of the UICollectionViewCells while "snapping" it to the nearest cell and also using UISelectionFeedbackGenerator to generate haptic feedback. Hope this helps someone with the same problem I had.

Related

UICollectionView Cell Anchor Point not working

I am trying to achieve a collection view where the cells are aligned at the bottom with a paging effect where the "selected" cell is bigger than the rest. Like this:
As of now, I am able to get the effect to work but the cells are aligned in the middle instead of at the bottom:
I have tried setting the anchorPoint property of the cell to pin the cells at (0, 1) in apply(_ layoutAttributes: UICollectionViewLayoutAttributes), but this causes the cells to move and as as a result they appear cut off. This ends up looking like this:
How do I pin these collection view cells at the bottom left corner, also respecting the CGAffine scale effect that occurs during paging?
Here is my code:
Custom UICollectionViewFlowLayout:
import Foundation
import UIKit
/// The layout used in the cover flow.
class CoverFlowLayout: UICollectionViewFlowLayout {
let activeDistance: CGFloat = 25
let zoomFactor: CGFloat = (CoverFlowCell.selectedSize / CoverFlowCell.unselectedSize) - 1
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
guard let collectionView = collectionView else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity) }
let itemSpace = itemSize.width + minimumInteritemSpacing
var currentItemIdx = round(collectionView.contentOffset.x / itemSpace)
let vX = velocity.x
if vX > 0 {
currentItemIdx += 1
} else if vX < 0 {
currentItemIdx -= 1
}
let nearestPageOffset = currentItemIdx * itemSpace
return CGPoint(x: nearestPageOffset, y: 0)
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let collectionView = collectionView else { return nil }
let rectAttributes = super.layoutAttributesForElements(in: rect)!.map { $0.copy() as! UICollectionViewLayoutAttributes }
let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.frame.size)
// Make the cells be zoomed when they reach the center of the screen
for attributes in rectAttributes where attributes.frame.intersects(visibleRect) {
let distance = (visibleRect.minX + 20) - attributes.frame.minX
let normalizedDistance = distance / activeDistance
if distance.magnitude < activeDistance {
let zoom = 1 + zoomFactor * (1 - normalizedDistance.magnitude)
attributes.transform = CGAffineTransform(scaleX: zoom, y: zoom)
}
}
return rectAttributes
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
// Invalidate layout so that every cell get a chance to be zoomed when it reaches the center of the screen
return true
}
override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
let context = super.invalidationContext(forBoundsChange: newBounds) as! UICollectionViewFlowLayoutInvalidationContext
context.invalidateFlowLayoutDelegateMetrics = newBounds.size != collectionView?.bounds.size
return context
}
}
Custom UICollectionView Cell
import Foundation
import UIKit
class CoverFlowCell: UICollectionViewCell {
static let unselectedSize: CGFloat = 185; // The size of the cell when it is not selected in the carousel
static let selectedSize: CGFloat = 200;
private var albumArt: UIImageView = {
let art = UIImageView()
art.backgroundColor = UIColor(hexString: "#ECF0F1")
art.translatesAutoresizingMaskIntoConstraints = false
art.layer.cornerRadius = 2
return art
}()
/// Initializer
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
setupUIConstraints()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func awakeFromNib() {
super.awakeFromNib()
}
private func setupUI() {
contentView.addSubview(albumArt)
}
override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {
super.apply(layoutAttributes)
//we must change the anchor point for propper cells positioning and scaling
self.layer.anchorPoint.x = 0
self.layer.anchorPoint.y = 1
}
private func setupUIConstraints() {
NSLayoutConstraint.activate([
self.albumArt.topAnchor.constraint(equalTo: topAnchor),
self.albumArt.bottomAnchor.constraint(equalTo: bottomAnchor),
self.albumArt.leftAnchor.constraint(equalTo: leftAnchor),
self.albumArt.rightAnchor.constraint(equalTo: rightAnchor)
])
}
}
I have tried referring to this thread:
Changing my CALayer's anchorPoint moves the view
But the solution provided did not help align the cells at the bottom.
Thanks
Add another transform to translate the y position, to slide it up after you scale it up:
let y = //set a negative number here, to slide up by that many points
transform = CGAffineTransform(translationX: 0, y: y)
I would also just apply the transform in the "did select item" method, rather than fuss with it in the layout attributes methods. Then when the cell is "deselected", you can just set the transform to .identity to reset it back to the normal layout.
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
//set transforms
}
func collectionView(_ UICollectionView, didDeselectItemAt: IndexPath) {
//set transform to .identity
}

UIKit Dynamics in Collection View always ends in chaos

When scrolling with my "springy flow layout" (meant to replicate the scrolling effect in Messages), the initial scroll works well, but repeated scrolling ends with all of the cells constantly bouncing all over the screen, and off the vertical axis.
I don't understand why the cells are moving off the vertical axis, since there is no movement applied in the horizontal axis. I'm simply applying this flow layout to my collection view which is currently only setup to create a bunch of dummy cells.
How do I prevent movement in the horizontal axis and how do I ensure that the cells always come to a rest eventually
class SpringyColumnLayout: UICollectionViewFlowLayout {
private lazy var dynamicAnimator = UIDynamicAnimator(collectionViewLayout: self)
override func prepare() {
super.prepare()
guard let cv = collectionView else { return }
let availableWidth = cv.bounds.inset(by: cv.layoutMargins).width
let minColumnWidth: CGFloat = 300
let maxNumberOfColumns = Int(availableWidth / minColumnWidth)
let cellWidth = (availableWidth / CGFloat(maxNumberOfColumns))
.rounded(.down)
self.itemSize = CGSize(width: cellWidth, height: 70)
self.sectionInset = UIEdgeInsets(
top: minimumInteritemSpacing,
left: 0,
bottom: 0,
right: 0
)
self.sectionInsetReference = .fromSafeArea
if dynamicAnimator.behaviors.isEmpty {
let contentSize = collectionViewContentSize
let contentBounds = CGRect(origin: .zero, size: contentSize)
guard let items = super.layoutAttributesForElements(in: contentBounds)
else { return }
for item in items {
let spring = UIAttachmentBehavior(
item: item,
attachedToAnchor: item.center
)
spring.length = 0
spring.damping = 0.8
spring.frequency = 1
self.dynamicAnimator.addBehavior(spring)
}
}
}
override func layoutAttributesForElements(
in rect: CGRect
) -> [UICollectionViewLayoutAttributes]? {
return dynamicAnimator.items(in: rect) as? [UICollectionViewLayoutAttributes]
}
override func layoutAttributesForItem(
at indexPath: IndexPath
) -> UICollectionViewLayoutAttributes? {
return dynamicAnimator.layoutAttributesForCell(at: indexPath)
}
override func shouldInvalidateLayout(
forBoundsChange newBounds: CGRect
) -> Bool {
let scrollView = self.collectionView!
let scrollDelta = newBounds.origin.y - scrollView.bounds.origin.y
let touchLocation = scrollView.panGestureRecognizer
.location(in: scrollView)
for case let spring as UIAttachmentBehavior in dynamicAnimator.behaviors {
let anchorPoint = spring.anchorPoint
let yDistanceFromTouch = abs(touchLocation.y - anchorPoint.y)
let xDistanceFromTouch = abs(touchLocation.x - anchorPoint.x)
let scrollResistance = (yDistanceFromTouch + xDistanceFromTouch) / 1500
let item = spring.items.first!
var center = item.center
if scrollDelta < 0 {
center.y += max(scrollDelta, scrollDelta * scrollResistance)
} else {
center.y += min(scrollDelta, scrollDelta * scrollResistance)
}
item.center = center
dynamicAnimator.updateItem(usingCurrentState: item)
}
return false
}
}

Animate Collection View Bounds Change with Custom Layout Animation

I have a collection view and I want it to have 2 states: collapsed and expanded.
Here are the two states:
The collection view has a red background, the cells have a purple background, and the containing view of the collection view has a gray background.
I am using a custom subclass of UICollectionViewFlowLayout that handles paging, scaling, and opacity of the cells. The layout is supposed to assure that one of the cells is always centered.
What I'm trying to achieve
A smooth animation between the two states where the cells simply grow/shrink with the collection view.
What I'm experiencing
Since the width of the cell changes between the two states, I'm seeing not only the default fade animation between the cells, but also the cell that was centered in one state is no longer centered.
What I tried
I tried following the UPCarouselLayout for the initial layout. I tried following this objc.io post for animation ideas and specifically the section on device rotations. I wasn't able to use their approach because the frame returned by layoutAttributesForElement(at indexPath: IndexPath) was incorrect.
The layout code
protocol TrayCarouselFlowLayoutDelegate: class {
func sizeForItemInCarousel(_ collectionView: UICollectionView, _ layout: TrayCarouselFlowLayout) -> CGSize
}
class TrayCarouselFlowLayout: UICollectionViewFlowLayout {
fileprivate var collectionViewSize: CGSize = .zero
fileprivate var peekItemScale: CGFloat = 0.95
fileprivate var peekItemAlpha: CGFloat = 0.65
fileprivate var peekItemShift: CGFloat = 0.0
var spacing = 16
weak var delegate: TrayCarouselFlowLayoutDelegate!
fileprivate var indexPathsToAnimate: [IndexPath] = []
override func prepare() {
super.prepare()
guard let size = collectionView?.bounds.size, collectionViewSize != size else { return }
setUpCollectionView()
updateLayout()
collectionViewSize = size
}
fileprivate func setUpCollectionView() {
guard let collectionView = collectionView else { return }
if collectionView.decelerationRate != UIScrollViewDecelerationRateFast {
collectionView.decelerationRate = UIScrollViewDecelerationRateFast
}
}
fileprivate func updateLayout() {
guard let collectionView = collectionView else { return }
itemSize = delegate.sizeForItemInCarousel(collectionView, self)
let yInset = (collectionView.bounds.height - itemSize.height) / 2
let xInset = (collectionView.bounds.width - itemSize.width) / 2
sectionInset = UIEdgeInsets(top: yInset, left: xInset, bottom: yInset, right: xInset)
let side = itemSize.width
let scaledItemOffset = (side - side * peekItemScale) / 2
minimumLineSpacing = spacing - scaledItemOffset
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
let oldBounds = collectionView?.bounds ?? .zero
if oldBounds != newBounds {
return true
}
return false
}
override 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($0) }
}
fileprivate func transformLayoutAttributes(_ attributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
guard let collectionView = collectionView else { return attributes }
let collectionCenter = collectionView.frame.size.width / 2
let offset = collectionView.contentOffset.x
let normalizedCenter = attributes.center.x - offset
let maxDistance = itemSize.width + minimumLineSpacing
let distance = min(abs(collectionCenter - normalizedCenter), maxDistance)
let ratio = (maxDistance - distance) / maxDistance
let alpha = ratio * (1 - peekItemAlpha) + peekItemAlpha
let scale = ratio * (1 - peekItemScale) + peekItemScale
let shift = (1 - ratio) * peekItemShift
attributes.alpha = alpha
attributes.transform3D = CATransform3DScale(CATransform3DIdentity, scale, scale, 1)
attributes.zIndex = Int(alpha * 10)
attributes.center.y += shift
return attributes
}
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint,
withScrollingVelocity velocity: CGPoint) -> CGPoint {
guard let collectionView = collectionView, !collectionView.isPagingEnabled,
let layoutAttributes = self.layoutAttributesForElements(in: collectionView.bounds)
else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset) }
let midSide: CGFloat = collectionViewSize.width / 2
let proposedContentOffsetCenterOrigin = proposedContentOffset.x + midSide
let closest = layoutAttributes
.sorted { abs($0.center.x - proposedContentOffsetCenterOrigin) < abs($1.center.x - proposedContentOffsetCenterOrigin) }
.first ?? UICollectionViewLayoutAttributes()
let targetContentOffset = CGPoint(x: floor(closest.center.x - midSide), y: proposedContentOffset.y)
return targetContentOffset
}
}
What I need help with
My goal is to simply grow the collection view and the cells along with it. Since I'm changing the section insets and the size of the cell in my code, the most challenging part is keeping the cell centered during the growth/shrinking. Any help would be greatly appreciated!
EDIT: If I don't change the width of the cell when expanding/collapsing, this behaves ALMOST how I want it. There is still a fade animation but that would be acceptable if it was the only part off the mark. For some reason, redefining the width while the layout cells are growing is what is causing the issue seen in the GIF.
Here is a GIF where the width is static:

Is it possible to create a scroll view with an animated page control in Swift?

The designer wants the following animation from a swipe gesture.
As it can be seen the user can swipe cards and see what each card has. At the same time, the user can see in the right side of the screen the following card and the last one in the left. Also, cards are changing their size while the user is moving the scroll.
I have already worked with page control views but I have no idea if this is possible with a page Control (which actually is the question of this post).
Also, I have already tried with a collectionView but when I swipe (actually is an horizontal scroll) the scroll has an uncomfortable inertia and also, I have no idea how to make the animation.
In this question a scrolled page control is implemented but now I just wondering if and animation like the gif provided is possible.
If the answer is yes, I would really appreciate if you can give tips of how I can make this possible.
Thanks in advance.
Based on the Denislava Shentova comment I found a good library that solves this issue.
For all people in the future and their work hours, I just took code from UPCarouselFlowLayout library and deleted some I didn't need.
Here is the code of a simple viewController that shows the following result:
import UIKit
class ViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource {
// CollectionView variable:
var collectionView : UICollectionView?
// Variables asociated to collection view:
fileprivate var currentPage: Int = 0
fileprivate var pageSize: CGSize {
let layout = self.collectionView?.collectionViewLayout as! UPCarouselFlowLayout
var pageSize = layout.itemSize
pageSize.width += layout.minimumLineSpacing
return pageSize
}
fileprivate var colors: [UIColor] = [UIColor.black, UIColor.red, UIColor.green, UIColor.yellow]
override func viewDidLoad() {
super.viewDidLoad()
self.addCollectionView()
self.setupLayout()
}
func setupLayout(){
// This is just an utility custom class to calculate screen points
// to the screen based in a reference view. You can ignore this and write the points manually where is required.
let pointEstimator = RelativeLayoutUtilityClass(referenceFrameSize: self.view.frame.size)
self.collectionView?.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive = true
self.collectionView?.topAnchor.constraint(equalTo: self.view.topAnchor, constant: pointEstimator.relativeHeight(multiplier: 0.1754)).isActive = true
self.collectionView?.widthAnchor.constraint(equalTo: self.view.widthAnchor).isActive = true
self.collectionView?.heightAnchor.constraint(equalToConstant: pointEstimator.relativeHeight(multiplier: 0.6887)).isActive = true
self.currentPage = 0
}
func addCollectionView(){
// This is just an utility custom class to calculate screen points
// to the screen based in a reference view. You can ignore this and write the points manually where is required.
let pointEstimator = RelativeLayoutUtilityClass(referenceFrameSize: self.view.frame.size)
// This is where the magic is done. With the flow layout the views are set to make costum movements. See https://github.com/ink-spot/UPCarouselFlowLayout for more info
let layout = UPCarouselFlowLayout()
// This is used for setting the cell size (size of each view in this case)
// Here I'm writting 400 points of height and the 73.33% of the height view frame in points.
layout.itemSize = CGSize(width: pointEstimator.relativeWidth(multiplier: 0.73333), height: 400)
// Setting the scroll direction
layout.scrollDirection = .horizontal
// Collection view initialization, the collectionView must be
// initialized with a layout object.
self.collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
// This line if for able programmatic constrains.
self.collectionView?.translatesAutoresizingMaskIntoConstraints = false
// CollectionView delegates and dataSource:
self.collectionView?.delegate = self
self.collectionView?.dataSource = self
// Registering the class for the collection view cells
self.collectionView?.register(CardCell.self, forCellWithReuseIdentifier: "cellId")
// Spacing between cells:
let spacingLayout = self.collectionView?.collectionViewLayout as! UPCarouselFlowLayout
spacingLayout.spacingMode = UPCarouselFlowLayoutSpacingMode.overlap(visibleOffset: 20)
self.collectionView?.backgroundColor = UIColor.gray
self.view.addSubview(self.collectionView!)
}
// MARK: - Card Collection Delegate & DataSource
func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return colors.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cellId", for: indexPath) as! CardCell
cell.customView.backgroundColor = colors[indexPath.row]
return cell
}
// MARK: - UIScrollViewDelegate
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
let layout = self.collectionView?.collectionViewLayout as! UPCarouselFlowLayout
let pageSide = (layout.scrollDirection == .horizontal) ? self.pageSize.width : self.pageSize.height
let offset = (layout.scrollDirection == .horizontal) ? scrollView.contentOffset.x : scrollView.contentOffset.y
currentPage = Int(floor((offset - pageSide / 2) / pageSide) + 1)
}
}
class CardCell: UICollectionViewCell {
let customView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.layer.cornerRadius = 12
return view
}()
override init(frame: CGRect) {
super.init(frame: frame)
self.addSubview(self.customView)
self.customView.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true
self.customView.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
self.customView.widthAnchor.constraint(equalTo: self.widthAnchor, multiplier: 1).isActive = true
self.customView.heightAnchor.constraint(equalTo: self.heightAnchor, multiplier: 1).isActive = true
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
} // End of CardCell
class RelativeLayoutUtilityClass {
var heightFrame: CGFloat?
var widthFrame: CGFloat?
init(referenceFrameSize: CGSize){
heightFrame = referenceFrameSize.height
widthFrame = referenceFrameSize.width
}
func relativeHeight(multiplier: CGFloat) -> CGFloat{
return multiplier * self.heightFrame!
}
func relativeWidth(multiplier: CGFloat) -> CGFloat{
return multiplier * self.widthFrame!
}
}
Note that there are some other clases in this code but temporarily you can run the whole code in the ViewController.swift file. After you test, please split them into different files.
In order tu run this code, you need the following module. Make a file called UPCarouselFlowLayout.swift and paste all this code:
import UIKit
public enum UPCarouselFlowLayoutSpacingMode {
case fixed(spacing: CGFloat)
case overlap(visibleOffset: CGFloat)
}
open class UPCarouselFlowLayout: UICollectionViewFlowLayout {
fileprivate struct LayoutState {
var size: CGSize
var direction: UICollectionViewScrollDirection
func isEqual(_ otherState: LayoutState) -> Bool {
return self.size.equalTo(otherState.size) && self.direction == otherState.direction
}
}
#IBInspectable open var sideItemScale: CGFloat = 0.6
#IBInspectable open var sideItemAlpha: CGFloat = 0.6
open var spacingMode = UPCarouselFlowLayoutSpacingMode.fixed(spacing: 40)
fileprivate var state = LayoutState(size: CGSize.zero, direction: .horizontal)
override open func prepare() {
super.prepare()
let currentState = LayoutState(size: self.collectionView!.bounds.size, direction: self.scrollDirection)
if !self.state.isEqual(currentState) {
self.setupCollectionView()
self.updateLayout()
self.state = currentState
}
}
fileprivate func setupCollectionView() {
guard let collectionView = self.collectionView else { return }
if collectionView.decelerationRate != UIScrollViewDecelerationRateFast {
collectionView.decelerationRate = UIScrollViewDecelerationRateFast
}
}
fileprivate func updateLayout() {
guard let collectionView = self.collectionView else { return }
let collectionSize = collectionView.bounds.size
let isHorizontal = (self.scrollDirection == .horizontal)
let yInset = (collectionSize.height - self.itemSize.height) / 2
let xInset = (collectionSize.width - self.itemSize.width) / 2
self.sectionInset = UIEdgeInsetsMake(yInset, xInset, yInset, xInset)
let side = isHorizontal ? self.itemSize.width : self.itemSize.height
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 = isHorizontal ? xInset : yInset
self.minimumLineSpacing = inset - fullSizeSideItemOverlap
}
}
override open func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
override open 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($0) })
}
fileprivate func transformLayoutAttributes(_ attributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
guard let collectionView = self.collectionView else { return attributes }
let isHorizontal = (self.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 ? self.itemSize.width : self.itemSize.height) + 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
attributes.transform3D = CATransform3DScale(CATransform3DIdentity, scale, scale, 1)
attributes.zIndex = Int(alpha * 10)
return attributes
}
override open func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
guard let collectionView = collectionView , !collectionView.isPagingEnabled,
let layoutAttributes = self.layoutAttributesForElements(in: collectionView.bounds)
else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset) }
let isHorizontal = (self.scrollDirection == .horizontal)
let midSide = (isHorizontal ? collectionView.bounds.size.width : collectionView.bounds.size.height) / 2
let proposedContentOffsetCenterOrigin = (isHorizontal ? proposedContentOffset.x : proposedContentOffset.y) + midSide
var targetContentOffset: CGPoint
if isHorizontal {
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)
}
else {
let closest = layoutAttributes.sorted { abs($0.center.y - proposedContentOffsetCenterOrigin) < abs($1.center.y - proposedContentOffsetCenterOrigin) }.first ?? UICollectionViewLayoutAttributes()
targetContentOffset = CGPoint(x: proposedContentOffset.x, y: floor(closest.center.y - midSide))
}
return targetContentOffset
}
}
Again, this module was made by Paul Ulric, you can installed with cocoa.

How to jump to any Cell in a UICollectionView by using a custom layout?

I have 40 cells in my horizontal UICollectionView and a button.
I can jump from cell number 5 to cell number 10 when I click on the button.
But as soon as I want to go to a further cell ( for example from 5 to 25 ),
it doesn't work, and instead it goes to 0.
code:
func setValue(value: Int, animated: Bool) {
self.value = value
if let row = find(values, value) {
let indexPath = NSIndexPath(forRow: row, inSection: 0)
selectedCell = collectionView.cellForItemAtIndexPath(indexPath) as? StepperCell
collectionView.scrollToItemAtIndexPath(indexPath, atScrollPosition: .CenteredHorizontally, animated: animated)
}
}
override func prepareLayout() {
let start = Int(collectionView!.bounds.size.width * 0.5) - statics.width / 2
let length = collectionView!.numberOfItemsInSection(0)
if cache.isEmpty && length > 0 {
for item in 0..<length {
let indexPath = NSIndexPath(forItem: item, inSection: 0)
let attributes = UICollectionViewLayoutAttributes(forCellWithIndexPath: indexPath)
attributes.frame = CGRect(
x: start + (statics.width + statics.padding) * indexPath.item,
y: 0,
width: statics.width,
height: statics.height
)
cache.append(attributes)
}
contentWidth = CGRectGetMaxX(cache.last!.frame) + CGFloat(start)
}
}
override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? {
var layoutAttributes = [UICollectionViewLayoutAttributes]()
for attributes in cache {
if CGRectIntersectsRect(attributes.frame, rect) {
layoutAttributes.append(attributes)
}
}
return layoutAttributes
}
}
I think you can dispense with the row logic. After all - you are just using the passed Int. If your if let clause fails, it won't scroll.
Also, drop the call to cellForItemAtIndexPath - you do not need it (you are not using it either).
I guess that perhaps you should use NSIndexPath(forItem, inSection) rather than NSIndexPath(forRow, inSection). (Table view uses row, collectionView uses item.)
collectionView.scrollToItemAtIndexPath(
NSIndexPath(forItem: value, inSection: 0),
atScrollPosition: .CenteredHorizontally, animated: true)
After some research, and some help from a fellow coder. I came up with a simpler and more effective solution.
I use custom layout only for the snapping effect (when switching between cells), and then I let the flow layout handle the rest.
here is the code :
struct Statics {
static let width = CGFloat(50.0)
static let height = CGFloat(50.0)
static let padding = CGFloat(5.0)
}
func commonInit() {
...
// I had to set layout from the code, didn't work from the Interface Builder
let layout = HorizontalLayout()
layout.scrollDirection = .Horizontal
collectionView.collectionViewLayout = layout
collectionView.dataSource = self
}
override func layoutSubviews() {
super.layoutSubviews()
// Set item size, line spacing and insets and let the flow layout do the rest
let layout = collectionView.collectionViewLayout as! HorizontalLayout
layout.itemSize = CGSize(width: Statics.width, height: Statics.height)
layout.minimumLineSpacing = Statics.padding
let sideMargin = (collectionView.bounds.width - Statics.width) / 2.0
layout.sectionInset = UIEdgeInsetsMake(0.0, sideMargin, 0.0, sideMargin)
setValue(value, animated: true)
}
class HorizontalLayout: UICollectionViewFlowLayout {
// This the only method you need to override
override func targetContentOffsetForProposedContentOffset(proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
let contentOffset: CGPoint
let itemWidth = itemSize.width
let padding = minimumLineSpacing
// Calculate nearest cell from the proposed content offset
let index = round(proposedContentOffset.x / (itemWidth + padding))
let x = (itemWidth + padding) * index
contentOffset = CGPoint(x: x, y: proposedContentOffset.y)
return contentOffset
}
}

Resources