Infinitely scrolling tables
One of the reasons I started my latest app, MealSchedule, was because I don’t care for presenting dates in blocks such as a week or a month. I don’t like that as you reach the end of the block a large proportion of the screen contains irrelevant historical data and I don’t like that it is cumbersome to compare items on either side of the block’s boundary. Time is continuous, therefore its display should be unbroken. Just because a week is nice and convenient for having one of each kind of day, you ignore that not every-one’s week starts on a Monday or a Sunday.
To translation a continuous time display to an app, you really need to use a vertical scroll as this is by far the most natural method for presenting an infinite amount of data. So the first challenge for my new app was to develop a way to handle and present an unlimited scrolling of dates.
The simplest option, of course, is to use a UITableView and return a very large number of rows. You can use scrollToRowAtIndexPath:
to position today as the initial row, somewhere in the middle of the table. This works fairly well and is very easy to implement provided, and this is a big constraint, that each row is always the same height, indeed you do not want to implement tableView:heightForRowAtIndexPath:
at all. iOS needs the height of each row to calculate the offsets of each cell within the scroll view of a table. When you call scrollToRowAtIndexPath:
for example, iOS must determine the height of each preceding row, if that height is variable then the costs of calculating the offsets become untenable as soon as the table has more than a couple of hundred rows. With a fixed height, iOS will simply do the math to calculate the offset for a particular row.
But MealSchedule was going to have variable height content, so the simplest solution is off the table (pun intended!) But maybe we could be smarter and use a smaller number of rows as a window on to the underlying set of dates? Every time the user scrolls I could just change the dates that the visible rows represent.
A UITableView is a subclass of UIScrollView, so by implementing scrollViewDidEndDecelerating:
and scrollViewDidEndDragging:willDecelerate:
from the UITableView delegate – a table’s delegate serves up all of the UIScrollViewDelegate methods, I can use a quick sleight of hand to update the dates for the visible rows and reload the table. You have to implement both methods because either will be called depending on how the user scrolls. This pattern is a huge improvement over the first option, but suffers from a jerky appearance because with only a certain number of rows in the table there is an end point of the scrolling and there is a delay before I can update them.
While this second option was better there is still too much lag because of UITableView overhead in calculating row size. So the third approach is to strip out the overhead. UITableView is a complex class that takes care of a lot of situations, but I didn’t necessarily need all of that. What if I just had a UIScrollView and used by own UIViews to represent each day?
This proves to be a great solution. I could quickly add and remove subviews from the start and end of the content when the UIScrollView was scrolled and I could have variable height rows because I could control the number of times each row had to be recalculated. layoutSubviews
is called every time a UIScrollView is scrolled, so I can quickly check to see if we are close to one end or the other, and if we are, remove the day subview from the opposite end, add in a new day and simply adjust the origins of the remaining subviews.
I choose to have a few extra days on either end of the visible set to allow for smoother scrolling. There is a function in the app to move to another date, and if that date is within the current set I can perform a nice animated scroll. But if the date is outside of the range, then I have to recalculate the entire set and there is no animated scroll. Most date changes are going to be by a week, so that is how many extra days I have available, just in case.
Here is the code in my UIScrollView subclass that handles the day subview creation and the scrolling (I have removed parts that are not relevant to this discussion.). The day subview is a UIView subclass called MealsDateView, the details of which are also irrelevant.
@interface MealPlanScrollView () { | |
NSMutableArray *_dayViews; | |
NSInteger _numberOfDays; | |
NSInteger _viewFirstDateNum; | |
NSInteger _viewLastDateNum; | |
NSInteger _todayDateNum; | |
NSInteger _maxNumberOfDays; | |
} | |
- (void)buildDayViews; | |
- (MealsDateView *)insertDayAtIndex:(NSInteger)index; | |
- (CGFloat)deleteDay:(MealsDateView *)day; | |
- (void)adjustDayOriginsBy:(CGFloat)distance startingAt:(NSInteger)start; | |
- (MealsDateView *)findViewForDateNum:(NSInteger)dateNum; | |
- (void)setTodayRow; | |
@end | |
@implementation MealPlanScrollView | |
- (id)initWithFrame:(CGRect)frame { | |
self = [super initWithFrame:frame]; | |
if (self) { | |
// Base the number of days on the device size | |
// use a week either side to allow for smooth scrolling when moving by 7 days | |
_numberOfDays = roundf([UIScreen portraitHeight] / DEFAULT_ROW_HEIGHT) + 14; | |
_maxNumberOfDays = [NSDate maxNumberOfDates]; | |
[self setContentSize:CGSizeMake(CGRectGetWidth(frame), 0)]; | |
[self setDelegate:self]; | |
[self setTodayRow]; | |
_viewFirstDateNum = _todayDateNum - _numberOfDays/2; | |
_viewLastDateNum = _todayDateNum + _numberOfDays/2; | |
// Build the day views | |
_dayViews = [[NSMutableArray alloc] init]; | |
[self buildDayViews]; | |
[self setYear]; | |
MealsDateView *firstVisible = _dayViews[(_numberOfDays/2)-1]; | |
[self setContentOffset:CGPointMake(0, CGRectGetMinY([firstVisible frame]))]; | |
} | |
return self; | |
} | |
- (void)layoutSubviews { | |
[super layoutSubviews]; | |
// Adjust the scroll offset if gone too far one way or the other | |
CGPoint currentOffset = [self contentOffset]; | |
currentOffset.x = 0; | |
CGFloat contentHeight = [self contentSize].height; | |
CGFloat centerOffsetY = (contentHeight - CGRectGetHeight([self bounds])) / 2.0f; | |
CGFloat distanceFromCenter = currentOffset.y - centerOffsetY; | |
CGFloat buffer = DEFAULT_ROW_HEIGHT; | |
if (fabs(distanceFromCenter) > buffer) { | |
if (distanceFromCenter < 0) { | |
// move from bottom to top | |
while (fabs(distanceFromCenter) > buffer) { | |
if (_viewFirstDateNum == 0) { | |
break; | |
} | |
_viewFirstDateNum--; | |
_viewLastDateNum--; | |
[self deleteDay:[_dayViews lastObject]]; | |
MealsDateView *firstDay = [self insertDayAtIndex:0]; | |
CGFloat h = CGRectGetHeight([firstDay frame]); | |
currentOffset.y += h; | |
[self adjustDayOriginsBy:h startingAt:1]; | |
distanceFromCenter += h; | |
if (distanceFromCenter >= 0) { | |
break; | |
} | |
} | |
} else { | |
// move from top to bottom | |
while (distanceFromCenter > buffer) { | |
if ((_viewLastDateNum-1) == _maxNumberOfDays) { | |
break; | |
} | |
_viewFirstDateNum++; | |
_viewLastDateNum++; | |
CGFloat h = [self deleteDay:_dayViews[0]]; | |
[self insertDayAtIndex:[_dayViews count]]; | |
[self adjustDayOriginsBy:-1*h startingAt:0]; | |
currentOffset.y -= h; | |
distanceFromCenter -= h; | |
if (distanceFromCenter <= 0) { | |
break; | |
} | |
} | |
} | |
} | |
[self setContentOffset:currentOffset]; | |
} | |
#pragma mark - Public | |
- (void)changeRowHeightOfDate:(NSDate *)date by:(CGFloat)delta { | |
NSInteger dateNum = [date daysSinceStart]; | |
MealsDateView *day = [self findViewForDateNum:dateNum]; | |
if (!day) { | |
return; | |
} | |
CGFloat oldHeight = CGRectGetHeight([day frame]); | |
// Adjust the size of the views | |
CGFloat newHeight = oldHeight + delta; | |
if (newHeight != oldHeight) { | |
CGFloat diff = newHeight - oldHeight; | |
CGSize s = [self contentSize]; | |
s.height += diff; | |
[self setContentSize:s]; | |
[self adjustDayOriginsBy:diff startingAt:(dateNum-_viewFirstDateNum+1)]; | |
} | |
} | |
- (void)layoutMeals { | |
CGRect f = [self frame]; | |
f.size.width = [UIScreen width]; | |
f.size.height = [UIScreen height]-[UIScreen statusBarHeight]-f.origin.y; | |
[self setFrame:f]; | |
CGFloat y = 0; | |
for (MealsDateView *day in _dayViews) { | |
CGRect df = [day frame]; | |
if (CGRectGetMinX(df) != y) { | |
df.origin.y = y; | |
[day setFrame:df]; | |
} | |
[day layoutMeals]; | |
y += CGRectGetHeight([day frame]); | |
} | |
CGSize s = [self contentSize]; | |
s.height = y; | |
[self setContentSize:s]; | |
} | |
#pragma mark - Meal actions | |
- (void)scrollToDate:(NSDate *)date inRect:(CGRect)rect { | |
BOOL reload = NO; | |
NSInteger newDateNum = [date daysSinceStart]; | |
if ((newDateNum <= _viewFirstDateNum) || (newDateNum >= _viewLastDateNum)) { | |
_viewFirstDateNum = newDateNum - _numberOfDays/2; | |
_viewLastDateNum = newDateNum + _numberOfDays/2; | |
if (_viewFirstDateNum < 0) { | |
_viewFirstDateNum = 0; | |
_viewLastDateNum = _viewFirstDateNum + _numberOfDays; | |
} | |
if (_viewLastDateNum > [NSDate maxNumberOfDates]) { | |
_viewLastDateNum = [NSDate maxNumberOfDates]; | |
_viewFirstDateNum = _viewLastDateNum - _numberOfDays; | |
} | |
reload = YES; | |
} | |
if (reload) { | |
[self buildDayViews]; | |
} | |
NSInteger newRow = newDateNum - _viewFirstDateNum; | |
if (newRow < 0) { | |
newRow = 0; | |
} | |
if (newRow >= [_dayViews count]) { | |
newRow = [_dayViews count] - 1; | |
} | |
MealsDateView *day = _dayViews[newRow]; | |
CGRect f = [self frame]; | |
CGRect df = [day frame]; | |
CGPoint offset = [self contentOffset]; | |
// rect is relative to the screen | |
offset.y = df.origin.y - (rect.origin.y - f.origin.y); | |
if (offset.y < 0) { | |
offset.y = 0; | |
} | |
[CATransaction begin]; | |
[self setContentOffset:offset animated:!reload]; | |
[CATransaction commit]; | |
} | |
- (void)scrollByDistance:(CGFloat)distance { | |
CGPoint offset = [self contentOffset]; | |
offset.y -= distance; | |
[UIView animateWithDuration:IOS_ANIMATION_DURATION | |
delay:0.0f | |
options:IOS_ANIMATION_CURVE | |
animations:^{ | |
[self setContentOffset:offset animated:NO]; | |
} | |
completion:^(BOOL finished) { | |
if (finished) { | |
} | |
}]; | |
} | |
#pragma mark - Local | |
- (void)buildDayViews { | |
for (MealsDateView *day in _dayViews) { | |
[day removeFromSuperview]; | |
} | |
[_dayViews removeAllObjects]; | |
CGSize s = [self contentSize]; | |
s.height = 0; | |
[self setContentSize:s]; | |
for (int i=0; i<_numberOfDays; i++) { | |
[self insertDayAtIndex:i]; | |
} | |
} | |
- (MealsDateView *)insertDayAtIndex:(NSInteger)index { | |
NSInteger dayNum = (_viewFirstDateNum + index); | |
NSDate *thisDate = [NSDate dateSinceStartForDayNumber:dayNum]; | |
CGFloat y = 0; | |
if (index > 0) { | |
UIView *prevDay = _dayViews[(index-1)]; | |
y = CGRectGetMaxY([prevDay frame]); | |
} | |
DateRowType rowType = DateRowTypeMiddle; | |
if (dayNum == 0) { | |
rowType = DateRowTypeFirst; | |
} else if (dayNum == _maxNumberOfDays) { | |
rowType = DateRowTypeLast; | |
} | |
MealsDateView *day = [[MealsDateView alloc] initWithDate:thisDate rowType:rowType]; | |
CGRect f = [day frame]; | |
f.origin.y = y; | |
[day setFrame:f]; | |
[_dayViews insertObject:day atIndex:index]; | |
[self insertSubview:day atIndex:0]; | |
CGSize s = [self contentSize]; | |
s.height += CGRectGetHeight([day frame]); | |
[self setContentSize:s]; | |
return day; | |
} | |
- (CGFloat)deleteDay:(MealsDateView *)day { | |
CGRect f = [day frame]; | |
CGSize s = [self contentSize]; | |
s.height -= CGRectGetHeight(f); | |
[self setContentSize:s]; | |
[day removeFromSuperview]; | |
[_dayViews removeObject:day]; | |
return CGRectGetHeight(f); | |
} | |
- (void)adjustDayOriginsBy:(CGFloat)distance startingAt:(NSInteger)start { | |
for (int i=start; i<[_dayViews count]; i++) { | |
MealsDateView *day = (MealsDateView *)_dayViews[i]; | |
CGRect f = [day frame]; | |
f.origin.y += distance; | |
[day setFrame:f]; | |
} | |
} | |
- (MealsDateView *)findViewForDateNum:(NSInteger)dateNum { | |
if ((dateNum < _viewFirstDateNum) || (dateNum > _viewLastDateNum)) { | |
return nil; | |
} | |
NSInteger index = dateNum - _viewFirstDateNum; | |
if ((index >= 0) && (index < [_dayViews count])) { | |
return _dayViews[index]; | |
} else { | |
return nil; | |
} | |
} | |
#pragma mark - Date/time methods | |
- (void)setTodayRow { | |
_todayDateNum = [[NSDate today] daysSinceStart]; | |
} |
I have a category to extend NSDate which provides methods to convert between a full date and an integer as it is easier to work with a simple scalar in managing the set of days.