28

I have been struggling with this assignment for quite some time now. What I would like to develop is a scrollview or collectionview which scrolls continuously both vertical and horizontal.

Here is an image of how I think this should look like. The transparent boxes are the views/cells which are re-loaded from the memory. As soon as a view/cell gets outside of the screen, it should be reused for upcoming new cell.. just like how a UITableViewController works.

Continuous scroll

I know that a UICollectionView can only be made to infinite scroll horizontal OR vertical, not both. However, I don't know how to do this using a UIScrollView. I tried the code attached to an answer on this question and I can get it to re-create views (e.g. % 20) but that's not really what I need.. besides, its not continuous.

I know it is possible, because the HBO Go app does this.. I want exactly the same functionality.

My Question: How can I achieve my goal? Are there any guides/tutorials that can show me how? I can't find any.

Community
  • 1
  • 1
Paul Peelen
  • 9,588
  • 15
  • 82
  • 162
  • Whats the plan for reusability ? I mean, are those tiles going to be the same or repeated images (just maybe different number labels) ? This is the hard part, the scrolling part should be straightforward. – Petar Mar 21 '13 at 13:49
  • The tiles are the same. E.g. if tile 1 would be an image of a red rose, next time tile 1 is shown it will be the exact same rose. Exactly the same as with the `HBO GO` app. Basically it should load the tiles from an NSArray with UIViews or UIButtons or similar. It should be straightforward, however I can't seem to figure out how to do it. – Paul Peelen Mar 21 '13 at 14:15
  • I have an idea how to do that. I will post some code soon. – Petar Mar 21 '13 at 16:23
  • Do you really need it to be infinite? I've done this with a collection view that repeats 20 cells (4 rows of 5 items like in your example), but if I make it appear to have more than about a 1000 rows in each direction, the scrolling becomes jerky. But even with 1000 in each direction, it seems pretty infinite. – rdelmar Mar 21 '13 at 16:54
  • @pe60t0 Ok. I look forward in reading your example. – Paul Peelen Mar 21 '13 at 18:40
  • @rdelmar what do you mean by "jerky"? Do you mean that it gets slow? – Paul Peelen Mar 21 '13 at 18:40
  • Not slow if you're panning, but if you do swipes, it goes fast then pauses then resumes. With 1000 cells or less, I don't really notice this. – rdelmar Mar 21 '13 at 19:29
  • ok. I spoke with my PL about your suggestion and its not approved. We have an other approach we are going to try... if it works I'll post the answer. – Paul Peelen Mar 21 '13 at 20:04

4 Answers4

30

You can get infinite scrolling, by using the technique of re-centering the UIScrollView after you get a certain distance away from the center. First, you need to make the contentSize big enough that you can scroll a bit, so I return 4 times the number of items in my sections and 4 times the number of sections, and use the mod operator in the cellForItemAtIndexPath method to get the right index into my array. You then have to override layoutSubviews in a subclass of UICollectionView to do the re-centering (this is demonstrated in the WWDC 2011 video, "Advanced Scroll View Techniques"). Here is the controller class that has the collection view (set up in IB) as a subview:

#import "ViewController.h"
#import "MultpleLineLayout.h"
#import "DataCell.h"

@interface ViewController ()
@property (weak,nonatomic) IBOutlet UICollectionView *collectionView;
@property (strong,nonatomic) NSArray *theData;
@end

@implementation ViewController

- (void)viewDidLoad {
    self.theData = @[@[@"1",@"2",@"3",@"4",@"5"], @[@"6",@"7",@"8",@"9",@"10"],@[@"11",@"12",@"13",@"14",@"15"],@[@"16",@"17",@"18",@"19",@"20"]];
    MultpleLineLayout *layout = [[MultpleLineLayout alloc] init];
    self.collectionView.collectionViewLayout = layout;
    self.collectionView.showsHorizontalScrollIndicator = NO;
    self.collectionView.showsVerticalScrollIndicator = NO;
    layout.scrollDirection = UICollectionViewScrollDirectionHorizontal;
    self.view.backgroundColor = [UIColor blackColor];
    [self.collectionView registerClass:[DataCell class] forCellWithReuseIdentifier:@"DataCell"];
    [self.collectionView reloadData];
}


- (NSInteger)collectionView:(UICollectionView *)view numberOfItemsInSection:(NSInteger)section {
    return 20;
}

- (NSInteger)numberOfSectionsInCollectionView: (UICollectionView *)collectionView {
    return 16;
}

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView  cellForItemAtIndexPath:(NSIndexPath *)indexPath {

    DataCell *cell = [collectionView  dequeueReusableCellWithReuseIdentifier:@"DataCell" forIndexPath:indexPath];
    cell.label.text = self.theData[indexPath.section %4][indexPath.row %5];
    return cell;
}

- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
   // UICollectionViewCell *item = [collectionView cellForItemAtIndexPath:indexPath];
    NSLog(@"%@",indexPath);

}

Here is the UICollectionViewFlowLayout subclass:

#define space 5
#import "MultpleLineLayout.h"

@implementation MultpleLineLayout { // a subclass of UICollectionViewFlowLayout
    NSInteger itemWidth;
    NSInteger itemHeight;
}

-(id)init {
    if (self = [super init]) {
        itemWidth = 60;
        itemHeight = 60;
    }
    return self;
}

-(CGSize)collectionViewContentSize {
    NSInteger xSize = [self.collectionView numberOfItemsInSection:0] * (itemWidth + space); // "space" is for spacing between cells.
    NSInteger ySize = [self.collectionView numberOfSections] * (itemHeight + space);
    return CGSizeMake(xSize, ySize);
}

- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)path {
    UICollectionViewLayoutAttributes* attributes = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:path];
    attributes.size = CGSizeMake(itemWidth,itemHeight);
    int xValue = itemWidth/2 + path.row * (itemWidth + space);
    int yValue = itemHeight + path.section * (itemHeight + space);
    attributes.center = CGPointMake(xValue, yValue);
    return attributes;
}


-(NSArray*)layoutAttributesForElementsInRect:(CGRect)rect {
    NSInteger minRow =  (rect.origin.x > 0)?  rect.origin.x/(itemWidth + space) : 0; // need to check because bounce gives negative values  for x.
    NSInteger maxRow = rect.size.width/(itemWidth + space) + minRow;
    NSMutableArray* attributes = [NSMutableArray array];
    for(NSInteger i=0 ; i < self.collectionView.numberOfSections; i++) {
        for (NSInteger j=minRow ; j < maxRow; j++) {
            NSIndexPath* indexPath = [NSIndexPath indexPathForItem:j inSection:i];
            [attributes addObject:[self layoutAttributesForItemAtIndexPath:indexPath]];
        }
    }
    return attributes;
}

And finally, here is the subclass of UICollectionView:

-(void)layoutSubviews {
    [super layoutSubviews];
    CGPoint currentOffset = self.contentOffset;
    CGFloat contentWidth = self.contentSize.width;
    CGFloat contentHeight = self.contentSize.height;
    CGFloat centerOffsetX = (contentWidth - self.bounds.size.width)/ 2.0;
    CGFloat centerOffsetY = (contentHeight - self.bounds.size.height)/ 2.0;
    CGFloat distanceFromCenterX = fabsf(currentOffset.x - centerOffsetX);
    CGFloat distanceFromCenterY = fabsf(currentOffset.y - centerOffsetY);

    if (distanceFromCenterX > contentWidth/4.0) { // this number of 4.0 is arbitrary
        self.contentOffset = CGPointMake(centerOffsetX, currentOffset.y);
    }
    if (distanceFromCenterY > contentHeight/4.0) {
        self.contentOffset = CGPointMake(currentOffset.x, centerOffsetY);
    }
}
Cesare
  • 8,326
  • 14
  • 64
  • 116
rdelmar
  • 102,832
  • 11
  • 203
  • 218
  • I played with that idea as well, but when I made an example project it seemed it loaded all the view at load, is that correct? It got really slow. (however I tried with an amount of 100.000 instead of 1000). Is there some way of tricking the `UICollectionView` to think it is showing section and row 10, while it actually is showing 510 or so? If so, one could make it infinite. – Paul Peelen Mar 21 '13 at 18:39
  • @PaulPeelen, No, it shouldn't load all the views at once -- just like a table view, it only creates enough cells that it needs to display on screen. I'm not sure why this gets jerky if you have more than 1000 cells in each direction, but 1000 seems like enough to make it seem infinite to the user. This example project I made isn't slow to load or scroll. – rdelmar Mar 21 '13 at 19:28
  • @PaulPeelen, I've edited my post to show how to do it in a truly infinite way. I only need to return a modest 4 times the size of my actual array to do it this way. It seems totally seamless. – rdelmar Mar 23 '13 at 04:31
  • awesome, I had gone the same route already but hadn't finished it. I was working on my own `UICollectionView`. I'll try your code out tomorrow. Since this is not the first time we've been in contact, if you like.. do add me on Skype =) – Paul Peelen Mar 24 '13 at 21:02
  • Worked like a charm! Thanks mate. I'll write a blog post on it sometime and link you in the credit! – Paul Peelen Mar 25 '13 at 15:11
  • 3
    Paul, where's the blog post? – Moshe Jan 08 '14 at 20:30
  • I tried to implement your code. I get a `gridLayout` but inserting the method `layoutSubviews` causes my view to repeat always the same. I also get sometimes the exception `[UICollectionViewData validateLayoutInRect:]` after scrolling a little bit. does somebody of you have some more input how to get it work? – Alex Cio Mar 04 '14 at 16:32
  • @rdelmar how to turn off infinite scrolling in this example. Means 1 to 10 , and then end of scrolling. 1 should not come after 10. – ViruMax Jul 16 '14 at 12:36
  • @ViruMax, If you don't want the infinite scrolling don't use the custom subclass of UICollectionView (that has the layoutSubviews method posted above), and return the correct number of items in numberOfItemsInSection and numberOfSectionsInCollectionView (I'm returning 4 times my actual values so you can scroll a ways before re-centereing the scroll view). Also, in cellForItemAtIndexPath, you don't need the %4 or %5 after the indexPath, it would just be self.theData[indexPath.section][indexPath.row]. – rdelmar Jul 16 '14 at 14:42
  • @rdelmar thanks for your reply, I removed the subclas RDCollectionView but I am getting following error: Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'UICollectionView recieved layout attributes for a cell with an index path that does not exist: {length = 2, path = 0 - 5}' – ViruMax Jul 16 '14 at 15:15
  • @ViruMax, are you returning the correct values in numberOfItemsInSection and numberOfSectionsInCollectionView appropriate for you data structure? – rdelmar Jul 16 '14 at 15:19
  • @rdelmar yes I have used 5*5 dimensional array and returning 5 in numberOfItemsInSection and 5 in numberOfSectionsInCollectionView, but application didn't crash for 5*4 dimensional array which is your default array. – ViruMax Jul 16 '14 at 15:21
  • @rdelmar I have posted my question with code http://stackoverflow.com/questions/24784877/uicollectionview-recieved-layout-attributes-for-a-cell-with-an-index-path-that-d – ViruMax Jul 16 '14 at 15:35
  • @rdelmar: I tried your code, but the result isn't that what I expected. See an image [here](http://www45.zippyshare.com/v/OF2tNh0T/file.html). The rows and columns are repeating itself multiple times one row after another. Has this code ever worked or am I doing something wrong? – testing Mar 30 '15 at 14:09
  • @testing, Yes this code worked. I always test the code I post in answers, so you must be doing something wrong. – rdelmar Mar 30 '15 at 15:37
  • @rdelmar: Do you have used storyboard? Where do you subclassed `UICollectionView`? I don't see it in your code. You are using a normal `UICollectionVIew` class. Is *DataCell* a nib or created in code? I think it's the latter one. Is *collectionView* placed on a nib and connected via the outlet? When you add a collection view via Interface Builder he wants a reuuse identifier and the registering of *DataCell* is done in Interface Builder and in code which leads to a black screen. – testing Mar 31 '15 at 07:56
  • @rdelmar: I got [it manged](http://stackoverflow.com/questions/29366643/instead-of-uicollectionview-a-black-screen-is-displayed). It crashes if you move fast to the left: `'NSInternalInconsistencyException', reason: 'UICollectionView received layout attributes for a cell with an index path that does not exist: {length = 2, path = 0 - 20}` – testing Mar 31 '15 at 12:19
  • What if I have to use header view inside collectionview section. The code is not working for that. I mean it's not showing headerview – Aditya Srivastava Dec 04 '17 at 09:35
9

@updated for swift 3 and changed how the maxRow is calculated otherwise the last column is cutoff and can cause errors

import UIKit

class NodeMap : UICollectionViewController {
    var rows = 10
    var cols = 10

    override func viewDidLoad(){
        self.collectionView!.collectionViewLayout = NodeLayout(itemWidth: 400.0, itemHeight: 300.0, space: 5.0)
    }

    override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return rows
    }

    override func numberOfSections(in collectionView: UICollectionView) -> Int {
        return cols
    }

    override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        return collectionView.dequeueReusableCell(withReuseIdentifier: "node", for: indexPath)
    }
}

class NodeLayout : UICollectionViewFlowLayout {
    var itemWidth : CGFloat
    var itemHeight : CGFloat
    var space : CGFloat
    var columns: Int{
        return self.collectionView!.numberOfItems(inSection: 0)
    }
    var rows: Int{
        return self.collectionView!.numberOfSections
    }

    init(itemWidth: CGFloat, itemHeight: CGFloat, space: CGFloat) {
        self.itemWidth = itemWidth
        self.itemHeight = itemHeight
        self.space = space
        super.init()
    }

    required init(coder aDecoder: NSCoder) {
        self.itemWidth = 50
        self.itemHeight = 50
        self.space = 3
        super.init()
    }

    override var collectionViewContentSize: CGSize{
        let w : CGFloat = CGFloat(columns) * (itemWidth + space)
        let h : CGFloat = CGFloat(rows) * (itemHeight + space)
        return CGSize(width: w, height: h)
    }

    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
        let x : CGFloat = CGFloat(indexPath.row) * (itemWidth + space)
        let y : CGFloat = CGFloat(indexPath.section) + CGFloat(indexPath.section) * (itemHeight + space)
        attributes.frame = CGRect(x: x, y: y, width: itemWidth, height: itemHeight)
        return attributes
    }

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        let minRow : Int = (rect.origin.x > 0) ? Int(floor(rect.origin.x/(itemWidth + space))) : 0
        let maxRow : Int = min(columns - 1, Int(ceil(rect.size.width / (itemWidth + space)) + CGFloat(minRow)))
        var attributes : Array<UICollectionViewLayoutAttributes> = [UICollectionViewLayoutAttributes]()
        for i in 0 ..< rows {
            for j in minRow ... maxRow {
                attributes.append(self.layoutAttributesForItem(at: IndexPath(item: j, section: i))!)
            }
        }
        return attributes
    }
}
TreeBucket
  • 143
  • 1
  • 7
2

@rdelmar's answer worked like a charm, but I needed to do it in swift. Here's the conversion :)

class NodeMap : UICollectionViewController {
    @IBOutlet var activateNodeButton : UIBarButtonItem?
    var rows = 10
    var cols = 10
    override func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return rows
    }
    override func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int {
        return cols
    }
    override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
        return collectionView.dequeueReusableCellWithReuseIdentifier("node", forIndexPath: indexPath)
    }
    override func viewDidLoad() {
        self.collectionView!.collectionViewLayout = NodeLayout(itemWidth: 100.0, itemHeight: 100.0, space: 5.0)
    }
}

class NodeLayout : UICollectionViewFlowLayout {
    var itemWidth : CGFloat
    var itemHeight : CGFloat
    var space : CGFloat
    init(itemWidth: CGFloat, itemHeight: CGFloat, space: CGFloat) {
        self.itemWidth = itemWidth
        self.itemHeight = itemHeight
        self.space = space
        super.init()
    }
    required init(coder aDecoder: NSCoder) {
        self.itemWidth = 50
        self.itemHeight = 50
        self.space = 3
        super.init()
    }
    override func collectionViewContentSize() -> CGSize {
        let w : CGFloat = CGFloat(self.collectionView!.numberOfItemsInSection(0)) * (itemWidth + space)
        let h : CGFloat = CGFloat(self.collectionView!.numberOfSections()) * (itemHeight + space)
        return CGSizeMake(w, h)
    }
    override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes! {
        let attributes = UICollectionViewLayoutAttributes(forCellWithIndexPath: indexPath)
        let x : CGFloat = CGFloat(indexPath.row) * (itemWidth + space)
        let y : CGFloat = CGFloat(indexPath.section) + CGFloat(indexPath.section) * (itemHeight + space)
        attributes.frame = CGRectMake(x, y, itemWidth, itemHeight)
        return attributes
    }
    override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? {
        let minRow : Int = (rect.origin.x > 0) ? Int(floor(rect.origin.x/(itemWidth + space))) : 0
        let maxRow : Int = Int(floor(rect.size.width/(itemWidth + space)) + CGFloat(minRow))
        var attributes : Array<UICollectionViewLayoutAttributes> = [UICollectionViewLayoutAttributes]()
        for i in 0...self.collectionView!.numberOfSections()-1 {
            for j in minRow...maxRow {
                attributes.append(self.layoutAttributesForItemAtIndexPath(NSIndexPath(forItem: j, inSection: i)))
            }
        }
        return attributes
    }
}
BadPirate
  • 24,683
  • 10
  • 85
  • 118
  • Nice! I'll have some use for this as well, I think. – Paul Peelen Jan 05 '15 at 00:30
  • Gets a little confusing around the concept of x vs y vs row vs column vs row vs section -- Hard to keep the orientation straight all the way through :) If anyone is thinking of copy pasting this send me a note and I'll update with the latest changes. – BadPirate Jan 05 '15 at 09:23
  • @BadPirate : Its working but getting space between sections: How i can remove that space? – Abhishek Thapliyal Oct 28 '17 at 06:09
  • What if I have to use header view inside collectionview section. The code is not working for that. I mean it's not showing headerview – Aditya Srivastava Dec 04 '17 at 09:36
1

Resetting the contentOffset probably is the best solution figured out so far. infinite scrolling final result

A few steps should be taken to achieve this:

  1. Pad extra items at both the left and right side of the original data set to achieve larger scrollable area; This is similar to having a large duplicated data set, but difference is the amount;
  2. At start, the collection view’s contentOffset is calculated to show only the original data set (drawn in black rectangles);
  3. When the user scrolls right and contentOffset hits the trigger value, we reset contentOffset to show same visual results; but actually different data; When the user scrolls left, the same logic is used.

enter image description here

So, the heavy lifting is in calculating how many items should be padded both on the left and right side. If you take a look at the illustration, you will find that a minimum of one extra screen of items should be padded on left and also, another extra screen on the right. The exact amount padded depends on how many items are in the original data set and how large your item size is.

I wrote a post on this solution:

http://www.awsomejiang.com/2018/03/24/Infinite-Scrolling-and-the-Tiling-Logic/

Jiang Wang
  • 249
  • 3
  • 8