I'm currently using a UICollectionView to display 3 images (each image spans the entire cell). I also have a UIPageControl that I placed on top of the UICollectionView. What I want to happen is to have the UIPageControl show the number of images (which in this case is 3), and also which image the user is currently viewing. The effect I am trying to go for is that of the Instagram app.
The way that I am currently using to achieve this effect is by placing the updating of the UIPageControl within the UICollectionView's willDisplay function, like so:
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
pictureDots.currentPage = indexPath.item
}
This manages to correctly hook up the paging effect between the collection view and page control. However, the problem that I have is that the UIPageControl starts off saying the user is on the third image, even though it is displaying the first image.
Does anyone know why this is happening and how to fix this problem?
Firstly add your UIPageControl into your storyboard with your UICollectionView, then connect them as outlets to your view controller.
#IBOutlet var pageControl: UIPageControl!
#IBOutlet var collectionView: UICollectionView!
Adjust your numberOfItemsInSection method in UICollectionViewDataSource to set the count of the page control to always be equal to the number of cells in the collection view.
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
let count = ...
pageControl.numberOfPages = count
pageControl.isHidden = !(count > 1)
return count
}
Lastly, using the UIScrollViewDelegate, we can tell which cell the UICollectionView stops on. If you are not using a UICollectionViewController, you may have to add the delegate protocol.
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
pageControl?.currentPage = Int(scrollView.contentOffset.x) / Int(scrollView.frame.width)
}
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
pageControl?.currentPage = Int(scrollView.contentOffset.x) / Int(scrollView.frame.width)
}
This is possible because a UICollectionView is in fact a UIScrollView under the hood.
Step 1 Create the variables for Collection View and Page Control
#IBOutlet weak var collectionView: UICollectionView!
#IBOutlet var pageControl:UIPageControl!
Step 2 Set the number of pages of Page Control
override func viewDidLoad() {
super.viewDidLoad()
self.pageControl.numberOfPages = procedures.count
//Set the delegate
self.collectionView.delegate = self
}
*Step 3 In the scrollViewDidScroll function calculate the width of collection cell and the index for the current page.
extension YourCollectionVC: UICollectionViewDataSource, UICollectionViewDelegate {
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
let witdh = scrollView.frame.width - (scrollView.contentInset.left*2)
let index = scrollView.contentOffset.x / witdh
let roundedIndex = round(index)
self.pageControl?.currentPage = Int(roundedIndex)
}
}
Note: This is for collection view displayed horizontally, for vertical direccion change the method.
Tested in swift 4.
Use this for smooth functioning.
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let witdh = scrollView.frame.width - (scrollView.contentInset.left*2)
let index = scrollView.contentOffset.x / witdh
let roundedIndex = round(index)
self.pageControl.currentPage = Int(roundedIndex)
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
pageControl.currentPage = Int(scrollView.contentOffset.x) / Int(scrollView.frame.width)
}
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
pageControl.currentPage = Int(scrollView.contentOffset.x) / Int(scrollView.frame.width)
}
With help of Combine, we can easily connect UIPageControl with any ScrollView.
var collectionView: UICollectionView
var bin: Set<AnyCancellable> = []
var pageControl: UIPageControl
// setup observer
collectionView
.publisher(for: \.contentOffset)
.map { [unowned self] offset in
Int(round(offset.x / max(1, self.collectionView.bounds.width)))
}
.removeDuplicates()
.assign(to: \.currentPage, on: pageControl) // may cause memory leak use sink
.store(in: &bin)
//For every iPhone screen, it is working(Swift 5)...
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
let offSet = scrollView.contentOffset.x
let width = scrollView.frame.width
let horizontalCenter = width / 2
_pageControl.currentPage = Int(offSet + horizontalCenter) / Int(width)
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
pageControl.currentPage = Int(scrollView.contentOffset.x) / Int(scrollView.frame.width)
}
Related
I have a collection view where the cell is of the size exactly to the collectionView, so each cell should occupy the whole screen. I have implemented a functionality where the cell is snapped to the complete view whenever it's dragged or decelerated through the scroll. This is how the UX works.
https://drive.google.com/open?id=1v8-WxCQUzfu8V_k9zM1UCWsf_-Zz4dpr
What I want:
As you can see from the clip, the cell snaps to the whole screen. Now, I want to execute a method after it snaps. Not before or not when it's partially displayed.
Following is the code I have written for snapping effect :
func scrollToMostVisibleCell(){
let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size)
let visiblePoint = CGPoint(x: visibleRect.midX, y: visibleRect.midY)
let visibleIndexPath = collectionView.indexPathForItem(at: visiblePoint)!
collectionView.scrollToItem(at: visibleIndexPath as IndexPath, at: .top, animated: true)
print("cell is ---> ", visibleIndexPath.row)
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
scrollToMostVisibleCell()
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
scrollToMostVisibleCell()
if !decelerate {
scrollToMostVisibleCell()
}
}
If I use willDisplayCell method, then it' just going to return me as soon as the cell is in the view, even if it's just peeping in the collectionView.
Is there a way where I can check if the cell is completely in the view and then I can perform a function?
I have scrapped the internet over this question, but ain't able to find a satisfactory answer.
Here is a complete example of a "full screen" vertical scrolling collection view controller, with paging enabled (5 solid color cells). When the cell has "snapped into place" it will trigger scrollViewDidEndDecelerating where you can get the index of the current cell and perform whatever actions you like.
Add a new UICollectionViewController to your storyboard, and assign its class to VerticalPagingCollectionViewController. No need to change any of the default settings for the controller in storyboard - it's all handled in the code below:
//
// VerticalPagingCollectionViewController.swift
//
// Created by Don Mag on 10/31/18.
//
import UIKit
private let reuseIdentifier = "Cell"
class VerticalPagingCollectionViewController: UICollectionViewController {
private var collectionViewFlowLayout: UICollectionViewFlowLayout {
return collectionViewLayout as! UICollectionViewFlowLayout
}
private var colors: [UIColor] = [.red, .green, .blue, .yellow, .orange]
override func viewDidLoad() {
super.viewDidLoad()
// Register cell classes
self.collectionView?.register(UICollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier)
// enable paging
self.collectionView?.isPagingEnabled = true
// set section insets and item spacing to Zero
collectionViewFlowLayout.sectionInset = UIEdgeInsets.zero
collectionViewFlowLayout.minimumLineSpacing = 0
collectionViewFlowLayout.minimumInteritemSpacing = 0
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if let cv = collectionViewLayout.collectionView {
collectionViewFlowLayout.itemSize = cv.frame.size
}
}
override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if let iPath = collectionView?.indexPathsForVisibleItems.first {
print("DidEndDecelerating - visible cell is: ", iPath)
// do what you want here...
}
}
override func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return colors.count
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath)
cell.backgroundColor = colors[indexPath.item]
return cell
}
}
Using followedCollectionView.indexPathsForVisibleItems() to get visible cells visibleIndexPaths and check your indexPath is contained in visibleIndexPaths or not, before doing anything with cells.
Ref : #anhtu Check whether cell at indexPath is visible on screen UICollectionView
Also from Apple : var visibleCells: [UICollectionViewCell] { get } . Returns an array of visible cells currently displayed by the collection view.
I have a UITableView with automatic sizing cells. The contents of each cell revolve around the counting of time, so the size of the cell could change every second. I am currently using a timer scheduled every 1 second to tell the table view to update the cell sizes:
Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(timeUpdateNotification), userInfo: nil, repeats: true)
#objc func timeUpdateNotification() {
// ... cells get notified to update contents here ...
tableView.beginUpdates()
tableView.endUpdates()
}
This works fairly well but has issues when the user taps to scroll to the top of the list. The animation is a bit janky and often times doesn't make it to the top. Is there a better way to handle this?
If you set a flag when the table is scrolling you can detect it in your timer function and not update while it is set. UITableView is a descendant of UIScrollView, so you can use some of the scroll view delegates to do this. If you override scrollViewShouldScrollToTop() and scrollViewDidScrollToTop() you will know when the scroll view is scrolling to the top and when it has finished.
override func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
NSLog("scrollViewShouldScrollToTop")
isScrolling = true
return true
}
override func scrollViewDidScrollToTop(_ scrollView: UIScrollView) {
NSLog("scrollViewDidScrollToTop")
isScrolling = false
}
You could also extend this to detect when the user is dragging/scrolling the view, to prevent the timer function from updating at these occasions too.
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
NSLog("scrollViewWillBeginDragging")
isScrolling = true
}
override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if (!decelerate) {
// Only catch if scrolling stopped
NSLog("scrollViewDidEndDragging")
isScrolling = false
}
}
override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
NSLog("scrollViewDidEndDecelerating")
isScrolling = false
}
I have added some logging to the functions, so that you can see what is going on. You can remove these, of course.
Invalidate the timer when they click to scroll to top, then as soon as it reaches top start it again.
Edit: That way, it won't update content which may move content higher without updating the "top" of the table view.
I had a similar problem.
I solved it with the help of a manual height calculation for each cell.
var cellHeights: [Int: CGFloat] = [:]
In the cellForRowAtIndexPath method, calculate the height:
cellHeights[byRow] = cell.getCellHeight()
In the cell itself
func getCellHeight() -> CGFloat {
let userInfoHeight: CGFloat = userInfoHeightConstraint.constant
let actionHeight: CGFloat = actionViewHeightConstraint.constant
let descriptionBottom: CGFloat = descriptionBottomConstraint.constant
let descriptionWidth = self.frame.width - leftAvatarConstraint.constant - descriptionRightConstraint.constant
let descriptionHeight: CGFloat = descriptionLabel.textHeight(width: descriptionWidth)
let height = userInfoHeight + actionHeight + descriptionBottom + descriptionHeight
return height
}
extension UILabel {
func textHeight(width: CGFloat) -> CGFloat {
var textHeight: CGFloat = 0
if let text = self.text {
let customLabel = UILabel(frame: CGRect(x: 0, y: 0, width: width, height: .greatestFiniteMagnitude))
customLabel.numberOfLines = self.numberOfLines
customLabel.text = text
customLabel.font = self.font
customLabel.sizeToFit()
textHeight = customLabel.frame.height
}
return textHeight
}}
I'm using UITableViewAutomaticDimension.
I additionally have the functionality to change the size of the cell at various clicked on it.
Therefore, in my task, this works fine:
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
return presenter.cellHeights[indexPath.row] ?? 0
}
But I think that you can immediately set the height:
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return presenter.cellHeights[indexPath.row] ?? 0
}
I wonder if tableview has any built-in function to add infinite scroll/pagination.
Right now my VC looks like this:
var data: JSON! = []
override func viewDidLoad() {
super.viewDidLoad()
//Init start height of cell
self.tableView.estimatedRowHeight = 122
self.tableView.rowHeight = UITableViewAutomaticDimension
self.tableView.delegate = self
self.tableView.dataSource = self
savedLoader.startAnimation()
//Load first page
loadSaved(1)
}
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return data.count
}
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("aCell") as! SavedTableViewCell
let info = data[indexPath.row]
cell.configureWithData(info)
return cell
}
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
performSegueWithIdentifier("WebSegue", sender: indexPath)
tableView.deselectRowAtIndexPath(indexPath, animated: false)
}
I fetch my data using loadSaved(1) by giving the function the current page I want to load. The function makes a API request using alomofire then populate the var data: JSON! = [] with the data that should be displayed
So what I want to do is when I scroll to the bottom of the tableview loadSaved(2) should be called loading more data into the tableview
Looks like amar’s answer might be a better solution now, but here’s my answer from 2017 anyway:
The UITableViewDelegate has a tableView(_:willDisplay:forRowAt:) instance method which "tells the delegate the table view is about to draw a cell for a particular row."
In your case I would use it something like this:
override open func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
if indexPath.row == data.count-1 { //you might decide to load sooner than -1 I guess...
//load more into data here
}
}
Depending on your code, you may need some checks around this to ensure you don't end up in an infinite loop if you've loaded all your data...
No, the UITableView has not any built-in function to achieve the infinite scroll or load on-demand cells like you want. What you can use is the function scrollViewDidScroll(_:) in the UIScrollViewDelegate implemented by default in the UITableView and in this way know when the user scroll more than the original height defined in the UITableView.
For example like in this code:
var indexOfPageToRequest = 1
override func scrollViewDidScroll(scrollView: UIScrollView) {
// calculates where the user is in the y-axis
let offsetY = scrollView.contentOffset.y
let contentHeight = scrollView.contentSize.height
if offsetY > contentHeight - scrollView.frame.size.height {
// increments the number of the page to request
indexOfPageToRequest += 1
// call your API for more data
loadSaved(indexOfPageToRequest)
// tell the table view to reload with the new data
self.tableView.reloadData()
}
}
To achieve the result of add the rest of the elements at the end of the UITableView you should add the new elements to the data source, in your case data inside your function loadSaved(numberOfPage).
I hope this help you.
All the above answers are correct but for iOS 10 and above we have a very nice
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath])
This is a prefetch delegate which needs to be set
tableView.prefetchDataSource = self
RayWeinderlich has a nice tutorial on the topic. Since Rays is a dependable site i am not posting code here
I have modified Victor's answer and used it as ,
var indexOfPageRequest = 1
var loadingStatus = false
func loadData(){
if !loadingStatus{
loadingStatus = true
viewModel.getData(pageIndex: indexOfPageRequest)
}
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
// calculates where the user is in the y-axis
let offsetY = scrollView.contentOffset.y
let contentHeight = scrollView.contentSize.height
if offsetY > contentHeight - scrollView.frame.size.height {
// increments the number of the page to request
indexOfPageRequest += 1
// call your API for more data
loadData()
// tell the table view to reload with the new data
self.tableView.reloadData()
}
}
Reset loadingStatus to true when you receive data. Without checking if the view was already loading more data, the tableview was flickering.
Ravi's answer looks good. But as he pointed out in the end, the tableView flickers a lot if you use scrollViewDidScroll(_ scrollView: UIScrollView)
This is because you are trying to reload tableView every time you are scrolling the tableView.
Instead you could use scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) delegate method to determine whether you have scrolled enough and have reached almost the end of the tableView.
override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
let offsetY = scrollView.contentOffset.y
let contentHeight = scrollView.contentSize.height
if offsetY > contentHeight - scrollView.frame.size.height {
indexOfPageRequest += 1
loadData()
self.tableView.reloadData()
}
}
Above Ans are also right, but may be some one help out this code also.
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath){
if(indexPath.row == self.arryOfData.count-1){
if(self.pageNumber <= self.resPgNumber){
if(remaining != 0){
let spinner = UIActivityIndicatorView(activityIndicatorStyle: .gray)
spinner.frame = CGRect(x: CGFloat(0), y: CGFloat(0), width: tableView.bounds.width, height: CGFloat(44))
spinner.startAnimating()
tableView.tableFooterView = spinner
tableView.tableFooterView?.isHidden = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.flgActivity = false
self.getActiveOrdersList()
}
}
else{
tableView.tableFooterView?.removeFromSuperview()
let view = UIView()
view.frame = CGRect(x: CGFloat(0), y: CGFloat(0), width: tableView.bounds.width, height: CGFloat(5))
tableView.tableFooterView = view
tableView.tableFooterView?.isHidden = true
}
}
else{
tableView.tableFooterView?.removeFromSuperview()
let view = UIView()
view.frame = CGRect(x: CGFloat(0), y: CGFloat(0), width: tableView.bounds.width, height: CGFloat(5))
tableView.tableFooterView = view
tableView.tableFooterView?.isHidden = true
}
}
else{
tableView.tableFooterView?.removeFromSuperview()
let view = UIView()
view.frame = CGRect(x: CGFloat(0), y: CGFloat(0), width: tableView.bounds.width, height: CGFloat(5))
tableView.tableFooterView = view
tableView.tableFooterView?.isHidden = true
}
}
There are number of ways we can do this. The essense of all different ways, is to load next set of data when user scroll to last. I have implemented it via adding an extra special cell at the end of tableView and when that cell gets loaded in willDisplay cell: forRowAtIndexPath: which triggers next set of fetching of data.
Athough this is simple to implement but in larger apps at times we need to implement it many places. To avoid this, I wrote a small framework which is non-intrusive and can be easyly integrated.
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
I'm building an app in iOS 8.4 with Swift.
I have a UITableView with a custom UITableViewCell that includes a UILabel and UIImageView. This is all fairly straight forward and everything renders fine.
I'm trying to create a parallax effect similar to the one demonstrated in this demo.
I currently have this code in my tableView.cellForRowAtIndexPath
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
var cell = self.tableView.dequeueReusableCellWithIdentifier("myitem", forIndexPath: indexPath) as! MixTableViewCell
cell.img.backgroundColor = UIColor.blackColor()
cell.title.text = self.items[indexPath.row]["title"]
cell.img.image = UIImage(named: "Example.png")
// ideally it would be cool to have an extension allowing the following
// cell.img.addParallax(50) // or some other configurable offset
return cell
}
That block exists inside a class that looks like class HomeController: UIViewController, UITableViewDelegate, UITableViewDataSource { ... }
I am also aware that I can listen to scroll events in my class via func scrollViewDidScroll.
Other than that, help is appreciated!
I figured it out! The idea was to do this without implementing any extra libraries especially given the simplicity of the implementation.
First... in the custom table view Cell class, you have to create an wrapper view. You can select your UIImageView in the Prototype cell, then choose Editor > Embed in > View. Drag the two into your Cell as outlets, then set clipToBounds = true for the containing view. (also remember to set the constraints to the same as your image.
class MyCustomCell: UITableViewCell {
#IBOutlet weak var img: UIImageView!
#IBOutlet weak var imgWrapper: UIView!
override func awakeFromNib() {
self.imgWrapper.clipsToBounds = true
}
}
Then in your UITableViewController subclass (or delegate), implement the scrollViewDidScroll — from here you'll continually update the UIImageView's .frame property. See below:
override func scrollViewDidScroll(scrollView: UIScrollView) {
let offsetY = self.tableView.contentOffset.y
for cell in self.tableView.visibleCells as! [MyCustomCell] {
let x = cell.img.frame.origin.x
let w = cell.img.bounds.width
let h = cell.img.bounds.height
let y = ((offsetY - cell.frame.origin.y) / h) * 25
cell.img.frame = CGRectMake(x, y, w, h)
}
}
See this in action.
I wasn't too happy with #ded's solution requiring a wrapper view, so I came up with another one that uses autolayout and is simple enough.
In the storyboard, you just have to add your imageView and set 4 constraints on the ImageView:
Leading to ContentView (ie Superview) = 0
Trailing to ContentView (ie Superview) = 0
Top Space to ContentView (ie Superview) = 0
ImageView Height (set to 200 here but this is recalculated based on the cell height anyway)
The last two constraints (top and height) need referencing outlets to your custom UITableViewCell (in the above pic, double click on the constraint in the rightmost column, and then Show the connection inspector - the icon is an arrow in a circle)
Your UITableViewCell should look something like this:
class ParallaxTableViewCell: UITableViewCell {
#IBOutlet weak var parallaxImageView: UIImageView!
// MARK: ParallaxCell
#IBOutlet weak var parallaxHeightConstraint: NSLayoutConstraint!
#IBOutlet weak var parallaxTopConstraint: NSLayoutConstraint!
override func awakeFromNib() {
super.awakeFromNib()
clipsToBounds = true
parallaxImageView.contentMode = .ScaleAspectFill
parallaxImageView.clipsToBounds = false
}
}
So basically, we tell the image to take as much space as possible, but we clip it to the cell frame.
Now your TableViewController should look like this:
class ParallaxTableViewController: UITableViewController {
override func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
return cellHeight
}
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("CellIdentifier", forIndexPath: indexPath) as! ParallaxTableViewCell
cell.parallaxImageView.image = … // Set your image
cell.parallaxHeightConstraint.constant = parallaxImageHeight
cell.parallaxTopConstraint.constant = parallaxOffsetFor(tableView.contentOffset.y, cell: cell)
return cell
}
// Change the ratio or enter a fixed value, whatever you need
var cellHeight: CGFloat {
return tableView.frame.width * 9 / 16
}
// Just an alias to make the code easier to read
var imageVisibleHeight: CGFloat {
return cellHeight
}
// Change this value to whatever you like (it sets how "fast" the image moves when you scroll)
let parallaxOffsetSpeed: CGFloat = 25
// This just makes sure that whatever the design is, there's enough image to be displayed, I let it up to you to figure out the details, but it's not a magic formula don't worry :)
var parallaxImageHeight: CGFloat {
let maxOffset = (sqrt(pow(cellHeight, 2) + 4 * parallaxOffsetSpeed * tableView.frame.height) - cellHeight) / 2
return imageVisibleHeight + maxOffset
}
// Used when the table dequeues a cell, or when it scrolls
func parallaxOffsetFor(newOffsetY: CGFloat, cell: UITableViewCell) -> CGFloat {
return ((newOffsetY - cell.frame.origin.y) / parallaxImageHeight) * parallaxOffsetSpeed
}
override func scrollViewDidScroll(scrollView: UIScrollView) {
let offsetY = tableView.contentOffset.y
for cell in tableView.visibleCells as! [MyCustomTableViewCell] {
cell.parallaxTopConstraint.constant = parallaxOffsetFor(offsetY, cell: cell)
}
}
}
Notes:
it is important to use tableView.dequeueReusableCellWithIdentifier("CellIdentifier", forIndexPath: indexPath) and not tableView.dequeueReusableCellWithIdentifier("CellIdentifier"), otherwise the image won't be offset until you start scrolling
So there you have it, parallax UITableViewCells that should work with any layout, and can also be adapted to CollectionViews.
This method works with table view and collection view.
first of all create the cell for the tableview and put the image view in it.
set the image height slightly more than the cell height. if cell height = 160 let the image height be 200 (to make the parallax effect and you can change it accordingly)
put this two variable in your viewController or any class where your tableView delegate is extended
let imageHeight:CGFloat = 150.0
let OffsetSpeed: CGFloat = 25.0
add the following code in the same class
func scrollViewDidScroll(scrollView: UIScrollView) {
// print("inside scroll")
if let visibleCells = seriesTabelView.visibleCells as? [SeriesTableViewCell] {
for parallaxCell in visibleCells {
var yOffset = ((seriesTabelView.contentOffset.y - parallaxCell.frame.origin.y) / imageHeight) * OffsetSpeedTwo
parallaxCell.offset(CGPointMake(0.0, yOffset))
}
}
}
where seriesTabelView is my UItableview
and now lets goto the cell of this tableView and add the following code
func offset(offset: CGPoint) {
posterImage.frame = CGRectOffset(self.posterImage.bounds, offset.x, offset.y)
}
were posterImage is my UIImageView
If you want to implement this to collectionView just change the tableView vairable to your collectionView variable
and thats it. i am not sure if this is the best way. but it works for me. hope it works for you too. and let me know if there is any problem
After combining answers from #ded and #Nycen I came to this solution, which uses embedded view, but changes layout constraint (only one of them):
In Interface Builder embed the image view into a UIView. For that view make [√] Clips to bounds checked in View > Drawing
Add the following constraints from the image to view: left and right, center Vertically, height
Adjust the height constraint so that the image is slightly higher than the view
For the Align Center Y constraint make an outlet into your UITableViewCell
Add this function into your view controller (which is either UITableViewController or UITableViewControllerDelegate)
private static let screenMid = UIScreen.main.bounds.height / 2
private func adjustParallax(for cell: MyTableCell) {
cell.imageCenterYConstraint.constant = -(cell.frame.origin.y - MyViewController.screenMid - self.tableView.contentOffset.y) / 10
}
Note: by editing the magic number 10 you can change how hard the effect will be applied, and by removing the - symbol from equation you can change the effect's direction
Call the function from when the cell is reused:
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "myCellId", for: indexPath) as! MyTableCell
adjustParallax(for: cell)
return cell
}
And also when scroll happens:
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
(self.tableView.visibleCells as! [MyTableCell]).forEach { cell in
adjustParallax(for: cell)
}
}