How to implement horizontally infinite scrolling UICollectionView?

若如初见. 提交于 2019-12-02 15:49:16

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

Bio-Matic

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.

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) {
    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)
}
Manikanta Adimulam
  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)
    }
    

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.

SebastianR

Also implying that your data is static and that all your UICollectionView cells should have the same size, I found this promising solution.

You should maybe just 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 and easy to use.

You basically follow these steps:

  • Create a InfiniteCollectionView in Storyboard
  • Set infiniteDataSource and infiniteDelegate
  • Implement the necessary functions that create your infinitely scrolling cells

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
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!