I'm trying to create a UICollectionView with all cells 100% visible, such that I will not need scrolling to see them all. I'm currently trying to get a 3x3 grid displayed, and calculating the size of the cells on the fly.
I have the CollectionView and a UIView for a header in a Container View. The header is pinned to the top of the container with a height of 100px. The CollectionView is below that, pinned to each side, the bottom, and has its top pinned to the bottom of the header.
When I use sizeForItemAt, I'm trying to find the size of the visible area to split it up into 1/3 sized chunks (padding/insets aside). My code looks like:
func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath) -> CGSize {
let numRows = self.numRows()
let itemsPerRow = self.itemsPerRow()
// let frameSize = collectionView.frame.size
let frameSize = collectionView.bounds.size
// let frameSize = collectionView.collectionViewLayout.collectionViewContentSize
// let frameSize = collectionView.intrinsicContentSize
let totalItemPadding = self.itemPadding * (itemsPerRow - 1)
let totalLinePadding = self.linePadding * (numRows - 1)
let availableWidth = frameSize.width - totalItemPadding
var widthPerItem = availableWidth / itemsPerRow
let availableHeight = frameSize.height - totalLinePadding
var heightPerItem = availableHeight / numRows
return CGSize(width: widthPerItem, height: heightPerItem)
}
The result is always that the 3rd row is about half-obscured, as it looks like the frameSize is "taller" than it actually displays in the simulator.
Is there something in UICollectionView that would give me the visible size? Am I at a wrong time in terms of layout timing, or should I add another method that invalidates size at some point?
I haven't found any tutorials out there for a collection view that shows all items, and does not vertically scroll, so any other pointers (or even libraries that do something like this) would be greatly appreciated.
Thanks!
Are you sure this method is getting called? Either add a log statement or a breakpoint in this routine and make sure it's getting called.
A common problem that would prevent this from getting called would be if you neglected formally declare your view controller to conform to UICollectionViewDelegateFlowLayout. In that case, it would use whatever it found in the storyboard. But when I did this, your code worked fine for me, for example:
extension ViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath) -> CGSize {
let numRows = self.numRows()
let itemsPerRow = self.itemsPerRow()
let frameSize = collectionView.bounds.size
let layout = collectionViewLayout as! UICollectionViewFlowLayout
let totalItemPadding = layout.minimumInteritemSpacing * (itemsPerRow - 1)
let totalLinePadding = layout.minimumInteritemSpacing * (numRows - 1)
let availableWidth = frameSize.width - totalItemPadding
let widthPerItem = availableWidth / itemsPerRow
let availableHeight = frameSize.height - totalLinePadding
let heightPerItem = availableHeight / numRows
return CGSize(width: widthPerItem, height: heightPerItem)
}
}
Note, I also used minimumInteritemSpacing, so I use the existing spacing parameter rather than defining your own. It strikes me as better to use an existing parameter (esp one that you can also set in IB).
By the way, the alternative, if it's always going to be on a single screen, is to use your own custom layout, rather than flow layout. That way you don't entangle the collection view's delegate with lots of cumbersome code. It would be a little more reusable. For example:
class GridLayout: UICollectionViewLayout {
var itemSpacing: CGFloat = 5
var rowSpacing: CGFloat = 5
private var itemSize: CGSize!
private var numberOfRows: Int!
private var numberOfColumns: Int!
override func prepare() {
super.prepare()
let count = collectionView!.numberOfItems(inSection: 0)
numberOfColumns = Int(ceil(sqrt(Double(count))))
numberOfRows = Int(ceil(Double(count) / Double(numberOfColumns)))
let width = (collectionView!.bounds.width - (itemSpacing * CGFloat(numberOfColumns - 1))) / CGFloat(numberOfColumns)
let height = (collectionView!.bounds.height - (rowSpacing * CGFloat(numberOfRows - 1))) / CGFloat(numberOfRows)
itemSize = CGSize(width: width, height: height)
}
override var collectionViewContentSize: CGSize {
return collectionView!.bounds.size
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.center = centerForItem(at: indexPath)
attributes.size = itemSize
return attributes
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return (0 ..< collectionView!.numberOfItems(inSection: 0)).map { IndexPath(item: $0, section: 0) }
.flatMap { layoutAttributesForItem(at: $0) }
}
private func centerForItem(at indexPath: IndexPath) -> CGPoint {
let row = indexPath.item / numberOfColumns
let col = indexPath.item - row * numberOfColumns
return CGPoint(x: CGFloat(col) * (itemSize.width + itemSpacing) + itemSize.width / 2,
y: CGFloat(row) * (itemSize.height + rowSpacing) + itemSize.height / 2)
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
}
And then, in the view controller:
override func viewDidLoad() {
super.viewDidLoad()
let layout = GridLayout()
layout.itemSpacing = 10
layout.rowSpacing = 5
collectionView?.collectionViewLayout = layout
}
For the layout size, you could use UICollectionViewFlowLayout and relate the dimensions in terms of your screen width and height so as to preserve the grid style look of the UICollectionView. See: https://developer.apple.com/documentation/uikit/uicollectionviewflowlayout and https://www.raywenderlich.com/136159/uicollectionview-tutorial-getting-started.
As for your scrolling issue, check that your scrolling is enabled, your constraints are setup correctly, and you don't have the problem mentioned at this link. UICollectionView does not scroll
Good luck! :)
Your scrolling is not working because the view is just the size of the screen...
Just to get the trick add an offset of 1000. The amount of the total height of the collectionView is every row * (row.size.height + padding).
In my case this was a lot simpler, the basic idea was to add extra padding to the end of collection view. On my case my orientation was horizontal but the same should apply to a vertical one (have not tested that one) The trick was to adjust the delegate function as follow:
extension ViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
return UIEdgeInsets.init(top: collectionView.bounds.width * 0.02, left: collectionView.bounds.width * 0.02, bottom: collectionView.bounds.width * 0.02, right: collectionView.bounds.width * 0.22)
}
}
Noticed the difference on the right value, in your case it would be the bottom value of the row.
Related
I have this older code example that I updated but I am still a newbie and can't figure out how to fix this one error:
In the class that holds the collection view:
var array = ["Just testing this out"]
Now in the UICollectionViewDelegateFlowLayout extension:
private func estimateFrameForText(text: String) -> CGRect {
//we make the height arbitrarily large so we don't undershoot height in calculation
let height: CGFloat = 1000
let size = CGSize(width: 398, height: height)
let options = NSStringDrawingOptions.usesFontLeading.union(.usesLineFragmentOrigin)
let attributes = [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 17, weight: UIFont.Weight.light)]
return NSString(string: text).boundingRect(with: size, options: options, attributes: attributes, context: nil)
}
Also:
private func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize {
var height: CGFloat = 295
//we are just measuring height so we add a padding constant to give the label some room to breathe!
var padding: CGFloat = 14
//estimate each cell's height
if let text = array[indexPath.item].text {
height = estimateFrameForText(text).height + padding
}
return CGSize(width: 398, height: height) }
This is where I get the error:
if let text = array[indexPath.item].text
And here's the UILabel from the collection view cell class I have no idea where to implement:
#IBOutlet weak var TextPosted: UILabel!
Your test array needs some adjusting. Try declaring it as an optional (since it likely will be with actual data loaded from somewhere).
var array: [String]? = ["Just testing this out", "Another test"]
Then this:
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return array?.count ?? 0
}
Then update your if let statement in your collecitonView sizeForItemAtIndexPath function:
if let text = array?[indexPath.item]{
}
On a side note, I wrote an open-source extension to measure string size for things like this a few days ago.
Hope this helps.
I'm trying to create a simple app, where one can enter a number of columns and a number of rows for an UICollectionView. The collection view then calculates the size of possible squares that fit into it and draws them.
I want to allow a maximum of 32 in width and 64 in height. Scrolling is disabled as the whole grid should be shown at once.
For example, 4x8 looks like this
and 8x4 will look like this
So as one can see that works fine. The problems comes with a higher amount of columns and/or rows. Up to 30x8 everything is fine but starting with 31 only 6 of the 8 rows are drawn.
So I don't understand why. Following is the code I use to calculate everything:
Number of section and number of rows:
func numberOfSections(in collectionView: UICollectionView) -> Int
{
let num = Int(heightInput.text!)
if(num != nil)
{
if(num! > 64)
{
return 64
}
return num!
}
return 8
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int
{
let num = Int(widthInput.text!)
if(num != nil)
{
if(num! > 32)
{
return 32
}
return num!
}
return 4
}
Cell for item at indexPath
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell
{
let size = calculateCellSize()
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
var origin = cell.frame.origin
origin.x = 1+CGFloat(indexPath.row) + size.width*CGFloat(indexPath.row)
origin.y = 1+CGFloat(indexPath.section) + size.height*CGFloat(indexPath.section)
cell.frame = CGRect(origin: origin, size: size)
NSLog("Cell X:%#, Cell Y:%#",origin.x.description,origin.y.description)
return cell
}
The calculate size method
func calculateCellSize() -> CGSize
{
//First check if we have valid values
let col = Int(widthInput.text!)
let row = Int(heightInput.text!)
if(col == nil || row == nil)
{
return CGSize(width: 48.0, height: 48.0)
}
//If there are more or equal amount of columns than rows
let columns = CGFloat(col!)
let rows = CGFloat(row!)
if(columns >= rows)
{
//Take the grid width
let gridWidth = drawCollection.bounds.size.width
//Calculate the width of the "pixels" that fit the width of the grid
var pixelWidth = gridWidth/columns
//Remember to substract the inset from the width
let drawLayout = drawCollection.collectionViewLayout as? UICollectionViewFlowLayout
pixelWidth -= (drawLayout?.sectionInset.left)! + 1/columns
return CGSize(width: pixelWidth, height: pixelWidth)
}
else
{
//Rows are more than columns
//Take the grid height as reference here
let gridHeight = drawCollection.bounds.size.height
//Calculate the height of the "pixels" that fit the height of the grid
var pixelHeight = gridHeight/rows
//Remember to substract the inset from the height
let drawLayout = drawCollection.collectionViewLayout as? UICollectionViewFlowLayout
pixelHeight -= (drawLayout?.sectionInset.top)! + 1/rows
return CGSize(width: pixelHeight, height: pixelHeight)
}
return CGSize(width: 48.0, height: 48.0)
}
For debugging reasons I put a counter into the cellforItemAtIndexPath method and in fact I can see that the last two rows are not called. The counter ends at 185 but in theory it should have been called 248 times and in fact the difference will show it is 2*32 - 1(for the uneven 31) so the last missing rows....
Several things came to my mind what the reason is but nothing of it seems to be:
the cells are not drawn at the right location (aka outside the grid) -> At least not correct as the method is only called 185 times.
The cells are calculated to be outside the grid therefore not tried to be rendered by the UICollectionView -> Still possible as I couldn't figure how to proof that.
There is a (if so hopefully configurable) maximum amount of elements the UICollectionView can draw and 31x8 already exceeds that number -> Still possible couldn't find anything about that.
So summary:
Is it possible to display all elements in the grid (32x64 max) and if so, what is wrong in my implementation?
Thank you all for your time and answers!
You're doing a whole lot of calculating that you don't need to do. Also, setting the .frame of a cell is a really bad idea. One big point of a collection view is to avoid having to set frames.
Take a look at this:
class GridCollectionViewController: UIViewController, UICollectionViewDataSource {
#IBOutlet weak var theCV: UICollectionView!
var numCols = 0
var numRows = 0
func updateCV() -> Void {
// subtract the number of colums (for the 1-pt spacing between cells), and divide by number of columns
let w = (theCV.frame.size.width - CGFloat((numCols - 1))) / CGFloat(numCols)
// subtract the number of rows (for the 1-pt spacing between rows), and divide by number of rows
let h = (theCV.frame.size.height - CGFloat((numRows - 1))) / CGFloat(numRows)
// get the smaller of the two values
let wh = min(w, h)
// set the cell size
if let layout = theCV.collectionViewLayout as? UICollectionViewFlowLayout {
layout.itemSize = CGSize(width: wh, height: wh)
}
// reload the collection view
theCV.reloadData()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// start with a 31x20 grid, just to see it
numCols = 31
numRows = 20
updateCV()
}
func numberOfSections(in collectionView: UICollectionView) -> Int {
return numRows
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return numCols
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = theCV.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
return cell
}
#IBAction func btnTap(_ sender: Any) {
// example of changing the number of rows/columns based on user action
numCols = 32
numRows = 64
updateCV()
}
}
You need to develop your own UICollectionViewLayout. With this approach you can achieve any result human being can imagine.
import UIKit
class CollectionLayout: UICollectionViewFlowLayout {
private var width: Int = 1
private var height: Int = 1
func set(width: Int, height: Int) {
guard width > 0, height > 0 else { return }
self.height = height
self.width = width
calculateItemSize()
}
private func calculateItemSize() {
guard let collectionView = collectionView else { return }
let size = collectionView.frame.size
var itemWidth = size.width / CGFloat(width)
// spacing is needed only if there're more than 2 items in a row
if width > 1 {
itemWidth -= minimumInteritemSpacing
}
var itemHeight = size.height / CGFloat(height)
if height > 1 {
itemHeight -= minimumLineSpacing
}
let edgeLength = min(itemWidth, itemHeight)
itemSize = CGSize(width: edgeLength, height: edgeLength)
}
// calculate origin for every item
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
let attributes = super.layoutAttributesForItem(at: indexPath)
// calculate item position in the grid
let col = CGFloat(indexPath.row % width)
let row = CGFloat(Int(indexPath.row / width))
// don't forget to take into account 'minimumInteritemSpacing' and 'minimumLineSpacing'
let x = col * itemSize.width + col * minimumInteritemSpacing
let y = row * itemSize.height + row * minimumLineSpacing
// set new origin
attributes?.frame.origin = CGPoint(x: x, y: y)
return attributes
}
// accumulate all attributes
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let attributes = super.layoutAttributesForElements(in: rect) else { return nil }
var newAttributes = [UICollectionViewLayoutAttributes]()
for attribute in attributes {
if let newAttribute = layoutAttributesForItem(at: attribute.indexPath) {
newAttributes.append(newAttribute)
}
}
return newAttributes
}
}
Set our layout to UICollectionView
Update collection view every time user enters width and height:
#IBAction func onSetTapped(_ sender: Any) {
width = Int(widthTextField.text!)
height = Int(heightTextField.text!)
if let width = width, let height = height,
let layout = collectionView.collectionViewLayout as? CollectionLayout {
layout.set(width: width, height: height)
collectionView.reloadData()
}
}
I have a dataset that is divided into multiple sections, however, I'd like to display this in a collectionView without breaks between sections. Here's an illustration of what I want to achieve:
Instead of:
0-0 0-1 0-2
0-3
1-0 1-1
2-0
3-0
I want:
0-0 0-1 0-2
0-3 1-0 1-1
2-0 3-0
I realize the solution likely lies with a custom UICollectionViewLayout subclass, but I'm not sure how to achieve something like this.
Thanks
You are correct that you need to subclass UICollectionViewLayout.
The essence to understand before starting is that you need to calculate at least position and size for every cell in the collection view. UICollectionViewLayout is just a structured way to provide that information. You get the structure, but you have to provide everything else yourself.
There are 4 methods you need to override:
prepare
invalidateLayout
layoutAttributesForItemAtIndexPath
layoutAttributesForElementsInRect
One trick is to cache the layout attributes in a lookup table (dictionary):
var cachedItemAttributes = [IndexPath: UICollectionViewLayoutAttributes]()
In prepare, you calculate the layout attributes for each indexPath in your collectionView:
override func prepare() {
super.prepare()
calculateAttributes()
}
In invalidateLayout you reset the cached layout attributes and recalculate them:
override func invalidateLayout() {
super.invalidateLayout()
cachedItemAttributes = [:]
calculateAttributes()
}
In layoutAttributesForItemAtIndexPath you use the lookup table to return the right layout attributes:
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return cachedItemAttributes[indexPath]
}
In layoutAttributesForElementsInRect you filter your lookup table for the elements within the specified rect:
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return cachedItemAttributes.filter { rect.intersects($0.value.frame) }.map { $0.value }
}
The final piece of the puzzle is the actual calculation of the layout attributes. Here I will provide only pseudo-code:
func calculateAttributes() {
// For each indexpath (you can get this from the collectionView property using numberOfSections and numberOfItems:inSection )
// calculate the frame, i.e the origin point and size of each cell in your collectionView and set it with UICollectionViewLayoutAttributes.frame
// There are many other properties on UICollectionViewLayoutAttributes that you can tweak for your layout, but frame is a good starting point from which you can start experimenting.
// Add the layout attributes to the lookup table
// end loop
}
To answer your question, here is pseudo-code to calculate the position of each cell:
// If width of cell + current width of row + spacing, insets and margins exceeds the available width
// move to next row.
// else
// cell origin.x = current width of row + interitem spacing
// cell origin.y = number of rows * (row height + spacing)
// endif
If you need your custom layout to be configurable, then either use UICollectionViewDelegateFlowLayout if the available signatures are sufficient, or define your own that inherits from UICollectionViewDelegateFlowLayout or UICollectionViewDelegate. Because your protocol inherits from UICollectionViewDelegateFlowLayout, which itself inherits from UICollectionViewDelegate, you can set it directly as the collectionView delegate in your viewcontroller. In your custom collection view layout you just need to cast the delegate from UICollectionViewDelegate to your custom protocol to use it. Remember to handle cases where the casting fails or where the protocol methods are not implemented by the delegate.
I found that for me, Marmoy's answer is missing one additional element:
overriding collectionViewContentSize.
Otherwise, depending on the size of your collectionView, you may get a call to layoutAttributesForElements(in rect: CGRect) which has a zero width or height, which will miss many of the cells. This is especially true if you're trying to dynamically size items in the collection view.
So a more complete version of Marmoy's answer would be:
import UIKit
class NoBreakSectionCollectionViewLayout: UICollectionViewLayout {
var cachedItemAttributes = [IndexPath: UICollectionViewLayoutAttributes]()
var cachedContentSize = CGSize.zero
override func prepare() {
super.prepare()
calculateAttributes()
}
override func invalidateLayout() {
super.invalidateLayout()
cachedItemAttributes = [:]
calculateAttributes()
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return cachedItemAttributes[indexPath]
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return cachedItemAttributes.filter { rect.intersects($0.value.frame) }.map { $0.value }
}
override var collectionViewContentSize: CGSize {
return cachedContentSize
}
func calculateAttributes() {
var y = CGFloat(0)
var x = CGFloat(0)
var lastHeight = CGFloat(0)
let xSpacing = CGFloat(5)
let ySpacing = CGFloat(2)
if let collectionView = collectionView, let datasource = collectionView.dataSource, let sizeDelegate = collectionView.delegate as? UICollectionViewDelegateFlowLayout {
let sections = datasource.numberOfSections?(in: collectionView) ?? 1
for section in 0..<sections {
for item in 0..<datasource.collectionView(collectionView, numberOfItemsInSection: section){
let indexPath = IndexPath(item: item, section: section)
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
if let size = sizeDelegate.collectionView?(collectionView, layout: self, sizeForItemAt: indexPath) {
if x > 0 && (x + size.width + xSpacing) > collectionView.bounds.width {
y += size.height + ySpacing
x = CGFloat(0)
}
attributes.frame = CGRect(x: x, y: y, width: size.width, height: size.height)
lastHeight = size.height
x += size.width + xSpacing
}
cachedItemAttributes[indexPath] = attributes
}
}
cachedContentSize = CGSize(width: collectionView.bounds.width, height: y + lastHeight)
}
}
}
Additionally, it's important for your delegate to implement UICollectionViewDelegateFlowLayout in the example above... Alternately, you can just calculate item sizes in the Layout if you know them without knowing about the cell content.
I'm trying to create an Nx2 (n rows, 2 columns) collection view where the cells size dynamically adjusts to perfectly fit the screen.
So I have the following controller in my storyboard:
This are the constraints for the Collection View:
And this is the respective View Controller code:
import UIKit
class SREventAttendeesCollectionViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate {
var screenSize: CGRect!
var screenWidth: CGFloat!
var screenHeight: CGFloat!
#IBOutlet weak var collectionView: UICollectionView!
override func viewDidLoad() {
super.viewDidLoad()
collectionView.delegate = self
collectionView.dataSource = self
screenSize = UIScreen.mainScreen().bounds
screenWidth = screenSize.width
screenHeight = screenSize.height
let collectionViewWidth = (self.collectionView.frame.size.width/2)
let layout: UICollectionViewFlowLayout = UICollectionViewFlowLayout()
layout.sectionInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
layout.itemSize = CGSize(width: collectionViewWidth, height: screenHeight/3)
collectionView.setCollectionViewLayout(layout, animated: true)
}
func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int {
return 2
}
func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 2
}
func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier("CollectionCell", forIndexPath: indexPath) as! SRCollectionViewCell
// Configure the cell
let collectionViewWidth = (self.collectionView.frame.size.width/2)
print(collectionViewWidth)
cell.frame.size.width = collectionViewWidth
cell.nameAgeLabel.frame.size.width = collectionViewWidth
//cell.pictureImageView.image = myImage
return cell
}
}
Sadly this is resulting on the following:
Does anyone have any idea what I'm missing here?
Sure, firstly there are several things wrong with your code in terms of the collection view flow layout.
1) You don't need to specify a type of UICollectionViewFlowLayout like in Objective C. Swift can match this automatically. Instead you should downcast to the required type (I'll get to this later)
2) I wouldn't create a new layout object and assign it. Instead I would just effect the current layout object (see my example code below)
3) Your not taking into account the minimumInterItemSpacing value which is why your cells are not fitting 2 per row.
I would make several changes to the way your collection view flow layout code is implemented. This is how I would achieve this (I've not compiled this so there might be some minor errors):
let screenWidth = collectionView.bounds.size.width;
if let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout {
layout.sectionInset = UIEdgeInsetsMake(0,0,0,0)
layout.minimumInteritemSpacing = 0
layout.minimumLineSpacing = 0
let avaliableWidth = screenWidth - (layout.sectionInset.left + layout.sectionInset.right + layout.minimumInteritemSpacing)
let itemWidth = avaliableWidth / 2
// You can set the value of 50 to whatever your height is
layout.itemSize = CGMake(itemWidth, 50)
}
By default (i.e., with a vertical scrolling direction), the UICollectionViewFlowLayout lays out cells by starting at the top-left, going from left to right, until the row is filled, and then proceeds to the next line down. Instead, I would like it to start at the bottom-left, go from left to right, until the row is filled, and then proceed to the next line up.
Is there a straightforward way to do this by subclassing UIScrollViewFlowLayout, or do I basically need to re-implement that class from scratch?
Apple's documentation on subclassing flow layout suggests that I only need to override and re-implement my own version of layoutAttributesForElementsInRect:, layoutAttributesForItemAtIndexPath:, and collectionViewContentSize. But this does not seem straightforward. Since UICollectionViewFlowLayout does not expose any of the grid layout calculations it makes internally in prepareLayout, I need to deduce all the layout values needed for the bottom-to-top layout from the values it generates for a top-to-bottom layout.
I am not sure this is possible. While I can re-use its calculations about which groups of items get put on the same rows, I will need to calculate new y offsets. And to make my calculations I will need information about all the items, but those superclass methods do not report that.
The very helpful answer by #insane-36 showed a way to do it when collectionView.bounds == collectionView.collectionViewContentSize.
But if you wish to support the case where collectionView.bounds < collectionViewcontentSize, then I believe you need to re-map the rects exactly to support scrolling properly. If you wish to support the case where collectionView.bounds > collectionViewContentSize, then you need to override collectionViewContentSize to ensure the content rect is positioned at the bottom of the collectionView (since otherwise it will be positioned at the top, due to the top-to-bottom default behavior of UIScrollView).
So the full solution is a bit more involved, and I ended up developing it here: https://github.com/algal/ALGReversedFlowLayout.
You could basically implement it with a simple logic, however this seems to be some how odd. If the collectionview contentsize is same as that of the collectionview bounds or if all the cells are visible then you could implement this with simple flowLayout as this,
#implementation SimpleFlowLayout
- (UICollectionViewLayoutAttributes*)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath{
UICollectionViewLayoutAttributes *attribute = [super layoutAttributesForItemAtIndexPath:indexPath];
[self modifyLayoutAttribute:attribute];
return attribute;
}
- (NSArray*)layoutAttributesForElementsInRect:(CGRect)rect{
NSArray *attributes = [super layoutAttributesForElementsInRect:rect];
for(UICollectionViewLayoutAttributes *attribute in attributes){
[self modifyLayoutAttribute:attribute];
}
return attributes;
}
- (void)modifyLayoutAttribute:(UICollectionViewLayoutAttributes*)attribute{
CGSize contentSize = self.collectionViewContentSize;
CGRect frame = attribute.frame;
frame.origin.x = contentSize.width - attribute.frame.origin.x - attribute.frame.size.width;
frame.origin.y = contentSize.height - attribute.frame.origin.y - attribute.frame.size.height;
attribute.frame = frame;
}
#end
And so the figure looks like this,
But, if you use more rows, more than the that can be seen on the screen at the same time, then there seems to be some problem with reusing. Since the UICollectionView datasource method, collectionView:cellForItemAtIndexPath: works linearly and asks for the indexPath as the user scrolls, the cell are asked in the usual increasing indexPath pattern such as 1 --- 100 though we would want it to reverse this pattern. While scrolling we would need the collectionView to ask us for the items in decreasing order since our 100 item resides at top and 1 item at bottom. So, I dont have any particular idea about how this could be accomplished.
UICollectionView with a reversed flow layout.
import Foundation
import UIKit
class InvertedFlowLayout: UICollectionViewFlowLayout {
override func prepare() {
super.prepare()
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard super.layoutAttributesForElements(in: rect) != nil else { return nil }
var attributesArrayNew = [UICollectionViewLayoutAttributes]()
if let collectionView = self.collectionView {
for section in 0 ..< collectionView.numberOfSections {
for item in 0 ..< collectionView.numberOfItems(inSection: section) {
let indexPathCurrent = IndexPath(item: item, section: section)
if let attributeCell = layoutAttributesForItem(at: indexPathCurrent) {
if attributeCell.frame.intersects(rect) {
attributesArrayNew.append(attributeCell)
}
}
}
}
for section in 0 ..< collectionView.numberOfSections {
let indexPathCurrent = IndexPath(item: 0, section: section)
if let attributeKind = layoutAttributesForSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, at: indexPathCurrent) {
attributesArrayNew.append(attributeKind)
}
}
}
return attributesArrayNew
}
override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
let attributeKind = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: elementKind, with: indexPath)
if let collectionView = self.collectionView {
var fullHeight: CGFloat = 0.0
for section in 0 ..< indexPath.section + 1 {
for item in 0 ..< collectionView.numberOfItems(inSection: section) {
let indexPathCurrent = IndexPath(item: item, section: section)
fullHeight += cellHeight(indexPathCurrent) + minimumLineSpacing
}
}
attributeKind.frame = CGRect(x: 0, y: collectionViewContentSize.height - fullHeight - CGFloat(indexPath.section + 1) * headerHeight(indexPath.section) - sectionInset.bottom + minimumLineSpacing/2, width: collectionViewContentSize.width, height: headerHeight(indexPath.section))
}
return attributeKind
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
let attributeCell = UICollectionViewLayoutAttributes(forCellWith: indexPath)
if let collectionView = self.collectionView {
var fullHeight: CGFloat = 0.0
for section in 0 ..< indexPath.section + 1 {
for item in 0 ..< collectionView.numberOfItems(inSection: section) {
let indexPathCurrent = IndexPath(item: item, section: section)
fullHeight += cellHeight(indexPathCurrent) + minimumLineSpacing
if section == indexPath.section && item == indexPath.item {
break
}
}
}
attributeCell.frame = CGRect(x: 0, y: collectionViewContentSize.height - fullHeight + minimumLineSpacing - CGFloat(indexPath.section) * headerHeight(indexPath.section) - sectionInset.bottom, width: collectionViewContentSize.width, height: cellHeight(indexPath) )
}
return attributeCell
}
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 {
for item in 0 ..< collectionView.numberOfItems(inSection: section) {
let indexPathCurrent = IndexPath(item: item, section: section)
height += cellHeight(indexPathCurrent) + minimumLineSpacing
}
}
height += sectionInset.bottom + CGFloat(collectionView.numberOfSections) * headerHeight(0)
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
}
func cellHeight(_ indexPath: IndexPath) -> CGFloat {
if let collectionView = self.collectionView, let delegateFlowLayout = collectionView.delegate as? UICollectionViewDelegateFlowLayout {
let size = delegateFlowLayout.collectionView!(collectionView, layout: self, sizeForItemAt: indexPath)
return size.height
}
return 0
}
func headerHeight(_ section: Int) -> CGFloat {
if let collectionView = self.collectionView, let delegateFlowLayout = collectionView.delegate as? UICollectionViewDelegateFlowLayout {
let size = delegateFlowLayout.collectionView!(collectionView, layout: self, referenceSizeForHeaderInSection: section)
return size.height
}
return 0
}
}