Left Align Cells in UICollectionView
I am using a UICollectionView in my project, where there are multiple cells of differing widths on a line. According to: https://developer.apple.com/library/content/documentation/WindowsViews/Conceptual/CollectionViewPGforIOS/UsingtheFlowLayout/UsingtheFlowLayout.html
it spreads the cells out across the line with equal padding. This happens as expected, except I want to left justify them, and hard code a padding width.
I figure I need to subclass UICollectionViewFlowLayout, however after reading some of the tutorials etc online I just don't seem to get how this works.
Solution 1:
The other solutions in this thread do not work properly, when the line is composed by only 1 item or are over complicated.
Based on the example given by Ryan, I changed the code to detect a new line by inspecting the Y position of the new element. Very simple and quick in performance.
Swift:
class LeftAlignedCollectionViewFlowLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let attributes = super.layoutAttributesForElements(in: rect)
var leftMargin = sectionInset.left
var maxY: CGFloat = -1.0
attributes?.forEach { layoutAttribute in
if layoutAttribute.frame.origin.y >= maxY {
leftMargin = sectionInset.left
}
layoutAttribute.frame.origin.x = leftMargin
leftMargin += layoutAttribute.frame.width + minimumInteritemSpacing
maxY = max(layoutAttribute.frame.maxY , maxY)
}
return attributes
}
}
If you want to have supplementary views keep their size, add the following at the top of the closure in the forEach
call:
guard layoutAttribute.representedElementCategory == .cell else {
return
}
Objective-C:
- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect {
NSArray *attributes = [super layoutAttributesForElementsInRect:rect];
CGFloat leftMargin = self.sectionInset.left; //initalized to silence compiler, and actaully safer, but not planning to use.
CGFloat maxY = -1.0f;
//this loop assumes attributes are in IndexPath order
for (UICollectionViewLayoutAttributes *attribute in attributes) {
if (attribute.frame.origin.y >= maxY) {
leftMargin = self.sectionInset.left;
}
attribute.frame = CGRectMake(leftMargin, attribute.frame.origin.y, attribute.frame.size.width, attribute.frame.size.height);
leftMargin += attribute.frame.size.width + self.minimumInteritemSpacing;
maxY = MAX(CGRectGetMaxY(attribute.frame), maxY);
}
return attributes;
}
Solution 2:
There are many great ideas included in the answers to this question. However, most of them have some drawbacks:
-
Solutions that don't check the cell's y value only work for single-line layouts. They fail for collection view layouts with multiple lines.
-
Solutions that do check the y value like Angel García Olloqui's answer only work if all cells have the same height. They fail for cells with a variable height.
-
Most solutions only override the
layoutAttributesForElements(in rect: CGRect)
function. They do not overridelayoutAttributesForItem(at indexPath: IndexPath)
. This is a problem because the collection view periodically calls the latter function to retrieve the layout attributes for a particular index path. If you don't return the proper attributes from that function, you're likely to run into all sort of visual bugs, e.g. during insertion and deletion animations of cells or when using self-sizing cells by setting the collection view layout'sestimatedItemSize
. The Apple docs state:Every custom layout object is expected to implement the
layoutAttributesForItemAtIndexPath:
method. -
Many solutions also make assumptions about the
rect
parameter that is passed to thelayoutAttributesForElements(in rect: CGRect)
function. For example, many are based on the assumption that therect
always starts at the beginning of a new line which is not necessarily the case.
So in other words:
Most of the solutions suggested on this page work for some specific applications, but they don't work as expected in every situation.
AlignedCollectionViewFlowLayout
In order to address these issues I've created a UICollectionViewFlowLayout
subclass that follows a similar idea as suggested by matt and Chris Wagner in their answers to a similar question. It can either align the cells
⬅︎ left:
or ➡︎ right:
and additionally offers options to vertically align the cells in their respective rows (in case they vary in height).
You can simply download it here:
https://github.com/mischa-hildebrand/AlignedCollectionViewFlowLayout
The usage is straight-forward and explained in the README file. You basically create an instance of AlignedCollectionViewFlowLayout
, specify the desired alignment and assign it to your collection view's collectionViewLayout
property:
let alignedFlowLayout = AlignedCollectionViewFlowLayout(
horizontalAlignment: .left,
verticalAlignment: .top
)
yourCollectionView.collectionViewLayout = alignedFlowLayout
(It's also available on Cocoapods.)
How it works (for left-aligned cells):
The concept here is to rely solely on the layoutAttributesForItem(at indexPath: IndexPath)
function. In the layoutAttributesForElements(in rect: CGRect)
we simply get the index paths of all cells within the rect
and then call the first function for every index path to retrieve the correct frames:
override public func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
// We may not change the original layout attributes
// or UICollectionViewFlowLayout might complain.
let layoutAttributesObjects = copy(
super.layoutAttributesForElements(in: rect)
)
layoutAttributesObjects?.forEach({ (layoutAttributes) in
if layoutAttributes.representedElementCategory == .cell { // Do not modify header views etc.
let indexPath = layoutAttributes.indexPath
// Retrieve the correct frame from layoutAttributesForItem(at: indexPath):
if let newFrame = layoutAttributesForItem(at: indexPath)?.frame {
layoutAttributes.frame = newFrame
}
}
})
return layoutAttributesObjects
}
(The copy()
function simply creates a deep copy of all layout attributes in the array. You may look into the source code for its implementation.)
So now the only thing we have to do is to implement the layoutAttributesForItem(at indexPath: IndexPath)
function properly. The super class UICollectionViewFlowLayout
already puts the correct number of cells in each line so we only have to shift them left within their respective row. The difficulty lies in computing the amount of space we need to shift each cell to the left.
As we want to have a fixed spacing between the cells the core idea is to just assume that the previous cell (the cell left of the cell that is currently laid out) is already positioned properly. Then we only have to add the cell spacing to the maxX
value of the previous cell's frame and that's the origin.x
value for the current cell's frame.
Now we only need to know when we've reached the beginning of a line, so that we don't align a cell next to a cell in the previous line. (This would not only result in an incorrect layout but it would also be extremely laggy.) So we need to have a recursion anchor. The approach I use for finding that recursion anchor is the following:
To find out if the cell at index i is in the same line as the cell with index i-1 ...
+---------+----------------------------------------------------------------+---------+
| | | |
| | +------------+ | |
| | | | | |
| section |- - -|- - - - - - |- - - - +---------------------+ - - - - - - -| section |
| inset | |intersection| | | line rect | inset |
| |- - -|- - - - - - |- - - - +---------------------+ - - - - - - -| |
| (left) | | | current item | (right) |
| | +------------+ | |
| | previous item | |
+---------+----------------------------------------------------------------+---------+
... I "draw" a rectangle around the current cell and stretch it over the width of the whole collection view. As the UICollectionViewFlowLayout
centers all cells vertically every cell in the same line must intersect with this rectangle.
Thus, I simply check if the cell with index i-1 intersects with this line rectangle created from the cell with index i.
-
If it does intersect, the cell with index i is not the left most cell in the line.
→ Get the previous cell's frame (with the index i−1) and move the current cell next to it. -
If it does not intersect, the cell with index i is the left most cell in the line.
→ Move the cell to the left edge of the collection view (without changing its vertical position).
I won't post the actual implementation of the layoutAttributesForItem(at indexPath: IndexPath)
function here because I think the most important part is to understand the idea and you can always check my implementation in the source code. (It's a little more complicated than explained here because I also allow .right
alignment and various vertical alignment options. However, it follows the same idea.)
Wow, I guess this is the longest answer I've ever written on Stackoverflow. I hope this helps. 😉
Solution 3:
The simple solution in 2019
This is one of those depressing questions where things have changed a lot over the years. It is now easy.
Basically you just do this:
// as you move across one row ...
a.frame.origin.x = x
x += a.frame.width + minimumInteritemSpacing
// and, obviously start fresh again each row
All you need now is the boilerplate code:
override func layoutAttributesForElements(
in rect: CGRect)->[UICollectionViewLayoutAttributes]? {
guard let att = super.layoutAttributesForElements(in: rect) else { return [] }
var x: CGFloat = sectionInset.left
var y: CGFloat = -1.0
for a in att {
if a.representedElementCategory != .cell { continue }
if a.frame.origin.y >= y { x = sectionInset.left }
a.frame.origin.x = x
x += a.frame.width + minimumInteritemSpacing
y = a.frame.maxY
}
return att
}
Simply copy and paste that in to a UICollectionViewFlowLayout
- you're done.
Full working solution to copy and paste:
This is the whole thing:
class TagsLayout: UICollectionViewFlowLayout {
required override init() {super.init(); common()}
required init?(coder aDecoder: NSCoder) {super.init(coder: aDecoder); common()}
private func common() {
estimatedItemSize = UICollectionViewFlowLayout.automaticSize
minimumLineSpacing = 10
minimumInteritemSpacing = 10
}
override func layoutAttributesForElements(
in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let att = super.layoutAttributesForElements(in:rect) else {return []}
var x: CGFloat = sectionInset.left
var y: CGFloat = -1.0
for a in att {
if a.representedElementCategory != .cell { continue }
if a.frame.origin.y >= y { x = sectionInset.left }
a.frame.origin.x = x
x += a.frame.width + minimumInteritemSpacing
y = a.frame.maxY
}
return att
}
}
And finally...
Give thanks to @AlexShubin above who first clarified this!
Solution 4:
With Swift 4.1 and iOS 11, according to your needs, you may choose one of the 2 following complete implementations in order to fix your problem.
#1. Left align autoresizing UICollectionViewCell
s
The implementation below shows how to use UICollectionViewLayout
's layoutAttributesForElements(in:)
, UICollectionViewFlowLayout
's estimatedItemSize
and UILabel
's preferredMaxLayoutWidth
in order to left align autoresizing cells in a UICollectionView
:
CollectionViewController.swift
import UIKit
class CollectionViewController: UICollectionViewController {
let array = ["1", "1 2", "1 2 3 4 5 6 7 8", "1 2 3 4 5 6 7 8 9 10 11", "1 2 3", "1 2 3 4", "1 2 3 4 5 6", "1 2 3 4 5 6 7 8 9 10", "1 2 3 4", "1 2 3 4 5 6 7", "1 2 3 4 5 6 7 8 9", "1", "1 2 3 4 5", "1", "1 2 3 4 5 6"]
let columnLayout = FlowLayout(
minimumInteritemSpacing: 10,
minimumLineSpacing: 10,
sectionInset: UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
)
override func viewDidLoad() {
super.viewDidLoad()
collectionView?.collectionViewLayout = columnLayout
collectionView?.contentInsetAdjustmentBehavior = .always
collectionView?.register(CollectionViewCell.self, forCellWithReuseIdentifier: "Cell")
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return array.count
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! CollectionViewCell
cell.label.text = array[indexPath.row]
return cell
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
collectionView?.collectionViewLayout.invalidateLayout()
super.viewWillTransition(to: size, with: coordinator)
}
}
FlowLayout.swift
import UIKit
class FlowLayout: UICollectionViewFlowLayout {
required init(minimumInteritemSpacing: CGFloat = 0, minimumLineSpacing: CGFloat = 0, sectionInset: UIEdgeInsets = .zero) {
super.init()
estimatedItemSize = UICollectionViewFlowLayoutAutomaticSize
self.minimumInteritemSpacing = minimumInteritemSpacing
self.minimumLineSpacing = minimumLineSpacing
self.sectionInset = sectionInset
sectionInsetReference = .fromSafeArea
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let layoutAttributes = super.layoutAttributesForElements(in: rect)!.map { $0.copy() as! UICollectionViewLayoutAttributes }
guard scrollDirection == .vertical else { return layoutAttributes }
// Filter attributes to compute only cell attributes
let cellAttributes = layoutAttributes.filter({ $0.representedElementCategory == .cell })
// Group cell attributes by row (cells with same vertical center) and loop on those groups
for (_, attributes) in Dictionary(grouping: cellAttributes, by: { ($0.center.y / 10).rounded(.up) * 10 }) {
// Set the initial left inset
var leftInset = sectionInset.left
// Loop on cells to adjust each cell's origin and prepare leftInset for the next cell
for attribute in attributes {
attribute.frame.origin.x = leftInset
leftInset = attribute.frame.maxX + minimumInteritemSpacing
}
}
return layoutAttributes
}
}
CollectionViewCell.swift
import UIKit
class CollectionViewCell: UICollectionViewCell {
let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .orange
label.preferredMaxLayoutWidth = 120
label.numberOfLines = 0
contentView.addSubview(label)
label.translatesAutoresizingMaskIntoConstraints = false
contentView.layoutMarginsGuide.topAnchor.constraint(equalTo: label.topAnchor).isActive = true
contentView.layoutMarginsGuide.leadingAnchor.constraint(equalTo: label.leadingAnchor).isActive = true
contentView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: label.trailingAnchor).isActive = true
contentView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: label.bottomAnchor).isActive = true
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
Expected result:
#2. Left align UICollectionViewCell
s with fixed size
The implementation below shows how to use UICollectionViewLayout
's layoutAttributesForElements(in:)
and UICollectionViewFlowLayout
's itemSize
in order to left align cells with predefined size in a UICollectionView
:
CollectionViewController.swift
import UIKit
class CollectionViewController: UICollectionViewController {
let columnLayout = FlowLayout(
itemSize: CGSize(width: 140, height: 140),
minimumInteritemSpacing: 10,
minimumLineSpacing: 10,
sectionInset: UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
)
override func viewDidLoad() {
super.viewDidLoad()
collectionView?.collectionViewLayout = columnLayout
collectionView?.contentInsetAdjustmentBehavior = .always
collectionView?.register(CollectionViewCell.self, forCellWithReuseIdentifier: "Cell")
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 7
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! CollectionViewCell
return cell
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
collectionView?.collectionViewLayout.invalidateLayout()
super.viewWillTransition(to: size, with: coordinator)
}
}
FlowLayout.swift
import UIKit
class FlowLayout: UICollectionViewFlowLayout {
required init(itemSize: CGSize, minimumInteritemSpacing: CGFloat = 0, minimumLineSpacing: CGFloat = 0, sectionInset: UIEdgeInsets = .zero) {
super.init()
self.itemSize = itemSize
self.minimumInteritemSpacing = minimumInteritemSpacing
self.minimumLineSpacing = minimumLineSpacing
self.sectionInset = sectionInset
sectionInsetReference = .fromSafeArea
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let layoutAttributes = super.layoutAttributesForElements(in: rect)!.map { $0.copy() as! UICollectionViewLayoutAttributes }
guard scrollDirection == .vertical else { return layoutAttributes }
// Filter attributes to compute only cell attributes
let cellAttributes = layoutAttributes.filter({ $0.representedElementCategory == .cell })
// Group cell attributes by row (cells with same vertical center) and loop on those groups
for (_, attributes) in Dictionary(grouping: cellAttributes, by: { ($0.center.y / 10).rounded(.up) * 10 }) {
// Set the initial left inset
var leftInset = sectionInset.left
// Loop on cells to adjust each cell's origin and prepare leftInset for the next cell
for attribute in attributes {
attribute.frame.origin.x = leftInset
leftInset = attribute.frame.maxX + minimumInteritemSpacing
}
}
return layoutAttributes
}
}
CollectionViewCell.swift
import UIKit
class CollectionViewCell: UICollectionViewCell {
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .cyan
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
Expected result: