Self sizing UICollectionView with FlowLayout and variable number of cells - ios

Please note that this is NOT a question about self sizing UICollectionViewCell.
Is it possible to create self sizing UICollectionView (with UICollectionViewFlowLayout) size of which depends on cells inside it?
I have a collection view with variable number of cells. I would like to constrain width of the collection view and then allow it to expand vertically depending on quantity of cells.
This question is similar to this one CollectionView dynamic height with Swift 3 in iOS but I have multiple cells per row.
Bonus points, if one could still use self sizing cells inside of collection view but it is ok if collection view delegate provides cell sizes.

I don't have enough reputation to comment, but I think
this is the answer
that you are looking for. Code below:
class DynamicCollectionView: UICollectionView {
override func layoutSubviews() {
super.layoutSubviews()
if bounds.size != intrinsicContentSize() {
invalidateIntrinsicContentSize()
}
}
override func intrinsicContentSize() -> CGSize {
return self.contentSize
}
}

#Michal Gorzalczany's answer led me to this answer (for my specific case):
Subclass UICollectionView
class DynamicCollectionView : UICollectionView {
weak var layoutResp : LayoutResponder?
override func invalidateIntrinsicContentSize() {
super.invalidateIntrinsicContentSize()
self.layoutResp?.updateLayout()
}
override var intrinsicContentSize : CGSize {
return self.contentSize
}
override var contentSize: CGSize {
get { return super.contentSize }
set {
super.contentSize = newValue
self.invalidateIntrinsicContentSize()
}
}
}
Protocol LayoutResponder should be adopted by the view which deals on a high level with layout of collection view (I have a relatively complex layout).
protocol LayoutResponder : class {
func updateLayout()
}
extension RespView : LayoutResponder {
func updateLayout() {
self.layoutResp?.setNeedsLayout()
self.layoutResp?.layoutIfNeeded()
}
}
In my case I actually forward updateLayout() even further up the chain.
I guess for simpler layouts you can skip step 2 alltogether.
This is in my opinion is a bit "hacky" so if someone has a better approach I would appreciate if you share.

Related

UITableView + AutoLayout + Intrinsic Size: Where/when to calculate size?

I have create a custom view MyIntrincView which calculates its height automatically when setting its content. This works fine both in simulator and InterfaceBuilder.
However, when placing MyIntrinsicView inside a UITableViewCell, the cell height is not calculated correctly. Instead of automatically adopting the cell height to the intrinsic height of the view, all cell keep the same, initial height.
// A simple, custom view with intrinsic height. The height depends on
// the value of someProperty. When the property is changed setNeedsLayout
// is set and the height changes automatically.
#IBDesignable class MyIntrinsicView: UIView {
#IBInspectable public var someProperty: Int = 5 {
didSet { setNeedsLayout() }
}
override func layoutSubviews() {
super.layoutSubviews()
calcContent()
}
func calcContent() {
height = CGFloat(20 * someProperty)
invalidateIntrinsicContentSize()
}
#IBInspectable var height: CGFloat = 50
override var intrinsicContentSize: CGSize {
return CGSize(width: super.intrinsicContentSize.width, height: height)
}
override func prepareForInterfaceBuilder() {
super.prepareForInterfaceBuilder()
invalidateIntrinsicContentSize()
}
}
// A simple cell which only contains a MyIntrinsicView subview. The view
// is attached to trailing, leading, top and bottom anchors of the cell.
// Thus the cell height should automatically match the height of the
// MyIntrinsicView
class MyIntrinsicCell: UITableViewCell {
#IBOutlet private var myIntrinsicView: MyIntrinsicView!
var someProperty: Int {
get { return myIntrinsicView.someProperty }
set {
myIntrinsicView.someProperty = newValue
// Cell DOES NOT rezise without manualle calling layoutSubviews()
myIntrinsicView.layoutSubviews()
}
}
}
...
// Simple tableView delegate which should create cells of different heights
// by giving each cell a different someProperty
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "IntrinsicCell", for: indexPath) as? MyIntrinsicCell ?? MyIntrinsicCell()
// Give all cell a different intrinsic height by setting someProperty to rowIndex
cell.someProperty = indexPath.row
return cell
}
I would expect, that each cell has a different height (20 * someProperty = 20 * indexPath.row). However, instead all cell have the same, initial height.
Only when explicitly calling myIntrinsicView.layoutSubviews() the cells are created with the correct height.
It seems that the tableView does not call myIntrinsicView.layoutSubviews(). Why is this?
When using a UILabel instad of MyIntrinsicView as cell content with different text lengths, everything works as expected. Thus the overall tableView setup is correct (= cell sizes are calculated automatically) and there has to be way to use intrinsic sizes correctly in UITableView as well. So, what exactly is the correct way to do this?
As with your previous question here I think you're not really understanding what intrinsicContentSize does and does not do.
When setting the text of a UILabel, yes, its intrinsicContentSize changes, but that's not all that happens.
You also don't want to do what you're trying inside layoutSubviews() ... if your code does trigger a change, you'll get into an infinite recursion loop (again, as with your previous question).
Take a look at modifications to your code:
// A simple, custom view with intrinsic height. The height depends on
// the value of someProperty. When the property is changed setNeedsLayout
// is set and the height changes automatically.
#IBDesignable class MyIntrinsicView: UIView {
#IBInspectable public var someProperty: Int = 5 {
didSet {
calcContent()
setNeedsLayout()
}
}
func calcContent() {
height = CGFloat(20 * someProperty)
invalidateIntrinsicContentSize()
}
#IBInspectable var height: CGFloat = 50
override var intrinsicContentSize: CGSize {
return CGSize(width: super.intrinsicContentSize.width, height: height)
}
override func prepareForInterfaceBuilder() {
super.prepareForInterfaceBuilder()
invalidateIntrinsicContentSize()
}
}
// A simple cell which only contains a MyIntrinsicView subview. The view
// is attached to trailing, leading, top and bottom anchors of the cell.
// Thus the cell height should automatically match the height of the
// MyIntrinsicView
class MyIntrinsicCell: UITableViewCell {
#IBOutlet private var myIntrinsicView: MyIntrinsicView!
var someProperty: Int {
get { return myIntrinsicView.someProperty }
set {
myIntrinsicView.someProperty = newValue
}
}
}
Two side notes...
1 - Your posted code shows you calling myIntrinsicView.layoutSubviews()... from Apple's docs:
You should not call this method directly. If you want to force a layout update, call the setNeedsLayout method instead to do so prior to the next drawing update. If you want to update the layout of your views immediately, call the layoutIfNeeded method.
2 - For the direction it looks like you're headed, you would probably be better off manipulating constraint constants, rather than intrinsic content size.

Nest UICollectionView into UITableViewCell

I'm trying to build something
I'm trying to build a tag list view using UICollectionView and nest it into my custom UITableViewCell.
What do I have now
After searching the internet, I find the key to the problem:
Subclass UICollectionView and implement it's intrinsic content size property.
However, when I nest my custom UICollectionView into a self-sizing UITableViewCell, the whole thing doesn't work well. The layout is broken.
No matter how do I change the code, I get one of the following 3 buggy UIs.
The height of the collection view is always wrong, either too small or too large, it can not hug it's content just right.
When I use Debug View Hierarchy to check the views, I find that although the UI is broken, the contentSize property of the collection view has a correct value. It seems that the content size property can not be reflected to the UI in time.
class IntrinsicCollectionView: UICollectionView {
override var contentSize: CGSize {
didSet {
invalidateIntrinsicContentSize()
}
}
override var intrinsicContentSize: CGSize {
layoutIfNeeded()
return CGSize(width: UIView.noIntrinsicMetric, height: collectionViewLayout.collectionViewContentSize.height)
}
override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
super.init(frame: frame, collectionViewLayout: layout)
isScrollEnabled = false
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
There are many solution about how to create a custom UICollectionView with intrinsic content size. Some of them can work correctly. But when nesting them into a UITableViewCell, none of them works well.
There are also some answer about just nest one UICollectionView into UITableViewCell without other views. But if there are also some UILabel in UITableViewCell, it won't work.
I upload all the code to github. https://github.com/yunhao/nest-collectionview-in-tableviewcell
Thank you!
I'll try to explain what's going on....
To make it easy to understand, in your ListViewController let's work with just one row to begin with:
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 1 // items.count
}
In your ListViewCell class, add these lines at the end of prepareViews():
// so we can see the element frames
titleLabel.backgroundColor = .green
subtitleLabel.backgroundColor = .cyan
collectionView.backgroundColor = .yellow
In your IntrinsicCollectionView class, let's add a print() statement to give us some information:
override var intrinsicContentSize: CGSize {
layoutIfNeeded()
// add this line
print("collView Width:", frame.width, "intrinsic height:", collectionViewLayout.collectionViewContentSize.height)
return CGSize(width: UIView.noIntrinsicMetric, height: collectionViewLayout.collectionViewContentSize.height)
}
When I then run the app on an iPhone 8, I get this result:
and I see this in the debug console:
collView Width: 66.0 intrinsic height: 350.0
collView Width: 343.0 intrinsic height: 30.0
What that tells me is that the collection view is asked for its intrinsicContentSize before it has a complete frame.
At that point, it fills in its cells, and its layout ends up with a .collectionViewContentSize.height of 350 (this row has six "tags" cells).
Auto-layout then performs another pass... the collection view now has a valid frame width (based on the cell width)... and the cells are re-laid-out.
Unfortunately, the table view has already set the row height(s), based on the initial collection view intrinsicContentSize.height.
So, two steps that may (should) fix this:
In ListViewCell, invalidate the content size of the collection view when you get the tags:
func setTags(_ tags: [String]) {
self.tags = tags
collectionView.reloadData()
// add this line
collectionView.invalidateIntrinsicContentSize()
}
Then, in ListViewController, we need to reload the table after its frame has changed:
// add this var
var currentWidth: CGFloat = 0
// implement viewDidLayoutSubviews()
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if view.frame.width != currentWidth {
currentWidth = view.frame.width
tableView.reloadData()
}
}
That seems (with very quick testing) to give me reliable results:
and on device rotation:

Nested tableView causing childTableView's content getting clipped

I am working with nested tableViews having a parentTableView and childTableView .
parentTableView has the cards and childTableView will have the data which will be available at runtime so I have used resizable cells
Now my problem is when I initially load the tableviews, the childTableView data is getting clipped
But when i reload the parentTableView (I have managed a logic for that) using reloadData(), the data is displaying correctly
I have subclassed my childTableView like --
class SectionsTableView: UITableView {
override var intrinsicContentSize: CGSize {
self.layoutIfNeeded()
return self.contentSize
}
override var contentSize: CGSize {
didSet{
self.invalidateIntrinsicContentSize()
}
}
override func reloadData() {
super.reloadData()
self.invalidateIntrinsicContentSize()
}
}
This reloadData() is a workaround but it creates issues in other part of my code which I have to explicitly deal with. Has anyone experienced the same ?

How to make all items in a section of a UICollectionView float over the scroll view?

I want the items of one section in a UICollectionView to remain stationary while the rest of the items inside the UICollectionView are being scrolled.
I tried to achieve this by setting Autolayout constraint that pin the items to the superview of the UICollectionView. However, this does not seem to work because the constraints complain about UICollectionViewCell and the UICollectionView's superview not having a common ancestor.
Is there any other way to achieve it?
Thanks to Ad-J's comment I was able to implement the solution.
I needed to override UICollectionViewFlowLayout and implement the following methods:
override func prepareLayout() {
super.prepareLayout()
//fill layoutInfo of type [NSIndexPath:UICollectionViewLayoutAttributes]
//with layoutAttributes you need in your layout
if let cv = self.collectionView {
for (indexPath, tileToFloat) in layoutInfo {
if indexPath.section == 0 {
var origin = tileToFloat.frame.origin
origin.y += cv.contentOffset.y + cv.contentInset.top
tileToFloat.frame = CGRect(origin: origin, size: tileToFloat.size)
}
tileToFloat.zIndex = 1
}
}
}
override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool {
return true
}
This will make all items in the first section stationary.

incorrect order of Accessibility in UICollectionView

So I have a UICollectionView in a UIViewController which is one of the root view controllers in the tab bar. I set a contentInset for the UICollectionView so I can Add a Label to the top of the collectionView, at which point it would mean that the UILabel is part of the collectionView but is not part of the headerView of the collectionView. To achieve the addition of the UILabel to the UICollectionView, I use
collectionView.addSubview(theLabel)
and I turn voice over on and run the application. what happens is that the voiceover goes through all the UICollectionViewCells in the correct order all the way to the last CollectionViewCell to begin, then goes to the Label which is at the top of the collectionView and then goes to the tabBar. I tried the answer in this Change order of read items with VoiceOver, but had no luck, this solution did change the order of
self.accessibilityElements
to the way I want, except the voice over doesn't really follow the order in self.accsibilityElements and I am not really sure what is going on, has anyone come across the same trouble with the accessibility order being screwed up because "addsubView" was used on the UICollectionView. IF (and I say IF, because I don't think anyone would have added a subView to a collectionView this way) anyone has any thoughts please help me out here, been stuck with this bug the longest time.
Edit
class CollectionViewSubViewsAddedByTags: UICollectionView {
override func awakeFromNib() {
super.awakeFromNib()
}
var accessibilityElementsArray = [AnyObject]()
override var accessibilityElements: [AnyObject]?{
get{
return accessibilityElementsArray
}
set(newValue) {
super.accessibilityElements = newValue
}
}
override func accessibilityElementCount() -> Int {
return accessibilityElementsArray.count
}
override func accessibilityElementAtIndex(index: Int) -> AnyObject? {
return self.accessibilityElementsArray[index]
}
override func indexOfAccessibilityElement(element: AnyObject) -> Int {
return (accessibilityElementsArray.indexOf({ (element) -> Bool in
return true
}))!
}
override func didAddSubview(subview: UIView) {
super.didAddSubview(subview)
accessibilityElementsArray.append(subview)
accessibilityElementsArray.sortInPlace { $0.tag<($1.tag)}
}
override func willRemoveSubview(subview: UIView) {
super.willRemoveSubview(subview)
if let index = (accessibilityElementsArray.indexOf({ (element) -> Bool in
return true
})) {
accessibilityElementsArray.removeAtIndex(index)
}
}
}
Thanks,
Shabri
I've run into the same issue - I'm using a top inset on our UICollectionView to allow room for a header that slides on/off screen with scroll. If I use Voice Over with this layout then the entire system gets confused and the focus order is incorrect. What I've done to get around this is use an alternate layout when VO is activated - instead of placing the header over the collection view with an inset, I place the header vertically above the collection view and set 0 top inset on the collection view.

Resources