How to implement horizontally infinite scrolling UICollectionView?
Asked Answered
S

9

50

I want to implement UICollectionView that scrolls horizontally and infinitely?

Stephaniestephannie answered 21/12, 2015 at 12:48 Comment(3)
Possible duplicate of Showing cells in demands in UICollectionView with vertical infinite scrollFar
Achieved this with scrollView - https://mcmap.net/q/355752/-how-can-i-make-uiscrollview-infinite-in-iosNagle
This thread has some useful information in it. developer.apple.com/forums/thread/11333Attis
H
53

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 :)

Hartley answered 21/12, 2015 at 13:33 Comment(12)
Very elegant solution! Just an addition - if you want to make the collection scrollable to the left from the beginning, you can use: CollectionView.scrollToItem() and give it some big value just after its initialization.Garin
@Gocy015 even if you tell it there are millions of items, if you use reusable cells, it will only ever create just enough to fill the view and have some prepared off screen.Lunula
returning Int.max crashes the Simulator :(Hag
@Lunula , yes , but cell reuse mechanism will only solve memory problems , if the size of your cell depends on the data it's displaying , and you calculate their size/height inside heightForRowAtIndexPath or sizeForItemAtIndexPath , these method will be called as many times as your numberOf .. InSection returned. I'm guessing this will probably cause a performance issue ? There will still be millions of unnecessary calls here even if i do some caching.Gitlow
@Gitlow even if the size is calculated for each cell, assuming every cell size is dynamic, it will still only run the calculation for the few cells currently being displayed or about to be displayed. It does this stuff very well, so I'm not convinced there would be a performance hit. I've nested UITableViews in UICollectionViewCells using this method and everything runs very smooth.Lunula
@Lunula , no ! since UITableView and UICollectionView is a subclass of UIScrollView , the system will calculate the size for each cell when initializing the scroll view to determine its contentSize , i haven't test this approach on a real-world app ,just concerned that i might raise a performance issue , it's also good to know that you've implement this and it's working fine.Gitlow
And what happesn if user swipe left from start? Will it go towards -1?Freund
I'm not sure if I understood it @Freund but indexPath.row will never be negative. If you somehow have negative indexes, when index % dataSource.count result is negative you have to add dataSource.count to keep accessing the data source in the same orderHartley
Use Int (INT_MAX) rather Int.max, it will not crash rest is fine Ref : #35939080Orr
Its crashing for Int(INT_MAX) and Int.max for both Simulator and Device.Lifeless
This is obviously not going to work by creating 2 billion items... first it'll crash due to out of memory, second it's not infinite scrolling where the collection view resets after the reaching the end.Ehrenberg
This is a good solution but it needs two critical adjustments to work with modern API: Int.max does indeed crash the simulator, but do we need the number to actually be that high? A number like 1000000 doesn't crash the simulator, and works just as well. Also, one needs to disable any kind of automatic cell sizing and just use fixed cell sizes to avoid heavy strain on CPU and memory.Firearm
B
17

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:

  1. Subclass UICollectionView.
  2. 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)
        }
    }
    
  3. 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)
            }
        }
    }
    
  4. 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.

Backdrop answered 30/5, 2017 at 9:41 Comment(1)
I used a very similar solution. This works well for collectionViews that use paging and show one cell at a time. I am also using the collectionView(sizeForItemAt indexPath) function to size the cells based on the device width, and the accepted answer will crash the app because it sets the size for every single cell... so making a ridiculously large collection won't work in my case.Deltadeltaic
T
6

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.

Tellurian answered 22/1, 2017 at 17:46 Comment(0)
C
5

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)
}
Canarese answered 29/5, 2017 at 21:48 Comment(0)
A
2

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
Amidst answered 16/6, 2016 at 9:26 Comment(0)
S
0

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.

Seabrook answered 21/8, 2016 at 23:32 Comment(0)
O
0

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

Outdated answered 30/12, 2021 at 14:40 Comment(0)
V
-2
  1. To apply this infinite loop functionality You should have proper collectionView layout

  2. 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)
    }
    
Variant answered 17/5, 2017 at 8:28 Comment(1)
use it in collectionview delegate for collection view layout refer #40586322Variant
I
-3

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
Ingrown answered 17/5, 2019 at 3:4 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.