I am trying to apply a kind of reverse-parallax effect on the first cell in my collectionview by overriding the layoutAttributesForElements(in rect:) method on UICollectionViewCompositionalLayout, calling the super implementation to obtain the default values then manipulating the frame of only the item at indexPath (0,0).
class ScrollingCardCollectionViewLayout: UICollectionViewCompositionalLayout {
override func shouldInvalidateLayout(forBoundsChange _: CGRect) -> Bool {
return true
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let attrs = super.layoutAttributesForElements(in: rect)
guard let attrs = attrs, let collectionView = collectionView else { return attrs }
if let attr = attrs.first(where: { $0.representedElementCategory == .cell && $0.indexPath.section == 0 && $0.indexPath.item == 0 }) {
let offset = collectionView.contentOffset
let inset = collectionView.contentInset
let adjustedOffset = offset.y + inset.top
attr.frame.origin.y = adjustedOffset / 3 * 2
}
}
}
This works perfectly when scrolling slowly, however if you scroll quickly it the scrollview seems to lose track of its contentOffset and jerks back 3-5 times. This occurs both when both panning to scroll or 'flicking' to allow it to animate / decelerate. It also starts working perfectly once you have scrolled to the bottom of the content at least once (i.e. once the layout has established the heights of all the index paths).
I am using a vertical UICollectionViewCompositionalLayout with fixed full-screen width and dynamically sized (height) cells.
Full sample project available here
I've also tried setting an affine transform translation on the layout attributes which yielded the same results. Is this an iOS bug or am I doing something I shouldn't be?
I am trying to implement a collection view, in which items have:
automatic height based on constraints
the full available width of the collection view.
I'm aware that this is pretty easy to accomplish UICollectionViewCompositionalLayout, but I'm looking to solve it for iOS 11+. I've decided to implement a custom UICollectionViewFlowLayout:
class SingleColumnFlowLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let collectionView = collectionView,
let layoutAttributes = super.layoutAttributesForElements(in: rect) else { return [] }
layoutAttributes
.filter { $0.representedElementCategory == .cell }
.forEach { attributes in
let availableWidth = collectionView.bounds
.inset(by: collectionView.contentInset)
.width
attributes.frame.origin.x = sectionInset.left
attributes.frame.size.width = availableWidth
}
return layoutAttributes
}
}
The result isn't quite what I have imagined:
The cell I'm using is pretty simple:
Interestingly if I add a fixed width constraint to the label, it works correctly, so my theory is that
for some reason the collection view fails to infer the width of the label correctly
for that reason, it thinks that it can fit multiple items in the same row
because of this, it calculates incorrect y values for some of the items.
I would like to make this work without fixed-width labels, so my question would be: am I missing anything obvious? Is there a way to fix this?
For anyone interested, I've uploaded the entire project to GitHub.
As it turns out, the issue was caused by the fixed trailing constraint of the label. The label's intrinsic width was smaller (due to short text), and since the entire cell was constrained horizontally, the cell width also became small. I fixed it by changing the trailing constraint from 'equal' to 'greater than'.
I have a tableview cell containing a custom view among other views and autolayout is used.
The purpose of my custom view is to layout its subviews in rows and breaks into a new row if the current subview does not fit in the current line. It kind of works like a multiline label but with views. I achieved this through exact positioning instead of autolayout.
Since I only know the width of my view in layoutSubviews(), I need to calculate the exact positions and number of lines there. This worked out well, but the frame(zero) of my view didn't match because of missing intrinsicContentSize.
So I added a check to the end of my calculation if my height changed since the last layout pass. If it did I update the height property which is used in my intrinsicContentSize property and call invalidateIntrinsicContentSize().
I observed that initially layoutSubviews() is called twice. The first pass works well and the intrinsicContentSize is taken into account even though the width of the cell is smaller than it should be. The second pass uses the actual width and also updates the intrinsicContentSize. However the parent(contentView in tableview cell) ignores this new intrinsicContentSize.
So basically the result is that the subviews are layout and drawn correctly but the frame of the custom view is not updated/used in parent.
The question:
Is there a way to notify the parent about the change of the intrinsic size or a designated place to update the size calculated in layoutSubviews() so the new size is used in the parent?
Edit:
Here is the code in my custom view.
FYI: 8 is just the vertical and horizontal space between two subviews
class WrapView : UIView {
var height = CGFloat.zero
override var intrinsicContentSize: CGSize {
CGSize(width: UIView.noIntrinsicMetric, height: height)
}
override func layoutSubviews() {
super.layoutSubviews()
guard frame.size.width != .zero else { return }
// Make subviews calc their size
subviews.forEach { $0.sizeToFit() }
// Check if there is enough space in a row to fit at least one view
guard subviews.map({ $0.frame.size.width }).max() ?? .zero <= frame.size.width else { return }
let width = frame.size.width
var row = [UIView]()
// rem is the remaining space in the current row
var rem = width
var y: CGFloat = .zero
var i = 0
while i < subviews.count {
let view = subviews[i]
let sizeNeeded = view.frame.size.width + (row.isEmpty ? 0 : 8)
let last = i == subviews.count - 1
let fit = rem >= sizeNeeded
if fit {
row.append(view)
rem -= sizeNeeded
i += 1
guard last else { continue }
}
let rowWidth = row.map { $0.frame.size.width + 8 }.reduce(-8, +)
var x = (width - rowWidth) * 0.5
for vw in row {
vw.frame.origin = CGPoint(x: x, y: y)
x += vw.frame.width + 8
}
y += row.map { $0.frame.size.height }.max()! + 8
rem = width
row = []
}
if height != y - 8 {
height = y - 8
invalidateIntrinsicContentSize()
}
}
}
After a lot of trying and research I finally solved the bug.
As #DonMag mentioned in the comments the new size of the cell wasn't recognized until a new layout pass. This could be verified by scrolling the cell off-screen and back in which showed the correct layout. Unfortunately it is harder than expected to trigger new pass as .beginUpdates() + .endUpdates()didn't
do the job.
Anyway I didn't find a way to trigger it but I followed the instructions described in this answer. Especially the part with the prototype cell for the height calculation provided a value which can be returned in tableview(heightForRowAt:).
Swift 5:
This is the code used for calculation:
let fitSize = CGSize(width: view.frame.size.width, height: .zero)
/* At this point populate the cell with the exact same data as the actual cell in the tableview */
cell.setNeedsUpdateConstraints()
cell.updateConstraintsIfNeeded()
cell.bounds = CGRect(x: .zero, y: .zero, width: view.frame.size.width, height: cell.bounds.height)
cell.setNeedsLayout()
cell.layoutIfNeeded()
height = headerCell.contentView.systemLayoutSizeFitting(fitSize).height + 1
The value is only calculated once and the cached as the size doesn't change anymore in my case.
Then the value can be returned in the delegate:
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
indexPath.row == 0 ? height : UITableView.automaticDimension
}
I only used for the first cell as it is my header cell and there is only one section.
I have a UITableView with custom UITableViewCells. Each cell has a bunch of UIView, UIStackView, and UILabel subviews to build out the design I want.
Everything within the UITableViewCell is positioned using AutoLayout, and I allow the UITableView to automatically give height to the cells by doing:
tableView.estimatedRowHeight = 85
tableView.rowHeight = UITableViewAutomaticDimension
However, because of the complexity of the cell's subview layout, scrolling down the tableView is very laggy/choppy as each cell tries to estimate and build out it's correct height.
I read up on some articles that discuss these issues like:
https://medium.com/ios-os-x-development/perfect-smooth-scrolling-in-uitableviews-fd609d5275a5
This inspired me to try a different approach where I would calculate the height of each cell by adding up all the vertical space, and calculating dynamic string height by utilizing functions like the following, and then caching these heights in a dictionary of [IndexPath.row (Int), Row Height (CGFloat)].
extension String {
func heightWithConstrainedWidth(width: CGFloat, font: UIFont) -> CGFloat {
let constraintRect = CGSize(width: width, height: CGFloat.max)
let boundingBox = self.boundingRectWithSize(constraintRect, options: NSStringDrawingOptions.UsesLineFragmentOrigin, attributes: [NSFontAttributeName: font], context: nil)
return boundingBox.height
}
}
func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
if let height = cellHeightCache[indexPath.row] {
return height
} else {
let data = dataItems[indexPath.row]
var height: CGFloat = 0
height += 20 // top padding above text
height += data.title.heightWithConstrainedWidth(tableView.frame.width - 72 * 2, font: UIFont.boldSystemFontOfSize(18)) // height of title
height += 20 // bottom padding below text
... // this goes on and on with if statements depending on if the data has a subtitle, image, other properties, etc to get the correct height for *that* cell
cellHeightCache[indexPath.row] = ceil(height)
return height
}
}
While this does work, now I am mixing frame calculations, tableView.frame.width - 72 * 2 with the AutoLayout of the cell's subviews to get the height. And because these are frame calculations, if the user rotates the device or on an iPad brings over the right split screen view, this clearly breaks. I could try and catch all these edge cases and recalculate when the user rotates/changes the size of the screen, but am I missing a way to calculate the height of these cells without using frames?
Is there anyway just using AutoLayout positioning I can calculate the height of the cells in the heightForRowAtIndexPath function?
Thanks
How can I get a pixel perfect one (1) pixel with border line in a UICollectionView (e.g. to make a month calendar). The issue is that where cells meet, their borders meet, so it is effectively 2 pixels (not 1 pixel).
Therefore this is a CollectionView layout related issue/question. As the way each cell puts it own 1 pixel border gives rise, when the cells all meet up with zero spacing, to what looks like a 2 pixels border overall (except for outer edges of the collectionView which would be 1 pixel)
Seeking what approach/code to use to solve this.
Here is the custom UICollectionViewCell class I use, and I put borders on the cells in here using drawrect.
import UIKit
class GCCalendarCell: UICollectionViewCell {
#IBOutlet weak var title : UITextField!
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
override init(frame: CGRect) {
super.init(frame: frame)
}
override func drawRect(rect: CGRect) {
self.layer.borderWidth = 1
self.layer.borderColor = UIColor.redColor().CGColor
}
}
There are a few strategies you can take to solve this issue. Which is best depends on some of the details of your layout.
Option 1: Half Borders
Draw borders of 1/2 the desired width around each item. This has been suggested. It achieves the correct width for borders between items, but the borders around the edge of the collection will also be half the desired width. To solve this you can draw a border around the entire collection, or sections.
If you're month layout only renders days in a given month, meaning each month has a unique non-rectangular shape, fixing the edge borders is more complicated than it's worth.
Option 2: Overlapping Cells
Draw the full border around each item but adjust the frames so they overlap by half of the width of the borders, hiding the redundancy.
This is the simplest strategy and works well, but be aware of a few things:
Be mindful that the outer edges of the layout are also slightly changed. Compensate for this if necessary.
The borders will overlap, so you can't use semi-transparent borders.
If not all the borders are the same color, some edges will be missing. If there is just a single cell with a different border, you can adjust the item's z-index to bring it on top of the other cells.
Option 3: Manual Drawing
Rather than using borders on the cell at all, you can add a border around the container and then manually draw the borders in the superview. This could be accomplished by overriding drawRect(_:) and using UIBezierPaths to draw horizontal and vertical lines at the intervals of the cells. This is the most manual option. It gives you more control, but is much more work.
Option 4: Draw Only Some Borders in Each Cell
Rather than assigning the layer's border width and border color, which draws a border around the entire view, selectively draw edges of cells. For example: each cell could only draw it's bottom and right edge, unless there is no cell above or to the right of it.
This can again lead to complications if you have an irregular grid. It also requires each cell to know about it's context in the grid which requires more structure. If you go this route, I would suggest subclassing UICollectionViewLayoutAttributes and adding additional information about which edges should be drawn. The edge drawing itself can again be done by creating UIBezierPath's in drawRect(_:) for each edge.
I usually divide the border width by the screen scale:
width = 1.0f / [UIScreen mainScreen].scale
Use nativeScale!
CGFloat width = 1.0f / [UIScreen mainScreen].nativeScale;
With respect to the very thorough accepted answer, I don't think any of those methods are very clean. To make this look good in all scenarios you're going to want to draw the grid "manually" but doing so in a drawRect isn't a good idea a) because this will draw under your cells and b) drawRect paints the screen 60 times a second so you will encounter performance issues with a large scrolling view.
Better IMO is to use CALayers. With CALayer you can drop a grid line onto the view once and it stays on the GPU from then on so even a huge grid can draw at 60fps. I also find the implementation easier and more flexible. Here's a working example:
class GridLineCollectionView: UICollectionView {
private var gridLineContainer: CALayer!
private var verticalLines = [CALayer]()
private var horizontalLiines = [CALayer]()
private var cols: Int {
return Int(self.contentSize.width / self.itemSize.width)
}
private var rows: Int {
return Int(self.contentSize.height / self.itemSize.height)
}
private var colWidth: CGFloat {
return self.itemSize.width + self.sectionInsets.left + self.sectionInsets.right
}
private var rowHeight: CGFloat {
return self.itemSize.height + self.sectionInsets.top + self.sectionInsets.bottom
}
private var itemSize: CGSize {
return (self.collectionViewLayout as! UICollectionViewFlowLayout).itemSize
}
private var sectionInsets: UIEdgeInsets {
return (self.collectionViewLayout as! UICollectionViewFlowLayout).sectionInset
}
override func layoutSubviews() {
super.layoutSubviews()
if self.gridLineContainer == nil {
self.gridLineContainer = CALayer()
self.gridLineContainer.frame = self.bounds
self.layer.addSublayer(self.gridLineContainer)
}
self.addNecessaryGridLayers()
self.updateVerticalLines()
self.updateHorizontalLines()
}
private func addNecessaryGridLayers() {
while self.verticalLines.count < cols - 1 {
let newLayer = CALayer()
newLayer.backgroundColor = UIColor.darkGray.cgColor
self.verticalLines.append(newLayer)
self.gridLineContainer.addSublayer(newLayer)
}
while self.horizontalLiines.count < rows + 1 {
let newLayer = CALayer()
newLayer.backgroundColor = UIColor.darkGray.cgColor
self.horizontalLiines.append(newLayer)
self.gridLineContainer.addSublayer(newLayer)
}
}
private func updateVerticalLines() {
for (idx,layer) in self.verticalLines.enumerated() {
let x = CGFloat(idx + 1) * self.colWidth
layer.frame = CGRect(x: x, y: 0, width: 1, height: CGFloat(self.rows) * self.rowHeight)
}
}
private func updateHorizontalLines() {
for (idx,layer) in self.horizontalLiines.enumerated() {
let y = CGFloat(idx) * self.rowHeight
layer.frame = CGRect(x: 0, y: y, width: CGFloat(self.cols) * self.colWidth, height: 1)
}
}
}
I solved this by subclassing the UICollectionViewFlowLayout and taking advantage of the layoutAttributesForElementsInRect method. With this you are able to set custom frame properties and allow overlapping. This is the most basic example:
#interface EqualBorderCellFlowLayout : UICollectionViewFlowLayout <UICollectionViewDelegateFlowLayout>
/*! #brief Border width. */
#property (strong, nonatomic) NSNumber *borderWidth;
#end
#import "EqualBorderCellFlowLayout.h"
#interface EqualBorderCellFlowLayout() { }
#end
#implementation EqualBorderCellFlowLayout
- (nullable NSArray<__kindof UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect {
NSArray *attributes = [super layoutAttributesForElementsInRect:rect];
for (UICollectionViewLayoutAttributes *attribute in attributes) {
if (self.borderWidth) {
CGFloat origin_x = attribute.frame.origin.x;
CGFloat origin_y = attribute.frame.origin.y;
CGFloat width = attribute.frame.size.width;
CGFloat height = attribute.frame.size.height;
if (attribute.frame.origin.x == 0) {
width = width + self.borderWidth.floatValue;
}
height = height + self.borderWidth.floatValue;
attribute.frame = CGRectMake(origin_x, origin_y, width, height);
}
}
return attributes;
}
- (CGFloat)minimumLineSpacing {
return 0;
}
- (CGFloat)minimumInteritemSpacing {
return 0;
}
- (UIEdgeInsets)sectionInset {
return UIEdgeInsetsMake(0, 0, 0, 0);
}
#end
The main challenge here is that its hard to come up with pixel perfect border/cell measurements due to screen WIDTH limitations. ie, there is almost always leftover pixels that cannot be divided. For example, if you want a 3-column grid with 1px inner borders, and the screen width is 375px, it means each column width has to be 124.333 px, which is not possible, so if you round down and use 124px as width, you ll have 1 px leftover. (Isnt it crazy how much that extra minuscule pixel affects the design?)
An option is to pick a border width that gives you perfect numbers, however thats not ideal cause you should not let chance dictate your design. Also, it potentially wont be a future proof solution.
I looked into the app that does this best, instagram, and as expected, their borders look pixel perfect. I checked out the width of each column, and it turns out, the last column is not the same width, which confirmed my suspicion. For the human eye its impossible to tell that a grid column is narrower or wider by a pixel, but its DEFINITELY visible when the borders are not the same width. So this approach seems to be the best
At first I tried using a single reusable cell view and update the width depending whether is the last column or not. This approach kinda worked, but the cells looked glitchy, because its not ideal to resize a cell. So I just created an additional reusable cell that would go at the last column to avoid resizing.
First, set the width and spacing of your collection and register 2 reusable identifiers for your collection view cell
let borderWidth : CGFloat = 1
let columnsCount : Int = 3
let cellWidth = (UIScreen.main.bounds.width - borderWidth*CGFloat(columnsCount-1))/CGFloat(columnsCount)
let layout = UICollectionViewFlowLayout()
layout.estimatedItemSize = CGSize(width: cellWidth, height: cellWidth)
layout.minimumLineSpacing = borderWidth
layout.minimumInteritemSpacing = borderWidth
collectionView = UICollectionView(frame: self.frame, collectionViewLayout: layout)
collectionView.register(CustomCollectionViewCell.self, forCellWithReuseIdentifier: lastReuseIdentifier)
collectionView.register(CustomCollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier)
Then create a helper method to see if a cell at a certain index path is in the last column
func isLastColumn(indexPath: IndexPath) -> Bool {
return (indexPath.row % columnsCount) == columnsCount-1
}
Update the cellForItemAt delegate method for your collection view, so the last column cell is used
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let identifier = isLastColumn(indexPath: indexPath) ? lastReuseIdentifier : reuseIdentifier
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: identifier, for: indexPath)
return cell
}
And finally, update the size of the cells programatically, so the last column cell takes over all the leftover available space
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let width = isLastColumn(indexPath: indexPath) ? UIScreen.main.bounds.width-(cellWidth+borderWidth)*CGFloat(columnsCount -1) : cellWidth
return CGSize(width: width, height: cellWidth)
}
The end result should look something like this (with bonus food pics):