Change collection view cell width at runtime - ios

I'm creating an EPG (Electronic Program Guide); to retrieve the program info I use JSON. For the design, I have a table view in the left half for the channel logo and name, and in the right half a collection view for all the TV programs for that channel. The thing is that for every show the collection view row must have a different width that depends on the duration of the show.
I'm building it based on this fantastic example called: EPG Grid
But in this example, all the channels are loaded previously in one array. This works fine if the channels list is short, but in my case the list is very large, so I need to load every channel in tableView:cellForRowAtIndexPath:, then create the layout with the specified width.
Any thoughts about this?

I figured out by my self, the thing is in my case not to use UICollectionViewLayout (CustomLayout) use layout Flow and add the collectionView inside tableView like this:
Then in every row of tableView delegate the collectionView.
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell
{
let cell: CustomTableViewCell = tableView.dequeueReusableCellWithIdentifier("cell", forIndexPath: indexPath) as! CustomTableViewCell
cell.collectionView.delegate = self
cell.collectionView.dataSource = self
cell.collectionView.tag = indexPath.row
cell.tag = 100 + indexPath.row;
return cell;
}
After delegate, the method collectionView:sizeForItemAtIndexPath is called, on this method you must to calculate width or height retrieving data using indexPath
func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize
{
//Calculation of width based on indexPath
//...
return CGSizeMake(width, 89)
}
Finally for making the scroll uniform for all the cells at the same time use the method scrollViewDidScroll. Check this post: Horizontally scroll ALL rows of UICollectionView together
func scrollViewDidScroll(scrollView: UIScrollView) {
var ret: Bool = false;
if (self.lastContentOffset > scrollView.contentOffset.x)
{
ret = true;
}
else if (self.lastContentOffset < scrollView.contentOffset.x)
{
ret = true;
}
if(scrollView.isKindOfClass(UICollectionView) && ret)
{
self.lastContentOffset = scrollView.contentOffset.x;
for var i = 0; i < data.count; i++
{
let cell = self.view.viewWithTag(100 + i) as? ChannelTableViewCell;
if(cell != nil)
{
let collectionCell: UICollectionView? = cell?.collectionView;
if(collectionCell != nil)
{
collectionCell!.contentOffset = scrollView.contentOffset;
}
}
}
}
}

This is the only thing that worked for me with Swift 5, I hope it helps.
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator)
{
super.viewWillTransition(to: size, with: coordinator)
collectionView.collectionViewLayout.invalidateLayout()
collectionView.frame.size = size
}

Related

UITableView inside UITableViewCell cutting from the bottom

I've a vertical listing with 7 types of UITableViewCells. One of them consist a UITableView inside the cell.
My requirement is the main tableview should autoresize the cell according to the cell's inner tableview contentSize. That os the inner tableview will show its full length and the scrolling will be off.
This approach working fine, but for cell with tableview only. When I introduce a UIImageView (with async loading image) above inner tableview, the total height of cell is somewhat smaller than the actual height of its contents. And so the inner tableview is getting cut from bottom.
Here is a representation of the bug.
I'm setting the height of UImageView according to the width to scale properly:
if let media = communityPost.media, media != "" {
postImageView.sd_setImage(with: URL(string: media), placeholderImage: UIImage(named: "placeholder"), options: .highPriority) { (image, error, cache, url) in
if let image = image {
let newWidth = self.postImageView.frame.width
let scale = newWidth/image.size.width
let newHeight = image.size.height * scale
if newHeight.isFinite && !newHeight.isNaN && newHeight != 0 {
self.postViewHeightConstraint.constant = newHeight
} else {
self.postViewHeightConstraint.constant = 0
}
if let choices = communityPost.choices {
self.datasource = choices
}
self.tableView.reloadData()
}
}
} else {
self.postViewHeightConstraint.constant = 0
if let choices = communityPost.choices {
datasource = choices
}
tableView.reloadData()
}
And the inner table view is a subclass of UITableView :
class PollTableView: UITableView {
override var intrinsicContentSize: CGSize {
self.layoutIfNeeded()
return self.contentSize
}
override var contentSize: CGSize {
didSet{
self.invalidateIntrinsicContentSize()
}
}
override func reloadData() {
super.reloadData()
self.invalidateIntrinsicContentSize()
}
}
The main table view is set to resize with automaticDimension :
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
cellHeights[indexPath] = cell.frame.size.height
}
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
return cellHeights[indexPath] ?? UITableView.automaticDimension
}
Can't seem to understand what is going wrong. Any help is appreciated.
Thanks.
When the table view is asking you to estimate the row height, you are calling back the table view. Thus you are not providing it with any information it doesn't already have.
The problem is probably with your async loading image, so you should predict the image size and provide the table view with properly estimated row height when the image hasn't loaded yet.

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.

How to do zoom in UICollectionView

I have working uicollectionview codes with CustomCollectionViewLayout , and inside have a lot of small cells but user cannot see them without zoom. Also all cells selectable.
I want to add my collection view inside zoom feature !
My clear codes under below.
class CustomCollectionViewController: UICollectionViewController {
var items = [Item]()
override func viewDidLoad() {
super.viewDidLoad()
customCollectionViewLayout.delegate = self
getDataFromServer()
}
func getDataFromServer() {
HttpManager.getRequest(url, parameter: .None) { [weak self] (responseData, errorMessage) -> () in
guard let strongSelf = self else { return }
guard let responseData = responseData else {
print("Get request error \(errorMessage)")
return
}
guard let customCollectionViewLayout = strongSelf.collectionView?.collectionViewLayout as? CustomCollectionViewLayout else { return }
strongSelf.items = responseData
customCollectionViewLayout.dataSourceDidUpdate = true
NSOperationQueue.mainQueue().addOperationWithBlock({ () -> Void in
strongSelf.collectionView!.reloadData()
})
}
}
}
extension CustomCollectionViewController {
override func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int {
return items.count
}
override func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return items[section].services.count + 1
}
override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier(reuseIdentifier, forIndexPath: indexPath) as! CustomCollectionViewCell
cell.label.text = items[indexPath.section].base
return cell
}
override func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath cellForItemAtIndexPath: NSIndexPath) {
print(items[cellForItemAtIndexPath.section].base)
}
}
Also my UICollectionView layout properties under below you can see there i selected maxZoom 4 but doesnt have any action !
Thank you !
You don't zoom a collection like you'd zoom a simple scroll view. Instead you should add a pinch gesture (or some other zoom mechanism) and use it to change the layout so your grid displays a different number of items in the visible part of the collection. This is basically changing the number of columns and thus the item size (cell size). When you update the layout the collection can animate between the different sizes, though it's highly unlikely you want a smooth zoom, you want it to go direct from N columns to N-1 columns in a step.
I think what you're asking for looks like what is done in the WWDC1012 video entitled Advanced Collection Views and Building Custom Layouts (demo starts at 20:20) https://www.youtube.com/watch?v=8vB2TMS2uhE
You basically have to add pinchGesture to you UICollectionView, then pass the pinch properties (scale, center) to the UICollectionViewLayout (which is a subclass of UICollectionViewFlowLayout), your layout will then perform the transformations needed to zoom on the desired cell.

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

Retaining button content in UICollectionView after scrolling with swift

I'm trying to make a UICollectionView that has infinite scrolling of buttons and the button's background is populated base on the result of http request to a server.
let reuseIdentifier = "Cell"
let screenSize: CGRect = UIScreen.mainScreen().bounds
let screenWidth = screenSize.width
let screenHeight = screenSize.height
let categoryApiUrl = "url"
let categoryImageField = "field"
class BrowseViewController: UICollectionViewController, UICollectionViewDataSource, UICollectionViewDelegate {
var categoryImgUrl:[String] = []
var buttonList:[UIButton] = []
func setupView(){
self.title = "Browse"
let layout: UICollectionViewFlowLayout = UICollectionViewFlowLayout()
layout.sectionInset = UIEdgeInsets(top: 20, left: 10, bottom: 10, right: 10)
layout.itemSize = CGSize(width: screenWidth/2-15, height: screenHeight/3.5)
collectionView = UICollectionView(frame: self.view.frame, collectionViewLayout: layout)
collectionView!.dataSource = self
collectionView!.delegate = self
collectionView!.registerClass(UICollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier)
collectionView!.backgroundColor = UIColor.whiteColor()
self.view.addSubview(collectionView!)
}
func setupButton(cell: UICollectionViewCell, cellNumber: Int){
var button = UIButton.buttonWithType(UIButtonType.System) as UIButton
button.frame = CGRectMake(0, 0, screenWidth/2-15, screenHeight/3.5)
button.backgroundColor = UIColor.orangeColor()
button.setTitle("Category", forState: UIControlState.Normal)
button.addTarget(self, action: "btnClicked:", forControlEvents: UIControlEvents.TouchUpInside)
buttonList.append(button)
cell.addSubview(button)
}
override func viewDidLoad(){
super.viewDidLoad()
let url = NSURL(string: categoryApiUrl)
let request = NSURLRequest(URL: url!)
NSURLConnection.sendAsynchronousRequest(request, queue: NSOperationQueue.mainQueue()) {(response, dataValue, error) in
let json = JSON(data: dataValue)
for(var i = 0; i < json.count; i++){
self.categoryImgUrl.append(json[i]["CATEGORY_IMAGE"].stringValue)
let imageUrl = self.categoryImgUrl[i]
let url = NSURL(string: imageUrl)
let data = NSData(contentsOfURL: url!)
let image = UIImage(data: data!)
self.buttonList[i].setBackgroundImage(image, forState: .Normal)
}
}
override func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int {
//#warning Incomplete method implementation -- Return the number of sections
return 1
}
override func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
//#warning Incomplete method implementation -- Return the number of items in the section
return 10;
}
override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier(reuseIdentifier, forIndexPath: indexPath) as UICollectionViewCell
let cellNumber = indexPath.row as Int
setupButton(cell, cellNumber: cellNumber)
// Configure the cell
return cell
}
override func scrollViewDidScroll(scrollView: UIScrollView) {
let offsetY = scrollView.contentOffset.y
let contentHeight = scrollView.contentSize.height
if offsetY > contentHeight - scrollView.frame.size.height {
numberOfItemsPerSection += 6
self.collectionView!.reloadData()
}
}
}
Currently, the code is able to pull the image from the server and populate it as the button's background image.
However, since I made this collection view scrollable. When I scroll the view down and then back up, the background image of the previous buttons disappear.
I did some research but couldn't find a solution to it. The reason that the button disappears is because IOS only loads the cell that is visible on screen. So when I scroll down and then scroll back up, the previous cells are consider as "New Cells". Therefore the background image that was in it are now gone.
Questions:
Does anyone have an idea on how to retain the previous buttons even if we scroll down and then scroll back up? In addition, with my current code, I added the image onto the button inside the http request because the http request is always the last execution that finishes. Is there anyway to change the code so then the http request will be finish before the cells get loaded?
I would suggest to create uicollectionview in interface builder, subclass uicollectionviewcell, add one dynamic cell to collectionview, change its class to collectionviewcell you subclassed, drop uibutton on it, and everytime cell is being created, you would download the image.
override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier(reuseIdentifier, forIndexPath: indexPath) as myCollectionViewCell
let cellNumber = indexPath.row as Int
//downloadimghere
cell.myButton.setBackgroundImage(downloadedImg, forState: .Normal)
return cell
}
This would download image everytime cell is being created. For more info you should checkout "lazy image loading". I think this is a better approach to your problem.
Now to your code, first of all you are not using your buttonList array, everytime cell is being created you create a new button and place it there, so you are not reusing already created buttons. If you fixed this, it might work like you wanted.
Here is another problem, since collectionview is reusing cells, everytime you create a button and place it on cell, it stays there, so basically now you are creating button on button. So if you want this to work correctly and have only one button on your cell, you need to remove previous button from the cell before you create it, you can do this in cellForItemAtIndexPath.
override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier(reuseIdentifier, forIndexPath: indexPath) as myCollectionViewCell
//something like this
for view in cell.subviews(){
if view == <UIButton>{
view.removeFromSuperview()
}
}
return cell
}
There might be some syntax errors in my code, I didnt test it, but you get the idea how to do it.

Resources