UICollectionView spacing is incorrect every other row - ios

I am creating a custom flow layout for a UICollectionView, and I am trying to get the spacing right. Despite setting minimumLineSpacing = 2 every other line, there is only 1px between each row of images, as can be seen here. I believe this is because Apple now uses points instead of pixels, but how do you fix this? Here is my code in my CollectionViewController that sets the flow layout:
let screenWidth = self.collectionView?.bounds.size.width
let flowLayout: UICollectionViewFlowLayout = UICollectionViewFlowLayout()
flowLayout.minimumInteritemSpacing = 2
flowLayout.minimumLineSpacing = 2
let totalSpacing = flowLayout.minimumInteritemSpacing * 4.0
let imageSize = (screenWidth!-totalSpacing)/4.0
flowLayout.itemSize = CGSize(width: imageSize, height: imageSize)

I figured it out. If the height of the cell is a repeating float, the spacing between each row will alternate, every other row. For instance, if I want to fit three cells with 2 points spacing between each one, each cell will be 123.6666666667 points wide on an iPhone 6. (An iPhone 6 is 375 points across, leading to the calculation (375-4)/3 = 123.6666666667). If I proceed to use the same value for height as I do width, the last 2/3 of a point will cause the rows' spacing to alternate, as seen here.
What I've found is that the cell height does not necessarily have to be an int to fix this; setting it to say 123.5 will solve this problem, but ints of course will also work.

Related

CollectionView won't stop complaining about undefined behaviour

I have a set of 4 different elements that can be shown in a small horizontal collectionView. There will only be one row, and the collectionView and the elements will always be the same height as each other - however, it needs to be dynamic, in case people increase their text size (accessibility larger text).
To make this work, I have a "spacer" label next to the CollectionView, and tell the collectionView to be the same height as the label, and I do the same in all the cells. They all have the same font.
When I try to run it, the CollectionView prints out this:
The behavior of the UICollectionViewFlowLayout is not defined because:
the item height must be less than the height of the UICollectionView
minus the section insets top and bottom values,
minus the content insets top and bottom values.
Please check the values returned by the delegate.
The relevant UICollectionViewFlowLayout instance is <...>,
and it is attached to <..; frame = (0 438.5; 414 29.5);
clipsToBounds = YES; autoresize = RM+BM; gestureRecognizers = <..>;
layer = <CALayer:..>; contentOffset: {0, 0}; contentSize: {32, 29.5};
adjustedContentInset: {0, 0, 0, 0}> collection view layout: <...>.
Make a symbolic breakpoint at UICollectionViewFlowLayoutBreakForInvalidSizes
to catch this in the debugger.
But everything I can find returns 29.5 or less.
collectionView.contentInsetAdjustmentBehavior = .never
flowLayout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
ContentInset is .zero, sectionInset is 0 for top and bottom, lineSpacing and interItem-spacing is 0, scrollDirection = .horizontal..
I have not overridden any sizeForItem or anything like that, because it should happen automatically, and I shouldn't have to create custom height logic.
I have tried this in each cell:
override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
setNeedsLayout()
layoutIfNeeded()
layoutAttributes.frame.size = contentView.systemLayoutSizeFitting(UICollectionViewCell.layoutFittingCompressedSize)
print("Height: ", layoutAttributes.size)
return layoutAttributes
}
But it just prints out 29.5, which should be correct.
It does print it out after the warnings though, so something happens before this.
When I print out the height of the cell directly after its initialization like this:
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let item = items[indexPath.row]
if let someItem = item as? SomeCellViewModel, let cell = self.dequeueReusableCell(withReuseIdentifier: "SomeIdentifier", for: indexPath) as? SomeCell{
cell.bind(with: someItem)
print("height: ", cell.bounds.size.height)
return cell
}else{ ...
it prints out "50.0". And as I understand, that's the default "Item Size", but I've changed all that! In Storyboard/IB/Xib, the "Collection View Flow Layout" was 50x50, but I have changed it to different things (0x0, 1x1, 20x20, tested a lot). Still it prints out 50.0. The xibs for the cells aren't even 50 in their file. If I try to use the systemLayoutSize-thing directly here to set its frame.size, it warns me about an autoresizing constraint that wants to be 0. I assume I shouldn't be doing such logic here anyway, so I nevermind this.
Since the warning says items "must be less than the height minus X minus Y" (not less than or equal to), I tried making the CollectionView a point taller than the spacer-logic, making the CollectionView 30.5 and the cells 29.5, but I still get the warning.
I have tried setting the symbolic breakpoint, but there is nothing of value there..
The thing is - it looks and behaves correct - on iOS 12. It does not work on iOS 11. There might be a separat constraint-issue within the elements that makes it wrong on iOS 11, I'm looking into it. Either way;
Why am I receiving this warning?
Edit: if I implement sizeForItemAt:IndexPath and statically tell it to be
width:100, height: 29.5, there is no warning at all. Everything is good. But I don't want to do that. I shouldn't need to calculate the individual height like this. And I don't really want to deal with prototype-cells either.
This should work.

UIStackView proportional layout with only intrinsicContentSize

I'm experiencing problems with layout of arranged subviews in UIStackView and was wondering if someone could help me understand what's going on.
So I have UIStackView with some spacing (for example 1, but this does not matter) and .fillProportionally distribution. I'm adding arranged subviews with only intrinsicContentSize of 1x1 (could be anything, just square views) and I need them to be stretched proportionally within stackView.
The problem is that if I add views without actual frame, only with intrinsic sizes, then I get this wrong layout
Otherwise, if I add views with frames of the same size, everything works as expected,
but I really prefer not to set view's frame at all.
I'm pretty sure that this is all about hugging and compression resistance priority, but can't figure out what right answer is.
Here is an Playground example:
import UIKit
import PlaygroundSupport
class LView: UIView {
// If comment this and leave only intrinsicContentSize - result is wrong
convenience init() {
self.init(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
}
// If comment this and leave only convenience init(), then everything works as expected
public override var intrinsicContentSize: CGSize {
return CGSize(width: 1, height: 1)
}
}
let container = UIView(frame: CGRect(x: 0, y: 0, width: 300, height: 300))
container.backgroundColor = UIColor.white
let sv = UIStackView()
container.addSubview(sv)
sv.leftAnchor.constraint(equalTo: container.leftAnchor).isActive = true
sv.rightAnchor.constraint(equalTo: container.rightAnchor).isActive = true
sv.topAnchor.constraint(equalTo: container.topAnchor).isActive = true
sv.bottomAnchor.constraint(equalTo: container.bottomAnchor).isActive = true
sv.translatesAutoresizingMaskIntoConstraints = false
sv.spacing = 1
sv.distribution = .fillProportionally
// Adding arranged subviews to stackView, 24 elements with intrinsic size 1x1
for i in 0..<24 {
let a = LView()
a.backgroundColor = (i%2 == 0 ? UIColor.red : UIColor.blue)
sv.addArrangedSubview(a)
}
sv.layoutIfNeeded()
PlaygroundPage.current.liveView = container
This is obviously a bug in the implementation of UIStackView (i.e. a system bug).
DonMag already gave a hint pointing in the right direction in his comment:
When you set the stack view's spacing to 0, everything works as expected. But when you set it to any other value, the layout breaks.
Here's the explanation why:
ℹ️ For the sake of simplicity I will assume that the stack view has
a horizontal axis and
10 arranged subviews
With the .fillProportionally distribution UIStackView creates system-constraints as follows:
For each arranged subview, it adds an equal width constraint (UISV-fill-proportionally) that relates to the stack view itself with a multiplier:
arrangedSubview[i].width = multiplier[i] * stackView.width
If you have n arranged subviews in the stack view, you get n of these constraints. Let's call them proportionalConstraint[i] (where i denotes the position of the respective view in the arrangedSubviews array).
These constraints are not required (i.e. their priority is not 1000). Instead, the constraint for the first element in the arrangedSubviews array is assigned a priority of 999, the second is assigned a priority of 998 etc.:
proportionalConstraint[0].priority = 999
proportionalConstraint[1].priority = 998
proportionalConstraint[2].priority = 997
proportionalConstraint[3].priority = 996
...
proportionalConstraint[n–1].priority = 1000 – n
This means that required constraints will always win over these proportional constraints!
For connecting the arranged subviews (possibly with a spacing) the system also creates n–1 constraints called UISV-spacing:
arrangedSubview[i].trailing + spacing = arrangedSubview[i+1].leading
These constraints are required (i.e. priority = 1000).
(The system will also create some other constraints (e.g. for the vertical axis and for pinning the first and last arranged subview to the edge of the stack view) but I won't go into detail here because they're not relevant for understanding what's going wrong.)
Apple's documentation on the .fillProportionally distribution states:
A layout where the stack view resizes its arranged views so that they fill the available space along the stack view’s axis. Views are resized proportionally based on their intrinsic content size along the stack view’s axis.
So according to this the multiplier for the proportionalConstraints should be computed as follows for spacing = 0:
totalIntrinsicWidth = ∑i intrinsicWidth[i]
multiplier[i] = intrinsicWidth[i] / totalIntrinsicWidth
If our 10 arranged subviews all have the same intrinsic width, this works as expected:
multiplier[i] = 0.1
for all proportionalConstraints. However, as soon as we change the spacing to a non-zero value, the calculation of the multiplier becomes a lot more complex because the widths of the spacings have to be taken into account. I've done the maths and the formula for multiplier[i] is:
Example:
For a stack view configured as follows:
stackView.width = 400
stackView.spacing = 2
the above equation would yield:
multiplier[i] = 0.0955
You can prove this correct by adding it up:
(10 * width) + (9 * spacing)
= (10 * multiplier * stackViewWidth) + (9 * spacing)
= (10 * 0.0955 * 400) + (9 * 2)
= (0.955 * 400) + 18
= 382 + 18
= 400
= stackViewWidth
However, the system assigns a different value:
multiplier[i] = 0.0917431
which adds up to a total width of
(10 * width) + (9 * spacing)
= (10 * 0.0917431 * 400) + (9 * 2)
= 384,97
< stackViewWidth
Obviously, this value is wrong.
As a consequence the system has to break a constraint. And of course, it breaks the constraint with the lowest priority which is the proportionalConstraint of the last arranged subview item.
That's the reason why the last arranged subview in your screenshot is stretched.
If you try out different spacings and stack view widths you'll end up with all sorts of weird-looking layouts. But they all have one thing in common:
The spacings always take precedence. (If you set the spacing to a greater value like 30 or 40 you'll only see the first two or three arranged subviews because the rest of the space is fully occupied by the required spacings.)
To sum things up:
The .fillProportionally distribution only works properly with spacing = 0.
For other spacings the system creates constraints with an incorrect multiplier.
This breaks the layout as
either one of the arranged subviews (the last) has to be stretched if the multiplier is smaller than it should be
multiple arranged subviews have to be compressed if the multiplier is greater than it should be.
The only way out of this is to "misuse" plain UIViews with a required fixed-width constraint as spacings between the views. (Normally, UILayoutGuides were introduced for this purpose but you cannot even use those either because you cannot add layout guides to a stack view.)
I'm afraid that due to this bug, there is no clean solution to do this.
As of Xcode 9.2 at least, the playground provided works as intended provided the initializer and the intrinsic content size are both commented out. In that case, the proportional filling works as expected, even with spacing > 0
This is an example with spacing = 5
That seems to make sense because the arranged subviews have no intrinsic content size and the StackView determines their widths to proportionally fill the designated axis.
If only the initializer is enabled (and not the intrinsic content size override), then I get this, which doesn't match the comment in the playground, so I guess this behaviour must have changed since the question was posted:
I don't understand that behaviour, because it would seem to me that setting the frame manually should be ignored when using Auto Layout.
If only the intrinsic content size override is enabled (and not the initializer) then I get the problematic image that originated this post (here with spacing = 5):
Essentially, the design now is conflicting and can't be realized, because views want to be 1 point wide, due to specified intrinsic content size. The total space here should be
24 views * 1 point/view + 23 spaces * 5 points/space = 139 < 300 = sv.bounds.width
with the last arranged view's constraint broken due to lowest priority as pointed out by Mischa.
Upon pixel-per-pixel inspection the first 23 views above are wider than 1 pixel though, 2 or 3 pixels actually, except for the last one, thus the math doesn't quite match, I don't know why, possibly rounding up of decimal numbers?
For reference, this is what it looks like in that case with intrinsic content size of width 5, still failing to satisfy the constraints.

Auto layout calculating same height for each collectionViewCell

So I have a collection view that uses a custom layout that I found on Github called CHTCollectionViewWaterfallLayout I like it so far, however I am looking to make the custom collection view cell I made dynamic in height, and I am having trouble connecting Auto Layout to calculate this. I shared a simple sample project on Github that generates random string sizes and displays them, only problem is that Auto Layout generates the same cell height for each Collection View Cell. The project can be found here.
To give you a run down of my thought process, I calculate the cell height by using the method CHTCollectionViewDelegateWaterfallLayout sizeForItemAtIndexPath. Since I also use the delegates method columnCountforSection my thought is since I provide a finite number of columns based on the orientation, I take the collectionView frame width and I divide by the number of columns to get me my width for the cell.
func collectionView (collectionView: UICollectionView,layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize
{
var cell = dict["heightCell"] as? CollectionViewCell
let randomString = RadomStrings[indexPath.item]
let float = CGFloat(columnCount)
let width = collectionView.bounds.size.width / float
if cell == nil {
cell = NSBundle.mainBundle().loadNibNamed("CollectionViewCell", owner: self, options: nil)[0] as? CollectionViewCell
dict["heightCell"] = cell
}
cell?.RandomStringLabel.text = randomString
cell!.frame = CGRectMake(0, 0, CGRectGetWidth(collectionView.bounds), CGRectGetHeight(cell!.frame))
cell!.setNeedsLayout()
cell!.layoutIfNeeded()
var size = cell?.contentView.systemLayoutSizeFittingSize(UILayoutFittingCompressedSize)
size?.width = width
return CGSize(width: width, height: size!.height)
}
My constraints are pretty basic. I have a constraint on each axis. One on the top of the content view, leading, trailing and bottom. I also have a height on the label that is greater than or equal to 45.
Using Auto Layout to calculate TableViewCell heights is easy to me and I like it because I like the DRY principle behind this height calculation approach. So I would like to keep this height calculation process the same throughout my app. CollectionViews are a relatively new layout process for me, so I would love to learn what I am doing wrong here. Hopefully I am clear for everyone, thanks!
I figured it out! What I didn't do is put a width constraint on the custom cell I created! Duh!

setting constant number of columns & weird collectionView width

I'm trying to set a number of columns in collection view to always be 3, regardless of device.
I'm using this code in viewDidLoad method:
let width = CGRectGetWidth(self.collectionView.frame)/3.0
let layout = self.collectionView.collectionViewLayout as! UICollectionViewFlowLayout
layout.itemSize = CGSizeMake(width, width)
layout.minimumInteritemSpacing = 0
layout.sectionInset = UIEdgeInsetsZero
layout.scrollDirection = UICollectionViewScrollDirection.Vertical
print("width:\(width), collectionViewWidth:\(CGRectGetWidth(self.collectionView!.frame)), mainViewWidth:\(CGRectGetWidth(self.view.frame))")
I always get only 1 column. The print method return some useful debugging information. They state the following:
width:186.666666666667,
collectionViewWidth:560.0,
mainViewWidth:320.0
What is going on? How is the collectionViewWidth bigger than main's? What is the correct way of setting a constant number of columns? I specified the size of collectionView using constraints in Interface Builder and it should be less than mainView's.

Set/Override Padding In UICollectionView Between Cells

I have a UICollectionView and I'm running into an issue with getting the padding between cells. In theory, I should be able to divide the screen by 4, and I can get a cellsize that has 4 images which perfectly take up the screen width. But, it chooses not to do that. Instead it creates 3 images with HUGE padding as shown below:
_cellSize = CGSizeMake(UIScreen.mainScreen().bounds.width/4,UIScreen.mainScreen().bounds.width/4)
So I decreased the cell size a bit to create some padding, and this was closer but I need about half this padding:
_cellSize = CGSizeMake(UIScreen.mainScreen().bounds.width/4.4,UIScreen.mainScreen().bounds.width/4.4)
So I tried making the cells a bit bigger and it rolls over to the next row and puts 3 items with HUGE padding again.
_cellSize = CGSizeMake(UIScreen.mainScreen().bounds.width/4.3,UIScreen.mainScreen().bounds.width/4.3)
Can I specify the padding between cells without it deciding that if the padding is below "x" amount that it creates a new row instead? Like can I set the threshold padding before a new row to say, 0?
I want to make it this thin:
You only need to do the correct calculation that includes the spaces you want to the edges as well as the space between the cells. For instance, if you want 3 cells across with 5 points to the edges and between the cells, you should have something like this,
override func viewDidLoad() {
super.viewDidLoad()
var layout = self.collectionView.collectionViewLayout as UICollectionViewFlowLayout
layout.sectionInset = UIEdgeInsetsMake(0, 5, 0, 5);
layout.minimumInteritemSpacing = 5; // this number could be anything <=5. Need it here because the default is 10.
layout.itemSize = CGSizeMake((self.collectionView.frame.size.width - 20)/3, 100) // 20 is 2*5 for the 2 edges plus 2*5 for the spaces between the cells
}

Resources