44

This is related to but distinct from To use Flow Layout, or to Customize?.

Here is an illustration of what I’m trying to do: Illustration of what I’m trying to do

I’m wondering if I can do this with a UICollectionViewFlowLayout, a subclass thereof, or if I need to create a completely custom layout? Based on the WWDC 2012 videos on UICollectionView, it appears that if you use Flow Layout with vertical scrolling, your layout lines are horizontal, and if you scroll horizontally, your layout lines are vertical. I want horizontal layout lines in a horizontally-scrolling collection view.

I also don’t have any inherent sections in my model - this is just a single set of items. I could group them into sections, but the collection view is resizable, so the number of items that can fit on a page would change sometimes, and it seems like the choice of which page each item goes on is better left to the layout than to the model if I don’t have any meaningful sections.

So, can I do this with Flow Layout, or do I need to create a custom layout?

8 Answers 8

35

Here I share my simple implementation!

The .h file:

/** 
 * CollectionViewLayout for an horizontal flow type:
 *
 *  |   0   1   |   6   7   |
 *  |   2   3   |   8   9   |   ----> etc...
 *  |   4   5   |   10  11  |
 *
 * Only supports 1 section and no headers, footers or decorator views.
 */
@interface HorizontalCollectionViewLayout : UICollectionViewLayout

@property (nonatomic, assign) CGSize itemSize;

@end

The .m file:

@implementation HorizontalCollectionViewLayout
{
    NSInteger _cellCount;
    CGSize _boundsSize;
}

- (void)prepareLayout
{
    // Get the number of cells and the bounds size
    _cellCount = [self.collectionView numberOfItemsInSection:0];
    _boundsSize = self.collectionView.bounds.size;
}

- (CGSize)collectionViewContentSize
{
    // We should return the content size. Lets do some math:

    NSInteger verticalItemsCount = (NSInteger)floorf(_boundsSize.height / _itemSize.height);
    NSInteger horizontalItemsCount = (NSInteger)floorf(_boundsSize.width / _itemSize.width);

    NSInteger itemsPerPage = verticalItemsCount * horizontalItemsCount;
    NSInteger numberOfItems = _cellCount;
    NSInteger numberOfPages = (NSInteger)ceilf((CGFloat)numberOfItems / (CGFloat)itemsPerPage);

    CGSize size = _boundsSize;
    size.width = numberOfPages * _boundsSize.width;
    return size;
}

- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect
{
    // This method requires to return the attributes of those cells that intsersect with the given rect.
    // In this implementation we just return all the attributes.
    // In a better implementation we could compute only those attributes that intersect with the given rect.

    NSMutableArray *allAttributes = [NSMutableArray arrayWithCapacity:_cellCount];

    for (NSUInteger i=0; i<_cellCount; ++i)
    {
        NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0];
        UICollectionViewLayoutAttributes *attr = [self _layoutForAttributesForCellAtIndexPath:indexPath];

        [allAttributes addObject:attr];
    }

    return allAttributes;
}

- (UICollectionViewLayoutAttributes*)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath
{
    return [self _layoutForAttributesForCellAtIndexPath:indexPath];
}

- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds
{
    // We should do some math here, but we are lazy.
    return YES;
}

- (UICollectionViewLayoutAttributes*)_layoutForAttributesForCellAtIndexPath:(NSIndexPath*)indexPath
{
    // Here we have the magic of the layout.

    NSInteger row = indexPath.row;

    CGRect bounds = self.collectionView.bounds;
    CGSize itemSize = self.itemSize;

    // Get some info:
    NSInteger verticalItemsCount = (NSInteger)floorf(bounds.size.height / itemSize.height);
    NSInteger horizontalItemsCount = (NSInteger)floorf(bounds.size.width / itemSize.width);
    NSInteger itemsPerPage = verticalItemsCount * horizontalItemsCount;

    // Compute the column & row position, as well as the page of the cell.
    NSInteger columnPosition = row%horizontalItemsCount;
    NSInteger rowPosition = (row/horizontalItemsCount)%verticalItemsCount;
    NSInteger itemPage = floorf(row/itemsPerPage);

    // Creating an empty attribute
    UICollectionViewLayoutAttributes *attr = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];

    CGRect frame = CGRectZero;

    // And finally, we assign the positions of the cells
    frame.origin.x = itemPage * bounds.size.width + columnPosition * itemSize.width;
    frame.origin.y = rowPosition * itemSize.height;
    frame.size = _itemSize;

    attr.frame = frame;

    return attr;
}

#pragma mark Properties

- (void)setItemSize:(CGSize)itemSize
{
    _itemSize = itemSize;
    [self invalidateLayout];
}

@end

And finally, if you want a paginated behaviour, you just need to configure your UICollectionView:

_collectionView.pagingEnabled = YES;

Hoping to be useful enough.

5
  • Why the _layoutForAttributesForCellAtIndexPath: method? Underscored-prefixed methods are reserved by Apple, and besides, all you’re doing is forwarding from layoutAttributesForItemAtIndexPath:. Jan 17, 2014 at 15:21
  • I usually use in private methods my custom prefix (2 letters) followed by an underscore: XX_myPrivateMethod. When copy-pasting it, I removed the prefixes of the class name and the private methods. I thought none would complain, and I see it is not ;)
    – vilanovi
    Jan 17, 2014 at 16:04
  • 2
    But why use a private method at all? Why not just do all that work in -layoutAttributesForItemAtIndexPath:? Jan 17, 2014 at 16:22
  • Yep, we could just do it that way.
    – vilanovi
    Jan 24, 2014 at 11:19
  • How do i make this support more than one sections? Apr 10, 2016 at 23:07
14

Converted vilanovi code to Swift in case someone, needs it in the future.

public class HorizontalCollectionViewLayout : UICollectionViewLayout {
private var cellWidth = 90 // Don't kow how to get cell size dynamically
private var cellHeight = 90

public override func prepareLayout() {
}

public override func collectionViewContentSize() -> CGSize {
    let numberOfPages = Int(ceilf(Float(cellCount) / Float(cellsPerPage)))
    let width = numberOfPages * Int(boundsWidth)
    return CGSize(width: CGFloat(width), height: boundsHeight)
}

public override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? {
    var allAttributes = [UICollectionViewLayoutAttributes]()

    for (var i = 0; i < cellCount; ++i) {
        let indexPath = NSIndexPath(forRow: i, inSection: 0)
        let attr = createLayoutAttributesForCellAtIndexPath(indexPath)
        allAttributes.append(attr)
    }

    return allAttributes
}

public override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes! {
    return createLayoutAttributesForCellAtIndexPath(indexPath)
}

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

private func createLayoutAttributesForCellAtIndexPath(indexPath:NSIndexPath)
    -> UICollectionViewLayoutAttributes {
        let layoutAttributes = UICollectionViewLayoutAttributes(forCellWithIndexPath: indexPath)
        layoutAttributes.frame = createCellAttributeFrame(indexPath.row)
        return layoutAttributes
}

private var boundsWidth:CGFloat {
    return self.collectionView!.bounds.size.width
}

private var boundsHeight:CGFloat {
    return self.collectionView!.bounds.size.height
}

private var cellCount:Int {
    return self.collectionView!.numberOfItemsInSection(0)
}

private var verticalCellCount:Int {
    return Int(floorf(Float(boundsHeight) / Float(cellHeight)))
}

private var horizontalCellCount:Int {
    return Int(floorf(Float(boundsWidth) / Float(cellWidth)))
}

private var cellsPerPage:Int {
    return verticalCellCount * horizontalCellCount
}

private func createCellAttributeFrame(row:Int) -> CGRect {
    let frameSize = CGSize(width:cellWidth, height: cellHeight )
    let frameX = calculateCellFrameHorizontalPosition(row)
    let frameY = calculateCellFrameVerticalPosition(row)
    return CGRectMake(frameX, frameY, frameSize.width, frameSize.height)
}

private func calculateCellFrameHorizontalPosition(row:Int) -> CGFloat {
    let columnPosition = row % horizontalCellCount
    let cellPage = Int(floorf(Float(row) / Float(cellsPerPage)))
    return CGFloat(cellPage * Int(boundsWidth) + columnPosition * Int(cellWidth))
}

private func calculateCellFrameVerticalPosition(row:Int) -> CGFloat {
    let rowPosition = (row / horizontalCellCount) % verticalCellCount
    return CGFloat(rowPosition * Int(cellHeight))
}

}

2
  • let frameSize = CGSize(width: cellHeight, height: cellWidth) oops
    – Mijail
    May 5, 2015 at 11:19
  • Thanks for the swift conversion ;)
    – vilanovi
    May 28, 2015 at 14:59
12

You're right – that's not how a stock horizontally-scrolling collection view lays out cells. I'm afraid that you're going to have to implement your own custom UICollectionViewLayout subclass. Either that, or separate your models into sections.

1
3

The previous above implementation was not complete, buggy, and with fixed cell size. Here's a more literal translation for the code:

import UIKit

class HorizontalFlowLayout: UICollectionViewLayout {
    var itemSize = CGSizeZero {
        didSet {
            invalidateLayout()
        }
    }
    private var cellCount = 0
    private var boundsSize = CGSizeZero

    override func prepareLayout() {
        cellCount = self.collectionView!.numberOfItemsInSection(0)
        boundsSize = self.collectionView!.bounds.size
    }

    override func collectionViewContentSize() -> CGSize {
        let verticalItemsCount = Int(floor(boundsSize.height / itemSize.height))
        let horizontalItemsCount = Int(floor(boundsSize.width / itemSize.width))

        let itemsPerPage = verticalItemsCount * horizontalItemsCount
        let numberOfItems = cellCount
        let numberOfPages = Int(ceil(Double(numberOfItems) / Double(itemsPerPage)))

        var size = boundsSize
        size.width = CGFloat(numberOfPages) * boundsSize.width
        return size
    }

    override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        var allAttributes = [UICollectionViewLayoutAttributes]()
        for var i = 0; i < cellCount; i++ {
            let indexPath = NSIndexPath(forRow: i, inSection: 0)
            let attr = self.computeLayoutAttributesForCellAtIndexPath(indexPath)
            allAttributes.append(attr)
        }
        return allAttributes
    }

    override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes? {
        return self.computeLayoutAttributesForCellAtIndexPath(indexPath)
    }

    override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool {
        return true
    }

    func computeLayoutAttributesForCellAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes {
        let row = indexPath.row
        let bounds = self.collectionView!.bounds

        let verticalItemsCount = Int(floor(boundsSize.height / itemSize.height))
        let horizontalItemsCount = Int(floor(boundsSize.width / itemSize.width))
        let itemsPerPage = verticalItemsCount * horizontalItemsCount

        let columnPosition = row % horizontalItemsCount
        let rowPosition = (row/horizontalItemsCount)%verticalItemsCount
        let itemPage = Int(floor(Double(row)/Double(itemsPerPage)))

        let attr = UICollectionViewLayoutAttributes(forCellWithIndexPath: indexPath)

        var frame = CGRectZero
        frame.origin.x = CGFloat(itemPage) * bounds.size.width + CGFloat(columnPosition) * itemSize.width
        frame.origin.y = CGFloat(rowPosition) * itemSize.height
        frame.size = itemSize
        attr.frame = frame

        return attr
    }
}
3
  • Does this work for you? I tried this and its rendering the first section ok. But other sections are not being rendered Apr 10, 2016 at 22:09
  • Yeah it does, you can see it working in this example: github.com/sprint84/TabbedCollectionView
    – Gui Moura
    Apr 12, 2016 at 22:17
  • @GuilhermeSprint It might be helpful to show how to add this layout to a collection view and avoid common errors like returning 0 sections and making sure to set the itemSize.
    – Wez
    Oct 13, 2016 at 9:40
3

Swift 4

Code:

public class HorizontalFlowLayout: UICollectionViewLayout {
    var itemSize = CGSize(width: 0, height: 0) {
        didSet {
            invalidateLayout()
        }
    }
    private var cellCount = 0
    private var boundsSize = CGSize(width: 0, height: 0)

    public override func prepare() {
        cellCount = self.collectionView!.numberOfItems(inSection: 0)
        boundsSize = self.collectionView!.bounds.size
    }
    public override var collectionViewContentSize: CGSize {
        let verticalItemsCount = Int(floor(boundsSize.height / itemSize.height))
        let horizontalItemsCount = Int(floor(boundsSize.width / itemSize.width))

        let itemsPerPage = verticalItemsCount * horizontalItemsCount
    let numberOfItems = cellCount
    let numberOfPages = Int(ceil(Double(numberOfItems) / Double(itemsPerPage)))

        var size = boundsSize
        size.width = CGFloat(numberOfPages) * boundsSize.width
        return size
    }

    public override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        var allAttributes = [UICollectionViewLayoutAttributes]()
        for i in 0...(cellCount-1) {
            let indexPath = IndexPath(row: i, section: 0)
            let attr = self.computeLayoutAttributesForCellAt(indexPath: indexPath)
            allAttributes.append(attr)
        }
        return allAttributes
    }

    public override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        return computeLayoutAttributesForCellAt(indexPath: indexPath)
    }

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

    private func computeLayoutAttributesForCellAt(indexPath:IndexPath)
        -> UICollectionViewLayoutAttributes {
            let row = indexPath.row
            let bounds = self.collectionView!.bounds

            let verticalItemsCount = Int(floor(boundsSize.height / itemSize.height))
            let horizontalItemsCount = Int(floor(boundsSize.width / itemSize.width))
            let itemsPerPage = verticalItemsCount * horizontalItemsCount

            let columnPosition = row % horizontalItemsCount
            let rowPosition = (row/horizontalItemsCount)%verticalItemsCount
            let itemPage = Int(floor(Double(row)/Double(itemsPerPage)))

            let attr = UICollectionViewLayoutAttributes(forCellWith: indexPath)

            var frame = CGRect(x: 0, y: 0, width: 0, height: 0)
            frame.origin.x = CGFloat(itemPage) * bounds.size.width + CGFloat(columnPosition) * itemSize.width
            frame.origin.y = CGFloat(rowPosition) * itemSize.height
            frame.size = itemSize
            attr.frame = frame

            return attr
    }
}

This is the Swift 3 version of @GuilhermeSprint answer

Code:

public class HorizontalCollectionViewLayout : UICollectionViewLayout {
    var itemSize = CGSize(width: 0, height: 0) {
        didSet {
            invalidateLayout()
        }
    }
    private var cellCount = 0
    private var boundsSize = CGSize(width: 0, height: 0)

    public override func prepare() {
        cellCount = self.collectionView!.numberOfItems(inSection: 0)
        boundsSize = self.collectionView!.bounds.size
    }
    public override var collectionViewContentSize: CGSize {
        let verticalItemsCount = Int(floor(boundsSize.height / itemSize.height))
        let horizontalItemsCount = Int(floor(boundsSize.width / itemSize.width))

        let itemsPerPage = verticalItemsCount * horizontalItemsCount
        let numberOfItems = cellCount
        let numberOfPages = Int(ceil(Double(numberOfItems) / Double(itemsPerPage)))

        var size = boundsSize
        size.width = CGFloat(numberOfPages) * boundsSize.width
        return size
    }

    public override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        var allAttributes = [UICollectionViewLayoutAttributes]()
        for i in 0...(cellCount-1) {
            let indexPath = IndexPath(row: i, section: 0)
            let attr = self.computeLayoutAttributesForCellAt(indexPath: indexPath)
            allAttributes.append(attr)
        }
        return allAttributes
    }

    public override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        return computeLayoutAttributesForCellAt(indexPath: indexPath)
    }

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

    private func computeLayoutAttributesForCellAt(indexPath:IndexPath)
        -> UICollectionViewLayoutAttributes {
            let row = indexPath.row
            let bounds = self.collectionView!.bounds

            let verticalItemsCount = Int(floor(boundsSize.height / itemSize.height))
            let horizontalItemsCount = Int(floor(boundsSize.width / itemSize.width))
            let itemsPerPage = verticalItemsCount * horizontalItemsCount

            let columnPosition = row % horizontalItemsCount
            let rowPosition = (row/horizontalItemsCount)%verticalItemsCount
            let itemPage = Int(floor(Double(row)/Double(itemsPerPage)))

            let attr = UICollectionViewLayoutAttributes(forCellWith: indexPath)

            var frame = CGRectMake(0, 0, 0, 0)
            frame.origin.x = CGFloat(itemPage) * bounds.size.width + CGFloat(columnPosition) * itemSize.width
            frame.origin.y = CGFloat(rowPosition) * itemSize.height
            frame.size = itemSize
            attr.frame = frame

            return attr
    }
}

Usage:

    // I want to have 4 items in the page / see screenshot below
    let itemWidth = collectionView.frame.width / 2.0
    let itemHeight = collectionView.frame.height / 2.0
    let horizontalCV = HorizontalCollectionViewLayout();
    horizontalCV.itemSize = CGSize(width: itemWidth, height: itemHeight)
    collectionView.collectionViewLayout = horizontalCV

Result

screenshot

My Delegates extension if you wanna check it also

extension MyViewController : UICollectionViewDelegateFlowLayout, UICollectionViewDataSource{
    // Delegate
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        print("Clicked")
    }

    // DataSource
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 1
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: BottomMenuCCell.xib, for: indexPath) as? BottomMenuCCell {
            cell.ibi = bottomMenuButtons[indexPath.row]
            cell.layer.borderWidth = 0
            return cell
        }
        return BaseCollectionCell()
    }
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return CGSize.init(width: (collectionView.width / 2.0), height: collectionView.height / 2.0)
    }
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return bottomMenuButtons.count
    }

    // removing spacing between items
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
        return UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
    }
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
        return 0.0
    }
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        return 0.0
    }
}
1
0

Can simply change Scroll Direction in UICollectionView.xib to Horizontal. And use with the correct order of elements in the array.

1
  • Interesting idea - reorder the elements in the array so they lay out correctly? I'd rather keep the data source (the array) pure, and save the layout order for the view/presentation layer of the app, so I would be more likely to go with an answer like this one by vilanovi. Mar 16, 2015 at 17:03
-1

Your last resort, of course, would be to use multiple vertically collection views inside each section in an outer horizontally scrolling collection view. Apart from increasing code complexity and difficulty in performing inter-section cell animations, I can't think of major issues with this approach right off my head.

-1
@interface HorizontalCollectionViewLayout : UICollectionViewFlowLayout

@end

@implementation HorizontalCollectionViewLayout

- (instancetype)init
{
    self = [super init];
    if (self) {
        self.scrollDirection = UICollectionViewScrollDirectionHorizontal;
        self.minimumLineSpacing = 0;
        self.minimumInteritemSpacing = 0;
    }
    return self;
}

- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect
{
    NSArray *attributesArray = [super layoutAttributesForElementsInRect:rect];

    NSInteger verticalItemsCount = (NSInteger)floorf(self.collectionView.bounds.size.height / self.itemSize.height);
    NSInteger horizontalItemsCount = (NSInteger)floorf(self.collectionView.bounds.size.width / self.itemSize.width);
    NSInteger itemsPerPage = verticalItemsCount * horizontalItemsCount;

    for (NSInteger i = 0; i < attributesArray.count; i++) {
        UICollectionViewLayoutAttributes *attributes = attributesArray[i];
        NSInteger currentPage = (NSInteger)floor((double)i / (double)itemsPerPage);
        NSInteger currentRow = (NSInteger)floor((double)(i - currentPage * itemsPerPage) / (double)horizontalItemsCount);
        NSInteger currentColumn = i % horizontalItemsCount;
        CGRect frame = attributes.frame;
        frame.origin.x = self.itemSize.width * currentColumn + currentPage * self.collectionView.bounds.size.width;
        frame.origin.y = self.itemSize.height * currentRow;
        attributes.frame = frame;
    }
    return attributesArray;
}

- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds
{
    return YES;
}

@end
1
  • This solution is almost good. However there is a bug past the 3rd page, when the collection view layout stops passing the first items, and pass only those that matter for the visible pages
    – Gui Moura
    Dec 8, 2015 at 20:33

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.