Fixed spacing between item in UICollectionView with flow layout [duplicate] - ios

This question already has answers here:
How do you determine spacing between cells in UICollectionView flowLayout
(12 answers)
Closed 5 years ago.
So, I'm trying to implement a tag list with UICollectionView. I'm following this tutorial: http://www.cocoanetics.com/2013/08/variable-sized-items-in-uicollectionview/
The issue is flow layout in UICollectionView tries to space items on the same row evenly.
As a developer, I can only specify minimumInteritemSpacingForSectionAtIndex, it's really up to the UICollectionView to determine the actual item spacing.
But what I really want to achieve is like this:
Any ideas?

I've converted Milo's solution to Swift: https://github.com/Coeur/UICollectionViewLeftAlignedLayout/
It simply subclasses UICollectionViewFlowLayout.
import UIKit
/**
* Simple UICollectionViewFlowLayout that aligns the cells to the left rather than justify them
*
* Based on https://stackoverflow.com/questions/13017257/how-do-you-determine-spacing-between-cells-in-uicollectionview-flowlayout
*/
open class UICollectionViewLeftAlignedLayout: UICollectionViewFlowLayout {
open override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return super.layoutAttributesForElements(in: rect)?.map { $0.representedElementKind == nil ? layoutAttributesForItem(at: $0.indexPath)! : $0 }
}
open override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
guard let currentItemAttributes = super.layoutAttributesForItem(at: indexPath)?.copy() as? UICollectionViewLayoutAttributes,
let collectionView = self.collectionView else {
// should never happen
return nil
}
let sectionInset = evaluatedSectionInsetForSection(at: indexPath.section)
guard indexPath.item != 0 else {
currentItemAttributes.leftAlignFrame(withSectionInset: sectionInset)
return currentItemAttributes
}
guard let previousFrame = layoutAttributesForItem(at: IndexPath(item: indexPath.item - 1, section: indexPath.section))?.frame else {
// should never happen
return nil
}
// if the current frame, once left aligned to the left and stretched to the full collection view
// width intersects the previous frame then they are on the same line
guard previousFrame.intersects(CGRect(x: sectionInset.left, y: currentItemAttributes.frame.origin.y, width: collectionView.frame.width - sectionInset.left - sectionInset.right, height: currentItemAttributes.frame.size.height)) else {
// make sure the first item on a line is left aligned
currentItemAttributes.leftAlignFrame(withSectionInset: sectionInset)
return currentItemAttributes
}
currentItemAttributes.frame.origin.x = previousFrame.origin.x + previousFrame.size.width + evaluatedMinimumInteritemSpacingForSection(at: indexPath.section)
return currentItemAttributes
}
func evaluatedMinimumInteritemSpacingForSection(at section: NSInteger) -> CGFloat {
return (collectionView?.delegate as? UICollectionViewDelegateFlowLayout)?.collectionView?(collectionView!, layout: self, minimumInteritemSpacingForSectionAt: section) ?? minimumInteritemSpacing
}
func evaluatedSectionInsetForSection(at index: NSInteger) -> UIEdgeInsets {
return (collectionView?.delegate as? UICollectionViewDelegateFlowLayout)?.collectionView?(collectionView!, layout: self, insetForSectionAt: index) ?? sectionInset
}
}
extension UICollectionViewLayoutAttributes {
func leftAlignFrame(withSectionInset sectionInset: UIEdgeInsets) {
frame.origin.x = sectionInset.left
}
}

Apple has provided the UICollectionViewFlowLayout class for us developers, which should be enough to solve the 'typical use case' of collection views. However, I believe you're correct in your assessment that the default layout does not allow you to create this tag cloud effect. If you need something different from the normal flow layout, you'll have to write your own subclass of UICollectionViewLayout.
Apple covers this topic in their 2012 WWDC session titled, "Advanced Collection Views and Building Custom Layouts"
Some additional Apple docs: https://developer.apple.com/library/ios/documentation/WindowsViews/Conceptual/CollectionViewPGforIOS/CreatingCustomLayouts/CreatingCustomLayouts.html
At the risk of seeming biased, I also wrote a quick blog post running through the basic steps: http://bradbambara.wordpress.com/2014/05/24/getting-started-with-custom-uicollectionview-layouts/
Hope that helps.

Related

Wrong frame from layoutAttributesForItem when using UICollectionViewFlowLayout with an UIDynamicAnimator

So I implemented an UICollectionView with a custom UICollectionViewFlowLayout containing a UIDynamicAnimator for animating my cells upon scrolling. I used the 2013 WWDC reference to replicate the Message bounce.
Everything is working fine, except that I noticed a weird cut in one side of my rounded views added in my cell. See screenshots below :
Is a screenshot of a cell when my FlowLayout is initialized with an UIDynamicAnimator. (Nothing fancy, I'm just showing one item which I
added to a UICollisionBehavior linked to my animator, see code below)
Is the same cell when using a simple FlowLayout without animator
If we pay close attention, we can notice that n°1 is missing a one-pixel vertical line on the red side, and that green side has one more.
This result in a cut effect on every subviews contained in my cell (no matter if its a view, an image etc.)
So I investigated to understand what was causing this, and I found that from the second pass into the method layoutAttributesForElements(in rect: CGRect) the returned x position was wrong.
My method sizeForItemAt() is returning a classic CGSize(width: collectionView.bounds.width, height: 100), but dynamicAnimator.items(in: rect) is returning a frame equal to CGRect(0.1666666666666572, 0.0, 375.0, 100.0) for my cell.
This x position is supposed to be 0 as I'm not applying any transform myself.
0.1666666666666572 being equal to 1/6, this looks like a float-precision issue.
Does anyone has an idea of what is causing this, and how to solve it ?
import Foundation
import UIKit
// Minimal implementation of https://developer.apple.com/videos/play/wwdc2013/217/ to reproduce 0.16667 error
class MinimalWWDCFlowLayout: UICollectionViewFlowLayout {
lazy var behavior: UICollisionBehavior = .init()
lazy var dynamicAnimator: UIDynamicAnimator = {
let res = UIDynamicAnimator(collectionViewLayout: self)
res.addBehavior(behavior)
return res
}()
override func prepare() {
super.prepare()
guard let items = super.layoutAttributesForElements(in: .init(origin: .zero, size: collectionViewContentSize)),
let firstItem = items.first else {
return
}
guard behavior.items.isEmpty else {
return
}
behavior.addItem(firstItem) // This create a debug log when called twice (no error, just a log)
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let items = dynamicAnimator.items(in: rect) as? [UICollectionViewLayoutAttributes]
guard let firstItem = items?.first else { return nil }
print("🕵️ item frame: \(firstItem.frame))") // This is returning frame.x = 0.1666..67. Calling super.layoutAttributesForElements(in:) instead is returning 0 as expected.
return items
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return dynamicAnimator.layoutAttributesForCell(at: indexPath)
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return false
}
}
Note: this seems to happen only on devices with 3x res. Running this code on an iPhone 11 works fine, but result in the described bug on an iPhone 11 Pro.

Align Nodes in tree hierarchy

Note:
I am looking for alternate solution, Since I have tried changing lots of Sprite Kit properties values, and came upto conclusion that it is not possible with those changes. So I ain't posting any code. You can try the mentioned library for more information.
Original Question:
I am using BLBubbleFilters library to create Bubbles.
This library used magnetic property to pull align items to the centre of the screen. And each time the screen opens the positioning of each node is different.
What have I tried so far?
I have tried playing around with the library and its physics attributes(e.g. Magnetic property) to achieve my goal.
The goal is:
Place 'Family' node over and above all nodes, and rest of the nodes below it.
What I thought is to that, each node will have a property for example "priority(Integer)". That is, the Priority 0 nodes will be placed above all nodes, priority 1 nodes below 0 priority nodes and so on.
Since SpriteKit deals with Physics properties, is there is any such property or way to achieve my goal?
See, how the family bubble is at the Top.
I had to do something like that and I did By creating a custom SelectInterestsCollectionViewFlowLayOut and I added the bubbles in the UICollectionViewCells.
The result was this.
Every cell is a white square and inside I have an image view with corner radius 1/2 * height.
The difference is that in my case I had the same bubble size for all my bubbles.
My code is:
enum InterestBubbleType {
case A
case B
case C
case D
case E
func getYPosition(cellSideSize: CGFloat) -> CGFloat {
let spaceFromTopForTwoCellsOnTop: CGFloat = cellSideSize / 2
switch self {
case .A:
return cellSideSize
case .B:
return cellSideSize * 2
case .C:
return spaceFromTopForTwoCellsOnTop
case .D:
return cellSideSize + spaceFromTopForTwoCellsOnTop
case .E:
return 0
}
}
}
class SelectInterestsCollectionViewFlowLayOut: UICollectionViewFlowLayout {
var cache: [UICollectionViewLayoutAttributes] = []
var layoutAttributes: [UICollectionViewLayoutAttributes] = []
var cellSideSize: CGFloat!
var xPosition: CGFloat = 0
var itemPositions: [InterestBubbleType] = [.A,.C,.D,.E,.A,.B,.C,.D,.E,.A,.B,.C,.D,.A,.B,.C,.D,.E,.A,.B]
var positionsToIncreaseXPosition = [0,2,5,7,10,12,14,16]
override func prepare() {
cache = []
super.prepare()
//Prepare constants
self.xPosition = 0
cellSideSize = self.collectionView!.height() / 3
self.configureCasheForTwentyInterests()
}
private func configureCasheForTwentyInterests() {
for i in 0...(self.itemPositions.count - 1) {
let indexPath = IndexPath(item: i, section: 0)
let xPosition = self.xPosition
let frame = CGRect(x: xPosition, y: self.itemPositions[i].getYPosition(cellSideSize: self.cellSideSize), width: self.cellSideSize, height: self.cellSideSize)
let atribute = UICollectionViewLayoutAttributes(forCellWith: indexPath)
atribute.frame = frame
cache.append(atribute)
if self.positionsToIncreaseXPosition.contains(i) {
self.xPosition += self.cellSideSize
}
}
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
self.layoutAttributes = []
for uiCollectionViewLayoutAttribute in self.cache {
self.layoutAttributes.append(uiCollectionViewLayoutAttribute)
}
return layoutAttributes
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return layoutAttributes[indexPath.row]
}
override var collectionViewContentSize: CGSize {
let totalWidth = cellSideSize * 9
return CGSize(width: totalWidth, height: self.collectionView!.height())
}
}
Now how it works is that in my collection view there are 5 different y positions that a bubble can be inside the collection view and they are A,B,C,D,E and I also keep in mind when you need to move 1 more x position which is 1 * collectionViewWidth size.
You can configure the code to achive your own result.
In my case I had a fixed number of bubbles and a fixed view to show them. The reason I chose to do it with collection view was because if the number change you can just add positions to change x position in positionsToIncreaseXPosition and it will work.

Swiping left to create a new collectionViewCell

I'm trying to implement a smoother way for a use to add new collectionViewCells for myCollectionView (which only shows one cell at a time). I want it to be something like when the user swipes left and if the user is on the last cell myCollectionView inserts a new cell when the user is swiping so that the user swipes left "into" the cell. And also I only allow the user to scroll one cell at a time.
EDIT:
So I think it is a bit hard to describe it in words so here is a gif to show what I mean
So in the past few weeks I have been trying to implement this in a number of different ways, and the one that I have found the most success with is by using the scrollViewWillEndDragging delegate method and I have implemented it like this:
func scrollViewWillEndDragging(scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
// Getting the size of the cells
let flowLayout = myCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
let cellWidth = flowLayout.itemSize.width
let cellPadding = 10.0 as! CGFloat
// Calculating which page "card" we should be on
let currentOffset = scrollView.contentOffset.x - cellWidth/2
print("currentOffset is \(currentOffset)")
let cardWidth = cellWidth + cellPadding
var page = Int(round((currentOffset)/(cardWidth) + 1))
print("current page number is: \(page)")
if (velocity.x < 0) {
page -= 1
}
if (velocity.x > 0) {
page += 1
}
print("Updated page number is: \(page)")
print("Previous page number is: \(self.previousPage)")
// Only allowing the user to scroll for one page!
if(page > self.previousPage) {
page = self.previousPage + 1
self.previousPage = page
}
else if (page == self.previousPage) {
page = self.previousPage
}
else {
page = self.previousPage - 1
self.previousPage = page
}
print("new page number is: " + String(page))
print("addedCards.count + 1 is: " + String(addedCards.count + 1))
if (page == addedCards.count) {
print("reloading data")
// Update data source
addedCards.append("card")
// Method 1
cardCollectionView.reloadData()
// Method 2
// let newIndexPath = NSIndexPath(forItem: addedCards.count - 1, inSection: 0)
// cardCollectionView.insertItemsAtIndexPaths([newIndexPath])
}
// print("Centering on new cell")
// Center the cardCollectionView on the new page
let newOffset = CGFloat(page * Int((cellWidth + cellPadding)))
print("newOffset is: \(newOffset)")
targetContentOffset.memory.x = newOffset
}
Although I think I have nearly got the desired result there are still some concerns and also bugs that I have found.
My main concern is the fact that instead of inserting a single cell at the end of myCollectionView I'm reloading the whole table. The reason that I do this is because if I didn't then myCollectionView.contentOffset wouldn't be changed and as a result when a new cell is created, myCollectionView isn't centered on the newly created cell.
1. If the user scrolls very slowly and then stops, the new cell gets created but then myCollectionView gets stuck in between two cells it doesn't center in on the newly created cell.
2. When myCollectionView is in between the second last cell and the last cell as a result of 1., the next time the user swipes right, instead of creating one single cell, two cells are created.
I've also used different ways to implement this behaviour such as using scrollViewDidScroll, and various other but to no avail. Can anyone point me in the right direction as I am kind of lost.
Here is a link to download my project if you want to see the interaction:
My Example Project
These the old methods if you're interested:
To do this I have tried 2 ways,
The first being:
func scrollViewWillEndDragging(scrollView: UIScrollView, withVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>) {
// page is the current cell that the user is on
// addedCards is the array that the data source works with
if (page == addedCards.count + 1) {
let placeholderFlashCardProxy = FlashCardProxy(phrase: nil, pronunciation: nil, definition: nil)
addedCards.append(placeholderFlashCardProxy)
let newIndexPath = NSIndexPath(forItem: addedCards.count, inSection: 0)
cardCollectionView.insertItemsAtIndexPaths([newIndexPath])
cardCollectionView.reloadData()
}
}
The problem with using this method is that:
Sometimes I will get a crash as a result of: NSInternalInconsistencyException', reason: 'Invalid update: invalid number of items in section 0.
When a new collectionCell is added it will sometimes display the input that the user has written from the previous cell (this might have sometimes to do with the dequeuing and re-use of cell, although again, I'm not sure and I would be grateful if someone answered it)
The inserting isn't smooth, I want the user to be able to swipe left at the last cell and "into" a new cell. As in if I am currently on the last cell, swiping left would put me automatically at the new cell, because right now when I swipe left a new cell is created by it doesn't center on the newly created cell
The second method that I am using is:
let swipeLeftGestureRecognizer = UISwipeGestureRecognizer(target: self, action: "swipedLeftOnCell:")
swipeLeftGestureRecognizer.direction = .Left
myCollectionView.addGestureRecognizer(swipeLeftGestureRecognizer)
swipeLeftGestureRecognizer.delegate = self
Although the swipe gesture very rarely responds, but if I use a tap gesture, myCollectionView always responds which is very weird (I know again this is a question on its own)
My question is which is the better way to implement what I have described above? And if none are good what should I work with to create the desired results, I've been trying to do this for two days now and I was wondering if someone could point me in the right direction. Thanks!
I hope this helps out in some way :)
UPDATE
I updated the code to fix the issue scrolling in either direction.
The updated gist can be found here
New Updated Gist
Old Gist
First I'm gonna define some model to for a Card
class Card {
var someCardData : String?
}
Next, create a collection view cell, with a card view inside that we will apply the transform to
class CollectionViewCell : UICollectionViewCell {
override init(frame: CGRect) {
super.init(frame: frame)
self.addSubview(cardView)
self.addSubview(cardlabel)
}
override func prepareForReuse() {
super.prepareForReuse()
cardView.alpha = 1.0
cardView.layer.transform = CATransform3DIdentity
}
override func layoutSubviews() {
super.layoutSubviews()
cardView.frame = CGRectMake(contentPadding,
contentPadding,
contentView.bounds.width - (contentPadding * 2.0),
contentView.bounds.height - (contentPadding * 2.0))
cardlabel.frame = cardView.frame
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
lazy var cardView : UIView = {
[unowned self] in
var view = UIView(frame: CGRectZero)
view.backgroundColor = UIColor.whiteColor()
return view
}()
lazy var cardlabel : UILabel = {
[unowned self] in
var label = UILabel(frame: CGRectZero)
label.backgroundColor = UIColor.whiteColor()
label.textAlignment = .Center
return label
}()
}
Next setup the view controller with a collection view. As you will see there is a CustomCollectionView class, which I will define near the end.
class ViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource {
var cards = [Card(), Card()]
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(collectionView)
collectionView.frame = CGRectMake(0, 0, self.view.bounds.width, tableViewHeight)
collectionView.contentInset = UIEdgeInsetsMake(0, 0, 0, contentPadding)
}
lazy var collectionView : CollectionView = {
[unowned self] in
// MARK: Custom Flow Layout defined below
var layout = CustomCollectionViewFlowLayout()
layout.contentDelegate = self
var collectionView = CollectionView(frame: CGRectZero, collectionViewLayout : layout)
collectionView.clipsToBounds = true
collectionView.showsVerticalScrollIndicator = false
collectionView.registerClass(CollectionViewCell.self, forCellWithReuseIdentifier: "CollectionViewCell")
collectionView.delegate = self
collectionView.dataSource = self
return collectionView
}()
// MARK: UICollectionViewDelegate, UICollectionViewDataSource
func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int {
return 1
}
func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return cards.count
}
func collectionView(collectionView : UICollectionView, layout collectionViewLayout:UICollectionViewLayout, sizeForItemAtIndexPath indexPath:NSIndexPath) -> CGSize {
return CGSizeMake(collectionView.bounds.width, tableViewHeight)
}
func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cellIdentifier = "CollectionViewCell"
let cell = collectionView.dequeueReusableCellWithReuseIdentifier(cellIdentifier, forIndexPath: indexPath) as! CollectionViewCell
cell.contentView.backgroundColor = UIColor.blueColor()
// UPDATE If the cell is not the initial index, and is equal the to animating index
// Prepare it's initial state
if flowLayout.animatingIndex == indexPath.row && indexPath.row != 0{
cell.cardView.alpha = 0.0
cell.cardView.layer.transform = CATransform3DScale(CATransform3DIdentity, 0.0, 0.0, 0.0)
}
return cell
}
}
UPDATED - Now For the really tricky part. I'm gonna define the CustomCollectionViewFlowLayout. The protocol callback returns the next insert index calculated by the flow layout
protocol CollectionViewFlowLayoutDelegate : class {
func flowLayout(flowLayout : CustomCollectionViewFlowLayout, insertIndex index : NSIndexPath)
}
/**
* Custom FlowLayout
* Tracks the currently visible index and updates the proposed content offset
*/
class CustomCollectionViewFlowLayout: UICollectionViewFlowLayout {
weak var contentDelegate: CollectionViewFlowLayoutDelegate?
// Tracks the card to be animated
// TODO: - Adjusted if cards are deleted by one if cards are deleted
private var animatingIndex : Int = 0
// Tracks thje currently visible index
private var visibleIndex : Int = 0 {
didSet {
if visibleIndex > oldValue {
if visibleIndex > animatingIndex {
// Only increment the animating index forward
animatingIndex = visibleIndex
}
if visibleIndex + 1 > self.collectionView!.numberOfItemsInSection(0) - 1 {
let currentEntryIndex = NSIndexPath(forRow: visibleIndex + 1, inSection: 0)
contentDelegate?.flowLayout(self, insertIndex: currentEntryIndex)
}
} else if visibleIndex < oldValue && animatingIndex == oldValue {
// if we start panning to the left, and the animating index is the old value
// let set the animating index to the last card.
animatingIndex = oldValue + 1
}
}
}
override init() {
super.init()
self.minimumInteritemSpacing = 0.0
self.minimumLineSpacing = 0.0
self.scrollDirection = .Horizontal
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// The width offset threshold percentage from 0 - 1
let thresholdOffsetPrecentage : CGFloat = 0.5
// This is the flick velocity threshold
let velocityThreshold : CGFloat = 0.4
override func targetContentOffsetForProposedContentOffset(proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
let leftThreshold = CGFloat(collectionView!.bounds.size.width) * ((CGFloat(visibleIndex) - 0.5))
let rightThreshold = CGFloat(collectionView!.bounds.size.width) * ((CGFloat(visibleIndex) + 0.5))
let currentHorizontalOffset = collectionView!.contentOffset.x
// If you either traverse far enought in either direction,
// or flicked the scrollview over the horizontal velocity in either direction,
// adjust the visible index accordingly
if currentHorizontalOffset < leftThreshold || velocity.x < -velocityThreshold {
visibleIndex = max(0 , (visibleIndex - 1))
} else if currentHorizontalOffset > rightThreshold || velocity.x > velocityThreshold {
visibleIndex += 1
}
var _proposedContentOffset = proposedContentOffset
_proposedContentOffset.x = CGFloat(collectionView!.bounds.width) * CGFloat(visibleIndex)
return _proposedContentOffset
}
}
And define the delegate methods in your view controller to insert a new card when the delegate tell it that it needs a new index
extension ViewController : CollectionViewFlowLayoutDelegate {
func flowLayout(flowLayout : CustomCollectionViewFlowLayout, insertIndex index : NSIndexPath) {
cards.append(Card())
collectionView.performBatchUpdates({
self.collectionView.insertItemsAtIndexPaths([index])
}) { (complete) in
}
}
And below is the custom Collection view that applies the animation while scrolling accordingly :)
class CollectionView : UICollectionView {
override var contentOffset: CGPoint {
didSet {
if self.tracking {
// When you are tracking the CustomCollectionViewFlowLayout does not update it's visible index until you let go
// So you should be adjusting the second to last cell on the screen
self.adjustTransitionForOffset(NSIndexPath(forRow: self.numberOfItemsInSection(0) - 1, inSection: 0))
} else {
// Once the CollectionView is not tracking, the CustomCollectionViewFlowLayout calls
// targetContentOffsetForProposedContentOffset(_:withScrollingVelocity:), and updates the visible index
// by adding 1, thus we need to continue the trasition on the second the last cell
self.adjustTransitionForOffset(NSIndexPath(forRow: self.numberOfItemsInSection(0) - 2, inSection: 0))
}
}
}
/**
This method applies the transform accordingly to the cell at a specified index
- parameter atIndex: index of the cell to adjust
*/
func adjustTransitionForOffset(atIndex : NSIndexPath) {
if let lastCell = self.cellForItemAtIndexPath(atIndex) as? CollectionViewCell {
let progress = 1.0 - (lastCell.frame.minX - self.contentOffset.x) / lastCell.frame.width
lastCell.cardView.alpha = progress
lastCell.cardView.layer.transform = CATransform3DScale(CATransform3DIdentity, progress, progress, 0.0)
}
}
}
I think u missed one point here in
let newIndexPath = NSIndexPath(forItem: addedCards.count, inSection: 0)
the indexPath should be
NSIndexPath(forItem : addedCards.count - 1, inSection : 0) not addedCards.count
Thats why you were getting the error
NSInternalInconsistencyException', reason: 'Invalid update: invalid number of items in section 0

UICollectionView cell reuse causing problems even after taking steps

I know this question has been asked many times.
I'm using a UICollectionView with custom cells which have a few properties, most important being an array of UISwitch's and and array of UILabel's. As you can guess, when I scroll, the labels overlap and the switches change state. I have implemented the method of UICollectionViewCell prepareForReuse in which I empty these arrays and reset the main label text.
I have tried to combine solutions from different answers and I have reached a point where my labels are preserved, but the state of my switches in the cells isn't. My next step was to create an array to preserve the state before removing the switches and then set the on property of a newly created switch to a value of this array at an index. This works, until after I scroll very fast and switches in cells which were not selected previously become selected(or unselected). This is creating a huge problem for me.
This is my collectionView cellForItemAtIndexPath method:
func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier("pitanjeCell", forIndexPath: indexPath) as! PitanjeCell
// remove views previously created and create array to preserve the state of switches
var selectedSwitches: [Bool] = []
for item: UIView in cell.contentView.subviews {
if (item.isKindOfClass(UILabel) && !item.isEqual(cell.tekstPitanjaLabel)){
item.removeFromSuperview()
}
if (item.isKindOfClass(UISwitch)){
selectedSwitches.append((item as! UISwitch).on)
item.removeFromSuperview()
}
}
// get relevant data needed to place cells programmatically
cell.tekstPitanjaLabel.text = _pitanja[indexPath.row].getText()
let numberOfLines: CGFloat = CGFloat(cell.tekstPitanjaLabel.numberOfLines)
cell.setOdgovori(_pitanja[indexPath.row].getOdgovori() as! [Odgovor])
var currIndex: CGFloat = 1
let floatCount: CGFloat = CGFloat(_pitanja.count)
let switchConstant: CGFloat = 0.8
let switchWidth: CGFloat = cell.frame.size.width * 0.18
let heightConstant: CGFloat = (cell.frame.size.height / (floatCount + 2) + (numberOfLines * 4))
let labelWidth: CGFloat = cell.frame.size.width * 0.9
for item in _pitanja[indexPath.row].getOdgovori() {
// create a switch
let odgovorSwitch: UISwitch = UISwitch(frame: CGRectMake((self.view.frame.size.width - (switchWidth * 2)), currIndex * heightConstant , switchWidth, 10))
odgovorSwitch.transform = CGAffineTransformMakeScale(switchConstant, switchConstant)
let switchValue: Bool = selectedSwitches.count > 0 ? selectedSwitches[Int(currIndex) - 1] : false
odgovorSwitch.setOn(switchValue, animated: false)
// cast current item to relevant class
let obj: Odgovor = item as! Odgovor
// create a label
let odgovorLabel: UILabel = UILabel(frame: CGRectMake((self.view.frame.size.width / 12), currIndex * heightConstant , labelWidth, 20))
odgovorLabel.text = obj.getText();
odgovorLabel.lineBreakMode = NSLineBreakMode.ByWordWrapping
odgovorLabel.font = UIFont(name: (odgovorLabel.font?.fontName)!, size: 15)
// add to cell
cell.addSwitch(odgovorSwitch)
cell.addLabel(odgovorLabel)
currIndex++
}
return cell
}
My custom cell also implements methods addSwitch and addLabel which add the element to the contentView as a subview.
Is there any way I can consistently preserve the state of switches when scrolling?
EDIT: As per #Victor Sigler suggestion, I created a bydimensional array like this:
var _switchStates: [[Bool]]!
I initialized it like this:
let odgovori: Int = _pitanja[0].getOdgovori().count
_switchStates = [[Bool]](count: _pitanja.count, repeatedValue: [Bool](count: odgovori, repeatedValue: false))
And I changed my method like this:
for item: UIView in cell.contentView.subviews {
if (item.isKindOfClass(UILabel) && !item.isEqual(cell.tekstPitanjaLabel)){
item.removeFromSuperview()
}
if (item.isKindOfClass(UISwitch)){
_switchStates[indexPath.row][current] = (item as! UISwitch).on
current++
selectedSwitches.append((item as! UISwitch).on)
item.removeFromSuperview()
}
}
And in the end:
let switchValue: Bool = _switchStates.count > 0 ? _switchStates[indexPath.row][Int(currIndex) - 1] : false
First of all as you said in your question regarding the default behavior of the cell in the UICollectionView you need to save the state of each cell, in your case with the UISwitch, but you need to preserve the state in a local property, not in the cellForRowAtIndexPath method because this method is calles every time a cell is going to be reused.
So first declare the array where you going to save the state of the UISwitch's outside this function, something like this:
var selectedSwitches: [Bool] = []
Or you can declare it and then in your viewDidLoad instantiate it, it's up to you, I recommend you instantiate it in your viewDidLoad and only declare it as a property like this:
var selectedSwitches: [Bool]!
Then you can do whatever you want with the state of your UISwitch's always of course preserving when change to on or off.
I hope this help you.
I have done it!
In the end I implemented this:
func collectionView(collectionView: UICollectionView, didEndDisplayingCell cell: UICollectionViewCell, forItemAtIndexPath indexPath: NSIndexPath) {
var current: Int = 0
var cell = cell as! PitanjeCell
for item: UISwitch in cell.getOdgovoriButtons() {
if(current == _switchStates[0].count) { break;}
_switchStates[indexPath.row][current] = (item).on
current++
}
}
In this function I saved the state.
This in combo with prepareForReuse:
public override func prepareForReuse() {
self.tekstPitanjaLabel.text = nil
self._odgovoriButtons.removeAll()
self._odgovoriLabels.removeAll()
self._odgovori.removeAll()
super.prepareForReuse()
}
Finally did it!

UICollectionViewFlowLayout: collectionView!.contentSize vs collectionViewContentSize

Update
The issue seems to be resolved when using the designated collectionViewContentSize() method like so:
let contentSize = collectionViewContentSize()
Nevertheless, I would be very interested in an explanation behind this behaviour, so I've updated my question accordingly.
Original Question
I am trying to recreate the steps found in the first example of this article, but using Swift and Storyboards.
I have a custom UICollectionViewFlowLayout subclass with the following content:
import UIKit
class SpringyFlowLayout: UICollectionViewFlowLayout {
private lazy var animator: UIDynamicAnimator = {
return UIDynamicAnimator(collectionViewLayout: self)
}()
override func prepareLayout() {
super.prepareLayout()
let contentSize = collectionView!.contentSize
let items = super.layoutAttributesForElementsInRect(CGRect(origin: CGPointZero, size: contentSize)) as! [UICollectionViewLayoutAttributes]
if animator.behaviors.isEmpty {
for item in items {
let spring = UIAttachmentBehavior(item: item, attachedToAnchor: item.center)
spring.length = 0
spring.damping = 0.8
spring.frequency = 1.0
animator.addBehavior(spring)
}
}
}
override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? {
return animator.itemsInRect(rect)
}
override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes! {
return animator.layoutAttributesForCellAtIndexPath(indexPath)
}
override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool {
let delta = newBounds.origin.y - collectionView!.bounds.origin.y
for spring in animator.behaviors {
let items = spring.items as! [UICollectionViewLayoutAttributes]
if let attributes = items.first {
attributes.center.y += delta
animator.updateItemUsingCurrentState(attributes)
}
}
return false
}
I have the correct Layout class set in Storyboards. When I run the app, my Collection View is empty.
I have determined that the issue is in the following snipped:
override func prepareLayout() {
super.prepareLayout()
let contentSize = collectionView!.contentSize
let items = super.layoutAttributesForElementsInRect(CGRect(origin: CGPointZero, size: contentSize)) as! [UICollectionViewLayoutAttributes]
//...
}
Since I subclass UICollectionViewFlowLayout, I thought I can rely on the super implementation to lay out the elements for me, and then modify their attributes. But when I check contentSize, it reports the width correctly, but the height is 0.
This leads to an empty items array -> no behaviors in animator -> animator.itemsInRect(_) returning an empty array -> the empty Collection View.
I just can't seem to find out what I'm missing. There should be no need to override the contentSize() method, since I'm using a flow layout.
The issue it that collectionView doesn't yet have a content size, because it just started to prepare it's layout. I don't believe that calling collectionView!.contentSize will actually compute the size. The reason collectionViewContentSize() works is because it will compute the size using your other layout code.
Here's something you might find interesting. I noticed in Ash Furrow's example in viewDidAppear a call to collectionViewLayout.invalidateLayout. Ash notes this isn't necessary when using storyboards due a difference in timing of the first invocation of prepareLayout versus when storyboards are not used.
I tried invalidating the collection view layout in viewDidAppear along with your code and a storyboard. Here is what I found:
Test Scenarios:
1) collectionView!.contentSize without invalidateLayout = cv is EMPTY
2) collectionViewContentSize() without invalidateLayout = cv WORKS
3) collectionView!.contentSize with invalidateLayout = cv WORKS
4) collectionViewContentSize() with invalidateLayout = cv WORKS
FYI

Resources