UICollectionView Cell Anchor Point not working - ios

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
}

Related

UICollectionViewFlowLayout edge cells disappear when translation transform is applied

I'm using a custom UICollectionViewFlowLayout to make cells zoom and fade as they reach the top. To do this, I'm applying an alpha and transform to the layout attributes. Here's my code (link to full demo repo):
class EdgeZoomLayout: UICollectionViewFlowLayout {
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)
for attributes in rectAttributes where attributes.frame.intersects(visibleRect) {
let positionInFrameY = attributes.center.y - visibleRect.origin.y /// y origin of rectangle
let cutoff = CGFloat(30)
if positionInFrameY <= cutoff {
let translation = cutoff - positionInFrameY /// distance from the cutoff, 0 if exactly on cutoff
let alpha = 1 - (translation / 100)
let scale = 1 - (translation / 1000)
attributes.alpha = alpha
attributes.zIndex = Int(alpha >= 1 ? 1 : 0) /// if alpha is 1, keep on the top
attributes.transform = CGAffineTransform(scaleX: scale, y: scale).translatedBy(x: 0, y: translation)
} else {
attributes.zIndex = 1 /// keep on top if not getting zoomed
}
}
return rectAttributes
}
/// boilerplate code
override init() { super.init() }
required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { 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
}
}
This is almost perfect, but the topmost cell disappears suddenly when it gets close to the edge:
How can I make the cell zoom and fade without vanishing like that? I think it might be because the collection view recycled the cell... but then, how do I keep it?
Either you can remove this line
where attributes.frame.intersects(visibleRect)
or you can add some point for visibleRect by this
for attributes in rectAttributes where attributes.frame.intersects(visibleRect.insetBy(dx: 0, dy: -15)) {

How archive like this CollectionView stacked Cell for Overlapping Cell Like Wallet

I want exactly when tap particular cell it will show expand
let layout = UICollectionViewFlowLayout()
layout.minimumLineSpacing = -UIScreen.main.bounds.width/2.08
i want expand cell?
Friends any suggestion and needs helpful for us
Thanks
Usually we use UICollectionViewFlowLayout, once the item's size and spacing is given, the item's frame is settled.
To do
CollectionView stacked Cell for Overlapping Cell Like Wallet
You need to give the frame you want for every item in UICollectionView.
Finishing it by customizing UICollectionViewLayout, and use your own UICollectionViewLayout subclass.
The final effect is as the following image
The basic thing:
To do custom layout is easy:
override public func prepare() ,
to calculate the frames for every item,
And put the frame for item in its container , your custom UICollectionViewLayoutAttributes
override public func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? ,
Retrieves layout information for an item at the specified index path with a corresponding cell.
to assign your prepared custom UICollectionViewLayoutAttributes for item layout
override public func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? ,
Retrieves the layout attributes for all of the cells and views in the specified rectangle.
There is no supplementary views, and decoration views in this situation, just to handle items.
to assign your prepared custom UICollectionViewLayoutAttributes for UICollectionView layout
Key Point:
You have two kinds of layout state.
the initial state, and the unselected state is the same, when no card is selected.
The front card are partly hidden, and the last card is full shown.
The custom UICollectionViewLayoutAttributes, with isExpand to record if there is cell selected.
isExpand is used for an cell added UIPanGestureRecognizer
class CardLayoutAttributes: UICollectionViewLayoutAttributes {
var isExpand = false
override func copy(with zone: NSZone? = nil) -> Any {
let attribute = super.copy(with: zone) as! CardLayoutAttributes
attribute.isExpand = isExpand
return attribute
}
}
The frame calculation of state unselected is easy.
Set the frame of the first item,
then the second ...
y: titleHeight * CGFloat(index) , all OK
fileprivate func setNoSelect(attribute:CardLayoutAttributes) {
guard let collection = collectionView else {
return
}
let noneIdx = Int(collection.contentOffset.y/titleHeight)
if noneIdx < 0 {
return
}
attribute.isExpand = false
let index = attribute.zIndex
var currentFrame = CGRect(x: collection.frame.origin.x, y: titleHeight * CGFloat(index), width: cellSize.width, height: cellSize.height)
if index == noneIdx{
attribute.frame = CGRect(x: currentFrame.origin.x, y: collection.contentOffset.y, width: cellSize.width, height: cellSize.height)
}
else if index <= noneIdx, currentFrame.maxY > collection.contentOffset.y{
currentFrame.origin.y -= (currentFrame.maxY - collection.contentOffset.y )
attribute.frame = currentFrame
}
else {
attribute.frame = currentFrame
}
}
the selected state, to select an item, the item is expanded , others will make room for it.
The logic is to put the selected item in the middle, the y position matters, collection.contentOffset.y + offsetSelected,
The center item's frame is known , then calculate the two sides.
One side is items from (selectedIdx-1) to 0, calculate the frame of item.
The Other side is items from (selectedIdx+1) to the final index , also calculate item's frame.
fileprivate func calculate(for attributes: [CardLayoutAttributes], choose selectedIP: IndexPath) -> [CGRect]{
guard let collection = collectionView else {
return []
}
let noneIdx = Int(collection.contentOffset.y / titleHeight)
if noneIdx < 0 {
return []
}
let x = collection.frame.origin.x
var selectedIdx = 0
for attr in attributes{
if attr.indexPath == selectedIP{
break
}
selectedIdx += 1
}
var frames = [CGRect](repeating: .zero, count: attributes.count)
// Edit here
let offsetSelected: CGFloat = 100
let marginBottomSelected: CGFloat = 10
frames[selectedIdx] = CGRect(x: x, y: collection.contentOffset.y + offsetSelected, width: cellSize.width, height: cellSize.height)
if selectedIdx > 0{
for i in 0...(selectedIdx-1){
frames[selectedIdx - i - 1] = CGRect(x: x, y: frames[selectedIdx].origin.y - titleHeight * CGFloat(i + 1), width: cellSize.width, height: cellSize.height)
}
}
if selectedIdx < (attributes.count - 1){
for i in (selectedIdx + 1)...(attributes.count - 1){
frames[i] = CGRect(x: x, y: frames[selectedIdx].origin.y + marginBottomSelected + titleHeight * CGFloat(i - selectedIdx - 1) + cellSize.height, width: cellSize.width, height: cellSize.height)
}
}
return frames
}
When there is a item selected, you should refresh the custom layout.
To call invalidateLayout(),
Invalidates the current layout and triggers a layout update.
fileprivate var _selectPath: IndexPath? {
didSet {
self.collectionView!.isScrollEnabled = (_selectPath == nil)
}
}
public var selectPath: IndexPath? {
set {
_selectPath = (_selectPath == newValue) ? nil : newValue
self.collectionView?.performBatchUpdates({
self.invalidateLayout()
}, completion: nil)
} get {
return _selectPath
}
}
one more thing, the sample demo in github
Here is the overlapping the layout, minimumLineSpacing negative number
if click the card it should be expanded and scroll down and up card
should not disclose anymoew like wallet
class OverlappedCustomFlowLayout: UICollectionViewFlowLayout {
override func prepare() {
super.prepare()
// This allows us to make intersection and overlapping
// A negative number implies overlapping whereas positive implies space between the adjacent edges of two cells.
minimumLineSpacing = -100
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let layoutAttributes = super.layoutAttributesForElements(in: rect)
for currentLayoutAttributes: UICollectionViewLayoutAttributes in layoutAttributes! {
// zIndex - Specifies the item’s position on the z-axis.
// Unlike a layer's zPosition, changing zIndex allows us to change not only layer position,
// but tapping/UI interaction logic too as it moves the whole item.
currentLayoutAttributes.zIndex = currentLayoutAttributes.indexPath.row + 1
}
return layoutAttributes
}
}
you can try the following idea
The layout part:
var selectedIdx: Int?
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let layoutAttributes = super.layoutAttributesForElements(in: rect)
var originY: CGFloat = 0
let defaultOffsetY: CGFloat = 80
if let idx = selectedIdx{
// expanded layout
for i in 0..<cnt{
// frame the attribute
if i == idx + 1{
// to expand is to make room for the rest items
originY += 400
}
else{
originY += defaultOffsetY
}
}
}
else{
// default layout
for i in 0..<cnt{
// frame the attribute
originY += defaultOffsetY
}
}
return layoutAttributes
}
trigger the expand action
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
// edit the state
layout.selectedIdx = indexPath.item
// trigger refreshing the collectionView's layout
collectionView.reloadData()
}
I have tried it's working fine, thanks to everyone
class CardLayout: UICollectionViewLayout {
var contentHeight: CGFloat = 0.0
var cachedAttributes = [UICollectionViewLayoutAttributes]()
var nextIndexPath: Int?
override init() {
super.init()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var collectionViewContentSize: CGSize {
var size = super.collectionViewContentSize
let collection = collectionView!
size.width = collection.bounds.size.width
if let _ = FlowLayoutAttributes.shared.cellIndex{
size.height = contentHeight+UIScreen.main.bounds.width/2+38
}else{
size.height = contentHeight
}
print("Contend",contentHeight)
return size
}
func reloadData(){
self.cachedAttributes = [UICollectionViewLayoutAttributes]()
}
override func prepare() {
cachedAttributes.removeAll()
guard let numberOfItems = collectionView?.numberOfItems(inSection: 0) else {
return
}
for index in 0..<numberOfItems {
let layout = UICollectionViewLayoutAttributes(forCellWith: IndexPath(row: index, section: 0))
layout.frame = frameFor(index: index)
if let indexExpand = FlowLayoutAttributes.shared.cellIndex, indexExpand == index {
self.nextIndexPath = index+1
contentHeight = CGFloat(CGFloat(numberOfItems)*getCardSize())+UIScreen.main.bounds.width/2+38*2
}else{
contentHeight = CGFloat(CGFloat(numberOfItems)*getCardSize())+UIScreen.main.bounds.width/2+38
}
layout.zIndex = index
layout.isHidden = false
cachedAttributes.append(layout)
}
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
var layoutAttributes = [UICollectionViewLayoutAttributes]()
for attributes in cachedAttributes {
if attributes.frame.intersects(rect) {
layoutAttributes.append(cachedAttributes[attributes.indexPath.item])
}
}
return layoutAttributes
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return cachedAttributes[indexPath.item]
}
func frameFor(index: Int) -> CGRect {
var frame = CGRect(origin: CGPoint(x: CGFloat(8), y:0), size: CGSize(width: UIScreen.main.bounds.width - CGFloat(8 + 8), height: CGFloat(UIScreen.main.bounds.width/2+38)))
var frameOrigin = frame.origin
if let indexExpand = FlowLayoutAttributes.shared.cellIndex{
if index > 0 {
if indexExpand < index {
let spacesHeight = CGFloat((getCardSize() * CGFloat(index)))+UIScreen.main.bounds.width/2+38-getCardSize()/2
frameOrigin.y = spacesHeight
}else{
frameOrigin.y = CGFloat((getCardSize() * CGFloat(index)))
}
}
}else{
if index > 0 {
frameOrigin.y = CGFloat((getCardSize() * CGFloat(index)))
}
}
frame.origin = frameOrigin
return frame
}
func getCardSize()-> CGFloat{
if UIDevice().userInterfaceIdiom == .phone {
switch UIScreen.main.nativeBounds.height {
case 1136:
print("iPhone 5 or 5S or 5C")
return 45.25
case 1334:
print("iPhone 6/6S/7/8")
return 45.25
case 1920, 2208:
print("iPhone 6+/6S+/7+/8+")
return 46
case 2436:
print("iPhone X/XS/11 Pro")
return 45.25
case 2688:
print("iPhone XS Max/11 Pro Max")
return 46
case 1792:
print("iPhone XR/ 11 ")
return 46
case 2532:
print("iPhone 12/ iPhone 12 Pro")
return 45.50
case 2778:
print("iPhone 12 Pro Max")
return 46.2
default:
return 46.2
}
}else{
return CGFloat(46.2)
}
}
}

UICollectionView horizontal paging layout

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.

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.

Resources