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.
Related
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 custom subclass of UITableViewController. It has one section containing many rows. Each row corresponds to the same custom table view cell class. Each custom cell has two labels: myLabel1 & myLabel2, both subviews of the cell's contentView.
Every myLabel1 has one line of text, and every myLabel2 has one or two lines of text, but every cell should have the same height, as if every myLabel2 has two lines of text.
The labels use Dynamic Type.
myLabel1.font = UIFont.preferredFont(forTextStyle: .headline)
myLabel2.font = UIFont.preferredFont(forTextStyle: .subheadline)
According to Working with Self-Sizing Table View Cells, I've positioned each label with Auto Layout and "set the table view’s rowHeight property to UITableViewAutomaticDimension" so that the row height changes with Dynamic Type.
How do I make every cell have the same height?
How should I estimate the table view's row height?
UITableViewAutomaticDimension will estimate the cell size depending on cells content size. We must calculate the max height that a cell could possibly have, and return this height for all cells instead of using UITableViewAutomaticDimension.
CustomTableViewCell
let kMargin: CGFloat = 8.0
class CustomTableViewCell: UITableViewCell {
#IBOutlet var singleLineLabel: UILabel!
#IBOutlet var doubleLineLabel: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
updateFonts()
}
override func prepareForReuse() {
updateFonts()
}
func updateFonts() {
singleLineLabel.font = UIFont.preferredFont(forTextStyle:.title3)
doubleLineLabel.font = UIFont.preferredFont(forTextStyle:.body)
}
func updateCellForCellWidth(_ width:CGFloat) {
doubleLineLabel.preferredMaxLayoutWidth = width - (2*kMargin)
}
func fillCellWith(_ firstString: String, _ secondString: String) {
singleLineLabel.text = firstString
doubleLineLabel.text = secondString
}
}
On View Controller
Setting up a dummy cell and listing to notifications for dynamic type
var heightCalculatorDummyCell: CustomTableViewCell!
var maxHeight: CGFloat = 0.0
override func viewDidLoad() {
super.viewDidLoad()
heightCalculatorDummyCell = tableView.dequeueReusableCell(withIdentifier: "cell_id") as! CustomTableViewCell
maxHeight = getMaxHeight()
NotificationCenter.default.addObserver(self, selector: #selector(AutomaticHeightTableViewController.didChangePreferredContentSize), name: .UIContentSizeCategoryDidChange, object:nil)
}
Getting max height using a dummy cell.
func getMaxHeight() -> CGFloat {
heightCalculatorDummyCell.updateFonts()
heightCalculatorDummyCell.fillCellWith("Title","A string that needs more than two lines. A string that needs more than two lines. A string that needs more than two lines. A string that needs more than two lines. A string that needs more than two lines.")
heightCalculatorDummyCell.updateCellForCellWidth(tableView.frame.size.width)
let size = heightCalculatorDummyCell.contentView.systemLayoutSizeFitting(UILayoutFittingCompressedSize)
return (size.height + 1)
}
Handling Table view reloads on notification
deinit {
NotificationCenter.default.removeObserver(self)
}
func didChangePreferredContentSize() {
maxHeight = getMaxHeight()
tableView.reloadData()
}
Final step, returning max heights in tableview delegate
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return titles.count
}
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return maxHeight
}
Make myLabel2 a FixedHeightLabel so that its height is always two lines.
class FixedHeightLabel: TopAlignedLabel {
override var intrinsicContentSize: CGSize {
let oldText = text
text = "\n"
let height = sizeThatFits(CGSize(width: .max, height: .max)).height
text = oldText
return CGSize(width: UIViewNoIntrinsicMetric, height: height)
}
}
class TopAlignedLabel: UILabel {
override func drawText(in rect: CGRect) {
let textRect = super.textRect(forBounds: bounds, limitedToNumberOfLines: numberOfLines)
super.drawText(in: textRect)
}
}
Check the image below. I need to highlight the focused cell such that it is above all the cells in collectionView.
I know I have to change the zIndex of the focused cell. How to do that?
I need the logic. My code is in objective-c.
This is for tvOS but iOS code will also work here I guess.
Have you tried setting value for cell.layer.zPosition?
Try manually applying layer.zPosition to be the zIndex in applyLayoutAttributes: method of UICollectionViewCell subclass:
- (void)applyLayoutAttributes:(UICollectionViewLayoutAttributes *)layoutAttributes
{
[super applyLayoutAttributes:layoutAttributes];
self.layer.zPosition = layoutAttributes.zIndex;
}
You need to create a custom collection view flow layout. Compute the zIndex based on the collection view's scroll position or visible rect. The sample class is shown below.
final class CustomCollectionViewLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let attributes = super.layoutAttributesForElements(in: rect)
attributes?.forEach {
$0.zIndex = 0 //Compute and set
}
return attributes
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
let attribute = super.layoutAttributesForItem(at: indexPath)
attribute?.zIndex = 0 //Compute and set
return attribute
}
}
Maybe set the z-position of collection view to -2, unhighlight to -1 and highlight to 0
I want to create a vertical infinite scroll and after reading so many tutorials have understood that I need to subclass UICollectionViewFlowLayout. The problem is that I don't fully understand how to do so.
I've tried following:
1. Created a new class newView and assigned it to my view controller in attribute inspector custom class section.
class newView: UICollectionViewController,
UICollectionViewDataSource, UICollectionViewDelegate {
Implemented (override) cellForItemAtIndexPath and sizeForItemAtIndexPath in this class which works fine. I have a vertical scrolling view so far containing 2 items in 1 row. But I have unequal spaces between 2 rows. After laying out first 2 items, the third one's vertical position is below the longer of the previous 2 items as shown below:
I've read many SO threads discussing and suggesting to subclass UICollectionViewFlowLayout and override layoutAttributesForElementsInRect method for desired display. But when I try to add flow layout in my view controller like below it gives me errors:
class DiscoverView: UICollectionViewController, UICollectionViewFlowLayout,
UICollectionViewDataSource, UICollectionViewDelegate {
I then thought that it's may be my view layout that needs to be subclassed instead of controller, so I tried to create a separate class like below:
class newViewLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? {
return super.layoutAttributesForElementsInRect(rect)
}
And then I tried to assign this class to my view layout. But it doesn't appear under custom class section (attribute inspector). Neither does it appear in Attribute inspector > collection view > layout > set custom > Class
I know it's some very basic and silly mistake but not sure what I'm doing wrong conceptually.
Though this is old, I wan´t to add the solution that made it work for me :)
You should add the subclass in viewDidLoad like:
collectionView?.collectionViewLayout = YourCustomClass
You need to override the flowlayout you declared in your main class
let flowLayout = flowLayoutClass()
override func viewDidLoad() {
super.viewDidLoad()
collectionView.collectionViewFlowLayout = flowLayout
}
class flowLayoutClass: UICollectionViewFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let arr = super.layoutAttributesForElements(in: rect)
for atts:UICollectionViewLayoutAttributes in arr! {
if nil == atts.representedElementKind {
let ip = atts.indexPath
atts.frame = (self.layoutAttributesForItem(at: ip)?.frame)!
}
}
return arr
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
let atts = super.layoutAttributesForItem(at: indexPath)
if indexPath.item == 0 || indexPath.item == 1 {
var frame = atts?.frame;
frame?.origin.y = sectionInset.top;
atts?.frame = frame!;
return atts
}
let ipPrev = IndexPath(item: indexPath.item - 2, section: indexPath.section)
let fPrev = self.layoutAttributesForItem(at: ipPrev)?.frame
let rightPrev = (fPrev?.origin.y)! + (fPrev?.size.height)! + 10
if (atts?.frame.origin.y)! <= rightPrev {
return atts
}
var f = atts?.frame
f?.origin.y = rightPrev
atts?.frame = f!
return atts
}
}
I have a vertically scrolling UICollectionView that uses a subclass of UICollectionViewFlowLayout to try and eliminate inter-item spacing. This would result in something that looks similar to a UITableView, but I need the CollectionView for other purposes. There is a problem in my implementation of the FlowLayout subclass that causes cells to disappear when scrolling fast. Here is the code for my FlowLayout subclass:
EDIT: See Comments For Update
class ListLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? {
if var answer = super.layoutAttributesForElementsInRect(rect) {
for attr in (answer as [UICollectionViewLayoutAttributes]) {
let ip = attr.indexPath
attr.frame = self.layoutAttributesForItemAtIndexPath(ip).frame
}
return answer;
}
return nil
}
override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes! {
let currentItemAtts = super.layoutAttributesForItemAtIndexPath(indexPath) as UICollectionViewLayoutAttributes
if indexPath.item == 0 {
var frame = currentItemAtts.frame
frame.origin.y = 0
currentItemAtts.frame = frame
return currentItemAtts
}
let prevIP = NSIndexPath(forItem: indexPath.item - 1, inSection: indexPath.section)
let prevFrame = self.layoutAttributesForItemAtIndexPath(prevIP).frame
let prevFrameTopPoint = prevFrame.origin.y + prevFrame.size.height
var frame = currentItemAtts.frame
frame.origin.y = prevFrameTopPoint
currentItemAtts.frame = frame
return currentItemAtts
}
}
One other thing to note: My cells are variable height. Their height is set by overriding preferredLayoutAttributesFittingAttributes in the subclass of the custom cell:
override func preferredLayoutAttributesFittingAttributes(layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes! {
let attr: UICollectionViewLayoutAttributes = layoutAttributes.copy() as UICollectionViewLayoutAttributes
attr.frame.size = CGSizeMake(self.frame.size.width, myHeight)
return attr
}
And I set the layout's estimated size on initialization:
flowLayout.estimatedItemSize = CGSize(width: UIScreen.mainScreen().bounds.size.width, height: 60)
Here is a GIF that demonstrates this problem:
Does anybody have an idea as to what's going on? Your help is much appreciated.
Thanks!