How to create a centered UICollectionView like in Spotify's Player

IosObjective CUiscrollviewUicollectionviewUicollectionviewlayout

Ios Problem Overview


I am have a lot of difficulty trying to create a UICollectionView like in Spotify's Player that acts like this:

a busy cat

The problem for me is two fold.

  1. How do I center the cells so that you can see the middle cell as well as the one of the left and right.
  • If I create cells that are square and add spacing between each cell, the cells are correctly displayed but are not centered.
  1. With pagingEnabled = YES, the collectionview correctly swipes from one page to another. However, without the cells being centered, it simply moves the collection view over a page which is the width of the screen. So the question is how do you make the pages move so you get the effect above.

  2. how do you animate the size of the cells as they move

  • I don't want to worry about this too much. If I can get that to work it would be great, but the harder problems are 1 and 2.

The code I have currently is a simple UICollectionView with normal delegate setup and custom UICollectionview cells that are squares. Maybe I neeed to subclass UICollectionViewFlowLayout? Or maybe I need to turn pagingEnabled to NO and then use custom swipe events? Would love any help!

Ios Solutions


Solution 1 - Ios

In order to create an horizontal carousel layout, you'll have to subclass UICollectionViewFlowLayout then override targetContentOffset(forProposedContentOffset:withScrollingVelocity:), layoutAttributesForElements(in:) and shouldInvalidateLayout(forBoundsChange:).

The following Swift 5 / iOS 12.2 complete code shows how to implement them.


CollectionViewController.swift

import UIKit

class CollectionViewController: UICollectionViewController {
    
    let collectionDataSource = CollectionDataSource()
    let flowLayout = ZoomAndSnapFlowLayout()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        title = "Zoomed & snapped cells"

        guard let collectionView = collectionView else { fatalError() }
        //collectionView.decelerationRate = .fast // uncomment if necessary
        collectionView.dataSource = collectionDataSource
        collectionView.collectionViewLayout = flowLayout
        collectionView.contentInsetAdjustmentBehavior = .always
        collectionView.register(CollectionViewCell.self, forCellWithReuseIdentifier: "Cell")
    }
    
}

ZoomAndSnapFlowLayout.swift

import UIKit

class ZoomAndSnapFlowLayout: UICollectionViewFlowLayout {
    
    let activeDistance: CGFloat = 200
    let zoomFactor: CGFloat = 0.3
    
    override init() {
        super.init()
        
        scrollDirection = .horizontal
        minimumLineSpacing = 40
        itemSize = CGSize(width: 150, height: 150)
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func prepare() {
        guard let collectionView = collectionView else { fatalError() }
        let verticalInsets = (collectionView.frame.height - collectionView.adjustedContentInset.top - collectionView.adjustedContentInset.bottom - itemSize.height) / 2
        let horizontalInsets = (collectionView.frame.width - collectionView.adjustedContentInset.right - collectionView.adjustedContentInset.left - itemSize.width) / 2
        sectionInset = UIEdgeInsets(top: verticalInsets, left: horizontalInsets, bottom: verticalInsets, right: horizontalInsets)
        
        super.prepare()
    }
    
    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.midX - attributes.center.x
            let normalizedDistance = distance / activeDistance
            
            if distance.magnitude < activeDistance {
                let zoom = 1 + zoomFactor * (1 - normalizedDistance.magnitude)
                attributes.transform3D = CATransform3DMakeScale(zoom, zoom, 1)
                attributes.zIndex = Int(zoom.rounded())
            }
        }
        
        return rectAttributes
    }
    
    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
        guard let collectionView = collectionView else { return .zero }
        
        // Add some snapping behaviour so that the zoomed cell is always centered
        let targetRect = CGRect(x: proposedContentOffset.x, y: 0, width: collectionView.frame.width, height: collectionView.frame.height)
        guard let rectAttributes = super.layoutAttributesForElements(in: targetRect) else { return .zero }
        
        var offsetAdjustment = CGFloat.greatestFiniteMagnitude
        let horizontalCenter = proposedContentOffset.x + collectionView.frame.width / 2
        
        for layoutAttributes in rectAttributes {
            let itemHorizontalCenter = layoutAttributes.center.x
            if (itemHorizontalCenter - horizontalCenter).magnitude < offsetAdjustment.magnitude {
                offsetAdjustment = itemHorizontalCenter - horizontalCenter
            }
        }
        
        return CGPoint(x: proposedContentOffset.x + offsetAdjustment, y: proposedContentOffset.y)
    }
    
    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
    }
    
}

CollectionDataSource.swift

import UIKit

class CollectionDataSource: NSObject, UICollectionViewDataSource {
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 9
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! CollectionViewCell
        return cell
    }
    
}

CollectionViewCell.swift

import UIKit

class CollectionViewCell: UICollectionViewCell {
    
    override init(frame: CGRect) {
        super.init(frame: frame)

        contentView.backgroundColor = .green
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
}

Expected result:

enter image description here


Source:

Solution 2 - Ios

Well, I made UICollectionview moving just like this, yesterday.

I can share my code with you :)

Here's my storyboard

make sure uncheck 'Paging Enabled'

Here's my code.

@interface FavoriteViewController () <UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout>
{
    NSMutableArray * mList;
    
    CGSize cellSize;
}

@property (weak, nonatomic) IBOutlet UICollectionView *cv;
@end

@implementation FavoriteViewController

- (void) viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];
    
    // to get a size.
    [self.view setNeedsLayout];
    [self.view layoutIfNeeded];
    
    CGRect screenFrame = [[UIScreen mainScreen] bounds];
    CGFloat width = screenFrame.size.width*self.cv.frame.size.height/screenFrame.size.height;
    cellSize = CGSizeMake(width, self.cv.frame.size.height);
    // if cell's height is exactly same with collection view's height, you get an warning message.
    cellSize.height -= 1;
    
    [self.cv reloadData];

    // setAlpha is for hiding looking-weird at first load
    [self.cv setAlpha:0];
}

- (void) viewDidAppear:(BOOL)animated
{
    [super viewDidAppear:animated];
    
    [self scrollViewDidScroll:self.cv];
    [self.cv setAlpha:1];
}

#pragma mark - scrollview delegate
- (void) scrollViewDidScroll:(UIScrollView *)scrollView
{
    if(mList.count > 0)
    {
        const CGFloat centerX = self.cv.center.x;
        for(UICollectionViewCell * cell in [self.cv visibleCells])
        {
            CGPoint pos = [cell convertPoint:CGPointZero toView:self.view];
            pos.x += cellSize.width/2.0f;
            CGFloat distance = fabs(centerX - pos.x);
            
// If you want to make side-cell's scale bigger or smaller,
// change the value of '0.1f'
            CGFloat scale = 1.0f - (distance/centerX)*0.1f;
            [cell setTransform:CGAffineTransformMakeScale(scale, scale)];
        }
    }
}

- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset
{ // for custom paging
    CGFloat movingX = velocity.x * scrollView.frame.size.width;
    CGFloat newOffsetX = scrollView.contentOffset.x + movingX;
    
    if(newOffsetX < 0)
    {
        newOffsetX = 0;
    }
    else if(newOffsetX > cellSize.width * (mList.count-1))
    {
        newOffsetX = cellSize.width * (mList.count-1);
    }
    else
    {
        NSUInteger newPage = newOffsetX/cellSize.width + ((int)newOffsetX%(int)cellSize.width > cellSize.width/2.0f ? 1 : 0);
        newOffsetX = newPage*cellSize.width;
    }
    
    targetContentOffset->x = newOffsetX;
}

#pragma mark - collectionview delegate
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section
{
    return mList.count;
}
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
    UICollectionViewCell * cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"list" forIndexPath:indexPath];
    
    NSDictionary * dic = mList[indexPath.row];
    
    UIImageView * iv = (UIImageView *)[cell.contentView viewWithTag:1];
    UIImage * img = [UIImage imageWithData:[dic objectForKey:kKeyImg]];
    [iv setImage:img];
    
    return cell;
}

- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath
{
    return cellSize;
}
- (UIEdgeInsets)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout insetForSectionAtIndex:(NSInteger)section
{
    CGFloat gap = (self.cv.frame.size.width - cellSize.width)/2.0f;
    return UIEdgeInsetsMake(0, gap, 0, gap);
}
- (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout minimumLineSpacingForSectionAtIndex:(NSInteger)section
{
    return 0;
}
- (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout minimumInteritemSpacingForSectionAtIndex:(NSInteger)section
{
    return 0;
}

Key code of make cell centered is

  1. scrollViewWillEndDragging

  2. insetForSectionAtIndex

Key code of animate the size is

  1. scrollviewDidScroll

I wish this helps you

P.S. If you want to change alpha just like the image that you uploaded, add [cell setalpha] in scrollViewDidScroll

Solution 3 - Ios

As you have said in the comment you want that in the Objective-c code, there is a very famous library called iCarousel which can be helpful in completing your requirement.Link: https://github.com/nicklockwood/iCarousel

You may use 'Rotary' or 'Linear' or some other style with little or no modification to implement the custom view

To implement it you have implement only some delegate methods of it and it's working for ex:

//specify the type you want to use in viewDidLoad
_carousel.type = iCarouselTypeRotary;

//Set the following delegate methods
- (NSInteger)numberOfItemsInCarousel:(iCarousel *)carousel
{
    //return the total number of items in the carousel
    return [_items count];
}

- (UIView *)carousel:(iCarousel *)carousel viewForItemAtIndex:(NSInteger)index reusingView:(UIView *)view
{
    UILabel *label = nil;
    
    //create new view if no view is available for recycling
    if (view == nil)
    {
        //don't do anything specific to the index within
        //this `if (view == nil) {...}` statement because the view will be
        //recycled and used with other index values later
        view = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 200.0f, 200.0f)];
        ((UIImageView *)view).image = [UIImage imageNamed:@"page.png"];
        view.contentMode = UIViewContentModeCenter;
        
        label = [[UILabel alloc] initWithFrame:view.bounds];
        label.backgroundColor = [UIColor clearColor];
        label.textAlignment = NSTextAlignmentCenter;
        label.font = [label.font fontWithSize:50];
        label.tag = 1;
        [view addSubview:label];
    }
    else
    {
        //get a reference to the label in the recycled view
        label = (UILabel *)[view viewWithTag:1];
    }
    
    //set item label
    label.text = [_items[index] stringValue];
    
    return view;
}

- (CGFloat)carousel:(iCarousel *)carousel valueForOption:(iCarouselOption)option withDefault:(CGFloat)value
{
    if (option == iCarouselOptionSpacing)
    {
        return value * 1.1;
    }
    return value;
}

You can check the full working demo from 'Examples/Basic iOS Example' which is included with the Github repository link

As it is old and popular you can find some related tutorials for it and it will also be much stable than the custom code implementation

Solution 4 - Ios

I wanted similar behavior a little while back, and with the help of @Mike_M I was able to figure it out. Although there are many, many way to do this, this particular implementation is to create a custom UICollectionViewLayout.

Code below(gist can be found here: https://gist.github.com/mmick66/9812223)

Now it's important to set the following: *yourCollectionView*.decelerationRate = UIScrollViewDecelerationRateFast, this prevents cells being skipped by a quick swipe.

That should cover part 1 and 2. Now, for part 3 you could incorporate that in the custom collectionView by constantly invalidating and updating, but it's a bit of a hassle if you ask me. So another approach would be to to set a CGAffineTransformMakeScale( , ) in the UIScrollViewDidScroll where you dynamically update the cell's size based on it's distance from the center of the screen.

You can get the indexPaths of the visible cells of the collectionView using [*youCollectionView indexPathsForVisibleItems] and then getting the cells for these indexPaths. For every cell, calculate the distance of its center to the center of yourCollectionView

The center of the collectionView can be found using this nifty method: CGPoint point = [self.view convertPoint:*yourCollectionView*.center toView:*yourCollectionView];

Now set up a rule, that if the cell's center is further than x away, the size of the cell is for example the 'normal size', call it 1. and the closer it gets to the center, the closer it gets to twice the normal size 2.

then you can use the following if/else idea:

 if (distance > x) {
        cell.transform = CGAffineTransformMakeScale(1.0f, 1.0f);
 } else if (distance <= x) {
        
        float scale = MIN(distance/x) * 2.0f;
        cell.transform = CGAffineTransformMakeScale(scale, scale);
 }
    

What happens is that the cell's size will exactly follow your touch. Let me know if you have any more questions as I'm writing most of this out of the top of my head).

- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)offset 
                             withScrollingVelocity:(CGPoint)velocity {

CGRect cvBounds = self.collectionView.bounds;
CGFloat halfWidth = cvBounds.size.width * 0.5f;
CGFloat proposedContentOffsetCenterX = offset.x + halfWidth;

NSArray* attributesArray = [self layoutAttributesForElementsInRect:cvBounds];

UICollectionViewLayoutAttributes* candidateAttributes;
for (UICollectionViewLayoutAttributes* attributes in attributesArray) {

    // == Skip comparison with non-cell items (headers and footers) == //
    if (attributes.representedElementCategory != 
        UICollectionElementCategoryCell) {
        continue;
    }

    // == First time in the loop == //
    if(!candidateAttributes) {
        candidateAttributes = attributes;
        continue;
    }

    if (fabsf(attributes.center.x - proposedContentOffsetCenterX) < 
        fabsf(candidateAttributes.center.x - proposedContentOffsetCenterX)) {
        candidateAttributes = attributes;
    }
}

return CGPointMake(candidateAttributes.center.x - halfWidth, offset.y);

}

Solution 5 - Ios

pagingEnabled should not be enabled as it needs each cell to be the width of you view which will not work for you since you need to see the edges of other cells. For your points 1 and 2. I think you'll find what you need here from one of my late answers to another question.

The animation of the cell sizes can be achieved by subclassing UIcollectionviewFlowLayout and overriding layoutAttributesForItemAtIndexPath: Within that modify the layout attributes provided by first calling super and then modify the layout attributes size based on the position as it relates to the window centre.

Hopefully this helps.

Solution 6 - Ios

If you want to have uniform spacing between cells you can replace the following method in ZoomAndSnapFlowLayout from Imanou Petit's solution:

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)
    let visibleAttributes = rectAttributes.filter { $0.frame.intersects(visibleRect) }

    // Keep the spacing between cells the same.
    // Each cell shifts the next cell by half of it's enlarged size.
    // Calculated separately for each direction.
    func adjustXPosition(_ toProcess: [UICollectionViewLayoutAttributes], direction: CGFloat, zoom: Bool = false) {
        var dx: CGFloat = 0

        for attributes in toProcess {
            let distance = visibleRect.midX - attributes.center.x
            attributes.frame.origin.x += dx

            if distance.magnitude < activeDistance {
                let normalizedDistance = distance / activeDistance
                let zoomAddition = zoomFactor * (1 - normalizedDistance.magnitude)
                let widthAddition = attributes.frame.width * zoomAddition / 2
                dx = dx + widthAddition * direction

                if zoom {
                    let scale = 1 + zoomAddition
                    attributes.transform3D = CATransform3DMakeScale(scale, scale, 1)
                }
            }
        }
    }

    // Adjust the x position first from left to right.
    // Then adjust the x position from right to left.
    // Lastly zoom the cells when they reach the center of the screen (zoom: true).
    adjustXPosition(visibleAttributes, direction: +1)
    adjustXPosition(visibleAttributes.reversed(), direction: -1, zoom: true)

    return rectAttributes
}

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionevenoddView Question on Stackoverflow
Solution 1 - IosImanou PetitView Answer on Stackoverflow
Solution 2 - IosnegaiproView Answer on Stackoverflow
Solution 3 - IosHardikDGView Answer on Stackoverflow
Solution 4 - IostrdavidsonView Answer on Stackoverflow
Solution 5 - IosDallas JohnsonView Answer on Stackoverflow
Solution 6 - IosMarián ČernýView Answer on Stackoverflow