Dynamic Height Issue for Custom UITableViewCell - ios

I have a Custom UITableViewCell with a UICollectionView in it.
I have the UICollectionView pinned to each side in its XIB file.
Within some of my cells, the content may carry down but with my current setup for dynamic heights, I am only seeing the top portion. I am adding images to my UICollectionView so in one cell there may be 20 while another may just be 5. Right now each row has the same height when some should be different.
To note, the UICollectionView in the cell will not scroll.
Here is what I am trying in my View Controller:
// Here is where I am getting the arr data, which is the folders
// that contains images.
func getDataForSections() {
let storageReference = Storage.storage()
let ref = storageReference.reference().child("abc/xyz/")
ref.listAll { (result, error) in
if let error = error {
// ...
}
for prefix in result.prefixes {
self.arr.append(prefix)
}
self.myTableView.reloadData()
}
}
func numberOfSections(in tableView: UITableView) -> Int {
return arr.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 1
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let data = arr[indexPath.section]
let cell = tableView.dequeueReusableCell(withIdentifier: "collectionCell", for: indexPath) as! collectionCell
cell.getImagesFromData(data: data)
cell.frame = tableView.bounds
cell.layoutIfNeeded()
cell.collectionView.reloadData()
cell.collectionView.heightAnchor.constraint(equalToConstant: cell.collectionView.collectionViewLayout.collectionViewContentSize.height).isActive = true
cell.layoutIfNeeded()
return cell
}
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
// How Do I get the Custom size here? Or in heightForRowAt?
return 500.0
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return UITableView.automaticDimension
}
/**
#param numberOfCellsInCollectionView the number of cells of your current collectioview
*/
func calculateHeight(numberOfCellsInCollectionView: Int) -> CGFloat {
let imageHeight: CGFloat = 40.0
let spaceBetweenRows: CGFloat = 20.0 /*Change as you please based on the space you put between your cells (if any) */
let rows: CGFloat = CGFloat(calculateRows(numberOfCellsInCollectionView: numberOfCellsInCollectionView))
/**
1) (rows*imageHeight) calculate the total space occupied by the pictures
2) (rows+2) assuming you want to put a little space between the top and the bottom of the collectionview i used +2. If you don't want to remove the +2
3) ((rows+2)*spaceBetweenRows) total space occupied by the spaces.
**/
let height = (rows*imageHeight)+((rows+2)*spaceBetweenRows)
return height
}
//Calculate the rows
func calculateRows(numberOfCellsInCollectionView: Int) -> Int {
let result = numberOfCellsInCollectionView/6 //Dividing the number of cells for the cells for row
let rest = numberOfCellsInCollectionView%6 //Calculating the module
if rest == 0 {
//If the rest is 0 (ie: you divide 18/6), then you get the result of the division (18/6 = 3)
return result
} else {
//If the rest is > 0 (ie: you divide 17/6), then you get the result of the division + 1 (17/6 = 3+1 = 4) so there's space for the last item
return result+1
}
}
Here is what I am trying in my Custom Cell that has a Collection View:
func getImagesFromData(prefix: String) {
let storageReference = Storage.storage()
let ref = storageReference.reference().child("abc/xyz/\(prefix)")
ref.listAll { (result, error) in
if let error = error {
// ...
}
self.folderImages.removeAll()
for item in result.items {
if !self.folderImages.contains(item) {
self.folderImages.append(item)
}
}
// Here is where I need to store (or retain) the count
// for each section and then calculate the height or pass
// this data back to the View Controller.
// But with Firebase I am not sure how to return this or
// use a completion.
// reload collection data
self.myCollectionView.reloadData()
}
}
extension customCollectionCell: UICollectionViewDataSource, UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return folderImages.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "imageCell", for: indexPath) as! imageCell
let theImage = folderImages[indexPath.item]
cell.imageView.sd_setImage(with: theImage, placeholderImage: UIImage(named: "blah")) { (image, error, cacheType, ref) in
if error != nil {
cell.imageView.image = UIImage(named: "blah")
}
}
return cell
}
}
Another Example:
Here instead of using a UITableView with the UICollectionViewCell, I made a UICollectionView with the UICollectionViewCell...
I also use a variation of the method within the cell in the UIViewController to get the count.
In this example, I can see the values prior to the return. I just need to know how to get that value within the return as the height.
Here's what I tried:
func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath) -> CGSize {
let prefix = arr[indexPath.section]
getImageCountFromPrefix(prefix: prefix.name, completion: { (count, success) in
if success {
self.h = self.calculateHeight(numberOfCellsInCollectionView: count)
// self.h has a value!!! How do I get it in the return?
}
})
return CGSize(width: collectionView.bounds.size.width, height: self.h)
}

Create a function to determine the height of the pictures inside the cells then only use the heightForRowAtIndexPath function like this :
private func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat{
/* Here I'll just assume the height of the picture is 30. You'll have to put here your function and return the dimension. */
return 30.0
}
Note that you may want to add a constant value to the returned value to make the cell's appearance more clear. I usually add 70.0
Oh, and I don't know about images, but when you deal with text you have to call these as well in the cellForRowAt.
cell.textLabel?.sizeToFit()
cell.textLabel?.numberOfLines = 0
Here's how to calculate the height of your collection view
/**
#param numberOfCellsInCollectionView the number of cells of your current collectioview
*/
func calculateHeight(numberOfCellsInCollectionView: Int) -> CGFloat{
let imageHeight: CGFloat = 40.0
let spaceBetweenRows: CGFloat = 5.0 /*Change as you please based on the space you put between your cells (if any) */
let rows: CGFloat = CGFloat(calculateRows(numberOfCellsInCollectionView: numberOfCellsInCollectionView))
/**
1) (rows*imageHeight) calculate the total space occupied by the pictures
2) (rows+2) assuming you want to put a little space between the top and the bottom of the collectionview i used +2. If you don't want to remove the +2
3) ((rows+2)*spaceBetweenRows) total space occupied by the spaces.
**/
let height = (rows*imageHeight)+((rows+2)*spaceBetweenRows)
return height
}
//Calculate the rows
func calculateRows(numberOfCellsInCollectionView: Int) -> Int{
let result = numberOfCellsInCollectionView/6 //Dividing the number of cells for the cells for row
let rest = numberOfCellsInCollectionView%6 //Calculating the module
if rest == 0 {
//If the rest is 0 (ie: you divide 18/6), then you get the result of the division (18/6 = 3)
return result
} else {
//If the rest is > 0 (ie: you divide 17/6), then you get the result of the division + 1 (17/6 = 3+1 = 4) so there's space for the last item
return result+1
}
}

Related

Swift: TableView is showing some of the arrays data but not all

I have two tableviews inside my stack view. I am resizing them depending on the amount of data that is retrieved from Firestore. The issue I am facing is whilst the tableview is resize the top table view "ingredientsTV" shows all the data where as the "instructionsTV" only shows some of the data. My array.count displays the correct number of items in the array but them items are not getting displayed.
//Code for resize tableviews
override func viewWillLayoutSubviews() {
super.updateViewConstraints()
self.ingredientsTVHeight?.constant = self.ingredientsTV.contentSize.height
self.instructionsTVHeight.constant = self.instructionsTV.contentSize.height
self.ingredientsTV.contentInset = UIEdgeInsets(top: 0, left: -20, bottom: 0, right: 0)
self.instructionsTV.contentInset = UIEdgeInsets(top: 0, left: -20, bottom: 0, right: 0)
}
//setupview, called in viewdidload
//MARK: Functions
private func setupView() {
ingredientsTV.delegate = self
ingredientsTV.dataSource = self
instructionsTV.delegate = self
instructionsTV.dataSource = self
recipeImage.layer.cornerRadius = 5
recipeNameLbl.text = recipe.name
prepTimeLbl.text = recipe.prepTime
cookTimeLbl.text = recipe.cookTime
servesLabel.text = recipe.serves
if let url = URL(string: recipe.imageUrl) {
recipeImage.kf.setImage(with: url)
recipeImage.layer.cornerRadius = 5
}
}
//MARK: Tableview functions
extension PocketChefRecipeDetailsVC {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if (tableView == self.ingredientsTV) {
return recipe.ingredients.count
}else {
return recipe.method.count
}
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
if (tableView == self.ingredientsTV) {
let cell = ingredientsTV.dequeueReusableCell(withIdentifier: "ingredientsCell", for: indexPath) as? ingredientsCell
cell?.ingredientsLbl.text = recipe.ingredients[indexPath.row]
return cell!
}else {
let cellB = instructionsTV.dequeueReusableCell(withIdentifier: "instructionsCell", for: indexPath) as? InstructionsCell
cellB?.instructionsLbl.text = recipe.method[indexPath.row]
return cellB!
}
}
}
*Recipe data is getting passed from previous view controller
I'm going to take a shot in the dark and say that maybe your stack view needs to be re laid out after you reload data.
Make sure to call
// after ingredientsTV.reloadData() and instructionsTV.reloadData() gets called
stackview.setNeedsLayout()
You can try adding a fixed height to each row and see if it has any populated data or not. Add this to your extension
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return 100
}
Additionally, you can also set the background color of the cell in cellForRowAt() just to ensure the rows are visible.
Note: Do check if your stack view's constraints are set for all 4 sides.

How to get total in footer cell of cells in sections?

Currently Im able to get cells to add up their total in the sections Footer Cell. But it calculates the total for every cell no matter what section its in, inside the all the sections footer.
I still can't get it to add up the different prices(Price1 - 3) for the cells that have a different prices selected passed into it the Section
code im using to add up total in the CartFooter for the Cells in the sections cartFooter.cartTotal.text = "\(String(cart.map{$0.cartItems.price1}.reduce(0.0, +)))"
PREVIOUSLY:
im trying to get the Cells in each section to add up their total in footer cell for each section that they're in.
The data in the CartVC is populated from another a VC(HomeVC). Which is why there is 3 different price options in the CartCell for when the data populates the cells.
Just kind of stuck on how I would be able to get the total in the footer for the cells in the section
Adding specific data for each section in UITableView - Swift
Thanks in advance, Your help is much appreciated
extension CartViewController: UITableViewDelegate, UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
return brands
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
let brand = brands[section]
return groupedCartItems[brand]!.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cartCell = tableView.dequeueReusableCell(withIdentifier: "CartCell") as! CartCell
let brand = brands[indexPath.section]
let cartItemsToDisplay = groupedCartItems[brand]![indexPath.row]
cartCell.configure(withCartItems: cartItemsToDisplay.cartItems)
return cartCell
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let cartHeader = tableView.dequeueReusableCell(withIdentifier: "CartHeader") as! CartHeader
let headerTitle = brands[section]
cartHeader.brandName.text = "Brand: \(headerTitle)"
return cartHeader
}
func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
let cartFooter = tableView.dequeueReusableCell(withIdentifier: "FooterCell") as! FooterCell
let sectionTotal = cart[section].getSectionTotal()
let calculate = String(cart.map{$0.cartItems.price1}.reduce(0.0, +))
cartFooter.cartTotal.text = "$\(calculate)"
return cartFooter
}
}
Update: these are the results that I am getting using this in the CartFooter
let calculate = String(cart.map{$0.cart.price1}.reduce(0.0, +))
cartFooter.cartTotal.text = "$\(calculate)"
which calculates the overall total (OT) for all the sections and places the OT in all all Footer Cells(as seen below ▼) when im trying to get the total for each section in their footers (as seen in image above ▲ on the right side)
Update2:
this what ive added in my cellForRowAt to get the totals to add up in the section footer. it adds up the data for the cells but it doesn't give an accurate total in the footer
var totalSum: Float = 0
for eachProduct in cartItems{
productPricesArray.append(eachProduct.cartItem.price1)
productPricesArray.append(eachProduct.cartItem.price2)
productPricesArray.append(eachProduct.cartItem.price3)
totalSum = productPricesArray.reduce(0, +)
cartFooter.cartTotal.text = String(totalSum)
}
There's a lot of code, and I'm not too sure where your coding error lies. With that said:
func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
let cartFooter = tableView.dequeueReusableCell(withIdentifier: "FooterCell") as! FooterCell
let sectionTotal = cart[section].getSectionTotal()
let calculate = String(cart.map{$0.cart.price1}.reduce(0.0, +))
cartFooter.cartTotal.text = "$\(calculate)"
return cartFooter
}
Your code seems to say let sectionTotal = cart[section].getSectionTotal() is the total you are looking for (i.e. the total within a section), while you are displaying the OT in a section, by summing up String(cart.map{$0.cart.price1}.reduce(0.0, +)).
In other words, if cartFooter is the container that will display the total within a section, one should read cartFooter.cartTotal.text = "$\(sectionTotal)" instead, no?
If that's not the answer, I suggest that you set a breakpoint each time the footerView is instantiated, and figure out why it output what it outputs (i.e. the OT, instead of the section total).
#Evelyn Try calculation directly in footer. Try below code.
func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
let cartFooter = tableView.dequeueReusableCell(withIdentifier: "FooterCell") as! FooterCell
let brand = brands[indexPath.section]
let arrAllItems = groupedCartItems[brand]!
var total: Float = 0
for item in arrAllItems {
if item.selectedOption == 1 {
total = total + Float(item.price1)
} else item cartItems.selectedOption == 2 {
total = total + Float(item.price2)
} else if item.selectedOption == 3 {
total = total + Float(item.price3)
}
}
cartFooter.cartTotal.text = "$\(total)"
return cartFooter
}
If you want to correct calculate total price in each section you need filter items for every section. now i think you sum all of your section.
For correct section you need yous snipper from your cellForRow method:
let brand = brands[indexPath.section]
let cartItemsToDisplay = groupedCartItems[brand]![indexPath.row]
cartCell.configure(withCartItems: cartItemsToDisplay.cart)
inside this:
func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
let cartFooter = tableView.dequeueReusableCell(withIdentifier: "FooterCell") as! FooterCell
let sectionTotal = cart[section].getSectionTotal()
let brand = brands[section]
let catrItemsToDisplay = // now you need to get associated items with this brand (cart)
// I think it is can looks like this = cartItemsToDisplay[brand]
let calculate = String(cartItemsToDisplay.cart.reduce(0.0, +))
cartFooter.cartTotal.text = "$\(calculate)"
return cartFooter
}
Modify your Model structure and the way values set to through TableViewDelegate. Giving a short hint. Please see if it helps:
class Cart {
var brandWiseProducts: [BrandWiseProductDetails]!
init() {
brandWiseProducts = [BrandWiseProductDetails]()
}
initWIthProducts(productList : [BrandWiseProductDetails]) {
self.brandWiseProducts = productList
}
}
class BrandWiseProductDetails {
var brandName: String!
var selectedItems: [Items]
var totalAmount: Double or anything
}
class SelectedItem {
var image
var name
var price
}
Sections:
Cart.brandWiseProducts.count
SectionTitle:
Cart.brandWiseProducts[indexPath.section].brandName
Rows in section
Cart.brandWiseProduct[indexPath.section].selectedItem[IndexPath.row]
Footer:
Cart.brandWiseProduct.totalAmount

How to correctly invalidate layout for supplementary views in UICollectionView

I am having a dataset displayed in a UICollectionView. The dataset is split into sections and each section has a header. Further, each cell has a detail view underneath it that is expanded when the cell is clicked.
For reference:
For simplicity, I have implemented the details cells as standard cells that are hidden (height: 0) by default and when the non-detail cell is clicked, the height is set to non-zero value. The cells are updates using invalidateItems(at indexPaths: [IndexPath]) instead of reloading cells in performBatchUpdates(_ updates: (() -> Void)?, completion: ((Bool) -> Void)? = nil) as the animations seems glitchy otherwise.
Now to the problem, the invalidateItems function obviously updates only cells, not supplementary views like the section header and therefore calling only this function will result in overflowing the section header:
After some time Googling, I found out that in order to update also the supplementary views, one has to call invalidateSupplementaryElements(ofKind elementKind: String, at indexPaths: [IndexPath]). This might recalculate the section header's bounds correctly, however results in the content not appearing:
This is most likely caused due to the fact that the func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView does not seem to be called.
I would be extremely grateful if somebody could tell me how to correctly invalidate supplementary views to the issues above do not happen.
Code:
override func numberOfSections(in collectionView: UICollectionView) -> Int {
return dataManager.getSectionCount()
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
let count = dataManager.getSectionItemCount(section: section)
reminder = count % itemsPerWidth
return count * 2
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
if isDetailCell(indexPath: indexPath) {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Reusable.CELL_SERVICE, for: indexPath) as! ServiceCollectionViewCell
cell.lblName.text = "Americano detail"
cell.layer.borderWidth = 0.5
cell.layer.borderColor = UIColor(hexString: "#999999").cgColor
return cell
} else {
let item = indexPath.item > itemsPerWidth ? indexPath.item - (((indexPath.item / itemsPerWidth) / 2) * itemsPerWidth) : indexPath.item
let product = dataManager.getItem(index: item, section: indexPath.section)
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Reusable.CELL_SERVICE, for: indexPath) as! ServiceCollectionViewCell
cell.lblName.text = product.name
cell.layer.borderWidth = 0.5
cell.layer.borderColor = UIColor(hexString: "#999999").cgColor
return cell
}
}
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
switch kind {
case UICollectionElementKindSectionHeader:
if indexPath.section == 0 {
let header = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionElementKindSectionHeader, withReuseIdentifier: Reusable.CELL_SERVICE_HEADER_ROOT, for: indexPath) as! ServiceCollectionViewHeaderRoot
header.lblCategoryName.text = "Section Header"
header.imgCategoryBackground.af_imageDownloader = imageDownloader
header.imgCategoryBackground.af_setImage(withURLRequest: ImageHelper.getURL(file: category.backgroundFile!))
return header
} else {
let header = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionElementKindSectionHeader, withReuseIdentifier: Reusable.CELL_SERVICE_HEADER, for: indexPath) as! ServiceCollectionViewHeader
header.lblCategoryName.text = "Section Header"
return header
}
default:
assert(false, "Unexpected element kind")
}
}
// MARK: UICollectionViewDelegate
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let width = collectionView.frame.size.width / CGFloat(itemsPerWidth)
if isDetailCell(indexPath: indexPath) {
if expandedCell == indexPath {
return CGSize(width: collectionView.frame.size.width, height: width)
} else {
return CGSize(width: collectionView.frame.size.width, height: 0)
}
} else {
return CGSize(width: width, height: width)
}
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
if section == 0 {
return CGSize(width: collectionView.frame.width, height: collectionView.frame.height / 3)
} else {
return CGSize(width: collectionView.frame.width, height: heightHeader)
}
}
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
if isDetailCell(indexPath: indexPath) {
return
}
var offset = itemsPerWidth
if isLastRow(indexPath: indexPath) {
offset = reminder
}
let detailPath = IndexPath(item: indexPath.item + offset, section: indexPath.section)
let context = UICollectionViewFlowLayoutInvalidationContext()
let maxItem = collectionView.numberOfItems(inSection: 0) - 1
var minItem = detailPath.item
if let expandedCell = expandedCell {
minItem = min(minItem, expandedCell.item)
}
// TODO: optimize this
var cellIndexPaths = (0 ... maxItem).map { IndexPath(item: $0, section: 0) }
var supplementaryIndexPaths = (0..<collectionView.numberOfSections).map { IndexPath(item: 0, section: $0)}
for i in indexPath.section..<collectionView.numberOfSections {
cellIndexPaths.append(contentsOf: (0 ... collectionView.numberOfItems(inSection: i) - 1).map { IndexPath(item: $0, section: i) })
//supplementaryIndexPaths.append(IndexPath(item: 0, section: i))
}
context.invalidateSupplementaryElements(ofKind: UICollectionElementKindSectionHeader, at: supplementaryIndexPaths)
context.invalidateItems(at: cellIndexPaths)
if detailPath == expandedCell {
expandedCell = nil
} else {
expandedCell = detailPath
}
UIView.animate(withDuration: 0.25) {
collectionView.collectionViewLayout.invalidateLayout(with: context)
collectionView.layoutIfNeeded()
}
}
EDIT:
Minimalistic project demonstrating this issue: https://github.com/vongrad/so-expandable-collectionview
You should use an Invalidation Context. It's a bit complex, but here's a rundown:
First, you need to create a custom subclass of UICollectionViewLayoutInvalidationContext since the default one used by most collection views will just refresh everything. There may be situations where you DO want to refresh everything though; in my instance, if the width of the collection view changes it has to layout all the cells again, so my solution looks like this:
class CustomInvalidationContext: UICollectionViewLayoutInvalidationContext {
var justHeaders: Bool = false
override var invalidateEverything: Bool { return !justHeaders }
override var invalidateDataSourceCounts: Bool { return false }
}
Now you need to tell the layout to use this context instead of the default:
override class var invalidationContextClass: AnyClass {
return CustomInvalidationContext.self
}
This won't trigger if we don't tell the layout it needs to update upon scrolling, so:
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
I'm passing true here because there will always be something to update when the user scrolls the collection view, even if it's only the header frames. We'll determine exactly what gets changed when in the next section.
Now that it is always updating when the bounds change, we need to provide it with information about which parts should be invalidated and which should not. To make this easier, I have a function called getVisibleSections(in: CGRect) that returns an optional array of integers representing which sections overlap the given bounds rectangle. I won't detail this here as yours will be different. I'm also caching the content size of the collection view as _contentSize since this only changes when a full layout occurs.
With a small number of sections you could probably just invalidate all of them. Be that as it may, we now need to tell the layout how to set up its invalidation context when the bounds changes.
Note: make sure you're calling super to get the context rather than just creating one yourself; this is the proper way to do things.
override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
let context = super.invalidationContext(forBoundsChange: newBounds) as! CustomInvalidationContext
// If we can't determine visible sections or the width has changed,
// we need to do a full layout - just return the default.
guard newBounds.width == _contentSize.width,
let visibleSections = getVisibleSections(in: newBounds)
else { return context }
// Determine which headers need a frame change.
context.justHeaders = true
let sectionIndices = visibleSections.map { IndexPath(item: 0, section: $0) }
context.invalidateSupplementaryElements(ofKind: "Header", at: sectionIndices)
return context
}
Note that I'm assuming your supplementary view kind is "Header"; change that if you need to. Now, provided that you've properly implemented layoutAttributesForSupplementaryView to return a suitable frame, your headers (and only your headers) should update as you scroll vertically.
Keep in mind that prepare() will NOT be called unless you do a full invalidation, so if you need to do any recalculations, override invalidateLayout(with:) as well, calling super at some point. Personally I do the calculations for shifting the header frames in layoutAttributesForSupplementaryView as it's simpler and just as performant.
Oh, and one last small tip: on the layout attributes for your headers, don't forget to set zIndex to a higher value than the one in your cells so that they definitely appear in front. The default is 0, I use 1 for my headers.
What I suggest is to create a separate subclass of a UICollectionFlowView
and set it up respectivel look at this example:
import UIKit
class StickyHeadersCollectionViewFlowLayout: UICollectionViewFlowLayout {
// MARK: - Collection View Flow Layout Methods
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let layoutAttributes = super.layoutAttributesForElements(in: rect) else { return nil }
// Helpers
let sectionsToAdd = NSMutableIndexSet()
var newLayoutAttributes = [UICollectionViewLayoutAttributes]()
for layoutAttributesSet in layoutAttributes {
if layoutAttributesSet.representedElementCategory == .cell {
// Add Layout Attributes
newLayoutAttributes.append(layoutAttributesSet)
// Update Sections to Add
sectionsToAdd.add(layoutAttributesSet.indexPath.section)
} else if layoutAttributesSet.representedElementCategory == .supplementaryView {
// Update Sections to Add
sectionsToAdd.add(layoutAttributesSet.indexPath.section)
}
}
for section in sectionsToAdd {
let indexPath = IndexPath(item: 0, section: section)
if let sectionAttributes = self.layoutAttributesForSupplementaryView(ofKind: UICollectionElementKindSectionHeader, at: indexPath) {
newLayoutAttributes.append(sectionAttributes)
}
}
return newLayoutAttributes
}
override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
guard let layoutAttributes = super.layoutAttributesForSupplementaryView(ofKind: elementKind, at: indexPath) else { return nil }
guard let boundaries = boundaries(forSection: indexPath.section) else { return layoutAttributes }
guard let collectionView = collectionView else { return layoutAttributes }
// Helpers
let contentOffsetY = collectionView.contentOffset.y
var frameForSupplementaryView = layoutAttributes.frame
let minimum = boundaries.minimum - frameForSupplementaryView.height
let maximum = boundaries.maximum - frameForSupplementaryView.height
if contentOffsetY < minimum {
frameForSupplementaryView.origin.y = minimum
} else if contentOffsetY > maximum {
frameForSupplementaryView.origin.y = maximum
} else {
frameForSupplementaryView.origin.y = contentOffsetY
}
layoutAttributes.frame = frameForSupplementaryView
return layoutAttributes
}
// MARK: - Helper Methods
func boundaries(forSection section: Int) -> (minimum: CGFloat, maximum: CGFloat)? {
// Helpers
var result = (minimum: CGFloat(0.0), maximum: CGFloat(0.0))
// Exit Early
guard let collectionView = collectionView else { return result }
// Fetch Number of Items for Section
let numberOfItems = collectionView.numberOfItems(inSection: section)
// Exit Early
guard numberOfItems > 0 else { return result }
if let firstItem = layoutAttributesForItem(at: IndexPath(item: 0, section: section)),
let lastItem = layoutAttributesForItem(at: IndexPath(item: (numberOfItems - 1), section: section)) {
result.minimum = firstItem.frame.minY
result.maximum = lastItem.frame.maxY
// Take Header Size Into Account
result.minimum -= headerReferenceSize.height
result.maximum -= headerReferenceSize.height
// Take Section Inset Into Account
result.minimum -= sectionInset.top
result.maximum += (sectionInset.top + sectionInset.bottom)
}
return result
}
}
then add your collection view to your view controller and this way you will implement the invalidation methods which currently are not getting triggered.
source here
Do reloadLoad cells in performBatchUpdates(_:) make it seems glitchy.
Just pass nil like below to update your cell's height.
collectionView.performBatchUpdates(nil, completion: nil)
EDIT:
I have recently found that performBatchUpdates(_:) only shift the header along with cell new height returned from the sizeForItemAt function. If using collection view cell sizing, your supplementary view may overlaps the cells. Then collectionViewLayout.invalidateLayout will fix without showing the animation.
If you want to go with sizing animation after calling performBatchUpdates(_:), try to calculate (then cache) and return cell's size in sizeForItemAt. It works for me.

Two custom tableViewCells in UITableView

I am trying to create a contacts page where you can see all your contacts with a friend request cell showing up when you receive a friend request, but not there when you do not have any. At the moment, both custom cells work fine. The issue I have is that the contactRequestTableViewCell overlaps the first cell of the contactListTableViewCell.
I have researched other questions about two custom tableviewcells and none are quite having the same issues that I am facing.
Here is my executing code at the moment, I am returning 2 sections in the table view.
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) as! ContactListTableViewCell
let requestCell = tableView.dequeueReusableCellWithIdentifier("requestCell", forIndexPath: indexPath) as! ContactRequestsTableViewCell
let user = OneRoster.userFromRosterAtIndexPath(indexPath: indexPath)
if (amountOfBuddyRequests > 0) {
if (indexPath.section == 0) {
requestCell.hidden = false
cell.hidden = false
requestCell.friendRequestLabel.text = "test"
} else if (indexPath.section >= 1) {
cell.contactNameLabel!.text = user.displayName;
cell.contactHandleLabel!.text = "# " + beautifyJID(user.jidStr)
cell.contactHandleLabel!.textColor = UIColor.grayColor()
OneChat.sharedInstance.configurePhotoForImageView(cell.imageView!, user: user)
}
return cell;
}
else { // if buddy requests == 0
requestCell.hidden = true
cell.contactNameLabel!.text = user.displayName;
cell.contactHandleLabel!.text = "# " + beautifyJID(user.jidStr)
cell.contactHandleLabel!.textColor = UIColor.grayColor()
print ("This is how many unreadMessages it has \(user.unreadMessages)")
// If there is unread messages for a person highlight it blue
// However this feature isn't working right now due to unreadMessages bug
if user.unreadMessages.intValue > 0 {
cell.backgroundColor = .blueColor()
} else {
cell.backgroundColor = .whiteColor()
}
OneChat.sharedInstance.configurePhotoForCell(cell, user: user)
return cell;
}
}
This is the current output that I have right now, my cells that have "test" are covering up other contactListTableViewCells.
The function tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell should always return one and the only one TableViewCell you want at indexPath, so you don't want to always return cell of type ContactListTableViewCell.
According to documentation, the cellForRowAtIndexPath tableView method asks for the cell at the indexPath, which means literally there can only be one cell at certain row of a certain section, so returning two cells is not an option.
I suggest you use two arrays to store the requests and contacts information. For example, you have arrays requests and contacts. Then you can tell the tableView how many rows you want:
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
// Return the number of rows in the section.
return requests.count + contacts.count
}
and then in cellForRowAtIndexpath you do something like:
override func tableView(_ tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
if indexPath.row < requests.count {
// return a request cell
}
else {
// return a contact cell
}
}
I'm only using one tableView section here. If you still want two sections you can simply return 2 in numberOfSections function and add if statements in cellForRowAtIndexPath for indexPath.section.
Hope this helps.
It turns out that the issue was dealing with the data sources. My data sources were not pointing to the correct tableviewcell. This resulted in them pointing to an incorrect cell. This issue was fixed by remaking the data sources system that was in place. This issue will not affect most as the data sources will point to the correct tableviewcell by default.
Contrary to what another poster said, you can in fact display two or more custom cells in a single table. This is how I fixed the tableView display issues:
var friendRequests = ["FriendRequest1", "FriendRequest2"]
var contacts = ["User1","User2","User3","User4"]
var amountOfBuddyRequests = 1
override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
if (amountOfBuddyRequests > 0) {
return 2
}
return 1
}
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if (amountOfBuddyRequests > 0) {
if (section == 0) {
return friendRequests.count
}
}
return contacts.count
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
if (amountOfBuddyRequests > 0) {
if (indexPath.section == 0) {
let requestCell = tableView.dequeueReusableCellWithIdentifier("requestCell") as! ContactRequestsTableViewCell
requestCell.friendRequestLabel.text = friendRequests[indexPath.row]
requestCell.onButtonTapped = {
self.friendRequests.removeAtIndex(indexPath.row)
self.tableView.reloadData()
}
requestCell.addButtonTapped = {
self.addUser(self.friendRequests[indexPath.row])
self.friendRequests.removeAtIndex(indexPath.row)
self.tableView.reloadData()
}
return requestCell
}
}
let friendCell = tableView.dequeueReusableCellWithIdentifier("FriendCell") as! ContactListTableViewCell
friendCell.contactNameLabel.text = contacts[indexPath.row]
return friendCell
}

How to implement horizontally infinite scrolling UICollectionView?

I want to implement UICollectionView that scrolls horizontally and infinitely?
If your data is static and you want a kind of circular behavior, you can do something like this:
var dataSource = ["item 0", "item 1", "item 2"]
func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return Int.max // instead of returnin dataSource.count
}
func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let itemToShow = dataSource[indexPath.row % dataSource.count]
let cell = UICollectionViewCell() // setup cell with your item and return
return cell
}
Basically you say to your collection view that you have a huge number of cells (Int.max won't be infinite, but might do the trick), and you access your data source using the % operator. In my example we'll end up with "item 0", "item 1", "item 2", "item 0", "item 1", "item 2" ....
I hope this helps :)
Apparently the closest to good solution was proposed by the Manikanta Adimulam. The cleanest solution would be to add the last element at the beginning of the data list, and the first one to the last data list position (ex: [4] [0] [1] [2] [3] [4] [0]), so we scroll to the first array item when we are triggering the last list item and vice versa. This will work for collection views with one visible item:
Subclass UICollectionView.
Override UICollectionViewDelegate and override the following methods:
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
let numberOfCells = items.count
let page = Int(scrollView.contentOffset.x) / Int(cellWidth)
if page == 0 { // we are within the fake last, so delegate real last
currentPage = numberOfCells - 1
} else if page == numberOfCells - 1 { // we are within the fake first, so delegate the real first
currentPage = 0
} else { // real page is always fake minus one
currentPage = page - 1
}
// if you need to know changed position, you can delegate it
customDelegate?.pageChanged(currentPage)
}
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
let numberOfCells = items.count
if numberOfCells == 1 {
return
}
let regularContentOffset = cellWidth * CGFloat(numberOfCells - 2)
if (scrollView.contentOffset.x >= cellWidth * CGFloat(numberOfCells - 1)) {
scrollView.contentOffset = CGPoint(x: scrollView.contentOffset.x - regularContentOffset, y: 0.0)
} else if (scrollView.contentOffset.x < cellWidth) {
scrollView.contentOffset = CGPoint(x: scrollView.contentOffset.x + regularContentOffset, y: 0.0)
}
}
Override layoutSubviews() method inside your UICollectionView in order to always to make a correct offset for the first item:
override func layoutSubviews() {
super.layoutSubviews()
let numberOfCells = items.count
if numberOfCells > 1 {
if contentOffset.x == 0.0 {
contentOffset = CGPoint(x: cellWidth, y: 0.0)
}
}
}
Override init method and calculate your cell dimensions:
let layout = self.collectionViewLayout as! UICollectionViewFlowLayout
cellPadding = layout.minimumInteritemSpacing
cellWidth = layout.itemSize.width
Works like a charm!
If you want to achieve this effect with collection view having multiple visible items, then use solution posted here.
I have implemented infinite scrolling in UICollectionView. Made the code available in github. You can give it a try. Its in swift 3.0.
InfiniteScrolling
You can add it using pod. Usage is pretty simple. Just intialise the InfiniteScrollingBehaviour as below.
infiniteScrollingBehaviour = InfiniteScrollingBehaviour(withCollectionView: collectionView, andData: Card.dummyCards, delegate: self)
and implement required delegate method to return a configured UICollectionViewCell. An example implementation will look like:
func configuredCell(forItemAtIndexPath indexPath: IndexPath, originalIndex: Int, andData data: InfiniteScollingData, forInfiniteScrollingBehaviour behaviour: InfiniteScrollingBehaviour) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CellID", for: indexPath)
if let collectionCell = cell as? CollectionViewCell,
let card = data as? Card {
collectionCell.titleLabel.text = card.name
}
return cell
}
It will add appropriate leading and trailing boundary elements in your original data set and will adjust collectionView's contentOffset.
In the callback methods, it will give you index of an item in the original data set.
Tested code
I achieved this by simply repeating cell for x amount of times. As following,
Declare how many loops would you like to have
let x = 50
Implement numberOfItems
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return myArray.count*x // large scrolling: lets see who can reach the end :p
}
Add this utility function to calculate arrayIndex given an indexPath row
func arrayIndexForRow(_ row : Int)-> Int {
return row % myArray.count
}
Implement cellForItem
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "myIdentifier", for: indexPath) as! MyCustomCell
let arrayIndex = arrayIndexForRow(indexPath.row)
let modelObject = myArray[arrayIndex]
// configure cell
return cell
}
Add utility function to scroll to middle of collectionView at given index
func scrollToMiddle(atIndex: Int, animated: Bool = true) {
let middleIndex = atIndex + x*yourArray.count/2
collectionView.scrollToItem(at: IndexPath(item: middleIndex, section: 0), at: .centeredHorizontally, animated: animated)
}
Also implying that your data is static and that all your UICollectionView cells should have the same size, I found this promising solution.
You could download the example project over at github and run the project yourself. The code in the ViewController that creates the UICollectionView is pretty straight forward.
You basically follow these steps:
Create a InfiniteCollectionView in Storyboard
Set infiniteDataSource and infiniteDelegate
Implement the necessary functions that create your infinitely scrolling cells
For those who are looking for infinitely and horizontally scrolling collection views whose data sources are appended to at the end--append to your data source in scrollViewDidScroll and call reloadData() on your collection view. It will maintain the scroll offset.
Sample code below. I use my collection view for a paginated date picker, where I load more pages (of entire months) when the user is towards the right end (second to the last):
func scrollViewDidScroll(scrollView: UIScrollView) {
let currentPage = self.customView.collectionView.contentOffset.x / self.customView.collectionView.bounds.size.width
if currentPage > CGFloat(self.months.count - 2) {
let nextMonths = self.generateMonthsFromDate(self.months[self.months.count - 1], forPageDirection: .Next)
self.months.appendContentsOf(nextMonths)
self.customView.collectionView.reloadData()
}
// DOESN'T WORK - adding more months to the left
// if currentPage < 2 {
// let previousMonths = self.generateMonthsFromDate(self.months[0], forPageDirection: .Previous)
// self.months.insertContentsOf(previousMonths, at: 0)
// self.customView.collectionView.reloadData()
// }
}
EDIT: - This doesn't seem to work when you are inserting at the beginning of the data source.
in case the cell.width == collectionView.width, this solution has worked for me:
first, you need your items * 2:
func set(items colors: [UIColor]) {
items = colors + colors
}
Then add these two computed variables to determine the indices:
var firstCellIndex: Int {
var targetItem = items.count / 2 + 1
if !isFirstCellSeen {
targetItem -= 1
isFirstCellSeen = true
}
return targetItem
}
var lastCellIndex: Int {
items.count / 2 - 2
}
as you can see, the firstCellIndex has a flag isFirstCellSeen. this flag is needed when the CV appears for the first time, otherwise, it will display items[1] instead of items[0]. So do not forget to add that flag into your code.
The main logic happens here:
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
if indexPath.item == 0 {
scroll(to: firstCellIndex)
} else if indexPath.item == items.count - 1 {
scroll(to: lastCellIndex)
}
}
private func scroll(to row: Int) {
DispatchQueue.main.async {
self.collectionView.scrollToItem(
at: IndexPath(row: row, section: 0),
at: .centeredHorizontally,
animated: false
)
}
}
That was it. The collection view scroll should now be infinite. I liked this solution because it does not require any additional pods and is very easy to understand: you just multiply your cv items by 2 and then always scroll to the middle when the indexPath == 0 or indexPath == lastItem
To apply this infinite loop functionality You should have proper collectionView layout
You need to add the first element of the array at last and last element of the array at first
ex:- array = [1,2,3,4]
presenting array = [4,1,2,3,4,1]
func infinateLoop(scrollView: UIScrollView) {
var index = Int((scrollView.contentOffset.x)/(scrollView.frame.width))
guard currentIndex != index else {
return
}
currentIndex = index
if index <= 0 {
index = images.count - 1
scrollView.setContentOffset(CGPoint(x: (scrollView.frame.width+60) * CGFloat(images.count), y: 0), animated: false)
} else if index >= images.count + 1 {
index = 0
scrollView.setContentOffset(CGPoint(x: (scrollView.frame.width), y: 0), animated: false)
} else {
index -= 1
}
pageController.currentPage = index
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
infinateLoop(scrollView: scrollView)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
infinateLoop(scrollView: scrollView)
}
The answers provided here are good to implement the feature. But in my opinion they contain some low level updates (setting content offset, manipulating the data source ...) which can be avoided. If you're still not satisfied and looking for a different approach here's what I've done.
The main idea is to update the number of cells whenever you reach the cell before the last one. Each time you increase the number of items by 1 so it gives the illusion of infinite scrolling. To do that we can utilize scrollViewDidEndDecelerating(_ scrollView: UIScrollView) function to detect when the user has finished scrolling, and then update the number of items in the collection view. Here's a code snippet to achieve that:
class InfiniteCarouselView: UICollectionView {
var data: [Any] = []
private var currentIndex: Int?
private var currentMaxItemsCount: Int = 0
// Set up data source and delegate
}
extension InfiniteCarouselView: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
// Set the current maximum to a number above the maximum count by 1
currentMaxItemsCount = max(((currentIndex ?? 0) + 1), data.count) + 1
return currentMaxItemsCount
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath)
let row = indexPath.row % data.count
let item = data[row]
// Setup cell
return cell
}
}
extension InfiniteCarouselView: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: collectionView.frame.width, height: collectionView.frame.height)
}
// Detect when the collection view has finished scrolling to increase the number of items in the collection view
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
// Get the current index. Note that the current index calculation will keep changing because the collection view is expanding its content size based on the number of items (currentMaxItemsCount)
currentIndex = Int(scrollView.contentOffset.x/scrollView.contentSize.width * CGFloat(currentMaxItemsCount))
// Reload the collection view to get the new number of items
reloadData()
}
}
Pros
Straightforward implementation
No use of Int.max (Which in my own opinion is not a good idea)
No use of an arbitrary number (Like 50 or something else)
No change or manipulation of the data
No manual update of the content offset or any other scroll view attributes
Cons
Paging should be enabled (Although the logic can be updated to support no paging)
Need to maintain a reference for some attributes (current index, current maximum count)
Need to reload the collection view on each scroll end (Not a big deal if the visible cells are minimal). This might affect you drastically if you're loading something asynchronously without caching (Which is a bad practice and data should be cached outside the cells)
Doesn't work if you want infinite scroll in both directions

Resources