diff --git a/Source/ASCollectionNode+Beta.h b/Source/ASCollectionNode+Beta.h index 136e02401..feb485ba8 100644 --- a/Source/ASCollectionNode+Beta.h +++ b/Source/ASCollectionNode+Beta.h @@ -33,21 +33,23 @@ NS_ASSUME_NONNULL_BEGIN @property (nullable, nonatomic, weak) id batchFetchingDelegate; /** - * When this mode is enabled, ASCollectionView matches the timing of UICollectionView as closely as possible, - * ensuring that all reload and edit operations are performed on the main thread as blocking calls. + * When this mode is enabled, ASCollectionView matches the timing of UICollectionView as closely as + * possible, ensuring that all reload and edit operations are performed on the main thread as + * blocking calls. * - * This mode is useful for applications that are debugging issues with their collection view implementation. - * In particular, some applications do not properly conform to the API requirement of UICollectionView, and these - * applications may experience difficulties with ASCollectionView. Providing this mode allows for developers to - * work towards resolving technical debt in their collection view data source, while ramping up asynchronous - * collection layout. + * This mode is useful for applications that are debugging issues with their collection view + * implementation. In particular, some applications do not properly conform to the API requirement + * of UICollectionView, and these applications may experience difficulties with ASCollectionView. + * Providing this mode allows for developers to work towards resolving technical debt in their + * collection view data source, while ramping up asynchronous collection layout. * - * NOTE: Because this mode results in expensive operations like cell layout being performed on the main thread, - * it should be used as a tool to resolve data source conformance issues with Apple collection view API. + * NOTE: Because this mode results in expensive operations like cell layout being performed on the + * main thread, it should be used as a tool to resolve data source conformance issues with Apple + * collection view API. * - * @default defaults to NO. + * @default defaults to ASCellLayoutModeNone. */ -@property (nonatomic) BOOL usesSynchronousDataLoading; +@property (nonatomic) ASCellLayoutMode cellLayoutMode; /** * Returns YES if the ASCollectionNode contents are completely synchronized with the underlying collection-view layout. @@ -72,8 +74,6 @@ NS_ASSUME_NONNULL_BEGIN - (void)endUpdatesAnimated:(BOOL)animated completion:(nullable void (^)(BOOL))completion ASDISPLAYNODE_DEPRECATED_MSG("Use -performBatchUpdates:completion: instead."); -- (void)invalidateFlowLayoutDelegateMetrics; - @end NS_ASSUME_NONNULL_END diff --git a/Source/ASCollectionNode.mm b/Source/ASCollectionNode.mm index 11fe678fb..cd533418a 100644 --- a/Source/ASCollectionNode.mm +++ b/Source/ASCollectionNode.mm @@ -42,7 +42,7 @@ @interface _ASCollectionPendingState : NSObject { @property (nonatomic) BOOL allowsSelection; // default is YES @property (nonatomic) BOOL allowsMultipleSelection; // default is NO @property (nonatomic) BOOL inverted; //default is NO -@property (nonatomic) BOOL usesSynchronousDataLoading; +@property (nonatomic) ASCellLayoutMode cellLayoutMode; @property (nonatomic) CGFloat leadingScreensForBatching; @property (nonatomic, weak) id layoutInspector; @property (nonatomic) BOOL alwaysBounceVertical; @@ -193,7 +193,7 @@ - (void)didLoad view.inverted = pendingState.inverted; view.allowsSelection = pendingState.allowsSelection; view.allowsMultipleSelection = pendingState.allowsMultipleSelection; - view.usesSynchronousDataLoading = pendingState.usesSynchronousDataLoading; + view.cellLayoutMode = pendingState.cellLayoutMode; view.layoutInspector = pendingState.layoutInspector; view.showsVerticalScrollIndicator = pendingState.showsVerticalScrollIndicator; view.showsHorizontalScrollIndicator = pendingState.showsHorizontalScrollIndicator; @@ -628,21 +628,21 @@ - (void)setBatchFetchingDelegate:(id)batchFetchingDeleg return _batchFetchingDelegate; } -- (BOOL)usesSynchronousDataLoading +- (ASCellLayoutMode)cellLayoutMode { if ([self pendingState]) { - return _pendingState.usesSynchronousDataLoading; + return _pendingState.cellLayoutMode; } else { - return self.view.usesSynchronousDataLoading; + return self.view.cellLayoutMode; } } -- (void)setUsesSynchronousDataLoading:(BOOL)usesSynchronousDataLoading +- (void)setCellLayoutMode:(ASCellLayoutMode)cellLayoutMode { if ([self pendingState]) { - _pendingState.usesSynchronousDataLoading = usesSynchronousDataLoading; + _pendingState.cellLayoutMode = cellLayoutMode; } else { - self.view.usesSynchronousDataLoading = usesSynchronousDataLoading; + self.view.cellLayoutMode = cellLayoutMode; } } diff --git a/Source/ASCollectionView.mm b/Source/ASCollectionView.mm index a14853c1c..f585bf1fd 100644 --- a/Source/ASCollectionView.mm +++ b/Source/ASCollectionView.mm @@ -63,6 +63,10 @@ flowLayout ? flowLayout.property : default; \ }) +// ASCellLayoutMode is an NSUInteger-based NS_OPTIONS field. Be careful with BOOL handling on the +// 32-bit Objective-C runtime, and pattern after ASInterfaceStateIncludesVisible() & friends. +#define ASCellLayoutModeIncludes(layoutMode) ((_cellLayoutMode & layoutMode) == layoutMode) + /// What, if any, invalidation should we perform during the next -layoutSubviews. typedef NS_ENUM(NSUInteger, ASCollectionViewInvalidationStyle) { /// Perform no invalidation. @@ -344,7 +348,7 @@ - (void)dealloc */ - (void)reloadData { - [super reloadData]; + [self _superReloadData:nil completion:nil]; // UICollectionView calls -reloadData during first layoutSubviews and when the data source changes. // This fires off the first load of cell nodes. @@ -685,9 +689,10 @@ - (CGSize)sizeForElement:(ASCollectionElement *)element if (wrapperNode.sizeForItemBlock) { return wrapperNode.sizeForItemBlock(wrapperNode, element.constrainedSize.max); } else { - // In this case, we should use the exact value that was stashed earlier by calling sizeForItem:, referenceSizeFor*, etc. - // Although the node would use the preferredSize in layoutThatFits, we can skip this because there's no constrainedSize. - return wrapperNode.style.preferredSize; + // In this case, it is possible the model indexPath for this element will be nil. Attempt to convert it, + // and call out to the delegate directly. If it has been deleted from the model, the size returned will be the layout's default. + NSIndexPath *indexPath = [_dataController.visibleMap indexPathForElement:element]; + return [self _sizeForUIKitCellWithKind:element.supplementaryElementKind atIndexPath:indexPath]; } } else { return [node layoutThatFits:element.constrainedSize].size; @@ -786,37 +791,9 @@ - (NSArray *)visibleNodes return visibleNodes; } -- (BOOL)usesSynchronousDataLoading -{ - return self.dataController.usesSynchronousDataLoading; -} - -- (void)setUsesSynchronousDataLoading:(BOOL)usesSynchronousDataLoading +- (void)invalidateFlowLayoutDelegateMetrics { - self.dataController.usesSynchronousDataLoading = usesSynchronousDataLoading; -} - -- (void)invalidateFlowLayoutDelegateMetrics { - for (ASCollectionElement *element in self.dataController.pendingMap) { - // This may be either a Supplementary or Item type element. - // For UIKit passthrough cells of either type, re-fetch their sizes from the standard UIKit delegate methods. - ASCellNode *node = element.node; - if (node.shouldUseUIKitCell) { - ASWrapperCellNode *wrapperNode = (ASWrapperCellNode *)node; - if (wrapperNode.sizeForItemBlock) { - continue; - } - NSIndexPath *indexPath = [_dataController.pendingMap indexPathForElement:element]; - NSString *kind = [element supplementaryElementKind]; - CGSize previousSize = node.style.preferredSize; - CGSize size = [self _sizeForUIKitCellWithKind:kind atIndexPath:indexPath]; - - if (!CGSizeEqualToSize(previousSize, size)) { - node.style.preferredSize = size; - [node invalidateCalculatedLayout]; - } - } - } + // Subclass hook } #pragma mark Internal @@ -869,17 +846,29 @@ - (CGSize)_sizeForUIKitCellWithKind:(NSString *)kind atIndexPath:(NSIndexPath *) return size; } +- (void)_superReloadData:(void(^)())updates completion:(void(^)(BOOL finished))completion +{ + if (updates) { + updates(); + } + [super reloadData]; + if (completion) { + completion(YES); + } +} + /** - Performing nested batch updates with super (e.g. resizing a cell node & updating collection view during same frame) - can cause super to throw data integrity exceptions because it checks the data source counts before - the update is complete. - - Always call [self _superPerform:] rather than [super performBatch:] so that we can keep our `superPerformingBatchUpdates` flag updated. + * Performing nested batch updates with super (e.g. resizing a cell node & updating collection view + * during same frame) can cause super to throw data integrity exceptions because it checks the data + * source counts before the update is complete. + * + * Always call [self _superPerform:] rather than [super performBatch:] so that we can keep our + * `superPerformingBatchUpdates` flag updated. */ - (void)_superPerformBatchUpdates:(void(^)())updates completion:(void(^)(BOOL finished))completion { ASDisplayNodeAssertMainThread(); - + _superBatchUpdateCount++; [super performBatchUpdates:updates completion:completion]; _superBatchUpdateCount--; @@ -1757,7 +1746,11 @@ - (void)layoutSubviews switch (invalidationStyle) { case ASCollectionViewInvalidationStyleWithAnimation: if (0 == _superBatchUpdateCount) { - [self _superPerformBatchUpdates:^{ } completion:nil]; + if (ASCellLayoutModeIncludes(ASCellLayoutModeAlwaysReloadData)) { + [self _superReloadData:nil completion:nil]; + } else { + [self _superPerformBatchUpdates:nil completion:nil]; + } } break; case ASCollectionViewInvalidationStyleWithoutAnimation: @@ -1864,6 +1857,41 @@ - (void)_beginBatchFetching #pragma mark - ASDataControllerSource +- (BOOL)dataController:(ASDataController *)dataController shouldEagerlyLayoutNode:(ASCellNode *)node +{ + NSAssert(!ASCellLayoutModeIncludes(ASCellLayoutModeAlwaysLazy), + @"ASCellLayoutModeAlwaysLazy flag is no longer supported"); + return !node.shouldUseUIKitCell; +} + +- (BOOL)dataController:(ASDataController *)dataController shouldSynchronouslyProcessChangeSet:(_ASHierarchyChangeSet *)changeSet +{ + // If we have AlwaysSync set, block and donate main priority. + if (ASCellLayoutModeIncludes(ASCellLayoutModeAlwaysSync)) { + return YES; + } + // Prioritize AlwaysAsync over the remaining heuristics for the Default mode. + if (ASCellLayoutModeIncludes(ASCellLayoutModeAlwaysAsync)) { + return NO; + } + // The heuristics below apply to the ASCellLayoutModeNone. + // If we have very few ASCellNodes (besides UIKit passthrough ones), match UIKit by blocking. + if (changeSet.countForAsyncLayout < 2) { + return YES; + } + CGSize contentSize = self.contentSize; + CGSize boundsSize = self.bounds.size; + if (contentSize.height <= boundsSize.height && contentSize.width <= boundsSize.width) { + return YES; + } + return NO; +} + +- (BOOL)dataControllerShouldSerializeNodeCreation:(ASDataController *)dataController +{ + return ASCellLayoutModeIncludes(ASCellLayoutModeSerializeNodeCreation); +} + - (id)dataController:(ASDataController *)dataController nodeModelForItemAtIndexPath:(NSIndexPath *)indexPath { if (!_asyncDataSourceFlags.nodeModelForItem) { @@ -1874,7 +1902,7 @@ - (id)dataController:(ASDataController *)dataController nodeModelForItemAtIndexP return [_asyncDataSource collectionNode:collectionNode nodeModelForItemAtIndexPath:indexPath]; } -- (ASCellNodeBlock)dataController:(ASDataController *)dataController nodeBlockAtIndexPath:(NSIndexPath *)indexPath +- (ASCellNodeBlock)dataController:(ASDataController *)dataController nodeBlockAtIndexPath:(NSIndexPath *)indexPath shouldAsyncLayout:(BOOL *)shouldAsyncLayout { ASDisplayNodeAssertMainThread(); ASCellNodeBlock block = nil; @@ -1904,22 +1932,29 @@ - (ASCellNodeBlock)dataController:(ASDataController *)dataController nodeBlockAt // or it is an error. if (_asyncDataSourceFlags.interop) { cell = [[ASWrapperCellNode alloc] init]; - cell.style.preferredSize = [self _sizeForUIKitCellWithKind:nil atIndexPath:indexPath]; } else { ASDisplayNodeFailAssert(@"ASCollection could not get a node block for item at index path %@: %@, %@. If you are trying to display a UICollectionViewCell, make sure your dataSource conforms to the protocol!", indexPath, cell, block); cell = [[ASCellNode alloc] init]; } } + + // This condition is intended to run for either cells received from the datasource, or created just above. + if (cell.shouldUseUIKitCell) { + *shouldAsyncLayout = NO; + } } // Wrap the node block + BOOL disableRangeController = ASCellLayoutModeIncludes(ASCellLayoutModeDisableRangeController); __weak __typeof__(self) weakSelf = self; return ^{ __typeof__(self) strongSelf = weakSelf; ASCellNode *node = (block ? block() : cell); ASDisplayNodeAssert([node isKindOfClass:[ASCellNode class]], @"ASCollectionNode provided a non-ASCellNode! %@, %@", node, strongSelf); - [node enterHierarchyState:ASHierarchyStateRangeManaged]; - + + if (!disableRangeController) { + [node enterHierarchyState:ASHierarchyStateRangeManaged]; + } if (node.interactionDelegate == nil) { node.interactionDelegate = strongSelf; } @@ -2001,7 +2036,6 @@ - (ASCellNodeBlock)dataController:(ASDataController *)dataController supplementa BOOL useUIKitCell = _asyncDataSourceFlags.interopViewForSupplementaryElement; if (useUIKitCell) { cell = [[ASWrapperCellNode alloc] init]; - cell.style.preferredSize = [self _sizeForUIKitCellWithKind:kind atIndexPath:indexPath]; } else { cell = [[ASCellNode alloc] init]; } @@ -2113,6 +2147,11 @@ - (NSString *)nameForRangeControllerDataSource #pragma mark - ASRangeControllerDelegate +- (BOOL)rangeControllerShouldUpdateRanges:(ASRangeController *)rangeController +{ + return !ASCellLayoutModeIncludes(ASCellLayoutModeDisableRangeController); +} + - (void)rangeController:(ASRangeController *)rangeController updateWithChangeSet:(_ASHierarchyChangeSet *)changeSet updates:(dispatch_block_t)updates { ASDisplayNodeAssertMainThread(); @@ -2144,14 +2183,14 @@ - (void)rangeController:(ASRangeController *)rangeController updateWithChangeSet if (changeSet.includesReloadData) { _superIsPendingDataLoad = YES; updates(); - [super reloadData]; + [self _superReloadData:nil completion:nil]; as_log_debug(ASCollectionLog(), "Did reloadData %@", self.collectionNode); [changeSet executeCompletionHandlerWithFinished:YES]; } else { [_layoutFacilitator collectionViewWillPerformBatchUpdates]; __block NSUInteger numberOfUpdates = 0; - id completion = ^(BOOL finished){ + let completion = ^(BOOL finished) { as_activity_scope(as_activity_create("Handle collection update completion", changeSet.rootActivity, OS_ACTIVITY_FLAG_DEFAULT)); as_log_verbose(ASCollectionLog(), "Update animation finished %{public}@", self.collectionNode); // Flush any range changes that happened as part of the update animations ending. @@ -2160,39 +2199,52 @@ - (void)rangeController:(ASRangeController *)rangeController updateWithChangeSet [changeSet executeCompletionHandlerWithFinished:finished]; }; - [self _superPerformBatchUpdates:^{ - updates(); + BOOL shouldReloadData = ASCellLayoutModeIncludes(ASCellLayoutModeAlwaysReloadData); + // TODO: Consider adding !changeSet.isEmpty as a check to also disable shouldReloadData. + if (ASCellLayoutModeIncludes(ASCellLayoutModeAlwaysBatchUpdateSectionReload) && + [changeSet sectionChangesOfType:_ASHierarchyChangeTypeReload].count > 0) { + shouldReloadData = NO; + } - for (_ASHierarchyItemChange *change in [changeSet itemChangesOfType:_ASHierarchyChangeTypeReload]) { - [super reloadItemsAtIndexPaths:change.indexPaths]; - numberOfUpdates++; - } - - for (_ASHierarchySectionChange *change in [changeSet sectionChangesOfType:_ASHierarchyChangeTypeReload]) { - [super reloadSections:change.indexSet]; - numberOfUpdates++; - } - - for (_ASHierarchyItemChange *change in [changeSet itemChangesOfType:_ASHierarchyChangeTypeOriginalDelete]) { - [super deleteItemsAtIndexPaths:change.indexPaths]; - numberOfUpdates++; - } - - for (_ASHierarchySectionChange *change in [changeSet sectionChangesOfType:_ASHierarchyChangeTypeOriginalDelete]) { - [super deleteSections:change.indexSet]; - numberOfUpdates++; - } - - for (_ASHierarchySectionChange *change in [changeSet sectionChangesOfType:_ASHierarchyChangeTypeOriginalInsert]) { - [super insertSections:change.indexSet]; - numberOfUpdates++; - } - - for (_ASHierarchyItemChange *change in [changeSet itemChangesOfType:_ASHierarchyChangeTypeOriginalInsert]) { - [super insertItemsAtIndexPaths:change.indexPaths]; - numberOfUpdates++; - } - } completion:completion]; + if (shouldReloadData) { + // When doing a reloadData, the insert / delete calls are not necessary. + // Calling updates() is enough, as it commits .pendingMap to .visibleMap. + [self _superReloadData:updates completion:completion]; + } else { + [self _superPerformBatchUpdates:^{ + updates(); + + for (_ASHierarchyItemChange *change in [changeSet itemChangesOfType:_ASHierarchyChangeTypeReload]) { + [super reloadItemsAtIndexPaths:change.indexPaths]; + numberOfUpdates++; + } + + for (_ASHierarchySectionChange *change in [changeSet sectionChangesOfType:_ASHierarchyChangeTypeReload]) { + [super reloadSections:change.indexSet]; + numberOfUpdates++; + } + + for (_ASHierarchyItemChange *change in [changeSet itemChangesOfType:_ASHierarchyChangeTypeOriginalDelete]) { + [super deleteItemsAtIndexPaths:change.indexPaths]; + numberOfUpdates++; + } + + for (_ASHierarchySectionChange *change in [changeSet sectionChangesOfType:_ASHierarchyChangeTypeOriginalDelete]) { + [super deleteSections:change.indexSet]; + numberOfUpdates++; + } + + for (_ASHierarchySectionChange *change in [changeSet sectionChangesOfType:_ASHierarchyChangeTypeOriginalInsert]) { + [super insertSections:change.indexSet]; + numberOfUpdates++; + } + + for (_ASHierarchyItemChange *change in [changeSet itemChangesOfType:_ASHierarchyChangeTypeOriginalInsert]) { + [super insertItemsAtIndexPaths:change.indexPaths]; + numberOfUpdates++; + } + } completion:completion]; + } as_log_debug(ASCollectionLog(), "Completed batch update %{public}@", self.collectionNode); diff --git a/Source/ASCollectionViewProtocols.h b/Source/ASCollectionViewProtocols.h index a33e433fa..16a547d7d 100644 --- a/Source/ASCollectionViewProtocols.h +++ b/Source/ASCollectionViewProtocols.h @@ -10,6 +10,44 @@ #import #import +typedef NS_OPTIONS(NSUInteger, ASCellLayoutMode) { + /** + * No options set. If cell layout mode is set to ASCellLayoutModeNone, the default values for + * each flag listed below is used. + */ + ASCellLayoutModeNone = 0, + /** + * If ASCellLayoutModeAlwaysSync is enabled it will cause the ASDataController to wait on the + * background queue, and this ensures that any new / changed cells are in the hierarchy by the + * very next CATransaction / frame draw. + * + * Note: Sync & Async flags force the behavior to be always one or the other, regardless of the + * items. Default: If neither ASCellLayoutModeAlwaysSync or ASCellLayoutModeAlwaysAsync is set, + * default behavior is synchronous when there are 0 or 1 ASCellNodes in the data source, and + * asynchronous when there are 2 or more. + */ + ASCellLayoutModeAlwaysSync = 1 << 1, // Default OFF + ASCellLayoutModeAlwaysAsync = 1 << 2, // Default OFF + ASCellLayoutModeForceIfNeeded = 1 << 3, // Deprecated, default OFF. + ASCellLayoutModeAlwaysPassthroughDelegate = 1 << 4, // Deprecated, default ON. + /** Instead of using performBatchUpdates: prefer using reloadData for changes for collection view */ + ASCellLayoutModeAlwaysReloadData = 1 << 5, // Default OFF + /** If flag is enabled nodes are *not* gonna be range managed. */ + ASCellLayoutModeDisableRangeController = 1 << 6, // Default OFF + ASCellLayoutModeAlwaysLazy = 1 << 7, // Deprecated, default OFF. + /** + * Defines if the node creation should happen serialized and not in parallel within the + * data controller + */ + ASCellLayoutModeSerializeNodeCreation = 1 << 8, // Default OFF + /** + * When set, the performBatchUpdates: API (including animation) is used when handling Section + * Reload operations. This is useful only when ASCellLayoutModeAlwaysReloadData is enabled and + * cell height animations are desired. + */ + ASCellLayoutModeAlwaysBatchUpdateSectionReload = 1 << 9 // Default OFF +}; + NS_ASSUME_NONNULL_BEGIN /** diff --git a/Source/ASSectionController.h b/Source/ASSectionController.h index de26770ce..5d48bbef1 100644 --- a/Source/ASSectionController.h +++ b/Source/ASSectionController.h @@ -35,6 +35,19 @@ NS_ASSUME_NONNULL_BEGIN */ - (ASCellNodeBlock)nodeBlockForItemAtIndex:(NSInteger)index; +/** + * Similar to -collectionView:cellForItemAtIndexPath:. + * + * Note: only called if nodeBlockForItemAtIndex: returns nil. + * + * @param index The index of the item. + * + * @return A node to display for the given item. This will be called on the main thread and should + * not implement reuse (it will be called once per item). Unlike UICollectionView's version, + * this method is not called when the item is about to display. + */ +- (ASCellNode *)nodeForItemAtIndex:(NSInteger)index; + @optional /** diff --git a/Source/ASSupplementaryNodeSource.h b/Source/ASSupplementaryNodeSource.h index 8f1bfcfa0..3d63a6900 100644 --- a/Source/ASSupplementaryNodeSource.h +++ b/Source/ASSupplementaryNodeSource.h @@ -25,6 +25,14 @@ NS_ASSUME_NONNULL_BEGIN */ - (ASCellNodeBlock)nodeBlockForSupplementaryElementOfKind:(NSString *)elementKind atIndex:(NSInteger)index; +/** + * Asks the controller to provide a node to display for the given supplementary element. + * + * @param kind The kind of supplementary element. + * @param index The index of the item. + */ +- (ASCellNode *)nodeForSupplementaryElementOfKind:(NSString *)kind atIndex:(NSInteger)index; + @optional /** diff --git a/Source/ASTableView.mm b/Source/ASTableView.mm index 99c1df82a..36edc5f6c 100644 --- a/Source/ASTableView.mm +++ b/Source/ASTableView.mm @@ -1519,6 +1519,11 @@ - (NSString *)nameForRangeControllerDataSource #pragma mark - ASRangeControllerDelegate +- (BOOL)rangeControllerShouldUpdateRanges:(ASRangeController *)rangeController +{ + return YES; +} + - (void)rangeController:(ASRangeController *)rangeController updateWithChangeSet:(_ASHierarchyChangeSet *)changeSet updates:(dispatch_block_t)updates { ASDisplayNodeAssertMainThread(); @@ -1666,13 +1671,43 @@ - (void)rangeController:(ASRangeController *)rangeController updateWithChangeSet #pragma mark - ASDataControllerSource +- (BOOL)dataController:(ASDataController *)dataController shouldEagerlyLayoutNode:(ASCellNode *)node +{ + return YES; +} + +- (BOOL)dataControllerShouldSerializeNodeCreation:(ASDataController *)dataController +{ + return NO; +} + +- (BOOL)dataController:(ASDataController *)dataController shouldSynchronouslyProcessChangeSet:(_ASHierarchyChangeSet *)changeSet +{ + // For more details on this method, see the comment in the ASCollectionView implementation. + if (changeSet.countForAsyncLayout < 2) { + return YES; + } + CGSize contentSize = self.contentSize; + CGSize boundsSize = self.bounds.size; + if (contentSize.height <= boundsSize.height && contentSize.width <= boundsSize.width) { + return YES; + } + return NO; +} + +- (void)dataControllerDidFinishWaiting:(ASDataController *)dataController +{ + // ASCellLayoutMode is not currently supported on ASTableView (see ASCollectionView for details). +} + - (id)dataController:(ASDataController *)dataController nodeModelForItemAtIndexPath:(NSIndexPath *)indexPath { // Not currently supported for tables. Will be added when the collection API stabilizes. return nil; } -- (ASCellNodeBlock)dataController:(ASDataController *)dataController nodeBlockAtIndexPath:(NSIndexPath *)indexPath { +- (ASCellNodeBlock)dataController:(ASDataController *)dataController nodeBlockAtIndexPath:(NSIndexPath *)indexPath shouldAsyncLayout:(BOOL *)shouldAsyncLayout +{ ASCellNodeBlock block = nil; if (_asyncDataSourceFlags.tableNodeNodeBlockForRow) { @@ -1720,6 +1755,8 @@ - (ASCellNodeBlock)dataController:(ASDataController *)dataController nodeBlockAt return ^{ __typeof__(self) strongSelf = weakSelf; ASCellNode *node = (block != nil ? block() : [[ASCellNode alloc] init]); + ASDisplayNodeAssert([node isKindOfClass:[ASCellNode class]], @"ASTableNode provided a non-ASCellNode! %@, %@", node, strongSelf); + [node enterHierarchyState:ASHierarchyStateRangeManaged]; if (node.interactionDelegate == nil) { node.interactionDelegate = strongSelf; diff --git a/Source/Details/ASCollectionInternal.h b/Source/Details/ASCollectionInternal.h index 0cd9e8db8..102356606 100644 --- a/Source/Details/ASCollectionInternal.h +++ b/Source/Details/ASCollectionInternal.h @@ -31,7 +31,7 @@ NS_ASSUME_NONNULL_BEGIN /** * @see ASCollectionNode+Beta.h for full documentation. */ -@property (nonatomic) BOOL usesSynchronousDataLoading; +@property (nonatomic) ASCellLayoutMode cellLayoutMode; /** * Attempt to get the view-layer index path for the item with the given index path. diff --git a/Source/Details/ASDataController.h b/Source/Details/ASDataController.h index fbd48731e..b64c0520c 100644 --- a/Source/Details/ASDataController.h +++ b/Source/Details/ASDataController.h @@ -52,7 +52,7 @@ AS_EXTERN NSString * const ASCollectionInvalidUpdateException; /** Fetch the ASCellNode block for specific index path. This block should return the ASCellNode for the specified index path. */ -- (ASCellNodeBlock)dataController:(ASDataController *)dataController nodeBlockAtIndexPath:(NSIndexPath *)indexPath; +- (ASCellNodeBlock)dataController:(ASDataController *)dataController nodeBlockAtIndexPath:(NSIndexPath *)indexPath shouldAsyncLayout:(BOOL *)shouldAsyncLayout; /** Fetch the number of rows in specific section. @@ -72,6 +72,18 @@ AS_EXTERN NSString * const ASCollectionInvalidUpdateException; - (nullable id)dataController:(ASDataController *)dataController nodeModelForItemAtIndexPath:(NSIndexPath *)indexPath; +/** + * Called just after dispatching ASCellNode allocation and layout to the concurrent background queue. + * In some cases, for example on the first content load for a screen, it may be desirable to call + * -waitUntilAllUpdatesAreProcessed at this point. + * + * Returning YES will cause the ASDataController to wait on the background queue, and this ensures + * that any new / changed cells are in the hierarchy by the very next CATransaction / frame draw. + */ +- (BOOL)dataController:(ASDataController *)dataController shouldSynchronouslyProcessChangeSet:(_ASHierarchyChangeSet *)changeSet; +- (BOOL)dataController:(ASDataController *)dataController shouldEagerlyLayoutNode:(ASCellNode *)node; +- (BOOL)dataControllerShouldSerializeNodeCreation:(ASDataController *)dataController; + @optional /** @@ -222,11 +234,6 @@ AS_EXTERN NSString * const ASCollectionInvalidUpdateException; @property (nonatomic, readonly) ASEventLog *eventLog; #endif -/** - * @see ASCollectionNode+Beta.h for full documentation. - */ -@property (nonatomic) BOOL usesSynchronousDataLoading; - /** @name Data Updating */ - (void)updateWithChangeSet:(_ASHierarchyChangeSet *)changeSet; diff --git a/Source/Details/ASDataController.mm b/Source/Details/ASDataController.mm index 962e6b2b9..4feef1f43 100644 --- a/Source/Details/ASDataController.mm +++ b/Source/Details/ASDataController.mm @@ -156,10 +156,14 @@ - (void)_allocateNodesFromElements:(NSArray *)elements { as_activity_create_for_scope("Data controller batch"); - // TODO: Should we use USER_INITIATED here since the user is probably waiting? - dispatch_queue_t queue = dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0); - ASDispatchApply(nodeCount, queue, 0, ^(size_t i) { - if (!weakDataSource) { + dispatch_queue_t queue = dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0); + NSUInteger threadCount = 0; + if ([_dataSource dataControllerShouldSerializeNodeCreation:self]) { + threadCount = 1; + } + ASDispatchApply(nodeCount, queue, threadCount, ^(size_t i) { + __strong id strongDataSource = weakDataSource; + if (strongDataSource == nil) { return; } @@ -181,6 +185,10 @@ - (void)_allocateNodesFromElements:(NSArray *)elements */ - (void)_layoutNode:(ASCellNode *)node withConstrainedSize:(ASSizeRange)constrainedSize { + if (![_dataSource dataController:self shouldEagerlyLayoutNode:node]) { + return; + } + ASDisplayNodeAssert(ASSizeRangeHasSignificantArea(constrainedSize), @"Attempt to layout cell node with invalid size range %@", NSStringFromASSizeRange(constrainedSize)); CGRect frame = CGRectZero; @@ -368,6 +376,7 @@ - (void)_insertElementsIntoMap:(ASMutableElementMap *)map LOG(@"Populating elements of kind: %@, for index paths: %@", kind, indexPaths); id dataSource = self.dataSource; id node = self.node; + BOOL shouldAsyncLayout = YES; for (NSIndexPath *indexPath in indexPaths) { ASCellNodeBlock nodeBlock; id nodeModel; @@ -389,13 +398,12 @@ - (void)_insertElementsIntoMap:(ASMutableElementMap *)map } } if (nodeBlock == nil) { - nodeBlock = [dataSource dataController:self nodeBlockAtIndexPath:indexPath]; + nodeBlock = [dataSource dataController:self nodeBlockAtIndexPath:indexPath shouldAsyncLayout:&shouldAsyncLayout]; } } else { - BOOL shouldAsyncLayout = YES; nodeBlock = [dataSource dataController:self supplementaryNodeBlockOfKind:kind atIndexPath:indexPath shouldAsyncLayout:&shouldAsyncLayout]; } - + ASSizeRange constrainedSize = ASSizeRangeUnconstrained; if (shouldFetchSizeRanges) { constrainedSize = [self constrainedSizeForNodeOfKind:kind atIndexPath:indexPath]; @@ -408,6 +416,7 @@ - (void)_insertElementsIntoMap:(ASMutableElementMap *)map owningNode:node traitCollection:traitCollection]; [map insertElement:element atIndexPath:indexPath]; + changeSet.countForAsyncLayout += (shouldAsyncLayout ? 1 : 0); } } @@ -666,8 +675,15 @@ - (void)updateWithChangeSet:(_ASHierarchyChangeSet *)changeSet }]; --_editingTransactionGroupCount; }); - - if (_usesSynchronousDataLoading) { + + // We've now dispatched node allocation and layout to a concurrent background queue. + // In some cases, it's advantageous to prevent the main thread from returning, to ensure the next + // frame displayed to the user has the view updates in place. Doing this does slightly reduce + // total latency, by donating the main thread's priority to the background threads. As such, the + // two cases where it makes sense to block: + // 1. There is very little work to be performed in the background (UIKit passthrough) + // 2. There is a higher priority on display latency than smoothness, e.g. app startup. + if ([_dataSource dataController:self shouldSynchronouslyProcessChangeSet:changeSet]) { [self waitUntilAllUpdatesAreProcessed]; } } @@ -869,7 +885,12 @@ - (void)_relayoutAllNodes _visibleMap = _pendingMap; for (ASCollectionElement *element in _visibleMap) { + // Ignore this element if it is no longer in the latest data. It is still recognized in the UIKit world but will be deleted soon. NSIndexPath *indexPathInPendingMap = [_pendingMap indexPathForElement:element]; + if (indexPathInPendingMap == nil) { + continue; + } + NSString *kind = element.supplementaryElementKind ?: ASDataControllerRowNodeKind; ASSizeRange newConstrainedSize = [self constrainedSizeForNodeOfKind:kind atIndexPath:indexPathInPendingMap]; diff --git a/Source/Details/ASRangeController.h b/Source/Details/ASRangeController.h index 7b062c43e..1ce295259 100644 --- a/Source/Details/ASRangeController.h +++ b/Source/Details/ASRangeController.h @@ -160,6 +160,8 @@ AS_SUBCLASSING_RESTRICTED */ - (void)rangeController:(ASRangeController *)rangeController updateWithChangeSet:(_ASHierarchyChangeSet *)changeSet updates:(dispatch_block_t)updates; +- (BOOL)rangeControllerShouldUpdateRanges:(ASRangeController *)rangeController; + @end @interface ASRangeController (ASRangeControllerUpdateRangeProtocol) diff --git a/Source/Details/ASRangeController.mm b/Source/Details/ASRangeController.mm index 9c8b7313c..5b135f6ef 100644 --- a/Source/Details/ASRangeController.mm +++ b/Source/Details/ASRangeController.mm @@ -208,7 +208,11 @@ - (void)_updateVisibleNodeIndexPaths if (!_layoutController || !_dataSource) { return; } - + + if (![_delegate rangeControllerShouldUpdateRanges:self]) { + return; + } + #if AS_RANGECONTROLLER_LOG_UPDATE_FREQ _updateCountThisFrame += 1; #endif diff --git a/Source/IGListAdapter+AsyncDisplayKit.mm b/Source/IGListAdapter+AsyncDisplayKit.mm index ec085da68..c3e81d3e3 100644 --- a/Source/IGListAdapter+AsyncDisplayKit.mm +++ b/Source/IGListAdapter+AsyncDisplayKit.mm @@ -31,7 +31,7 @@ - (void)setASDKCollectionNode:(ASCollectionNode *)collectionNode } // Make a data source and retain it. - dataSource = [[ASIGListAdapterBasedDataSource alloc] initWithListAdapter:self]; + dataSource = [[ASIGListAdapterBasedDataSource alloc] initWithListAdapter:self collectionDelegate:collectionNode.delegate]; objc_setAssociatedObject(self, _cmd, dataSource, OBJC_ASSOCIATION_RETAIN_NONATOMIC); // Attach the data source to the collection node. diff --git a/Source/Private/ASIGListAdapterBasedDataSource.h b/Source/Private/ASIGListAdapterBasedDataSource.h index 44380bc07..5be2185f2 100644 --- a/Source/Private/ASIGListAdapterBasedDataSource.h +++ b/Source/Private/ASIGListAdapterBasedDataSource.h @@ -20,7 +20,7 @@ NS_ASSUME_NONNULL_BEGIN AS_SUBCLASSING_RESTRICTED @interface ASIGListAdapterBasedDataSource : NSObject -- (instancetype)initWithListAdapter:(IGListAdapter *)listAdapter; +- (instancetype)initWithListAdapter:(IGListAdapter *)listAdapter collectionDelegate:(nullable id)collectionDelegate; @end diff --git a/Source/Private/ASIGListAdapterBasedDataSource.mm b/Source/Private/ASIGListAdapterBasedDataSource.mm index 1bbf65c89..ade87eb58 100644 --- a/Source/Private/ASIGListAdapterBasedDataSource.mm +++ b/Source/Private/ASIGListAdapterBasedDataSource.mm @@ -38,6 +38,7 @@ @interface ASIGListAdapterBasedDataSource () @property (nonatomic, weak, readonly) IGListAdapter *listAdapter; @property (nonatomic, readonly) id delegate; @property (nonatomic, readonly) id dataSource; +@property (nonatomic, weak, readonly) id collectionDelegate; /** * The section controller that we will forward beginBatchFetchWithContext: to. @@ -52,7 +53,7 @@ @interface ASIGListAdapterBasedDataSource () @implementation ASIGListAdapterBasedDataSource -- (instancetype)initWithListAdapter:(IGListAdapter *)listAdapter +- (instancetype)initWithListAdapter:(IGListAdapter *)listAdapter collectionDelegate:(nullable id)collectionDelegate { if (self = [super init]) { #if IG_LIST_COLLECTION_VIEW @@ -63,6 +64,7 @@ - (instancetype)initWithListAdapter:(IGListAdapter *)listAdapter ASDisplayNodeAssert([listAdapter conformsToProtocol:@protocol(UICollectionViewDataSource)], @"Expected IGListAdapter to conform to UICollectionViewDataSource."); ASDisplayNodeAssert([listAdapter conformsToProtocol:@protocol(UICollectionViewDelegateFlowLayout)], @"Expected IGListAdapter to conform to UICollectionViewDelegateFlowLayout."); _listAdapter = listAdapter; + _collectionDelegate = collectionDelegate; } return self; } @@ -114,6 +116,10 @@ - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView - (BOOL)shouldBatchFetchForCollectionNode:(ASCollectionNode *)collectionNode { + if ([_collectionDelegate respondsToSelector:@selector(shouldBatchFetchForCollectionNode:)]) { + return [_collectionDelegate shouldBatchFetchForCollectionNode:collectionNode]; + } + NSInteger sectionCount = [self numberOfSectionsInCollectionNode:collectionNode]; if (sectionCount == 0) { return NO; @@ -131,6 +137,11 @@ - (BOOL)shouldBatchFetchForCollectionNode:(ASCollectionNode *)collectionNode - (void)collectionNode:(ASCollectionNode *)collectionNode willBeginBatchFetchWithContext:(ASBatchContext *)context { + if ([_collectionDelegate respondsToSelector:@selector(collectionNode:willBeginBatchFetchWithContext:)]) { + [_collectionDelegate collectionNode:collectionNode willBeginBatchFetchWithContext:context]; + return; + } + ASIGSectionController *ctrl = self.sectionControllerForBatchFetching; self.sectionControllerForBatchFetching = nil; [ctrl beginBatchFetchWithContext:context]; @@ -207,6 +218,11 @@ - (ASCellNodeBlock)collectionNode:(ASCollectionNode *)collectionNode nodeBlockFo return [[self sectionControllerForSection:indexPath.section] nodeBlockForItemAtIndex:indexPath.item]; } +- (ASCellNode *)collectionNode:(ASCollectionNode *)collectionNode nodeForItemAtIndexPath:(NSIndexPath *)indexPath +{ + return [[self sectionControllerForSection:indexPath.section] nodeForItemAtIndex:indexPath.item]; +} + - (ASSizeRange)collectionNode:(ASCollectionNode *)collectionNode constrainedSizeForItemAtIndexPath:(NSIndexPath *)indexPath { ASIGSectionController *ctrl = [self sectionControllerForSection:indexPath.section]; @@ -222,6 +238,11 @@ - (ASCellNodeBlock)collectionNode:(ASCollectionNode *)collectionNode nodeBlockFo return [[self supplementaryElementSourceForSection:indexPath.section] nodeBlockForSupplementaryElementOfKind:kind atIndex:indexPath.item]; } +- (ASCellNode *)collectionNode:(ASCollectionNode *)collectionNode nodeForSupplementaryElementOfKind:(NSString *)kind atIndexPath:(NSIndexPath *)indexPath +{ + return [[self supplementaryElementSourceForSection:indexPath.section] nodeForSupplementaryElementOfKind:kind atIndex:indexPath.item]; +} + - (NSArray *)collectionNode:(ASCollectionNode *)collectionNode supplementaryElementKindsInSection:(NSInteger)section { return [[self supplementaryElementSourceForSection:section] supportedElementKinds]; diff --git a/Source/Private/_ASHierarchyChangeSet.h b/Source/Private/_ASHierarchyChangeSet.h index c617c58fd..66f5966e6 100644 --- a/Source/Private/_ASHierarchyChangeSet.h +++ b/Source/Private/_ASHierarchyChangeSet.h @@ -113,6 +113,9 @@ NSString *NSStringFromASHierarchyChangeType(_ASHierarchyChangeType changeType); /// Indicates whether the change set is empty, that is it includes neither reload data nor per item or section changes. @property (nonatomic, readonly) BOOL isEmpty; +/// The count of new ASCellNodes that can undergo async layout calculation. May be zero if all UIKit passthrough cells. +@property (nonatomic, assign) NSUInteger countForAsyncLayout; + /// The top-level activity for this update. @property (nonatomic, OS_ACTIVITY_NULLABLE) os_activity_t rootActivity; diff --git a/Source/Private/_ASHierarchyChangeSet.mm b/Source/Private/_ASHierarchyChangeSet.mm index 5ff483817..72e39c6d7 100644 --- a/Source/Private/_ASHierarchyChangeSet.mm +++ b/Source/Private/_ASHierarchyChangeSet.mm @@ -119,6 +119,7 @@ @interface _ASHierarchyChangeSet () @end @implementation _ASHierarchyChangeSet { + NSUInteger _countForAsyncLayout; std::vector _oldItemCounts; std::vector _newItemCounts; void (^_completionHandler)(BOOL finished); @@ -127,6 +128,7 @@ @implementation _ASHierarchyChangeSet { @synthesize reverseSectionMapping = _reverseSectionMapping; @synthesize itemMappings = _itemMappings; @synthesize reverseItemMappings = _reverseItemMappings; +@synthesize countForAsyncLayout = _countForAsyncLayout; - (instancetype)init { diff --git a/Tests/ASCollectionViewTests.mm b/Tests/ASCollectionViewTests.mm index 92fb349e8..c4fc4c914 100644 --- a/Tests/ASCollectionViewTests.mm +++ b/Tests/ASCollectionViewTests.mm @@ -163,6 +163,7 @@ - (instancetype)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibB @interface ASCollectionView (InternalTesting) - (NSArray *)dataController:(ASDataController *)dataController supplementaryNodeKindsInSections:(NSIndexSet *)sections; +- (BOOL)dataController:(ASDataController *)dataController shouldSynchronouslyProcessChangeSet:(_ASHierarchyChangeSet *)changeSet; @end @@ -1045,25 +1046,43 @@ - (void)_primitiveBatchFetchingFillTestAnimated:(BOOL)animated visible:(BOOL)vis } - (void)testInitialRangeBounds +{ + [self testInitialRangeBoundsWithCellLayoutMode:ASCellLayoutModeNone]; +} + +- (void)testInitialRangeBoundsCellLayoutModeAlwaysAsync +{ + [self testInitialRangeBoundsWithCellLayoutMode:ASCellLayoutModeAlwaysAsync]; +} + +- (void)testInitialRangeBoundsWithCellLayoutMode:(ASCellLayoutMode)cellLayoutMode { UIWindow *window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; ASCollectionViewTestController *testController = [[ASCollectionViewTestController alloc] initWithNibName:nil bundle:nil]; ASCollectionNode *cn = testController.collectionNode; + cn.cellLayoutMode = cellLayoutMode; [cn setTuningParameters:{ .leadingBufferScreenfuls = 2, .trailingBufferScreenfuls = 0 } forRangeMode:ASLayoutRangeModeMinimum rangeType:ASLayoutRangeTypePreload]; window.rootViewController = testController; + [testController.collectionNode.collectionViewLayout invalidateLayout]; + [testController.collectionNode.collectionViewLayout prepareLayout]; + [window makeKeyAndVisible]; // Trigger the initial reload to start [window layoutIfNeeded]; - // Test the APIs that monitor ASCollectionNode update handling - XCTAssertTrue(cn.isProcessingUpdates, @"ASCollectionNode should still be processing updates after initial layoutIfNeeded call (reloadData)"); - [cn onDidFinishProcessingUpdates:^{ - XCTAssertTrue(!cn.isProcessingUpdates, @"ASCollectionNode should no longer be processing updates inside -onDidFinishProcessingUpdates: block"); - }]; + // Test the APIs that monitor ASCollectionNode update handling if collection node should + // layout asynchronously + if (![cn.view dataController:nil shouldSynchronouslyProcessChangeSet:nil]) { + XCTAssertTrue(cn.isProcessingUpdates, @"ASCollectionNode should still be processing updates after initial layoutIfNeeded call (reloadData)"); - // Wait for ASDK reload to finish - [cn waitUntilAllUpdatesAreProcessed]; + [cn onDidFinishProcessingUpdates:^{ + XCTAssertTrue(!cn.isProcessingUpdates, @"ASCollectionNode should no longer be processing updates inside -onDidFinishProcessingUpdates: block"); + }]; + + // Wait for ASDK reload to finish + [cn waitUntilAllUpdatesAreProcessed]; + } XCTAssertTrue(!cn.isProcessingUpdates, @"ASCollectionNode should no longer be processing updates after -wait call");