How to implement horizontally infinite scrolling UICollectionView? - ios

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

Related

Auto Select Middle Visible Cell Of Collection View

I'm trying to select and highlight the middle cell of the visible cells in a collection view at any given time. The collection view in question displays days for six months forwards and back.
I've tried using the scroll view delegates and the collection view delegates. But all that works is select and highlight code in didSelectItem() collection view delegate.
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
print("delegate called")
collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
collectionView.cellForItem(at: indexPath)?.backgroundColor = UIColor.highlightCellGreen()
if let cell = collectionView.cellForItem(at: indexPath) as? ClientListDateCollectionViewCell{
monthLabel.text = cell.monthName
monthLabel.text = monthLabel.text?.capitalized
}
I tried to select the middle cell while scrolling using the viewDidScroll() delegate. But, I wasn't able to get the output I wanted.
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let visibleCellCount = dateCollectionView.indexPathsForVisibleItems.count
let cellCount = dateCollectionView.visibleCells.count
let visibleCells = dateCollectionView.indexPathsForVisibleItems[visibleCellCount-1/2]
if visibleCellCount>0{
let middle = visibleCellCount/2
let midValue = dateCollectionView.indexPathsForVisibleItems[middle]
dateCollectionView.selectItem(at: midValue, animated: true, scrollPosition: .centeredHorizontally)
}
How do I go about selecting the middle cell?
edit 1: The collection view starts on the leftmost point and then scrolls to the middle i.e, today's date
You can use delegate of UICollectionView (i.e: didHighlightItemAtIndexPath). just make sure to call collection view delegates on your desired time by calling reload function
self.collectionView.reloadData()
and in you collection view delegate just do this
func collectionView(collectionView: UICollectionView, didHighlightItemAtIndexPath indexPath: NSIndexPath){
var cell : UICollectionViewCell = UICollectionViewCell()
self.collectionView.cellForItemAtIndexPath = indexPath
//change highlighted color as of your need
cell.view.backgroundColor = UIColor.init(red: 25, green: 118, blue: 210).cgColor
}
This will highlight you selected item
Disable multiple selection (or selection entirely?) to make things easier.
collectionView.allowsMultipleSelection = false
On scrollViewDidScroll(_:) get the center point of the screen as CGpoint.
let center = collectionView.center
Use that information to get the index path of the center item
let indexPath = collectionView.indexPathForItem(at: center)
Select the item
collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .top)
Suppose that you have the horizontal of displaying, and you want to have the auto scroll to the center of your item in datasource.
Creating a method and calling it immediately after your collection view is completely configured:
func scrollToCenterIndex() {
let centerIndex = LIST_OF_YOUR_DATA_SOURCE.count / 2
let indexPath = IndexPath(item: centerIndex, section: 0)
self.collectionView.scrollToItem(at: indexPath,
at: .right,
animated: false)
}
Inside the method:
public func collectionView(_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CELL,
for: indexPath) as? CustomCell else {
fatalError("Cannot create cell")
}
If indexPath.row == LIST_OF_YOUR_DATA_SOURCE.count / 2 {
// perform your hight light color to the cell
} else {
// reset your hight light color to default color
}
let model = LIST_OF_YOUR_DATA_SOURCE[indexPath.row]
cell.configure(model)
return cell
}
I think you can use a method to get the center point of collection view, and use this value to get the the middle of visible cell.
let centerPoint = self.view.convert(collectionView.center, to: collection)
Here is an example I did it with a tableView. You can apply it to your collection view with the same approach.
class ViewController: UIViewController {
#IBOutlet weak var tableView: UITableView!
var dataSource = Array(1...31)
var centerIndex: IndexPath?
func setCellSelected(cell: UITableViewCell, _ selected: Bool) {
cell.contentView.backgroundColor = selected ? .green : .white
}
}
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
dataSource.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "CELL")
cell?.textLabel?.text = String(dataSource[indexPath.row])
let center = self.view.convert(tableView.center, to: tableView)
if let index = tableView.indexPathForRow(at: center), let cell = cell {
setCellSelected(cell: cell, indexPath.row == index.row)
}
return cell!
}
}
extension ViewController: UITableViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// reset the previous hight light cell
if let centerIndex = centerIndex, let cell = tableView.cellForRow(at: centerIndex) {
setCellSelected(cell: cell, false)
}
// set hight light to a new center cell
let center = self.view.convert(tableView.center, to: tableView)
if let index = tableView.indexPathForRow(at: center), let cell = tableView.cellForRow(at: index) {
setCellSelected(cell: cell, true)
centerIndex = index
}
}
}
I was also trying to do the auto-selection of the middle visible cell of the collection view, and I got the solution, here is the solution:
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
// Reload Collection View
collectionView.reloadData()
// Find centre point of collection view
let visiblePoint = CGPoint(x: collectionView.center.x + collectionView.contentOffset.x, y: collectionView.center.y + collectionView.contentOffset.y)
// Find index path using centre point
guard let newIndexPath = collectionView.indexPathForItem(at: visiblePoint) else { return }
// Select the new centre item
collectionView.selectItem(at: newIndexPath, animated: true, scrollPosition: .centeredHorizontally) }
You need to use the Scroll view delegate function, scrollViewDidEndDecelerating. Reload the collection view first. Second, find the center visible point of the collection view. Third, using the center visible point, find the indexPath of collection view and finally use the index to select the item in the collection view.
I know I answered this question a little late, still thinking that it will be helpful for someone.
Cheers!

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.

JSQMessagesView problems with animating row height change

I am implementing a simple messenger for my app where the users can chat among themselves. The messenger is based on UICollectionView (JSQMessagesViewController) where each message is represented by one UICollectionView row. Each message also has a top label that is used to display when the message was sent. This label is initially hidden (height=0) and when the user taps the particular message (row), the label gets displayed by setting the height correspondingly. (height=25)
The problem I am facing is the actual animation of displaying the label. (height change). Part of the row overlays the row bellow by several pixels before it gets to it's position. Also when hiding the label back, the animation first sets the height to zero and then the text fades out overlaying part of the message bellow which looks really bad.
So basically what I am trying to achieve is to get rid of those two previously mentioned problems.
Code:
override func collectionView(_ collectionView: JSQMessagesCollectionView!, layout collectionViewLayout: JSQMessagesCollectionViewFlowLayout!, heightForCellTopLabelAt indexPath: IndexPath!) -> CGFloat {
if indexPath == indexPathTapped {
return 25
}
let messageCurrent = messages[indexPath.item]
let messagePrev: JSQMessage? = indexPath.item - 1 >= 0 ? messages[indexPath.item - 1] : nil
if messageCurrent.senderId == messagePrev?.senderId || messagePrev == nil {
return 0
}
else{
return 25
}
}
override func collectionView(_ collectionView: JSQMessagesCollectionView!, didTapMessageBubbleAt indexPath: IndexPath!) {
if let indexPathTapped = indexPathTapped, indexPathTapped == indexPath {
self.indexPathTapped = nil
}
else{
indexPathTapped = indexPath
}
collectionView.reloadItems(at: [indexPath])
// UIView.animate(withDuration: 0.5, delay: 0.0, options: .curveLinear, animations: {
// collectionView.performBatchUpdates({
// collectionView.reloadItems(at: [indexPath])
// }, completion: nil)
// }, completion: nil)
}
Demo: (Sorry for the quality)
I would really appreciate if somebody could help me with this as I have already spent several hours trying to figure it out without getting anywhere.
Thank you in advance!
EDIT:
I tried the solution proposed by #jamesk as following:
override func collectionView(_ collectionView: JSQMessagesCollectionView!, didTapMessageBubbleAt indexPath: IndexPath!) {
if let indexPathTapped = indexPathTapped, indexPathTapped == indexPath {
self.indexPathTapped = nil
}
else{
indexPathTapped = indexPath
}
UIView.animate(withDuration: 0.25) {
collectionView.performBatchUpdates(nil)
}
}
And override the apply of JSQMessagesCollectionViewCell:
extension JSQMessagesCollectionViewCell {
override open func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {
super.apply(layoutAttributes)
layoutIfNeeded()
}
}
However those changes resulted in:
I also tried the second solution with invalidating the layout:
override func collectionView(_ collectionView: JSQMessagesCollectionView!, didTapMessageBubbleAt indexPath: IndexPath!) {
if let indexPathTapped = indexPathTapped, indexPathTapped == indexPath {
self.indexPathTapped = nil
}
else{
indexPathTapped = indexPath
}
var paths = [IndexPath]()
let itemsCount = collectionView.numberOfItems(inSection: 0)
for i in indexPath.item...itemsCount - 1 {
paths.append(IndexPath(item: i, section: 0))
}
let context = JSQMessagesCollectionViewFlowLayoutInvalidationContext()
context.invalidateItems(at: paths)
UIView.animate(withDuration: 0.25) {
self.collectionView?.collectionViewLayout.invalidateLayout(with: context)
self.collectionView?.layoutIfNeeded()
}
}
Which resulted in the following:
There seem to be two issues. The first issue is that the call to reloadItems(at:) is limited to cross-fading between the old cell and the new cell—it won't interpolate between the layout attributes for the old cell and the layout attributes for the new cell. The second issue is that there doesn't seem to be any code that instructs your selected cell to perform a layout pass if needed upon new layout attributes being applied to it.
The JSQMessagesViewController framework uses subclasses of UICollectionViewFlowLayout and UICollectionViewFlowLayoutInvalidationContext, so we can leverage the invalidation behaviour of the flow layout when updating and animating items. All that is needed is to invalidate the layout attributes (i.e. position) and delegate metrics (i.e. size) for the items affected by the change in cell height.
The code below was written for use with the Swift example project included in the release_7.3 branch of JSQMessagesViewController:
override func collectionView(_ collectionView: JSQMessagesCollectionView!, didTapMessageBubbleAt indexPath: IndexPath!) {
// Determine the lowest item index affected by the change in cell size.
// Lesser of previous tapped item index (if any) and current tapped item index.
let minItem = min(tappedIndexPath?.item ?? indexPath.item, indexPath.item)
// Update tapped index path.
tappedIndexPath = (tappedIndexPath == indexPath ? nil : indexPath)
// Prepare invalidation context spanning all affected items.
let context = JSQMessagesCollectionViewFlowLayoutInvalidationContext()
let maxItem = collectionView.numberOfItems(inSection: 0) - 1
let indexPaths = (minItem ... maxItem).map { IndexPath(item: $0, section: 0) }
context.invalidateItems(at: indexPaths) // Must include all affected items.
context.invalidateFlowLayoutAttributes = true // Recompute item positions (for all affected items).
context.invalidateFlowLayoutDelegateMetrics = true // Recompute item sizes (needed for tapped item).
UIView.animate(withDuration: 0.25) {
collectionView.collectionViewLayout.invalidateLayout(with: context)
collectionView.layoutIfNeeded() // Ensure layout pass for visible cells.
}
}
The above code should be reasonably performant.
While the positions of affected items must always be recomputed, it isn't necessary to recompute the sizes of all affected items as is done above. It would be sufficient to recompute only the size of the tapped item. But as the effect of the invalidateFlowLayoutDelegateMetrics property is always applied to every invalidated item, to implement that narrower approach, you would need to use two flow layout invalidation contexts and divide the items between them (or implement a custom invalidation context with corresponding invalidation behaviour). It is probably not worth it unless Instruments tells you otherwise.
After inserting the data try adding this piece of code.
collectionView.reloadItems(at: [indexPath])
UIView.animate(withDuration: 0.6) {
self.view.layoutIfNeeded()
}

Change collection view cell width at runtime

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
}

Swift tableView Pagination

I have success working tableview with json parsing code. But may have 1000 more item so I need pagination when scrolling bottom side. I don't know how can I do this for my code shown below. For objective-C, there are a lot of examples but for Swift I didn't find a working example.
import UIKit
class ViewController: UIViewController, UITableViewDataSource,UITableViewDelegate {
let kSuccessTitle = "Congratulations"
let kErrorTitle = "Connection error"
let kNoticeTitle = "Notice"
let kWarningTitle = "Warning"
let kInfoTitle = "Info"
let kSubtitle = "You've just displayed this awesome Pop Up View"
#IBOutlet weak var myTableView: UITableView!
#IBOutlet weak var myActivityIndicator: UIActivityIndicatorView!
var privateList = [String]()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
}
override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
loadItems()
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
internal func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int
{
return privateList.count
}
internal func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell
{
let cell:myCell = tableView.dequeueReusableCellWithIdentifier("myCell") as! myCell
cell.titleLabel.text = privateList[indexPath.row]
return cell
}
func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {
if (editingStyle == UITableViewCellEditingStyle.Delete){
print(indexPath.row)
let alert = SCLAlertView()
alert.addButton("Hayır"){ }
alert.addButton("Evet") {
self.myTableView.beginUpdates()
self.privateList.removeAtIndex(indexPath.row)
tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: UITableViewRowAnimation.Left)
print("Silindi")
self.myTableView.endUpdates()
self.loadItems()
}
alert.showSuccess(kSuccessTitle, subTitle: kSubtitle)
}
}
func tableView(tableView: UITableView, canEditRowAtIndexPath indexPath: NSIndexPath) -> Bool {
// the cells you would like the actions to appear needs to be editable
return true
}
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
if(segue.identifier == "Detail") {
let destinationView = segue.destinationViewController as! DetailViewController
if let indexPath = myTableView.indexPathForCell(sender as! UITableViewCell) {
destinationView.privateLista = privateList[indexPath.row]
}
}
}
internal func tableView(tableView: UITableView, estimatedHeightForHeaderInSection section: Int) -> CGFloat
{
return 0.0
}
func loadItems()
{
loadItemsNow("privateList")
}
func loadItemsNow(listType:String){
myActivityIndicator.startAnimating()
let listUrlString = "http://bla.com/json2.php?listType=" + listType + "&t=" + NSUUID().UUIDString
let myUrl = NSURL(string: listUrlString);
let request = NSMutableURLRequest(URL:myUrl!);
request.HTTPMethod = "GET";
let task = NSURLSession.sharedSession().dataTaskWithRequest(request) {
data, response, error in
if error != nil {
print(error!.localizedDescription)
dispatch_async(dispatch_get_main_queue(),{
self.myActivityIndicator.stopAnimating()
})
return
}
do {
let json = try NSJSONSerialization.JSONObjectWithData(data!, options: .MutableContainers) as? NSArray
if let parseJSON = json {
self.privateList = parseJSON as! [String]
}
} catch {
print(error)
}
dispatch_async(dispatch_get_main_queue(),{
self.myActivityIndicator.stopAnimating()
self.myTableView.reloadData()
})
}
task.resume()
}
}
For that you need to have server side change also.
Server will accept fromIndex and batchSize in the API url as query param.
let listUrlString = "http://bla.com/json2.php?listType=" + listType + "&t=" + NSUUID().UUIDString + "&batchSize=" + batchSize + "&fromIndex=" + fromIndex
In the server response, there will be an extra key totalItems. This will be used to identify all items are received or not. An array or items fromIndex to batchSize number of items.
In the app side
First loadItem() will be called with fromIndex = 0 and batchSize = 20 (for example in viewDidLoad() or viewWillAppear). removeAll items from privateList array before calling loadItem() for the first time
Server returns an array of first 20 items and totalItems total number of items in the server.
Append the 20 items in privateList array and reload tableView
In tableView:cellForRowAtIndexPath method check if the cell is the last cell. And check if totalItems (form server) is greater than privateList.count. That means there are more items in the server to load
if indexPath.row == privateList.count - 1 { // last cell
if totalItems > privateList.count { // more items to fetch
loadItem() // increment `fromIndex` by 20 before server call
}
}
Question: where is refresh ? will be scrolling ?
Refresh after appending new items in the array when server response received. (step 3)
Scrolling will trigger tableView:cellForRowAtIndexPath for every cell when user scrolls. Code is checking if it is the last cell and fetch remaining items. (step 4)
Sample project added: https://github.com/rishi420/TableViewPaging
SWIFT 3.0 and 4.0
If you're sending the page number in the API request then this is the ideal way for implementing pagination in your app.
declare the variable current Page with initial Value 0 and a bool to check if any list is being loaded with initial value false
var currentPage : Int = 0
var isLoadingList : Bool = false
This is the function that gets the list example:
func getListFromServer(_ pageNumber: Int){
self.isLoadingList = false
self.table.reloadData()
}
This is the function that increments page number and calls the API function
func loadMoreItemsForList(){
currentPage += 1
getListFromServer(currentPage)
}
this is the method that will be called when the scrollView scrolls
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if (((scrollView.contentOffset.y + scrollView.frame.size.height) > scrollView.contentSize.height ) && !isLoadingList){
self.isLoadingList = true
self.loadMoreItemsForList()
}
}
P.S. the bool isLoadingList role is to prevent the scroll view from getting more lists in one drag to the bottom of the table view.
The good and efficient way to do it is by using scrollviewDelegate in tableview
Just add UIScrollViewDelegate in your viewController
In view controller
//For Pagination
var isDataLoading:Bool=false
var pageNo:Int=0
var limit:Int=20
var offset:Int=0 //pageNo*limit
var didEndReached:Bool=false
viewDidLoad(_){
tableview.delegate=self //To enable scrollviewdelegate
}
Override two methods from this delegate
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
print("scrollViewWillBeginDragging")
isDataLoading = false
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
print("scrollViewDidEndDecelerating")
}
//Pagination
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
print("scrollViewDidEndDragging")
if ((tableView.contentOffset.y + tableView.frame.size.height) >= tableView.contentSize.height)
{
if !isDataLoading{
isDataLoading = true
self.pageNo=self.pageNo+1
self.limit=self.limit+10
self.offset=self.limit * self.pageNo
loadCallLogData(offset: self.offset, limit: self.limit)
}
}
}
This is now a little bit easier with the addition of a new protocol in iOS10: UITableViewDataSourcePrefetching
https://developer.apple.com/documentation/uikit/uitableviewdatasourceprefetching
//It works fine
func getPageCount(TotalCount : Int) -> Int{
var num = TotalCount
let reminder = num % 50
print(reminder)
if reminder != 0{
num = TotalCount/50
num = num + 1
}else{
num = TotalCount/50
}
return num
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
let TotalPage = self.getPageCount(TotalCount: Int(Datacount)!)
let lastItem = self.mainArr.count - 1
if indexPath.row == lastItem {
print("IndexRow\(indexPath.row)")
if self.page < TotalPage-1 {
self.view_Loader.isHidden = false
self.view_LoaderHeight.constant = 50
self.page += 1
self.YourAPI()
}
}
}`
By using UITableViewDelegate, u can call the function
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
let lastItem = self.mes.count - 1
if indexPath.row == lastItem {
print("IndexRow\(indexPath.row)")
if currentPage < totalPage {
currentPage += 1
//Get data from Server
}
}
}
I needed something similar on a project and my solution was:
1 - create a variable numberOfObjectsInSubArray (initial value 30 or whatever you want)
2 - create a subarray to add a number of objects from your privateList array every time i tap "show more"
let subArray = privateList?.subarrayWithRange(NSMakeRange(0, numberOfObjectsInSubArray))
And use it on
internal func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int
{
return subArray.count
}
3- Whenever you need to show more objects, do:
func addMoreObjectsOnTableView () {
numberOfObjectsInSubArray += 30
if (numberOfObjectsInSubArray < privateList.count) {
subArray = privateList?.subarrayWithRange(NSMakeRange(0, numberOfObjectsInSubArray))
} else {
subArray = privateList?.subarrayWithRange(NSMakeRange(0, privateList.count))
}
tableView.reloadData()
}
I hope it helps
I've tried an approach with willDisplayCell. But it produces unwanted stops during scrolling which makes the user experience not good.
I think a better way is to do it in scrollViewDidEndDecelerating delegate method. It calls when the scroll finishes and only then new data comes. User sees that there is new content and scroll again if he wants. I've taken the answer here but instead of scrollViewDidEndDragging I use scrollViewDidEndDecelerating. It looks just better in my case. Here is some code from my project.
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
guard scrollView == tableView,
(scrollView.contentOffset.y + scrollView.frame.size.height) >= scrollView.contentSize.height,
!viewModel.isLastPeriodicsPage else { return }
viewModel.paginatePeriodics(tableView.getLastIndexPath())
}
Another way of doing this is: You may set a threshold for getting elements while sending request each time:
Lets say you you are fetching 20 elements first time. You will be saving last fetched record id or number for getting list of next 20 elements.
let lastFetchedIndex = 20;
I am assuming that you have already added these records in your myArray. MyArray is the dataSource of tableView. Now myArray is containing 40 objects. I am going to make a list of indexPaths of rows that needs to be inserted in tableView now.
var indexPathsArray = [NSIndexPath]()
for index in lastFetchedIndex..<myArray.count{
let indexPath = NSIndexPath(forRow: index, inSection: 0)
indexPathsArray.append(indexPath)
}
Here I am updating my tableView. Make sure your dataSource i mean your myArray has already been updated. So that it may insert rows properly.
self.tableView.beginUpdates()
tableView!.insertRowsAtIndexPaths(indexPathsArray, withRowAnimation: .Fade)
self.tableView.endUpdates()
Add another section to your tableview, let this section have only 1 row which will be a cell containing an activity indicator, to denote loading.
internal func numberOfSectionsInTableView(tableView: UITableView) -> Int
{
return 2;
}
internal func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int
{
if section == 0 {
return privateList.count
} else if section == 1 { // this is going to be the last section with just 1 cell which will show the loading indicator
return 1
}
}
internal func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell
{
if section == 0 {
let cell:myCell = tableView.dequeueReusableCellWithIdentifier("myCell") as! myCell
cell.titleLabel.text = privateList[indexPath.row]
return cell
} else if section == 1 {
//create the cell to show loading indicator
...
//here we call loadItems so that there is an indication that something is loading and once loaded we relaod the tableview
self.loadItems()
}
}
here is a sample code for collection view :
var page = 0
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell{
print("page Num:\(page)")
}
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath){
if arrImagesData.count-1 == indexPath.row && arrImagesData.count%10 == 0{
getMoreImages(page)
}
}
func getMoreImages(page:Int){
//hit api
if api_success == true {
if self.page == 0 {
self.arrImagesData.removeAll()
}
self.arrImagesData.appendContentsOf(api_data)
self.collectionImages.reloadData()
self.page = self.page + 1
}
}
API handler is api handler for network call that just do POST and GET calls. getNotifications is basically just a post call with params( offset and pageSize ) and in response there is list.
Main logic is changing offset depending on cell in willDisplay collectionView delegate. Comment if you having any question , happy to help.
var isFetching: Bool = false
var offset = 0
var totalListOnServerCount = 20 // it must be returned from server
var pageSize = 10 // get 10 objects for instance
// MARK: - API Handler
private func fetchNotifications(){
// return from function if already fetching list
guard !isFetching else {return}
if offset == 0{
// empty list for first call i.e offset = 0
self.anyList.removeAll()
self.collectionView.reloadData()
}
isFetching = true
// API call to fetch notifications with given offset or page number depends on server logic just simple POST Call
APIHandler.shared.getNotifications(offset: offset) {[weak self] (response, error) in
if let response = response {
self?.isFetching = false
if self?.offset == 0{
// fetch response from server for first fetch
self?.notificationsResponse = response
if self?.refreshControl.isRefreshing ?? false {
self?.refreshControl.endRefreshing()
}
}else{
// append if already exist ( pagination )
self?.notificationsResponse?.notifications.append(contentsOf: response.notifications)
}
self?.collectionView.reloadData()
}
}
}
// MARK: - Collection View Delegate
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
guard let anyList = responseFromServer else { return }
// check if scroll reach last index available and keep fetching till our model list has all entries from server
if indexPath.item == anyList.count - 1 && anyList.count < totalListOnServerCount{
offset += pageSize
fetchNotifications()
}
}
Made a General purpouse pagination framework: 🎉
https://github.com/eonist/PaginationTable
let table = Table(rowData: [], frame: .zero, style: .plain)
view = table
table.isFetching = true
Table.fetchData(range: table.paginationRange) { rowItem in
DispatchQueue.main.async { [weak table] in
table?.rowData += rowItem
table?.reloadData()
table?.paginationIndex += Table.paginationAmount // set the new pagination index
table?.isFetching = false
}
}
Swift 5 (Full comprehensive pagination solution)
The UI code:
https://github.com/eonist/PaginationTable
The Data Model code:
https://github.com/eonist/PaginationService
Core components:
rowData: This array will grow on each scroll-ended-event until it has loaded all items from backend-API
paginationAmount: The amount to fetch on each pagination cycle
paginationIndex: The current amount of cells (this grows as you load more data
isFetching: A boolean that lets the code know if data is already loading or not, to avoid double fetching etc
fetchData: Simulates getting data from remote-api
Gotchas:
The example code is not reliant on a backend. It simply tests with data from a file and simulates network calls by sleeping for some seconds
The example uses some dependencies in order to speed up the creation of this example. But its basic stuff like AFNetwork, Json parsing, Autollayout. All of which could easily be substituted
Requirements:
Backend-API that can provide the count of items
Backend-API that can return items for a range (startIndex, endIndex)

Resources