Swiftgram/AsyncDisplayKit/Details/ASRangeController.mm
2014-09-24 14:23:42 -07:00

622 lines
21 KiB
Plaintext

/* Copyright (c) 2014-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
#import "ASRangeController.h"
#import "ASAssert.h"
#import "ASDisplayNodeExtras.h"
#import "ASDisplayNodeInternal.h"
#import "ASRangeControllerInternal.h"
typedef NS_ENUM(NSInteger, ASScrollDirection) {
ASScrollDirectionUp,
ASScrollDirectionDown,
};
@interface ASRangeController () {
// index path -> node mapping
NSMutableDictionary *_nodes;
// array of boxed CGSizes. _nodeSizes.count == the number of nodes that have been sized
// TODO optimise this, perhaps by making _nodes an array
NSMutableArray *_nodeSizes;
// consumer data source information
NSArray *_sectionCounts;
NSInteger _totalNodeCount;
// used for global <-> section.row mapping. _sectionOffsets[section] is the index at which the section starts
NSArray *_sectionOffsets;
// sized data source information
NSInteger _sizedNodeCount;
// ranges
BOOL _queuedRangeUpdate;
ASScrollDirection _scrollDirection;
NSRange _visibleRange;
NSRange _workingRange;
NSMutableOrderedSet *_workingIndexPaths;
}
@end
@implementation ASRangeController
#pragma mark -
#pragma mark Lifecycle.
- (instancetype)init
{
if (!(self = [super init]))
return nil;
_tuningParameters = {
.trailingBufferScreenfuls = 1,
.leadingBufferScreenfuls = 2,
};
return self;
}
+ (dispatch_queue_t)sizingQueue
{
static dispatch_queue_t sizingQueue = NULL;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sizingQueue = dispatch_queue_create("com.facebook.AsyncDisplayKit.ASRangeController.sizingQueue", DISPATCH_QUEUE_CONCURRENT);
// we use the highpri queue to prioritize UI rendering over other async operations
dispatch_set_target_queue(sizingQueue, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0));
});
return sizingQueue;
}
+ (UIView *)workingView
{
// we add nodes' views to this invisible window to start async rendering
static UIWindow *workingWindow = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
workingWindow = [[UIWindow alloc] initWithFrame:CGRectZero];
workingWindow.windowLevel = UIWindowLevelNormal - 1000;
workingWindow.userInteractionEnabled = NO;
workingWindow.clipsToBounds = YES;
workingWindow.hidden = YES;
});
return workingWindow;
}
#pragma mark -
#pragma mark Helpers.
static NSOrderedSet *ASCopySetMinusSet(NSOrderedSet *minuend, NSOrderedSet *subtrahend)
{
NSMutableOrderedSet *difference = [minuend mutableCopy];
[difference minusOrderedSet:subtrahend];
return difference;
}
// useful for debugging: working range, buffer sizes, and visible range
__attribute__((unused)) static NSString *ASWorkingRangeDebugDescription(NSRange workingRange, NSRange visibleRange)
{
NSInteger visibleRangeLastElement = NSMaxRange(visibleRange) - 1;
NSInteger workingRangeLastElement = NSMaxRange(workingRange) - 1;
return [NSString stringWithFormat:@"[%zd(%zd) [%zd, %zd] (%zd)%zd]",
workingRange.location,
visibleRange.location - workingRange.location,
visibleRange.location,
visibleRangeLastElement,
workingRangeLastElement - visibleRangeLastElement,
workingRangeLastElement];
}
#pragma mark NSRange <-> NSIndexPath.
static BOOL ASRangeIsValid(NSRange range)
{
return range.location != NSNotFound && range.length > 0;
}
- (NSIndexPath *)indexPathForIndex:(NSInteger)index
{
ASDisplayNodeAssert(index < _totalNodeCount, @"invalid argument");
for (NSInteger section = _sectionCounts.count - 1; section >= 0; section--) {
NSInteger offset = [_sectionOffsets[section] integerValue];
if (offset <= index) {
return [NSIndexPath indexPathForRow:index - offset inSection:section];
}
}
ASDisplayNodeAssert(NO, @"logic error");
return nil;
}
- (NSArray *)indexPathsForRange:(NSRange)range
{
ASDisplayNodeAssert(ASRangeIsValid(range) && NSMaxRange(range) <= _totalNodeCount, @"invalid argument");
NSMutableArray *result = [NSMutableArray arrayWithCapacity:range.length];
NSIndexPath *indexPath = [self indexPathForIndex:range.location];
for (NSInteger i = range.location; i < NSMaxRange(range); i++) {
[result addObject:indexPath];
if (indexPath.row + 1 >= [_sectionCounts[indexPath.section] integerValue]) {
indexPath = [NSIndexPath indexPathForRow:0 inSection:indexPath.section + 1];
} else {
indexPath = [NSIndexPath indexPathForRow:indexPath.row + 1 inSection:indexPath.section];
}
}
return result;
}
- (NSInteger)indexForIndexPath:(NSIndexPath *)indexPath
{
NSInteger index = [_sectionOffsets[indexPath.section] integerValue] + indexPath.row;
ASDisplayNodeAssert(index < _totalNodeCount, @"invalid argument");
return index;
}
#pragma mark View manipulation.
- (void)discardNode:(ASCellNode *)node
{
ASDisplayNodeAssertMainThread();
ASDisplayNodeAssert(node, @"invalid argument");
NSInteger index = [self indexForIndexPath:node.asyncdisplaykit_indexPath];
if (NSLocationInRange(index, _workingRange)) {
// move the node's view to the working range area, so its rendering persists
[self moveNodeToWorkingView:node];
} else {
// this node isn't in the working range, remove it from the view hierarchy
[self removeNodeFromWorkingView:node];
}
}
- (void)removeNodeFromWorkingView:(ASCellNode *)node
{
ASDisplayNodeAssertMainThread();
ASDisplayNodeAssert(node, @"invalid argument");
[node recursiveSetPreventOrCancelDisplay:YES];
[node.view removeFromSuperview];
[_workingIndexPaths removeObject:node.asyncdisplaykit_indexPath];
}
- (void)moveNodeToWorkingView:(ASCellNode *)node
{
ASDisplayNodeAssertMainThread();
ASDisplayNodeAssert(node, @"invalid argument");
[self moveNode:node toView:[ASRangeController workingView]];
[_workingIndexPaths addObject:node.asyncdisplaykit_indexPath];
}
- (void)moveNode:(ASCellNode *)node toView:(UIView *)view
{
ASDisplayNodeAssertMainThread();
ASDisplayNodeAssert(node && view, @"invalid argument, did you mean -removeNodeFromWorkingView:?");
// use an explicit transaction to force CoreAnimation to display nodes in order
[CATransaction begin];
// if this node is in the view hierarchy, moving it will cause a redisplay, so we disable hierarchy notifications.
// if it *isn't* in the view hierarchy, we need it to receive those notifications to trigger its first display.
BOOL nodeIsInHierarchy = (node.view.superview != nil);
if (nodeIsInHierarchy)
ASDisplayNodeDisableHierarchyNotifications(node);
[view addSubview:node.view];
if (nodeIsInHierarchy)
ASDisplayNodeEnableHierarchyNotifications(node);
[CATransaction commit];
}
#pragma mark -
#pragma mark API.
- (void)recalculateDataSourceCounts
{
// data source information (_sectionCounts, _sectionOffsets, _totalNodeCount) is not currently thread-safe
ASDisplayNodeAssertMainThread();
NSInteger sections = [_delegate rangeControllerSections:self];
NSMutableArray *sectionCounts = [NSMutableArray arrayWithCapacity:sections];
for (NSInteger section = 0; section < sections; section++) {
sectionCounts[section] = @([_delegate rangeController:self rowsInSection:section]);
}
NSMutableArray *sectionOffsets = [NSMutableArray arrayWithCapacity:sections];
NSInteger offset = 0;
for (NSInteger section = 0; section < sections; section++) {
sectionOffsets[section] = @(offset);
offset += [sectionCounts[section] integerValue];
}
_sectionCounts = sectionCounts;
_sectionOffsets = sectionOffsets;
_totalNodeCount = offset;
}
- (void)rebuildData
{
/*
* teardown
*/
for (ASCellNode *node in _nodes.objectEnumerator) {
[node removeFromSupernode];
[node.view removeFromSuperview];
}
[_nodes removeAllObjects];
_nodes = nil;
for (UIView *view in [[ASRangeController workingView] subviews]) {
[view removeFromSuperview];
}
/*
* setup
*/
[self recalculateDataSourceCounts];
_nodes = [NSMutableDictionary dictionaryWithCapacity:_totalNodeCount];
_visibleRange = _workingRange = NSMakeRange(NSNotFound, 0);
_sizedNodeCount = 0;
_nodeSizes = [NSMutableArray array];
_scrollDirection = ASScrollDirectionDown;
_workingIndexPaths = [NSMutableOrderedSet orderedSet];
// don't bother sizing if the data source is empty
if (_totalNodeCount > 0) {
[self sizeNextBlock];
}
}
- (void)visibleNodeIndexPathsDidChange
{
if (_queuedRangeUpdate)
return;
// coalesce these events -- handling them multiple times per runloop is noisy and expensive
_queuedRangeUpdate = YES;
[self performSelector:@selector(updateVisibleNodeIndexPaths)
withObject:nil
afterDelay:0
inModes:@[ NSRunLoopCommonModes ]];
}
- (void)updateVisibleNodeIndexPaths
{
NSArray *indexPaths = [_delegate rangeControllerVisibleNodeIndexPaths:self];
if (indexPaths.count) {
[self setVisibleRange:NSMakeRange([self indexForIndexPath:[indexPaths firstObject]],
indexPaths.count)];
}
_queuedRangeUpdate = NO;
}
- (NSInteger)numberOfSizedSections
{
// short-circuit if we haven't started sizing
if (_sizedNodeCount == 0)
return 0;
NSIndexPath *lastSizedIndex = [self indexPathForIndex:_sizedNodeCount - 1];
NSInteger sizedSectionCount = lastSizedIndex.section + 1;
ASDisplayNodeAssert(sizedSectionCount <= _sectionCounts.count, @"logic error");
return sizedSectionCount;
}
- (NSInteger)numberOfSizedRowsInSection:(NSInteger)section
{
// short-circuit if we haven't started sizing
if (_sizedNodeCount == 0)
return 0;
if (section > _sectionCounts.count) {
ASDisplayNodeAssert(NO, @"this isn't even a valid section");
return 0;
}
NSIndexPath *lastSizedIndex = [self indexPathForIndex:_sizedNodeCount - 1];
if (section > lastSizedIndex.section) {
ASDisplayNodeAssert(NO, @"this section hasn't been sized yet");
return 0;
} else if (section == lastSizedIndex.section) {
// we're still sizing this section, return the count we have
return lastSizedIndex.row + 1;
} else {
// we've already sized beyond this section, return the full count
return [_sectionCounts[section] integerValue];
}
}
- (void)configureContentView:(UIView *)contentView forIndexPath:(NSIndexPath *)indexPath
{
ASCellNode *newNode = [self sizedNodeForIndexPath:indexPath];
ASDisplayNodeAssert(newNode, @"this node hasn't been sized yet!");
if (newNode.view.superview == contentView) {
// this content view is already correctly configured
return;
}
for (UIView *view in contentView.subviews) {
ASDisplayNode *node = view.asyncdisplaykit_node;
if (node) {
// plunk this node back into the working range, if appropriate
ASDisplayNodeAssert([node isKindOfClass:[ASCellNode class]], @"invalid node");
[self discardNode:(ASCellNode *)node];
} else {
// if it's not a node, it's something random UITableView added to the hierarchy. kill it.
[view removeFromSuperview];
}
}
[self moveNode:newNode toView:contentView];
}
- (CGSize)calculatedSizeForNodeAtIndexPath:(NSIndexPath *)indexPath
{
// TODO add an assertion (here or in ASTableView) that the calculated size isn't bogus (eg must be < tableview width)
ASCellNode *node = [self sizedNodeForIndexPath:indexPath];
return node.calculatedSize;
}
#pragma mark -
#pragma mark Working range.
- (void)setTuningParameters:(ASRangeTuningParameters)tuningParameters
{
_tuningParameters = tuningParameters;
if (ASRangeIsValid(_visibleRange)) {
[self recalculateWorkingRange];
}
}
static NSRange ASCalculateWorkingRange(ASRangeTuningParameters params, ASScrollDirection scrollDirection,
NSRange visibleRange, NSArray *nodeSizes, CGSize viewport)
{
ASDisplayNodeCAssert(NSMaxRange(visibleRange) <= nodeSizes.count, @"nodes can't be visible until they're sized");
// extend the visible range by enough nodes to fill at least the requested number of screenfuls
// NB. this logic assumes (UITableView-style) vertical scrolling and would need to be changed for ASCollectionView
CGFloat minUpperBufferSize, minLowerBufferSize;
switch (scrollDirection) {
case ASScrollDirectionUp:
minUpperBufferSize = viewport.height * params.leadingBufferScreenfuls;
minLowerBufferSize = viewport.height * params.trailingBufferScreenfuls;
break;
case ASScrollDirectionDown:
minUpperBufferSize = viewport.height * params.trailingBufferScreenfuls;
minLowerBufferSize = viewport.height * params.leadingBufferScreenfuls;
break;
}
// "top" buffer (above the screen, if we're scrolling vertically)
NSInteger upperBuffer = 0;
CGFloat upperBufferHeight = 0.0f;
for (NSInteger idx = visibleRange.location - 1; idx >= 0 && upperBufferHeight < minUpperBufferSize; idx--) {
upperBuffer++;
upperBufferHeight += [nodeSizes[idx] CGSizeValue].height;
}
// "bottom" buffer (below the screen, if we're scrolling vertically)
NSInteger lowerBuffer = 0;
CGFloat lowerBufferHeight = 0.0f;
for (NSInteger idx = NSMaxRange(visibleRange); idx < nodeSizes.count && lowerBufferHeight < minLowerBufferSize; idx++) {
lowerBuffer++;
lowerBufferHeight += [nodeSizes[idx] CGSizeValue].height;
}
return NSMakeRange(visibleRange.location - upperBuffer,
visibleRange.length + upperBuffer + lowerBuffer);
}
- (void)setVisibleRange:(NSRange)visibleRange
{
if (NSEqualRanges(_visibleRange, visibleRange))
return;
ASDisplayNodeAssert(ASRangeIsValid(visibleRange), @"invalid argument");
NSRange previouslyVisible = ASRangeIsValid(_visibleRange) ? _visibleRange : visibleRange;
_visibleRange = visibleRange;
// figure out where we're going, because that's where the bulk of the working range needs to be
NSInteger scrollDelta = _visibleRange.location - previouslyVisible.location;
if (scrollDelta < 0)
_scrollDirection = ASScrollDirectionUp;
if (scrollDelta > 0)
_scrollDirection = ASScrollDirectionDown;
[self recalculateWorkingRange];
}
- (void)recalculateWorkingRange
{
NSRange workingRange = ASCalculateWorkingRange(_tuningParameters,
_scrollDirection,
_visibleRange,
_nodeSizes,
[_delegate rangeControllerViewportSize:self]);
[self setWorkingRange:workingRange];
}
- (void)setWorkingRange:(NSRange)newWorkingRange
{
if (NSEqualRanges(_workingRange, newWorkingRange))
return;
// the working range is a superset of the visible range, but we only care about offscreen nodes
ASDisplayNodeAssert(NSEqualRanges(_visibleRange, NSIntersectionRange(_visibleRange, newWorkingRange)), @"logic error");
NSOrderedSet *visibleIndexPaths = [NSOrderedSet orderedSetWithArray:[self indexPathsForRange:_visibleRange]];
NSOrderedSet *oldWorkingIndexPaths = ASCopySetMinusSet(_workingIndexPaths, visibleIndexPaths);
NSOrderedSet *newWorkingIndexPaths = ASCopySetMinusSet([NSOrderedSet orderedSetWithArray:[self indexPathsForRange:newWorkingRange]], visibleIndexPaths);
// update bookkeeping for visible nodes; these will be removed from the working range later in -configureContentView::
[_workingIndexPaths minusOrderedSet:visibleIndexPaths];
// evict nodes that have left the working range (i.e., those that are in the old working range but not the new one)
NSOrderedSet *removedIndexPaths = ASCopySetMinusSet(oldWorkingIndexPaths, newWorkingIndexPaths);
for (NSIndexPath *indexPath in removedIndexPaths) {
ASCellNode *node = [self sizedNodeForIndexPath:indexPath];
ASDisplayNodeAssert(node, @"an unsized node should never have entered the working range");
[self removeNodeFromWorkingView:node];
}
// add nodes that have entered the working range (i.e., those that are in the new working range but not the old one)
NSOrderedSet *addedIndexPaths = ASCopySetMinusSet(newWorkingIndexPaths, oldWorkingIndexPaths);
for (NSIndexPath *indexPath in addedIndexPaths) {
// if a node in the working range is still sizing, the sizing logic will add it to the working range for us later
ASCellNode *node = [self sizedNodeForIndexPath:indexPath];
if (node) {
[self moveNodeToWorkingView:node];
} else {
ASDisplayNodeAssert(_sizedNodeCount != _totalNodeCount, @"logic error");
}
}
_workingRange = newWorkingRange;
}
#pragma mark -
#pragma mark Async sizing.
- (ASCellNode *)sizedNodeForIndexPath:(NSIndexPath *)indexPath
{
if ([self indexForIndexPath:indexPath] >= _sizedNodeCount) {
// this node hasn't been sized yet
return nil;
}
// work around applebug: a UIMutableIndexPath with row r and section s is not considered equal to an NSIndexPath with
// row r and section s, so we cannot use the provided indexPath directly as a dictionary index.
ASCellNode *sizedNode = _nodes[[NSIndexPath indexPathForRow:indexPath.row inSection:indexPath.section]];
ASDisplayNodeAssert(sizedNode, @"this node should be sized but doesn't even exist");
ASDisplayNodeAssert([sizedNode.asyncdisplaykit_indexPath isEqual:indexPath], @"this node has the wrong index path");
[sizedNode recursiveSetPreventOrCancelDisplay:NO];
return sizedNode;
}
- (void)sizeNextBlock
{
// concurrently size as many nodes as the CPU allows
static const NSInteger blockSize = [[NSProcessInfo processInfo] processorCount];
NSRange sizingRange = NSMakeRange(_sizedNodeCount, MIN(blockSize, _totalNodeCount - _sizedNodeCount));
// manage sizing on a throwaway background queue; we'll be blocking it
dispatch_async(dispatch_queue_create(NULL, DISPATCH_QUEUE_CONCURRENT), ^{
dispatch_group_t group = dispatch_group_create();
NSArray *indexPaths = [self indexPathsForRange:sizingRange];
for (NSIndexPath *indexPath in indexPaths) {
ASCellNode *node = [_delegate rangeController:self nodeForIndexPath:indexPath];
node.asyncdisplaykit_indexPath = indexPath;
_nodes[indexPath] = node;
dispatch_group_async(group, [ASRangeController sizingQueue], ^{
[node measure:[_delegate rangeController:self constrainedSizeForNodeAtIndexPath:indexPath]];
node.frame = CGRectMake(0.0f, 0.0f, node.calculatedSize.width, node.calculatedSize.height);
});
}
// wait for all sizing to finish, then bounce back to main
// TODO consider using a semaphore here -- we currently don't size nodes while updating the working range
dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
dispatch_async(dispatch_get_main_queue(), ^{
// update sized node information
_sizedNodeCount = NSMaxRange(sizingRange);
for (NSIndexPath *indexPath in indexPaths) {
ASCellNode *node = _nodes[indexPath];
_nodeSizes[[self indexForIndexPath:indexPath]] = [NSValue valueWithCGSize:node.calculatedSize];
}
ASDisplayNodeAssert(_nodeSizes.count == _sizedNodeCount, @"logic error");
// update the working range
if (ASRangeIsValid(_visibleRange)) {
[self recalculateWorkingRange];
}
// delegateify
[_delegate rangeController:self didSizeNodesWithIndexPaths:indexPaths];
// kick off the next block
if (_sizedNodeCount < _totalNodeCount) {
[self performSelector:@selector(sizeNextBlock) withObject:NULL afterDelay:0];
}
});
});
}
#pragma mark -
#pragma mark Editing.
static BOOL ASIndexPathsAreSequential(NSIndexPath *first, NSIndexPath *second)
{
BOOL row = (second.row == first.row + 1 && second.section == first.section);
BOOL section = (second.row == 0 && second.section == first.section + 1);
return row || section;
}
- (void)appendNodesWithIndexPaths:(NSArray *)indexPaths
{
// sanity-check input
// TODO this is proof-of-concept-quality, expand validation when fleshing out update / editing support
NSIndexPath *lastNode = (_totalNodeCount > 0) ? [self indexPathForIndex:_totalNodeCount - 1] : nil;
BOOL indexPathsAreValid = ((lastNode && ASIndexPathsAreSequential(lastNode, [indexPaths firstObject])) ||
[[indexPaths firstObject] isEqual:[NSIndexPath indexPathForRow:0 inSection:0]]);
if (!indexPaths || !indexPaths.count || !indexPathsAreValid) {
ASDisplayNodeAssert(NO, @"invalid argument");
return;
}
// update all the things
void (^updateBlock)() = ^{
BOOL isSizing = (_sizedNodeCount < _totalNodeCount);
NSInteger expectedTotalNodeCount = _totalNodeCount + indexPaths.count;
[self recalculateDataSourceCounts];
ASDisplayNodeAssert(_totalNodeCount == expectedTotalNodeCount, @"data source error");
if (!isSizing) {
// the last sizing pass completely finished, start a new one
[self sizeNextBlock];
}
};
// trampoline to main if necessary, we don't have locks on _sectionCounts / _sectionOffsets / _totalNodeCount
if (![NSThread isMainThread]) {
dispatch_sync(dispatch_get_main_queue(), ^{
updateBlock();
});
} else {
updateBlock();
}
}
@end