I am trying to modify UICollectionViewFlowLayout (vertical scroll) in order to place each section header to the left of all items of that section (as opposed to on top, which is the default).
That is, this is the default behaviour:
...and this is what I want:
So I subclassed UICollectionViewFlowLayout:
override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let attributesToReturn = super.layoutAttributesForElementsInRect(rect) else {
return nil
}
// Copy to avoid the dreadded "Cached frame mismatch" runtime warning:
var copiedAttributes = [UICollectionViewLayoutAttributes]()
for attribute in attributesToReturn {
copiedAttributes.append(attribute.copy() as! UICollectionViewLayoutAttributes)
}
for attributes in copiedAttributes {
if let kind = attributes.representedElementKind {
// Non nil: It is a supplementary View
if kind == UICollectionElementKindSectionHeader {
// HEADER
var frame = attributes.frame
frame.origin.y = frame.origin.y + frame.size.height
frame.size.width = sectionInset.left
attributes.frame = frame
}
}
else{
// Nil: It is an item
}
}
return copiedAttributes
}
Also, for good measure (?), I adopted the protocol UICollectionViewDelegateFlowLayout and implemented this method (although it is not clear what takes precedence. And then, there is the settings in the storyboard file, but those seem to be overriden by their runtime counterparts):
func collectionView(
collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
referenceSizeForHeaderInSection section: Int) -> CGSize {
let left = (collectionViewLayout as! UICollectionViewFlowLayout).sectionInset.left
return CGSizeMake(left, 1)
}
...and I succeed in lowering the header to the first row of its section; However, the space originally occupied by the header stays open:
...and the only way I can accomplish that is by setting the header view height to 1 and "Clips Subviews" to false, so that the label is displayed (If I set the height to 0, the label is not drawn), but this is definitely not the most elegant solution (and will likely break in -say- iOS 9.2)
That is, the actual height of the header is linked to the vertical space between sections: I can not set the space to zero while keeping the header view at a reasonable size for display.
Perhaps I should also move all section items up (by the same amount as my header height) instead, to fill the hole?
- (NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect
{
NSArray *originalAttributes = [super layoutAttributesForElementsInRect:rect];
NSMutableArray *allAttributes = [NSMutableArray new];
for (UICollectionViewLayoutAttributes* attributes in originalAttributes) {
[allAttributes addObject:[attributes copy]];
}
for (UICollectionViewLayoutAttributes *attributes in allAttributes) {
NSString *kind = attributes.representedElementKind;
if (kind == UICollectionElementKindSectionHeader) {
CGRect frame = attributes.frame;
UICollectionViewLayoutAttributes *cellAttrs = [super layoutAttributesForItemAtIndexPath:attributes.indexPath];
frame.origin.x = frame.origin.x;
frame.size.height = self.sectionInset.top;
frame.size.width = cellAttrs.frame.size.width;
attributes.frame = frame;
}
}
return allAttributes;
}
OK, so this is what I did:
First of all, I subclassed UICollectionViewFlowLayout based on this github project, to get the left alignment I was looking for (I had to convert it from Objective-C to swift, but other than than it's pretty much the same).
Since I am already subclassing the layout object, I can implement any modifications in this subclass.
I declared a property to store the width of my "side-headers" (this quantity also doubles as the left inset of each section):
import UIKit
class LeftAlignedFlowLayout: UICollectionViewFlowLayout
{
let customHeaderWidth:CGFloat = 150.0
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
sectionInset.left = customHeaderWidth
}
Then, in the implementation of layoutAttributesForElementsInRect() I did this:
override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]?
{
guard let attributesToReturn = super.layoutAttributesForElementsInRect(rect) else {
return nil
}
var copiedAttributes = [UICollectionViewLayoutAttributes]()
for attribute in attributesToReturn {
// Must copy attributes to avoid runtime warning:
copiedAttributes.append(attribute.copy() as! UICollectionViewLayoutAttributes)
}
for attributes in copiedAttributes {
if let kind = attributes.representedElementKind {
// Non nil : Supplementary View
if kind == UICollectionElementKindSectionHeader {
// [A] HEADER
var frame = attributes.frame
frame.origin.y = frame.origin.y + frame.size.height
frame.size.width = sectionInset.left
frame.size.height = 60 // Hard-coded - header height
attributes.frame = frame
}
else if kind == UICollectionElementKindSectionFooter {
// [B] FOOTER
var frame = attributes.frame
// My footer view is a "hairline" taking most of the width
// (save for 8 points of inset margin on each side):
frame.origin.x += 8
frame.size.width -= 16
attributes.frame = frame
}
}
else{
// Kind is nil : Item (cell)
if let attributesForItem = self.layoutAttributesForItemAtIndexPath(attributes.indexPath){
attributes.frame = attributesForItem.frame
}
}
}
return copiedAttributes
}
There is also the implementation of layoutAttributesForItemAtIndexPath(), but that pertains more to the left alignment of the items-part, so I omitted it (if anyone is interested, please check the above-linked github project).
I will create a repository with a demo project of my implementation as soon as I have time.
These are the Size Inspector settings for the collection view and the flow layout object, respectively:
(By the way, item size is variable and determined at runtime by the delegate method, so ignore those values)
I also adopted the UICollectionViewDelegateFlowLayout protocol, like this:
func collectionView(collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
referenceSizeForHeaderInSection section: Int) -> CGSize
{
let left = (collectionViewLayout as! UICollectionViewFlowLayout).sectionInset.left
return CGSizeMake(left, 1)
}
Although, to be honest, some of the layout attributes can be specified in many places (Interface Builder, properties of layout object, return value from delegate methods, etc... ) and I don't remember exactly which takes precendence in each case, so I need to clean up my code a bit.
Related
I have a custom UICollectionView layout that resizes when the user scrolls. As the header shrinks at one point it begins to flicker.
I'm guessing the issue is that when the header shrinks the collection view thinks it's out of frame and perhaps dequeues it but then it calculates that it is in frame and re-queues it which might be what's causing the flicker.
class CustomLayout: UICollectionViewFlowLayout, UICollectionViewDelegateFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let layoutAttributes = super.layoutAttributesForElements(in: rect) as! [UICollectionViewLayoutAttributes]
let offset = collectionView!.contentOffset ?? CGPoint.zero
let minY = -sectionInset.top
if (offset.y >= minY) {
let setOffset = fabs(170 - minY)
let extraOffset = fabs(offset.y - minY)
if offset.y <= 170 {
for attributes in layoutAttributes {
if let elementKind = attributes.representedElementKind {
if elementKind == UICollectionElementKindSectionHeader {
var frame = attributes.frame
frame.size.height = max(minY, headerReferenceSize.height - (extraOffset * 1.25))
frame.origin.y = frame.origin.y + (extraOffset * 1.25)
attributes.frame = frame
}
}
}
} else {
for attributes in layoutAttributes {
if let elementKind = attributes.representedElementKind {
if elementKind == UICollectionElementKindSectionHeader {
var frame = attributes.frame
frame.size.height = max(minY, headerReferenceSize.height - (setOffset * 1.25))
frame.origin.y = frame.origin.y + (setOffset * 1.25)
attributes.frame = frame
}
}
}
}
}
return layoutAttributes
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
}
Here is a gif showing the behavior. Notice how it starts out fine and begins to flicker. Also fast scrolling has an undesired effect.
Any suggestions?
I don't think your issue is related to the layout code. I copied and tried using your CustomLayout in a simple sample app with no obvious flickering issues.
Other things to try:
Make sure your collectionView(viewForSupplementaryElementOfKind:at:) function properly reuses the header view, using collectionView.dequeueReusableSupplementaryView(ofKind:withReuseIdentifier:for:)). Creating a new header view each time could cause substantial delays.
Do you have any complex drawing code in the cell itself?
Worst case, you could try profiling the app using Instruments Time Profiler to see what operations are taking up the most CPU cycles (assuming dropped frames are your issue).
Looks like that your collection view moves the header to the back.
Try insert this in code where you're changing frame of the header:
collectionView.bringSubview(toFront: elementKind)
I would like to populate UICollectionView in reverse order so that the last item of the UICollectionView fills first and then the second last and so on. Actually I'm applying animation and items are showing up one by one. Therefore, I want the last item to show up first.
Swift 4.2
I found a simple solution and worked for me to show last item first of a collection view:
Inside viewDidLoad() method:
collectionView.transform = CGAffineTransform.init(rotationAngle: (-(CGFloat)(Double.pi)))
and inside collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) method before returning the cell:
cell.transform = CGAffineTransform(rotationAngle: CGFloat.pi)
(optional) Below lines will be necessary to auto scroll and show new item with smooth scroll.
Add below lines after loading new data:
if self.dataCollection.count > 0 {
self.collectionView.scrollToItem(at: //scroll collection view to indexpath
NSIndexPath.init(row:(self.collectionView?.numberOfItems(inSection: 0))!-1, //get last item of self collectionview (number of items -1)
section: 0) as IndexPath //scroll to bottom of current section
, at: UICollectionView.ScrollPosition.bottom, //right, left, top, bottom, centeredHorizontally, centeredVertically
animated: true)
}
I'm surprised that Apple scares people away from writing their own UICollectionViewLayout in the documentation. It's really very straightforward. Here's an implementation that I just used in an app that will do exactly what are asking. New items appear at the bottom, and the while there is not enough content to fill up the screen the the items are bottom justified, like you see in message apps. In other words item zero in your data source is the lowest item in the stack.
This code assumes that you have multiple sections, each with items of a fixed height and no spaces between items, and the full width of the collection view. If your layout is more complicated, such as different spacing between sections and items, or variable height items, Apple's intention is that you use the prepare() callback to do the heavy lifting and cache size information for later use.
This code uses Swift 3.0.
//
// Created by John Lyon-Smith on 1/7/17.
// Copyright © 2017 John Lyon-Smith. All rights reserved.
//
import Foundation
import UIKit
class InvertedStackLayout: UICollectionViewLayout {
let cellHeight: CGFloat = 100.00 // Your cell height here...
override func prepare() {
super.prepare()
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
var layoutAttrs = [UICollectionViewLayoutAttributes]()
if let collectionView = self.collectionView {
for section in 0 ..< collectionView.numberOfSections {
if let numberOfSectionItems = numberOfItemsInSection(section) {
for item in 0 ..< numberOfSectionItems {
let indexPath = IndexPath(item: item, section: section)
let layoutAttr = layoutAttributesForItem(at: indexPath)
if let layoutAttr = layoutAttr, layoutAttr.frame.intersects(rect) {
layoutAttrs.append(layoutAttr)
}
}
}
}
}
return layoutAttrs
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
let layoutAttr = UICollectionViewLayoutAttributes(forCellWith: indexPath)
let contentSize = self.collectionViewContentSize
layoutAttr.frame = CGRect(
x: 0, y: contentSize.height - CGFloat(indexPath.item + 1) * cellHeight,
width: contentSize.width, height: cellHeight)
return layoutAttr
}
func numberOfItemsInSection(_ section: Int) -> Int? {
if let collectionView = self.collectionView,
let numSectionItems = collectionView.dataSource?.collectionView(collectionView, numberOfItemsInSection: section)
{
return numSectionItems
}
return 0
}
override var collectionViewContentSize: CGSize {
get {
var height: CGFloat = 0.0
var bounds = CGRect.zero
if let collectionView = self.collectionView {
for section in 0 ..< collectionView.numberOfSections {
if let numItems = numberOfItemsInSection(section) {
height += CGFloat(numItems) * cellHeight
}
}
bounds = collectionView.bounds
}
return CGSize(width: bounds.width, height: max(height, bounds.height))
}
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
if let oldBounds = self.collectionView?.bounds,
oldBounds.width != newBounds.width || oldBounds.height != newBounds.height
{
return true
}
return false
}
}
Just click on UICollectionView in storyboard,
in inspector menu under view section change semantic to Force Right-to-Left
I have attach an image to show how to do it in the inspector menu:
I'm assuming you are using UICollectionViewFlawLayout, and this doesn't have logic to do that, it only works in a TOP-LEFT BOTTOM-RIGHT order. To do that you have to build your own layout, which you can do creating a new object that inherits from UICollectionViewLayout.
It seems like a lot of work but is not really that much, you have to implement 4 methods, and since your layout is just bottom-up should be easy to know the frames of each cell.
Check the apple tutorial here: https://developer.apple.com/library/prerelease/ios/documentation/WindowsViews/Conceptual/CollectionViewPGforIOS/CreatingCustomLayouts/CreatingCustomLayouts.html
The data collection does not actually have to be modified but that will produce the expected result. Since you control the following method:
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell
Simply return cells created from inverting the requested index. The index path is the cell's index in the collection, not necessarily the index in the source data set. I used this for a reversed display from a CoreData set.
let desiredIndex = dataProfile!.itemEntries!.count - indexPath[1] - 1;
Don't know if this still would be useful but I guess it might be quite useful for others.
If your collection view's cells are of the same height there is actually a much less complicated solution for your problem than building a custom UICollectionViewLayout.
Firstly, just make an outlet of your collection view's top constraint and add this code to the view controller:
-(void)viewWillAppear:(BOOL)animated {
[self.view layoutIfNeeded]; //for letting the compiler know the actual height and width of your collection view before we start to operate with it
if (self.collectionView.frame.size.height > self.collectionView.collectionViewLayout.collectionViewContentSize.height) {
self.collectionViewTopConstraint.constant = self.collectionView.frame.size.height - self.collectionView.collectionViewLayout.collectionViewContentSize.height;
}
So basically you calculate the difference between collection view's height and its content only if the view's height is bigger. Then you adjust it to the constraint's constant. Pretty simple. But if you need to implement cell resizing as well, this code won't be enough. But I guess this approach may be quite useful. Hope this helps.
A simple working solution is here!
// Change the collection view layer transform.
collectionView.transform3D = CATransform3DMakeScale(1, -1, 1)
// Change the cell layer transform.
cell.transform3D = CATransform3DMakeScale(1, -1, 1)
It is as simple as:
yourCollectionView.inverted = true
PS : Same for Texture/IGListKit..
I've been working on a set of classes that allow for UITableView's and their associated classes to uses auto layout and dynamic type. It's based around an answer on Stack Overflow that aims to add auto layout support.
So far, it works well, but I've run in to a couple of problems when using size classes. The first is directly related to the table calculating the height:
When I create a new UITableViewCell, but don't add it to any views, the size class is AnyxAny, so when I've got some subviews or constraints that change based on the size class, they're always working as if they're in an AnyxAny situation. So far, my very hacky solution is to create a new UIWindow that I add the cells to by:
UIWindow(frame: UIScreen.mainScreen().applicationFrame)
This functions correctly, but I've got a couple of issues with it:
I'm now creating an entire new UIWindow object, which seems inefficient
Each cell has to be added to the same window
When the screen rotates, the size class may change (i.e., iPhone 6 Plus), so I need to listen for changes to the application frame and update my window's frame (not implemented yet)
Is there an easy/more efficient way to ensure the UITableViewCell knows its size class, without having to create a new UIWindow, or do that more efficiently? Maybe I can add a
Most of the current code can be found via the GitHub page, but the most relevant methods are:
DynamicTableViewController
private var cachedClassesForCellReuseIdentifiers = [String : UITableViewCell.Type]()
private var cachedNibsForCellReuseIdentifiers = [String : UINib]()
private var offscreenCellRowsForReuseIdentifiers = [String : UITableViewCell]()
private var offScreenWindow: UIWindow = {
return UIWindow(frame: UIScreen.mainScreen().applicationFrame)
}()
override public func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
// This method is called with an NSMutableIndexPath, which is not compatible with an imutable NSIndexPath,
// so we create an imutable NSIndexPath to be passed to the following methods
let imutableIndexPath = NSIndexPath(forRow: indexPath.row, inSection: indexPath.section)
if let reuseIdentifier = self.cellReuseIdentifierForIndexPath(imutableIndexPath) {
if let cell = self.cellForReuseIdentifier(reuseIdentifier) {
self.configureCell(cell, forIndexPath: indexPath)
if let dynamicCell = cell as? DynamicTableViewCell {
let height = dynamicCell.heightInTableView(tableView)
return height
} else {
// Fallback for non-DynamicTableViewCell cells
let size = cell.contentView.systemLayoutSizeFittingSize(UILayoutFittingCompressedSize)
let cellBoundsHeight = CGRectGetHeight(cell.bounds)
if size.height > 0 && size.height >= cellBoundsHeight {
// +1 for the cell separator
return size.height + 1
} else {
// In some situations (such as the content view not having any/enough constraints to get a height), the
// size from the systemLayoutSizeFittingSize: will be 0. However, because this can _sometimes_ be intended
// (e.g., when adding to a default style; see: DynamicSubtitleTableViewCell), we just return
// the height of the cell as-is. This may make some cells look wrong, but overall will also prevent 0 being returned,
// hopefully stopping some things from breaking.
return cellBoundsHeight + 1
}
}
}
}
return UITableViewAutomaticDimension
}
private func cellForReuseIdentifier(reuseIdentifier: String) -> UITableViewCell? {
if self.offscreenCellRowsForReuseIdentifiers[reuseIdentifier] == nil {
if let cellClass = self.cachedClassesForCellReuseIdentifiers[reuseIdentifier] {
let cell = cellClass()
self.offScreenWindow.addSubview(cell)
self.offscreenCellRowsForReuseIdentifiers[reuseIdentifier] = cell
} else if let cellNib = self.cachedNibsForCellReuseIdentifiers[reuseIdentifier] {
if let cell = cellNib.instantiateWithOwner(nil, options: nil).first as? UITableViewCell {
self.offScreenWindow.addSubview(cell)
self.offscreenCellRowsForReuseIdentifiers[reuseIdentifier] = cell
}
}
}
return self.offscreenCellRowsForReuseIdentifiers[reuseIdentifier]
}
DynamicTableViewCell
public func heightInTableView(tableView: UITableView) -> CGFloat {
var height: CGFloat!
if self.calculateHeight {
self.setNeedsUpdateConstraints()
self.updateConstraintsIfNeeded()
self.bounds = CGRectMake(0, 0, CGRectGetWidth(tableView.bounds), CGRectGetHeight(self.bounds))
self.setNeedsLayout()
self.layoutIfNeeded()
let size = self.contentView.systemLayoutSizeFittingSize(UILayoutFittingCompressedSize)
let boundsHeight = CGRectGetHeight(self.bounds)
if size.height > 0 && size.height >= boundsHeight {
// +1 for the cell separator
height = size.height + 1
} else {
// In some situations (such as the content view not having any/enough constraints to get a height), the
// size from the systemLayoutSizeFittingSize: will be 0. However, because this can _sometimes_ be intended
// (e.g., when adding to a default style; see: DynamicSubtitleTableViewCell), we just return
// the height of the cell as-is. This may make some cells look wrong, but overall will also prevent 0 being returned,
// hopefully stopping some things from breaking.
height = boundsHeight + 1
}
} else {
height = self.cellHeight
}
if height < self.minimumHeight && self.minimumHeight != nil {
return self.minimumHeight!
} else {
return height
}
}
This solutions works for iOS 7 and 8, and so should any future solutions. This restriction has also removed the use of UITraitCollection, so I've not gone down that route (I'm not even sure it'd help)
With IOS 8 and Autolayout You can set an estimated row height and set constraints for inner views with content view so it will scale according to the content in it. Here's my answer Which is the best approach among Autolayout or calculating the height using NSAttributedString, to implement dynamic height of an UITableViewCell?
Update: for IOS7 you need to calculate height when it is about to arrive you can do it in
- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath Delegate method
#define HEIGHT_FOR_ONE_LINE 15; //define it according to your fonts
#define HEIGHT_FOR_SINGLE_ROWCELL 32;
#pragma mark - Table view data source
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
NSString *textToView= [self.yourDataSourceArray objectAtIndex:indexPath.row];
CGFloat screenWidth=[[UIScreen mainScreen]bounds].size.width;
CGFloat characterPerPoint= 10;
CGFloat widthTaken= characterPerPoint * textToView.length;
NSInteger numberOfLines= ceil(widthTaken/screenWidth);
CGFloat height=numberOfLines* HEIGHT_FOR_ONE_LINE;
height = height + HEIGHT_FOR_SINGLE_ROWCELL;
return height;
}
I'm working in a CollectionView which is horizontal:
I can do this easily with a CollectionView except for the label '6 day streak', I was thinking that view is a footer but in a horizontal CollectionView, this appears in the top or in the bottom in a portrait mode.
I was thinking to create a ScrollView under this CollectionView and to move both at same time, but I don't know if it's a horrible idea.
I think this is not possible with Flow Layout and I'm not an expert of Custom Layout, is this posible?
Any ideas? Thanks
It is old question, but I leave solution for some other people who have same problem.
You can make footer always horizontally located in last, subclassing your UICollectionViewFlowLayout and overriding layoutAttributesForElementsInRect().
override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
if let layoutAttributesForElementsInRect = super.layoutAttributesForElementsInRect(rect), let collectionView = self.collectionView {
for layoutAttributes in layoutAttributesForElementsInRect {
if layoutAttributes.representedElementKind == UICollectionElementKindSectionFooter {
let section = layoutAttributes.indexPath.section
let numberOfItemsInSection = collectionView.numberOfItemsInSection(section)
if numberOfItemsInSection > 0 {
let lastCellIndexPath = NSIndexPath(forItem: max(0, numberOfItemsInSection - 1), inSection: section)
if let lastCellAttrs = self.layoutAttributesForItemAtIndexPath(lastCellIndexPath) {
var origin = layoutAttributes.frame.origin
origin.x = CGRectGetMaxX(lastCellAttrs.frame)
layoutAttributes.zIndex = 1024
layoutAttributes.frame.origin = origin
layoutAttributes.frame.size = layoutAttributes.frame.size
}
}
}
}
return layoutAttributesForElementsInRect
}
return nil
}
I need a UICollectionView to display a grid that is potentially larger than the visible frame in both width and height, while maintaining row and column integrity. The default UICollectionViewFlowLayout allows sections to scroll off-screen, but it wraps items within a section to keep them all on-screen, which screws up my grid.
Recognizing that UICollectionView is a subclass of UIScrollView, I tried just manually setting the collection view's content size property in viewDidLoad:
self.collectionView.contentSize = CGSizeMake((columns * (cellWidth + itemSpacingX), (rows * (cellHeight + itemSpacingY));
But this had no effect. Questions:
Is there an easy way to accomplish this without building a custom layout?
Would using a custom layout and overriding the collectionViewContentSize method succeed in getting the collection view to stop wrapping items and scroll in both directions?
If I do have to build a custom layout--which I'll have to invest some time to learn--do I subclass UICollectionViewLayout, or would subclassing UICollectionViewFlowLayout save time?
UPDATE:
I tried embedding the UICollectionView as a subview of a UIScrollView. The collection view itself is behaving correctly--the rows aren't wrapping at the edge of the scroll view, telling me that it is filling the UIScrollView content size that I set. But the scroll view doesn't pan in the horizontal direction, i.e. it only scrolls vertically, which the collection view does by itself anyway. So stuck again. Could there be an issue with the responder chain?
Figured out two ways to do this. Both required a custom layout. The problem is that the default flow layout--and I now know from the Collection View Programming Guide this is partly the definition of a flow layout--generates the cell layout attributes based on the bounds of the superview, and will wrap items in a section to keep them in bounds so that scrolling occurs in only one axis. Will skip the code details, as it isn't hard, and my problem was mainly confusion on what approach to take.
Easy way: use a UIScrollView and subclass 'UICollectionViewFlowLayout'. Embed the UICollectionView in a UIScrollView. Set the contentSize property of the scroll view in viewDiDLoad to match the full size that your collection view will occupy (this will let the default flow layout place items in a single line within a section without wrapping). Subclass UICollectionViewFlowLayout, and set that object as your custom layout for the collection view. In the custom flow layout, override collectionViewContentSize to return the full size of the collection view matrix. With this approach, you'll be using a flow layout, but will be able to scroll in both directions to view un-wrapped sections. The disadvantage is that you still have a flow layout that is pretty limited. Plus, it seems clunky to put a UICollectionView inside an instance of its own superclass just to get the functionality that the collection view by itself should have.
Harder way, but more versatile and elegant: subclass UICollectionViewLayout. I used this tutorial to learn how to implement a complete custom layout. You don't need a UIScrollView here. If you forego the flow layout, subclass UICollectionViewLayout, and set that as the custom layout, you can build out the matrix and get the right behavior from the collection view itself. It's more work because you have to generate all the layout attributes, but you'll be positioned to make the collection view do whatever you want.
In my opinion, Apple should add a property to the default flow layout that suppresses wrapping. Getting a device to display a 2D matrix with intact rows and columns isn't an exotic functionality and it seems like it should be easier to do.
Here is a version of AndrewK's code updated to Swift 4:
import UIKit
class CollectionViewMatrixLayout: UICollectionViewLayout {
var itemSize: CGSize
var interItemSpacingY: CGFloat
var interItemSpacingX: CGFloat
var layoutInfo: [IndexPath: UICollectionViewLayoutAttributes]
override init() {
itemSize = CGSize(width: 50, height: 50)
interItemSpacingY = 1
interItemSpacingX = 1
layoutInfo = [IndexPath: UICollectionViewLayoutAttributes]()
super.init()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepare() {
guard let collectionView = self.collectionView else {
return
}
var cellLayoutInfo = [IndexPath: UICollectionViewLayoutAttributes]()
var indexPath = IndexPath(item: 0, section: 0)
let sectionCount = collectionView.numberOfSections
for section in 0..<sectionCount {
let itemCount = collectionView.numberOfItems(inSection: section)
for item in 0..<itemCount {
indexPath = IndexPath(item: item, section: section)
let itemAttributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
itemAttributes.frame = frameForCell(at: indexPath)
cellLayoutInfo[indexPath] = itemAttributes
}
self.layoutInfo = cellLayoutInfo
}
}
func frameForCell(at indexPath: IndexPath) -> CGRect {
let row = indexPath.section
let column = indexPath.item
let originX = (itemSize.width + interItemSpacingX) * CGFloat(column)
let originY = (itemSize.height + interItemSpacingY) * CGFloat(row)
return CGRect(x: originX, y: originY, width: itemSize.width, height: itemSize.height)
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?
{
var allAttributes = Array<UICollectionViewLayoutAttributes>()
for (_, attributes) in self.layoutInfo {
if (rect.intersects(attributes.frame)) {
allAttributes.append(attributes)
}
}
return allAttributes
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return self.layoutInfo[indexPath]
}
override var collectionViewContentSize: CGSize {
guard let collectionView = self.collectionView else {
return .zero
}
let sectionCount = collectionView.numberOfSections
let height = (itemSize.height + interItemSpacingY) * CGFloat(sectionCount)
let itemCount = Array(0..<sectionCount)
.map { collectionView.numberOfItems(inSection: $0) }
.max() ?? 0
let width = (itemSize.width + interItemSpacingX) * CGFloat(itemCount)
return CGSize(width: width, height: height)
}
}
Here is complete matrix customLayout:
import UIKit
class MatrixLayout: UICollectionViewLayout {
var itemSize: CGSize!
var interItemSpacingY: CGFloat!
var interItemSpacingX: CGFloat!
var layoutInfo: Dictionary<NSIndexPath, UICollectionViewLayoutAttributes>!
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
itemSize = CGSizeMake(50.0, 50.0)
interItemSpacingY = 1.0
interItemSpacingX = 1.0
}
override func prepareLayout() {
var cellLayoutInfo = Dictionary<NSIndexPath, UICollectionViewLayoutAttributes>()
let sectionCount = self.collectionView?.numberOfSections()
var indexPath = NSIndexPath(forItem: 0, inSection: 0)
for (var section = 0; section < sectionCount; section += 1)
{
let itemCount = self.collectionView?.numberOfItemsInSection(section)
for (var item = 0; item < itemCount; item += 1)
{
indexPath = NSIndexPath(forItem:item, inSection: section)
let itemAttributes = UICollectionViewLayoutAttributes(forCellWithIndexPath: indexPath)
itemAttributes.frame = frameForCellAtIndexPath(indexPath)
cellLayoutInfo[indexPath] = itemAttributes
}
self.layoutInfo = cellLayoutInfo
}
}
func frameForCellAtIndexPath(indexPath: NSIndexPath) -> CGRect
{
let row = indexPath.section
let column = indexPath.item
let originX = (self.itemSize.width + self.interItemSpacingX) * CGFloat(column)
let originY = (self.itemSize.height + self.interItemSpacingY) * CGFloat(row)
return CGRectMake(originX, originY, self.itemSize.width, self.itemSize.height)
}
override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]?
{
var allAttributes = Array<UICollectionViewLayoutAttributes>()
for (index, attributes) in self.layoutInfo
{
if (CGRectIntersectsRect(rect, attributes.frame))
{
allAttributes.append(attributes)
}
}
return allAttributes
}
override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes? {
return self.layoutInfo[indexPath]
}
override func collectionViewContentSize() -> CGSize {
let width:CGFloat = (self.itemSize.width + self.interItemSpacingX) * CGFloat((self.collectionView?.numberOfItemsInSection(0))!)
let height:CGFloat = (self.itemSize.height + self.interItemSpacingY) * CGFloat((self.collectionView?.numberOfSections())!)
return CGSizeMake(width, height)
}
}
sections are rows,
items are columns