UICollection View Flow Layout Vertical Align
Asked Answered
V

12

41

By default, when you are using the flow layout in a collection view, cells are centered vertically. Is there a way to change this alignment ?

enter image description here

Virgina answered 30/5, 2013 at 13:43 Comment(2)
what alignment/design you want??Provencal
Bottom alignment (along the red line). But bottom or top, it's not important, the way to do it should be the same, right ?Virgina
F
33

following code worked for me

@interface TopAlignedCollectionViewFlowLayout : UICollectionViewFlowLayout

- (void)alignToTopForSameLineElements:(NSArray *)sameLineElements;

@end

@implementation TopAlignedCollectionViewFlowLayout

- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect;
{
    NSArray *attrs = [super layoutAttributesForElementsInRect:rect];
    CGFloat baseline = -2;
    NSMutableArray *sameLineElements = [NSMutableArray array];
    for (UICollectionViewLayoutAttributes *element in attrs) {
        if (element.representedElementCategory == UICollectionElementCategoryCell) {
            CGRect frame = element.frame;
            CGFloat centerY = CGRectGetMidY(frame);
            if (ABS(centerY - baseline) > 1) {
                baseline = centerY;
                [self alignToTopForSameLineElements:sameLineElements];
                [sameLineElements removeAllObjects];
            }
            [sameLineElements addObject:element];
        }
    }
    [self alignToTopForSameLineElements:sameLineElements];//align one more time for the last line
    return attrs;
}

- (void)alignToTopForSameLineElements:(NSArray *)sameLineElements
{
    if (sameLineElements.count == 0) {
        return;
    }
    NSArray *sorted = [sameLineElements sortedArrayUsingComparator:^NSComparisonResult(UICollectionViewLayoutAttributes *obj1, UICollectionViewLayoutAttributes *obj2) {
        CGFloat height1 = obj1.frame.size.height;
        CGFloat height2 = obj2.frame.size.height;
        CGFloat delta = height1 - height2;
        return delta == 0. ? NSOrderedSame : ABS(delta)/delta;
    }];
    UICollectionViewLayoutAttributes *tallest = [sorted lastObject];
    [sameLineElements enumerateObjectsUsingBlock:^(UICollectionViewLayoutAttributes *obj, NSUInteger idx, BOOL *stop) {
        obj.frame = CGRectOffset(obj.frame, 0, tallest.frame.origin.y - obj.frame.origin.y);
    }];
}

@end
Flourish answered 8/5, 2014 at 11:9 Comment(5)
Tested a bunch of solutions, this was the only one working as expected.Virulence
For some reason this would work sometimes, but then I would scroll through and cells previously top aligned would then be centered. Very strange.Liddie
yep, it seems to work, but kind of weird. It doesn't seem to work on iOS 9. Any suggestion, help. thanksMedication
Had to do some tweaking because I was using drag and drop, but other than that works perfectly. Thank you! Also in alignToTopForSameLineElement wouldn't be better to check if sameLineEments is less or equal to 1? Since there is no need to align the element when it's only one for row.Promiscuous
@Liddie I know why. Had to find it myself - sorting return statement is wrong, it has to be negated like this: return delta != 0. ? NSOrderedSame : ABS(delta)/delta;. Order matters.Backhouse
E
37

Swift 4 with functional oriented approach:

class TopAlignedCollectionViewFlowLayout: UICollectionViewFlowLayout {
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        let attributes = super.layoutAttributesForElements(in: rect)?
            .map { $0.copy() } as? [UICollectionViewLayoutAttributes]

        attributes?
            .reduce([CGFloat: (CGFloat, [UICollectionViewLayoutAttributes])]()) {
                guard $1.representedElementCategory == .cell else { return $0 }
                return $0.merging([ceil($1.center.y): ($1.frame.origin.y, [$1])]) {
                    ($0.0 < $1.0 ? $0.0 : $1.0, $0.1 + $1.1)
                }
            }
            .values.forEach { minY, line in
                line.forEach {
                    $0.frame = $0.frame.offsetBy(
                        dx: 0,
                        dy: minY - $0.frame.origin.y
                    )
                }
            }

        return attributes
    }
}
Enesco answered 17/7, 2018 at 20:6 Comment(4)
Careful using this solution as it's now incomplete. It may cause crashes in iOS 15 and will always crash in iOS 16. I've based a solution on this one, fixing the issue, below.Outwash
This worked in iOS 16.2, Swift 5.7, for me without crashing (my use case has under 10 cells). This is a clever educational / "Advanced Swift" nerdy answer; a worthwhile study if someone has the time to reverse engineer it. But it's very cryptic. Fabio's answer should per S.O.'s "no code-only answers" (guideline) include an explanation, since it's barely maintainable code, e.g. time-consuming headache anytime one need to re-understand it, tweak or reuse it in future, thus I think @Outwash is on right track with his more workable solution, wherein one can more readily see what's going on.Ladylike
1st Fabio's code copies attrs of each cell in rect from super. Then uses reduce() to build dict of [center.y, (cell origin.y, [cell properties])] where center.y is min. center.y value found. Then, each cell's origin is shifted up using the minY/origin.y delta. I have not had any luck changing the frame size property of each cell in order to override the row height collectionView/layout seems to assume, meaning, each cell's content is easily shifted up by tweaking origin, but making the next row's cell follow the cell above it closely, doesn't seem to work. Next row hugs largest orig size Y.Ladylike
> This is a clever educational / "Advanced Swift" nerdy answer; a worthwhile study if someone has the time to reverse engineer it. I take this as a compliment! > Fabio's answer should per S.O.'s "no code-only answers" (guideline) include an explanation. Thanks for the feedback! I didn't include an explanation because this code is just a minimal version of other answers posted here but I get your point and looking at this code after some years I realize it's not super straightforward to understand.Enesco
F
33

following code worked for me

@interface TopAlignedCollectionViewFlowLayout : UICollectionViewFlowLayout

- (void)alignToTopForSameLineElements:(NSArray *)sameLineElements;

@end

@implementation TopAlignedCollectionViewFlowLayout

- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect;
{
    NSArray *attrs = [super layoutAttributesForElementsInRect:rect];
    CGFloat baseline = -2;
    NSMutableArray *sameLineElements = [NSMutableArray array];
    for (UICollectionViewLayoutAttributes *element in attrs) {
        if (element.representedElementCategory == UICollectionElementCategoryCell) {
            CGRect frame = element.frame;
            CGFloat centerY = CGRectGetMidY(frame);
            if (ABS(centerY - baseline) > 1) {
                baseline = centerY;
                [self alignToTopForSameLineElements:sameLineElements];
                [sameLineElements removeAllObjects];
            }
            [sameLineElements addObject:element];
        }
    }
    [self alignToTopForSameLineElements:sameLineElements];//align one more time for the last line
    return attrs;
}

- (void)alignToTopForSameLineElements:(NSArray *)sameLineElements
{
    if (sameLineElements.count == 0) {
        return;
    }
    NSArray *sorted = [sameLineElements sortedArrayUsingComparator:^NSComparisonResult(UICollectionViewLayoutAttributes *obj1, UICollectionViewLayoutAttributes *obj2) {
        CGFloat height1 = obj1.frame.size.height;
        CGFloat height2 = obj2.frame.size.height;
        CGFloat delta = height1 - height2;
        return delta == 0. ? NSOrderedSame : ABS(delta)/delta;
    }];
    UICollectionViewLayoutAttributes *tallest = [sorted lastObject];
    [sameLineElements enumerateObjectsUsingBlock:^(UICollectionViewLayoutAttributes *obj, NSUInteger idx, BOOL *stop) {
        obj.frame = CGRectOffset(obj.frame, 0, tallest.frame.origin.y - obj.frame.origin.y);
    }];
}

@end
Flourish answered 8/5, 2014 at 11:9 Comment(5)
Tested a bunch of solutions, this was the only one working as expected.Virulence
For some reason this would work sometimes, but then I would scroll through and cells previously top aligned would then be centered. Very strange.Liddie
yep, it seems to work, but kind of weird. It doesn't seem to work on iOS 9. Any suggestion, help. thanksMedication
Had to do some tweaking because I was using drag and drop, but other than that works perfectly. Thank you! Also in alignToTopForSameLineElement wouldn't be better to check if sameLineEments is less or equal to 1? Since there is no need to align the element when it's only one for row.Promiscuous
@Liddie I know why. Had to find it myself - sorting return statement is wrong, it has to be negated like this: return delta != 0. ? NSOrderedSame : ABS(delta)/delta;. Order matters.Backhouse
C
23

@DongXu: Your solution worked for me too. Here is the SWIFT version if it:

class TopAlignedCollectionViewFlowLayout: UICollectionViewFlowLayout
{
    override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]?
    {
        if let attrs = super.layoutAttributesForElementsInRect(rect)
        {
            var baseline: CGFloat = -2
            var sameLineElements = [UICollectionViewLayoutAttributes]()
            for element in attrs
            {
                if element.representedElementCategory == .Cell
                {
                    let frame = element.frame
                    let centerY = CGRectGetMidY(frame)
                    if abs(centerY - baseline) > 1
                    {
                        baseline = centerY
                        TopAlignedCollectionViewFlowLayout.alignToTopForSameLineElements(sameLineElements)
                        sameLineElements.removeAll()
                    }
                    sameLineElements.append(element)
                }
            }
            TopAlignedCollectionViewFlowLayout.alignToTopForSameLineElements(sameLineElements) // align one more time for the last line
            return attrs
        }
        return nil
    }

    private class func alignToTopForSameLineElements(sameLineElements: [UICollectionViewLayoutAttributes])
    {
        if sameLineElements.count < 1
        {
            return
        }
        let sorted = sameLineElements.sort { (obj1: UICollectionViewLayoutAttributes, obj2: UICollectionViewLayoutAttributes) -> Bool in

            let height1 = obj1.frame.size.height
            let height2 = obj2.frame.size.height
            let delta = height1 - height2
            return delta <= 0
        }
        if let tallest = sorted.last
        {
            for obj in sameLineElements
            {
                obj.frame = CGRectOffset(obj.frame, 0, tallest.frame.origin.y - obj.frame.origin.y)
            }
        }
    }
}
Cranston answered 29/2, 2016 at 15:5 Comment(1)
You can get rid of the if clause inside of the for loop like this: for element in attrs where element.representedElementCategory == .cell { . . . }Algebra
H
11

Swift 3 Version in case someone just wants to Copy & Paste:

class TopAlignedCollectionViewFlowLayout: UICollectionViewFlowLayout {
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        if let attrs = super.layoutAttributesForElements(in: rect) {
            var baseline: CGFloat = -2
            var sameLineElements = [UICollectionViewLayoutAttributes]()
            for element in attrs {
                if element.representedElementCategory == .cell {
                    let frame = element.frame
                    let centerY = frame.midY
                    if abs(centerY - baseline) > 1 {
                        baseline = centerY
                        alignToTopForSameLineElements(sameLineElements: sameLineElements)
                        sameLineElements.removeAll()
                    }
                    sameLineElements.append(element)
                }
            }
            alignToTopForSameLineElements(sameLineElements: sameLineElements) // align one more time for the last line
            return attrs
        }
        return nil
    }

    private func alignToTopForSameLineElements(sameLineElements: [UICollectionViewLayoutAttributes]) {
        if sameLineElements.count < 1 { return }
        let sorted = sameLineElements.sorted { (obj1: UICollectionViewLayoutAttributes, obj2: UICollectionViewLayoutAttributes) -> Bool in
            let height1 = obj1.frame.size.height
            let height2 = obj2.frame.size.height
            let delta = height1 - height2
            return delta <= 0
        }
        if let tallest = sorted.last {
            for obj in sameLineElements {
                obj.frame = obj.frame.offsetBy(dx: 0, dy: tallest.frame.origin.y - obj.frame.origin.y)
            }
        }
    }
}
Ho answered 20/6, 2017 at 7:24 Comment(0)
O
6

The awesome top voted answer from Fabio Felici needs updating for iOS 15 and 16. On iOS 16 it will crash almost every time you scroll.

Here's a more thorough solution that implements layoutAttributesForItem to ensure that attributes are the same for all execution paths (to avoid a looping crash).

open class TopAlignedCollectionViewFlowLayout: UICollectionViewFlowLayout {
    open override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        guard let layoutAttributes = super.layoutAttributesForItem(at: indexPath)?.copy() as? UICollectionViewLayoutAttributes else { return nil }
        guard layoutAttributes.representedElementCategory == .cell else { return layoutAttributes }
        
        func layoutAttributesForRow() -> [UICollectionViewLayoutAttributes]? {
            guard let collectionView = collectionView else { return [layoutAttributes] }
            let contentWidth = collectionView.frame.size.width - sectionInset.left - sectionInset.right
            var rowFrame = layoutAttributes.frame
            rowFrame.origin.x = sectionInset.left
            rowFrame.size.width = contentWidth
            return super.layoutAttributesForElements(in: rowFrame)
        }
        
        let minYs = minimumYs(from: layoutAttributesForRow())
        guard let minY = minYs[layoutAttributes.indexPath] else { return layoutAttributes }
        layoutAttributes.frame = layoutAttributes.frame.offsetBy(dx: 0, dy: minY - layoutAttributes.frame.origin.y)
        return layoutAttributes
    }
    
    open override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        let attributes = super.layoutAttributesForElements(in: rect)?
            .map { $0.copy() } as? [UICollectionViewLayoutAttributes]
        
        let minimumYs = minimumYs(from: attributes)
        attributes?.forEach {
            guard $0.representedElementCategory == .cell else { return }
            guard let minimumY = minimumYs[$0.indexPath] else { return }
            $0.frame = $0.frame.offsetBy(dx: 0, dy: minimumY - $0.frame.origin.y)
        }
        return attributes
    }
    
    /// Returns the minimum Y values based for each index path.
    private func minimumYs(from layoutAttributes: [UICollectionViewLayoutAttributes]?) -> [IndexPath: CGFloat] {
        layoutAttributes?
            .reduce([CGFloat: (CGFloat, [UICollectionViewLayoutAttributes])]()) {
                guard $1.representedElementCategory == .cell else { return $0 }
                return $0.merging([ceil($1.center.y): ($1.frame.origin.y, [$1])]) {
                    ($0.0 < $1.0 ? $0.0 : $1.0, $0.1 + $1.1)
                }
            }
            .values.reduce(into: [IndexPath: CGFloat]()) { result, line in
                line.1.forEach { result[$0.indexPath] = line.0 }
            } ?? [:]
    }
}
Outwash answered 18/7, 2022 at 10:7 Comment(1)
phenomenal 👍..Aplanatic
M
4

I have used something similar to the before answers. In my case I want to align cells by colum with different heights.

import UIKit

class AlignedCollectionViewFlowLayout: UICollectionViewFlowLayout {

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        if let attributes = super.layoutAttributesForElements(in: rect) {
            let sectionElements: [Int : [UICollectionViewLayoutAttributes]] = attributes
                .filter {
                    return $0.representedElementCategory == .cell //take cells only
                }.groupBy {
                    return $0.indexPath.section //group attributes by section
            }

            sectionElements.forEach { (section, elements) in
                //get suplementary view (header) to align each section
                let suplementaryView = attributes.first {
                    return $0.representedElementCategory == .supplementaryView && $0.indexPath.section == section
                }
                //call align method
                alignToTopSameSectionElements(elements, with: suplementaryView)
            }

            return attributes
        }

        return super.layoutAttributesForElements(in: rect)
    }

    private func alignToTopSameSectionElements(_ elements: [UICollectionViewLayoutAttributes], with suplementaryView: UICollectionViewLayoutAttributes?) {
        //group attributes by colum 
        let columElements: [Int : [UICollectionViewLayoutAttributes]] = elements.groupBy {
            return Int($0.frame.midX)
        }

        columElements.enumerated().forEach { (columIndex, object) in
            let columElement = object.value.sorted {
                return $0.indexPath < $1.indexPath
            }

            columElement.enumerated().forEach { (index, element) in
                var frame = element.frame

                if columIndex == 0 {
                    frame.origin.x = minimumLineSpacing
                }

                switch index {
                case 0:
                    if let suplementaryView = suplementaryView {
                        frame.origin.y = suplementaryView.frame.maxY
                    }
                default:
                    let beforeElement = columElement[index-1]
                    frame.origin.y = beforeElement.frame.maxY + minimumInteritemSpacing
                }

                element.frame = frame
            }
        }
    }
}

public extension Array {

    func groupBy <U> (groupingFunction group: (Element) -> U) -> [U: Array] {

        var result = [U: Array]()

        for item in self {

            let groupKey = group(item)

            if result.has(groupKey) {
                result[groupKey]! += [item]
            } else {
                result[groupKey] = [item]
            }
        }

        return result
    }
}

This is the result of this layout:

enter image description here

Maguire answered 22/9, 2017 at 16:55 Comment(1)
Hi @GOrozco58, I try your code. The vertical space between items are not identical, some items have more space, some has less space. Do you know how to overcome this problem?Hemicycle
C
3

This may or may not work for your particular situation, but I had some luck subclassing UICollectionViewFlowLayout in the following way:

@interface CustomFlowLayout : UICollectionViewFlowLayout
@end

@implementation CustomFlowLayout

- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect{
    NSArray* attributesToReturn = [super layoutAttributesForElementsInRect:rect];
    for (UICollectionViewLayoutAttributes* attributes in attributesToReturn) {
        if (nil == attributes.representedElementKind) {
            NSIndexPath* indexPath = attributes.indexPath;
            attributes.frame = [self layoutAttributesForItemAtIndexPath:indexPath].frame;
        }
    }
    return attributesToReturn;
}

- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath {
    UICollectionViewLayoutAttributes *currentItemAttributes = [super layoutAttributesForItemAtIndexPath:indexPath];

    currentItemAttributes.frame = CGRectOffset(currentItemAttributes.frame, 0, 0.5 * CGRectGetHeight(currentItemAttributes.frame));

    return currentItemAttributes;
}

@end
Croatia answered 26/9, 2013 at 0:51 Comment(0)
E
1

@DongXu: Your solution worked for me too. Here is the Xamarin.iOS version if it:

public class TopAlignedCollectionViewFlowLayout : UICollectionViewFlowLayout
{
    public override UICollectionViewLayoutAttributes[] LayoutAttributesForElementsInRect(CGRect rect)
    {
        if (base.LayoutAttributesForElementsInRect(rect) is UICollectionViewLayoutAttributes[] attrs)
        {
            // Find all the cells and group them together by the rows they appear on
            var cellsGroupedByRow = attrs
                .Where(attr => attr.RepresentedElementCategory == UICollectionElementCategory.Cell)
                // The default flow layout aligns cells in the middle of the row.
                // Thus, cells with the same Y center point are in the same row.
                // Convert to int, otherwise float values can be slighty different for cells on the same row and cause bugs.
                .GroupBy(attr => Convert.ToInt32(attr.Frame.GetMidY()));

            foreach (var cellRowGroup in cellsGroupedByRow)
            {
                TopAlignCellsOnSameLine(cellRowGroup.ToArray());
            }

            return attrs;
        }

        return null;
    }

    private static void TopAlignCellsOnSameLine(UICollectionViewLayoutAttributes[] cells)
    {
        // If only 1 cell in the row its already top aligned.
        if (cells.Length <= 1) return;

        // The tallest cell has the correct Y value for all the other cells in the row
        var tallestCell = cells.OrderByDescending(cell => cell.Frame.Height).First();

        var topOfRow = tallestCell.Frame.Y;

        foreach (var cell in cells)
        {
            if (cell.Frame.Y == topOfRow) continue;

            var frame = cell.Frame;

            frame.Y = topOfRow;

            cell.Frame = frame;
        }
    }
}
Efficacious answered 5/7, 2019 at 15:42 Comment(0)
H
1

I used https://cocoapods.org/pods/AlignedCollectionViewFlowLayout

  1. Install the pod or simply add the file AlignedCollectionViewFlowLayout.swift to your project

  2. In storyboard, select the Collection View's "Collection Layout" and assign the class AlignedCollectionViewFlowLayout

enter image description here

enter image description here

  1. In your View Controller's ViewDidLoad() function add:
let alignedFlowLayout = collectionView?.collectionViewLayout as? AlignedCollectionViewFlowLayout  
alignedFlowLayout?.horizontalAlignment = .left  
alignedFlowLayout?.verticalAlignment = .top
Hypersensitize answered 21/9, 2021 at 23:5 Comment(1)
thanks for this and highly recommended.Bayle
R
0

The UICollectionViewFlowLayout class is derived from the UICollectionViewLayout base class. And if you look at the documentation for that, you'll see there are a number of methods you can override, the most likely candidate being layoutAttributesForItemAtIndexPath:.

If you override that method, you could let it call its super implementation, and then adjust the properties of the returned UICollectionViewLayoutAttributes object. Specifically, you'll likely need to adjust the frame property to reposition the item so it's no longer centered.

Replace answered 30/5, 2013 at 14:34 Comment(0)
L
0

I used this code (https://github.com/yoeriboven/TopAlignedCollectionViewLayout) after DongXu's solution didn't quite work. The only modification was that it's originally designed to be used with a grid so I needed to instantiate the layout with an arbitrarily high column count...

let collectionViewFlowLayout = YBTopAlignedCollectionViewFlowLayout(numColumns: 1000)
Liddie answered 22/3, 2016 at 22:2 Comment(0)
H
0

@DongXu's answer is correct. However, I suggest to make those calculations in UICollectionViewFlowLayout's prepare() method. It will prevent multiple calculations on the same cell's attributes. Moreover, prepare() is better place to manage attributes cache.

Heavenward answered 28/8, 2017 at 13:30 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.