Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Infinitely scrolling in both directions of UICollectionView with sections

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.

like image 828
testing Avatar asked Mar 27 '15 10:03

testing


1 Answers

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

like image 122
Amit Singh Avatar answered Oct 19 '22 21:10

Amit Singh