14

I have a month view similar to the iOS calendar and an UICollectionView is used. Now it would be interesting to implement an infinite scrolling behavior so that the user can scroll in each direction vertically and it will never end. The question now is how can such a behavior be implemented in an efficient way? This is what I've found out now:

Basically you can check if you hit the end of the current scroll view. You can check this in scrollViewDidScroll: or in collectionView:cellForItemAtIndexPath:. It would be simple to add another content to the datasource, but I think there is more than that. If you only add data you could only scroll downwards for example. The user should be able to scroll in both directions (upwards, downwards). Don't know if reloadData would do the trick. Also the contentOffset would change and there should be no jumping behavior.

Another possibility would be to use the approach shown in Advanced ScrollView Techniques of WWDC 2011. Here layoutSubviews is used to set the contentOffset to the center of the UIScrollView and the frames of the subviews are adjusted to the same amount of the distance from the center. This approach would work fine if I have no sections. How would this work with sections?

I don't want to use a high value for the number of sections to fake a infinite scroll, because user will find the end. Also I don't use any paging.

So how can I implement infinite scrolling for the collection view?

Edit:

Now I tried to increase the number of section if I hit the end of the UICollectionView. To show the new sections one has to call reloadData. On calling this method all calculations for all current available sections are done again! This performance issue is causing big stutters when scrolling through the collection view and it gets slower and slower if you scroll down. Don't know if one could transfer this work on a background thread. With this approach one could scroll upwards and downwards if you make the needed adaptions.

Bounty:

Now I'm offering a bounty for answering this question. I'm interested in how the month view of the iOS calendar is implemented. In detail how does the infinite scrolling works. Here it works in both directions (upwards, downwards) and it never ends (real infinite - no repeating). Also there is no lag at all (even on an iPhone 4). I want to use the UICollectionView and the data consists of different sections and each section has a different number of items. One has to do some calculations to get the next section. I don't need the calendar part - only the infinite scrolling behavior with the different items in a section. Feel free to ask question.

Adding Sections:

public override void Scrolled(UIScrollView scrollView)
{
    NSIndexPath[] currentIndexPaths = currentVisibleIndexPaths();

    // if we are at the top
    if (currentIndexPaths.First().Section == 0)
    {
        NSIndexPath oldIndexPath = NSIndexPath.FromItemSection(0, 0);
        UICollectionViewLayoutAttributes attributes_before = this.controller.CollectionView.GetLayoutAttributesForItem(oldIndexPath);
        CGRect before = attributes_before.Frame;
        CGPoint contentOffset = this.controller.CollectionView.ContentOffset;
        this.controller.CollectionView.PerformBatchUpdatesAsync(delegate ()
        {
            // some calendar calculations and updating the data source not shown here
            this.controller.CurrentNumberOfSections += 12;
            this.controller.CollectionView.InsertSections(NSIndexSet.FromNSRange(new NSRange(0, 12)));
        }

        );
        NSIndexPath newIndexPath = NSIndexPath.FromItemSection(0, 12);
        UICollectionViewLayoutAttributes attributes_after = this.controller.CollectionView.GetLayoutAttributesForItem(newIndexPath);
        CGRect after = attributes_after.Frame;
        contentOffset.Y += (after.Y - before.Y);
        this.controller.CollectionView.SetContentOffset(contentOffset, false);
    }

    // if we are near the end
    if (currentIndexPaths.Last().Section == this.controller.CurrentNumberOfSections - 1)
    {
        this.controller.CollectionView.PerformBatchUpdatesAsync(delegate ()
        {
            // some calendar calculations and updating the data source not shown here
            this.controller.CollectionView.InsertSections(NSIndexSet.FromNSRange(new NSRange(this.controller.CurrentNumberOfSections, 12)));
            this.controller.CurrentNumberOfSections += 12;
        }

        );
    }
}

If we are near the top the app crashes with

Snapshotting a view that has not been rendered results in an empty snapshot. Ensure your view has been rendered at least once before snapshotting or snapshot after screen updates. Assertion failure in -[Procet_UICollectionViewCell _addUpdateAnimation], /SourceCache/UIKit_Sim/UIKit-2935.137/UICollectionViewCell.m:147

I think it crashes because it is called too often. If I remove the contentOffset adaptions it does work, but I'm always on top. If I'm on top more and more sections are added. So this algorithm needs to be restricted. I also have an initial content offset. This offset is wrong because on initialization the algorithm is also called and adds some sections. Now I tried to add the sections in didEndDisplayingCell but it crashes.

Adding sections at the end does work, but it doesn't matter when I add it (one section before or 10 sections before). When the update takes place the scrolling has some stutter. Another thing I tried was to decrease the number of sections from 12 to 3, but then more and more stutter occur.

testing
  • 17,950
  • 38
  • 208
  • 373
  • 1
    Did you try performBatchUpdates method as described here? https://developer.apple.com/library/ios/documentation/WindowsViews/Conceptual/CollectionViewPGforIOS/CreatingCellsandViews/CreatingCellsandViews.html#//apple_ref/doc/uid/TP40012334-CH7-SW7 – Guilherme Torres Castro Mar 30 '15 at 18:03
  • @GuilhermeTorresCastro: Now I tried `performBatchUpdates`. The advantage here is that only the calculations for the new inserted sections are done. Nevertheless, I have a small stutter when a new year is appearing. If you scroll fast than the scrolling will end and you have to scroll again because of the stutter. Is there a possibility to offload this task to a background thread so that the user never get the chance of seeing the stutter? Another thing: How can I scroll upwards infinitively? Adding at the end is no problem. But for the beginning I think I have to do all calculations again. – testing Mar 31 '15 at 14:53
  • Can you post some of your code? The key is calculate the new elements before scroll reach the end. – Guilherme Torres Castro Mar 31 '15 at 15:42
  • @GuilhermeTorresCastro: I've edited my question with my current code. It's in C# but you should be able to get the idea. – testing Apr 01 '15 at 09:38

3 Answers3

19

After a lot of R&D I have come up with an answer for you, and the answer is :-

RSDayFlow which is developed using DayFlow I have gone through most of the part of it and I recommend, if you want to make calendar app, use the DayFlow Library, its good.

Now we come to the part as to how they have managed the infinite flow, and trust me my friend, it took me quite a while to understand this, these guys had really thought it through while building this!

1.) Firstly, they have started with creating a struct, in RSDayFlow.h

typedef struct {
    NSUInteger year;
    NSUInteger month;
    NSUInteger day;
} RSDFDatePickerDate;

this is the used for maintaining two properties

@property (nonatomic, readonly, assign) RSDFDatePickerDate fromDate;
@property (nonatomic, readonly, assign) RSDFDatePickerDate toDate;

in RSDFDatePickerView which is the view which holds UICollectionView ( subclassed to RSDFDatePickerCollectionView ) and everything else visible on the screen ( Apart from the navigationBar and TabBar of-course). RSDFDatePickerView is initialised from RSDFDatePickerViewController with same view bounds as that of the ViewController.

Now, as name suggest, fromDate and toDate is used as a range to display the calendar. Initially this fromDate and toDate is calculated as -6 months and +6 months from the current date respectively, also the current date is set in the RSDFDatePickerViewController it self calling the following method:

[self.datePickerView selectDate:today];

Now while initialising following method is called in the RSDFDatePickerView

- (void)commonInitializer
{
    NSDateComponents *nowYearMonthComponents = [self.calendar components:(NSCalendarUnitYear | NSCalendarUnitMonth) fromDate:[NSDate date]];
    NSDate *now = [self.calendar dateFromComponents:nowYearMonthComponents];

    _fromDate = [self pickerDateFromDate:[self.calendar dateByAddingComponents:((^{
        NSDateComponents *components = [NSDateComponents new];
        components.month = -6;
        return components;
    })()) toDate:now options:0]];

    _toDate = [self pickerDateFromDate:[self.calendar dateByAddingComponents:((^{
        NSDateComponents *components = [NSDateComponents new];
        components.month = 6;
        return components;
    })()) toDate:now options:0]];

    NSDateComponents *todayYearMonthDayComponents = [self.calendar components:(NSCalendarUnitYear | NSCalendarUnitMonth | NSCalendarUnitDay) fromDate:[NSDate date]];
    _today = [self.calendar dateFromComponents:todayYearMonthDayComponents];

    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(significantTimeChange:)
                                                 name:UIApplicationSignificantTimeChangeNotification
                                               object:nil];
}

And now again one more important thing, while assigning the current date i.e. today's date, the indexpath of the current cell item of the CollectionView is also decided, have a look at the function called previously:

- (void)selectDate:(NSDate *)date
{
    if (![self.selectedDate isEqual:date]) {
        if (self.selectedDate &&
            [self.selectedDate compare:[self dateFromPickerDate:self.fromDate]] != NSOrderedAscending &&
            [self.selectedDate compare:[self dateFromPickerDate:self.toDate]] != NSOrderedDescending) {
            NSIndexPath *previousSelectedCellIndexPath = [self indexPathForDate:self.selectedDate];
            [self.collectionView deselectItemAtIndexPath:previousSelectedCellIndexPath animated:NO];
            UICollectionViewCell *previousSelectedCell = [self.collectionView cellForItemAtIndexPath:previousSelectedCellIndexPath];
            if (previousSelectedCell) {
                [previousSelectedCell setNeedsDisplay];
            }
        }

        _selectedDate = date;

        if (self.selectedDate &&
            [self.selectedDate compare:[self dateFromPickerDate:self.fromDate]] != NSOrderedAscending &&
            [self.selectedDate compare:[self dateFromPickerDate:self.toDate]] != NSOrderedDescending) {
            NSIndexPath *indexPathForSelectedDate = [self indexPathForDate:self.selectedDate];
            [self.collectionView selectItemAtIndexPath:indexPathForSelectedDate animated:NO scrollPosition:UICollectionViewScrollPositionNone];
            UICollectionViewCell *selectedCell = [self.collectionView cellForItemAtIndexPath:indexPathForSelectedDate];
            if (selectedCell) {
                [selectedCell setNeedsDisplay];
            }
        }
    }
}

So as one can guess, the current section turns out to be 6 i.e. the Month and cell item no. is the day.

Phew! that's it, above was the basic overview, for us to understand the infinite scroll, here it comes...

2.) Our SubClass of UICollectionView i.e. RSDFDatePickerCollectionView Overrides the

- (void)layoutSubviews;

method of the UICollectionView (called by layoutIfNeeded automatically). Now we have a protocol defined in our RSDFDatePickerCollectionView.

@protocol RSDFDatePickerCollectionViewDelegate <UICollectionViewDelegate>

///---------------------------------
/// @name Supporting Layout Subviews
///---------------------------------

/**
 Tells the delegate that the collection view will layout subviews.

 @param pickerCollectionView The collection view which will layout subviews.
 */
- (void) pickerCollectionViewWillLayoutSubviews:(RSDFDatePickerCollectionView *)pickerCollectionView;

@end

this delegate is called from the - (void)layoutSubviews; in CollectionView and its been implemented in RSDFDatePickerView.m

Hey! Why don't you come to the point straight away ???

Hey! Why don't you come to the point straight away ???

:-| I am about to, just hang in there, alright!

So, as I was explaining, following is the implementation of the RSDFDatePickerCollectionViewDelegate in RSDFDatePickerView.m

#pragma mark - RSDFDatePickerCollectionViewDelegate

- (void)pickerCollectionViewWillLayoutSubviews:(RSDFDatePickerCollectionView *)pickerCollectionView
{
    //  Note: relayout is slower than calculating 3 or 6 months’ worth of data at a time
    //  So we punt 6 months at a time.

    //  Running Time    Self        Symbol Name
    //
    //  1647.0ms   23.7%    1647.0      objc_msgSend
    //  193.0ms    2.7% 193.0       -[NSIndexPath compare:]
    //  163.0ms    2.3% 163.0       objc::DenseMap<objc_object*, unsigned long, true, objc::DenseMapInfo<objc_object*>, objc::DenseMapInfo<unsigned long> >::LookupBucketFor(objc_object* const&, std::pair<objc_object*, unsigned long>*&) const
    //  141.0ms    2.0% 141.0       DYLD-STUB$$-[_UIHostedTextServiceSession dismissTextServiceAnimated:]
    //  138.0ms    1.9% 138.0       -[NSObject retain]
    //  136.0ms    1.9% 136.0       -[NSIndexPath indexAtPosition:]
    //  124.0ms    1.7% 124.0       -[_UICollectionViewItemKey isEqual:]
    //  118.0ms    1.7% 118.0       _objc_rootReleaseWasZero
    //  105.0ms    1.5% 105.0       DYLD-STUB$$CFDictionarySetValue$shim

    if (pickerCollectionView.contentOffset.y < 0.0f) {
        [self appendPastDates];
    }

    if (pickerCollectionView.contentOffset.y > (pickerCollectionView.contentSize.height - CGRectGetHeight(pickerCollectionView.bounds))) {
        [self appendFutureDates];
    }
}

Here, above is the key, to achieve inner peace :-)

Inner Peace !!

As you can see, the logic, talking in terms of y-component i.e. height, if pickerCollectionView.contentOffset becomes less then zero we will keep adding past dates by 6 months and if the pickerCollectionView.contentOffset becomes greater then the difference of contentSize and bounds we will keep adding future dates by 6 months.

But nothing comes this easy in life my friend, These two functions is everything..

- (void)appendPastDates
{
    [self shiftDatesByComponents:((^{
        NSDateComponents *dateComponents = [NSDateComponents new];
        dateComponents.month = -6;
        return dateComponents;
    })())];
}

- (void)appendFutureDates
{
    [self shiftDatesByComponents:((^{
        NSDateComponents *dateComponents = [NSDateComponents new];
        dateComponents.month = 6;
        return dateComponents;
    })())];
}

In these two function you will notice a block is performed, its shiftDatesByComponents, its the heart of the logic according to me, coz this guy does the real magic, its bit tricky, here it is :

- (void)shiftDatesByComponents:(NSDateComponents *)components
{
    RSDFDatePickerCollectionView *cv = self.collectionView;
    RSDFDatePickerCollectionViewLayout *cvLayout = (RSDFDatePickerCollectionViewLayout *)self.collectionView.collectionViewLayout;

    NSArray *visibleCells = [cv visibleCells];
    if (![visibleCells count])
        return;

    NSIndexPath *fromIndexPath = [cv indexPathForCell:((UICollectionViewCell *)visibleCells[0]) ];
    NSInteger fromSection = fromIndexPath.section;
    NSDate *fromSectionOfDate = [self dateForFirstDayInSection:fromSection];
    UICollectionViewLayoutAttributes *fromAttrs = [cvLayout layoutAttributesForItemAtIndexPath:[NSIndexPath indexPathForItem:0 inSection:fromSection]];
    CGPoint fromSectionOrigin = [self convertPoint:fromAttrs.frame.origin fromView:cv];

    _fromDate = [self pickerDateFromDate:[self.calendar dateByAddingComponents:components toDate:[self dateFromPickerDate:self.fromDate] options:0]];
    _toDate = [self pickerDateFromDate:[self.calendar dateByAddingComponents:components toDate:[self dateFromPickerDate:self.toDate] options:0]];

#if 0

    //  This solution trips up the collection view a bit
    //  because our reload is reactionary, and happens before a relayout
    //  since we must do it to avoid flickering and to heckle the CA transaction (?)
    //  that could be a small red flag too

    [cv performBatchUpdates:^{

        if (components.month < 0) {

            [cv deleteSections:[NSIndexSet indexSetWithIndexesInRange:(NSRange){
                cv.numberOfSections - abs(components.month),
                abs(components.month)
            }]];

            [cv insertSections:[NSIndexSet indexSetWithIndexesInRange:(NSRange){
                0,
                abs(components.month)
            }]];

        } else {

            [cv insertSections:[NSIndexSet indexSetWithIndexesInRange:(NSRange){
                cv.numberOfSections,
                abs(components.month)
            }]];

            [cv deleteSections:[NSIndexSet indexSetWithIndexesInRange:(NSRange){
                0,
                abs(components.month)
            }]];

        }

    } completion:^(BOOL finished) {

        NSLog(@"%s %x", __PRETTY_FUNCTION__, finished);

    }];

    for (UIView *view in cv.subviews)
        [view.layer removeAllAnimations];

#else

    [cv reloadData];
    [cvLayout invalidateLayout];
    [cvLayout prepareLayout];

    [self restoreSelection];

#endif

    NSInteger toSection = [self sectionForDate:fromSectionOfDate];
    UICollectionViewLayoutAttributes *toAttrs = [cvLayout layoutAttributesForItemAtIndexPath:[NSIndexPath indexPathForItem:0 inSection:toSection]];
    CGPoint toSectionOrigin = [self convertPoint:toAttrs.frame.origin fromView:cv];

    [cv setContentOffset:(CGPoint) {
        cv.contentOffset.x,
        cv.contentOffset.y + (toSectionOrigin.y - fromSectionOrigin.y)
    }];
}

To explain the above function in few lines what it basically does is, depending update what range has been calculated, be it future 6 month rage or past 6 month range, it manipulates the dataSource of the collectionView, future 6 months will not be a problem, you will just have to add stuff, but past 6 months is the real challenge.

Here what happens,

if (components.month < 0) {

            [cv deleteSections:[NSIndexSet indexSetWithIndexesInRange:(NSRange){
                cv.numberOfSections - abs(components.month),
                abs(components.month)
            }]];

            [cv insertSections:[NSIndexSet indexSetWithIndexesInRange:(NSRange){
                0,
                abs(components.month)
            }]];

        }

Man I am tired! I didn't sleep a bit because of this problem, do one thing, if you have any doubt, ping me!

P.S. This is the only technique which gives you smooth scrolling like the Official iOS Calendar App, I saw many people manipulating the scrollView and its delegate method to achieve infinite scrolling, didn't see any smoothness there. The thing is, manipulating the UICollectionView Delegate will cause less harm if done correctly, coz they are made for hard work.

enter image description here

Amit Singh
  • 1,045
  • 1
  • 7
  • 18
  • 3
    Hi, I wrote DayFlow. Amit’s answer looks reasonable. :) – Evadne Wu Apr 05 '15 at 07:16
  • 1
    The most important thing you must keep in mind is that when you are handling infinite scrolling, you are looking at a world which is infinite, but any Scroll View is designed to show you a sub-section of that world which is in turn limited by its bounds. When you do infinite scrolling, you must re-position the bounds continuously depending on where your user is at, in terms of scroll offset. – Evadne Wu Apr 05 '15 at 07:18
  • 2
    The trick I used for re-positioning the scroll view is quite simple. First, generate more data in the direction that requires more data. If you are scrolling up, then generate older data, otherwise generate newer data. Second, once data has been generated, find a common element and calculate the offset delta, then compensate for that very quickly. – Evadne Wu Apr 05 '15 at 07:20
  • The reason why I decided to not relayout continuously in DayFlow was actually that it tripped up my iPhone 4S at the time, but since a 6-month view was relatively cheap to render it helped reduce jitter. Instruments is your friend here as each app will have a different performance profile. – Evadne Wu Apr 05 '15 at 07:40
  • @Amit Singh: Wow! You did a great job with answering my question. Some things I noticed while reading your answer: Adding the new sections in `layoutSubviews` is new for me. I did a similar approach in `scrollViewDidScroll`. Another difference I noticed is to delete the old sections and call `removeAllAnimations`. Perhaps that is the key component I was missing. I don't accomplish to fully implement this when the bounty ends, but you deserve the points. Nice job! – testing Apr 06 '15 at 11:46
  • @EvadneWu: Yeah I did something similar but my main problem is that it doesn't work as smooth as I like. Because I only added the new sections I used the `contentSize` height to calculate the offset delta. What do you mean with *relayout continuously*? I tried to use the *Time Profiler* and the only thing I found out was that `prepareLayout` was consuming a third of the CPU power ... – testing Apr 06 '15 at 12:02
  • @testing I have a feeling that you may want to dig deeper and see why prepareLayout is spending so much time, is it because that your items are of different heights? – Evadne Wu Apr 06 '15 at 16:29
  • @EvadneWu: The [last function](http://stackoverflow.com/questions/29416490/performance-issue-when-inserting-sections-into-uicollectionview-while-scrolling) which consumes that much time was `_getSizingInfos`. The height is always the same. But now I'll try your approach/approach described by Amit. – testing Apr 06 '15 at 16:40
  • @AmitSingh: One important thing I noticed: There are never inserted sections at all (only reloadData)! Also you have to set the content offset directly instead of calling `setContentOffset:animated:`. Now I got it managed to get the project running. The smoothness seems to be better for the first test, but not completely fluent. I'll have to test more. Thanks. – testing Apr 07 '15 at 11:23
  • Yeah I realised that when writing this component dynamic insertion performance was so poor I completely worked around it – Evadne Wu Apr 08 '15 at 09:56
  • Another thing I noticed: I didn't found a way to keep my snap in feature I had. With this approach it doesn't work anymore. Has [anyone](http://stackoverflow.com/questions/29509772/snap-in-at-beginning-of-section-despite-underlying-data-changed) an idea? – testing Apr 08 '15 at 12:07
0

More simple solution, which works for me:

Use viewWillLayoutSubviews to determine when and how updated your model.

override func viewWillLayoutSubviews() {
    super.viewWillLayoutSubviews()

    let topEdge: CGFloat = 0
    let bottomEdge = collectionView.contentSize.height - collectionView.bounds.height

    if collectionView.contentOffset.y < topEdge {
        insertTop()
    } else if collectionView.contentOffset.y > bottomEdge {
        insertBottom()
    }
}

Append to bottom is usually easy, just append data into you model and call reloadData() on collection view, that's it.

Insert into top is a little bit tricky because we need to adjust content's offset. Calculate how much content we inserted on top.

func insertTop {

    let beforeSize = collectionView.collectionViewLayout.collectionViewContentSize

    // insert data at the beginning of your model
    // ...

    collectionView.reloadData()

    let afterSize = collectionView.collectionViewLayout.collectionViewContentSize
    let diff = afterSize.height - beforeSize.height
    collectionView.contentOffset = CGPoint(
        x: collectionView.contentOffset.x,
        y: collectionView.contentOffset.y + diff
    )
}
avdyushin
  • 1,782
  • 15
  • 19
-4

Create UITableViewController's subclass and then add UICollectionView in the table cell. Here is a sample code which does the same.

Dmitriy
  • 5,347
  • 12
  • 23
  • 37
Rajender Kumar
  • 1,357
  • 17
  • 31
  • Thanks for your help. I didn't used the correct word in my question, but I need endless scrolling. So it should never end. How would your approach would work here? – testing Apr 01 '15 at 09:07
  • Try AFTabledCollectionView for the same. its very simple and effective. AFTabledCollectionView github link: https://github.com/ashfurrow/AFTabledCollectionView – Rajender Kumar Apr 01 '15 at 09:11
  • But how can I add/exchange cells on the fly? The approach shown has some `const` definition. – testing Apr 01 '15 at 09:16
  • What you mean by add/exchange cells on the fly? – Rajender Kumar Apr 01 '15 at 09:18
  • Have a look on the month view of the iOS calendar. You can scroll upwards or downwards endlessly. Either the cells are added on the fly or some kind of reuse machanism is used which fakes the endless scrolling and only the values of the cells are exchanged. – testing Apr 01 '15 at 09:21