From a41cbb48b32dc56777d62b617a60c3c355f15258 Mon Sep 17 00:00:00 2001 From: appleguy Date: Tue, 13 Mar 2018 01:03:18 -0700 Subject: [PATCH] [ASWrapperCellNode] Introduce a new class allowing more control of UIKit passthrough cells. (#797) * - [ASWrapperCellNode] Introduce a new class allowing more control of UIKit passthrough cells. A few minor fixes to Collections behavior as well, including a new isSynchronized API. The difference from processingUpdates is that after Synchronized, all animations have also completed (or runloop turn if animations disabled, so .collectionViewLayout can be relied on being fully in sync). More upstreaming to come after this can land... * Fix -[ASDataController clearData] to take no action before initial data loading. * Empty commit to kick CI * Spacing change to kick CI (since an empty commit doesn't work...) * Tweak ASDataController changes to handle an edge case in _editingTransactionQueueCount management. * Avoid excess cyclic calls to onDidFinishProcessingUpdates: by avoiding ASMainSerialQueue. * Reverting my initial change as it wasn't the right approach, following the real fix before this. --- CHANGELOG.md | 1 + Source/ASCellNode.h | 2 +- Source/ASCellNode.mm | 19 +++ Source/ASCollectionNode+Beta.h | 13 ++ Source/ASCollectionNode.h | 26 +++- Source/ASCollectionNode.mm | 128 ++++++++++++++++++- Source/ASCollectionView.h | 8 +- Source/ASCollectionView.mm | 180 ++++++++++++++++----------- Source/ASTableNode.h | 2 +- Source/ASTableNode.mm | 5 +- Source/ASTableView.h | 2 +- Source/ASTableView.mm | 2 +- Source/Details/ASDataController.h | 15 ++- Source/Details/ASDataController.mm | 101 ++++++++++++--- Source/Private/ASCellNode+Internal.h | 16 ++- 15 files changed, 415 insertions(+), 105 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2311fb0e..e27918bad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ * Add your own contributions to the next release on the line below this with your name. - [tvOS] Fixes errors when building against tvOS SDK [Alex Hill](https://github.com/alexhillc) [#728](https://github.com/TextureGroup/Texture/pull/728) - [ASDisplayNode] Add unit tests for layout z-order changes (with an open issue to fix). +- [ASWrapperCellNode] Introduce a new class allowing more control of UIKit passthrough cells. - [ASDisplayNode] Consolidate main thread initialization and allow apps to invoke it manually instead of +load. - [ASRunloopQueue] Introduce new runloop queue(ASCATransactionQueue) to coalesce Interface state update calls for view controller transitions. - [ASRangeController] Fix stability of "minimum" rangeMode if the app has more than one layout before scrolling. diff --git a/Source/ASCellNode.h b/Source/ASCellNode.h index b846e549d..87f72cf5c 100644 --- a/Source/ASCellNode.h +++ b/Source/ASCellNode.h @@ -221,7 +221,7 @@ typedef NS_ENUM(NSUInteger, ASCellNodeVisibilityEvent) { - (instancetype)initWithViewBlock:(ASDisplayNodeViewBlock)viewBlock didLoadBlock:(nullable ASDisplayNodeDidLoadBlock)didLoadBlock __unavailable; -- (void)setLayerBacked:(BOOL)layerBacked AS_UNAVAILABLE("ASCellNode does not support layer-backing"); +- (void)setLayerBacked:(BOOL)layerBacked AS_UNAVAILABLE("ASCellNode does not support layer-backing, although subnodes may be layer-backed."); @end diff --git a/Source/ASCellNode.mm b/Source/ASCellNode.mm index cd3bed447..c93da2db6 100644 --- a/Source/ASCellNode.mm +++ b/Source/ASCellNode.mm @@ -352,6 +352,25 @@ - (BOOL)supportsLayerBacking return NO; } +- (BOOL)shouldUseUIKitCell +{ + return NO; +} + +@end + + +#pragma mark - +#pragma mark ASWrapperCellNode + +// TODO: Consider if other calls, such as willDisplayCell, should be bridged to this class. +@implementation ASWrapperCellNode : ASCellNode + +- (BOOL)shouldUseUIKitCell +{ + return YES; +} + @end diff --git a/Source/ASCollectionNode+Beta.h b/Source/ASCollectionNode+Beta.h index 952227896..2c8163b71 100644 --- a/Source/ASCollectionNode+Beta.h +++ b/Source/ASCollectionNode+Beta.h @@ -57,6 +57,19 @@ NS_ASSUME_NONNULL_BEGIN */ @property (nonatomic, assign) BOOL usesSynchronousDataLoading; +/** + * Returns YES if the ASCollectionNode contents are completely synchronized with the underlying collection-view layout. + */ +@property (nonatomic, readonly, getter=isSynchronized) BOOL synchronized; + +/** + * Schedules a block to be performed (on the main thread) as soon as the completion block is called + * on performBatchUpdates:. + * + * When isSynchronized == YES, the block is run block immediately (before the method returns). + */ +- (void)onDidFinishSynchronizing:(void (^)(void))didFinishSynchronizing; + - (instancetype)initWithFrame:(CGRect)frame collectionViewLayout:(UICollectionViewLayout *)layout layoutFacilitator:(nullable id)layoutFacilitator; - (instancetype)initWithLayoutDelegate:(id)layoutDelegate layoutFacilitator:(nullable id)layoutFacilitator; diff --git a/Source/ASCollectionNode.h b/Source/ASCollectionNode.h index 4a56217cf..b25372efc 100644 --- a/Source/ASCollectionNode.h +++ b/Source/ASCollectionNode.h @@ -109,6 +109,30 @@ NS_ASSUME_NONNULL_BEGIN */ @property (nonatomic, assign) BOOL allowsMultipleSelection; +/** + * A Boolean value that determines whether bouncing always occurs when vertical scrolling reaches the end of the content. + * The default value of this property is NO. + */ +@property (nonatomic, assign) BOOL alwaysBounceVertical; + +/** + * A Boolean value that determines whether bouncing always occurs when horizontal scrolling reaches the end of the content view. + * The default value of this property is NO. + */ +@property (nonatomic, assign) BOOL alwaysBounceHorizontal; + +/** + * A Boolean value that controls whether the vertical scroll indicator is visible. + * The default value of this property is YES. + */ +@property (nonatomic, assign) BOOL showsVerticalScrollIndicator; + +/** + * A Boolean value that controls whether the horizontal scroll indicator is visible. + * The default value of this property is NO. + */ +@property (nonatomic, assign) BOOL showsHorizontalScrollIndicator; + /** * The layout used to organize the node's items. * @@ -284,7 +308,7 @@ NS_ASSUME_NONNULL_BEGIN * * Calling -waitUntilAllUpdatesAreProcessed is one way to flush any pending update completion blocks. */ -- (void)onDidFinishProcessingUpdates:(nullable void (^)(void))didFinishProcessingUpdates; +- (void)onDidFinishProcessingUpdates:(void (^)(void))didFinishProcessingUpdates; /** * Blocks execution of the main thread until all section and item updates are committed to the view. This method must be called from the main thread. diff --git a/Source/ASCollectionNode.mm b/Source/ASCollectionNode.mm index d0cc5f208..a7f9e9bf7 100644 --- a/Source/ASCollectionNode.mm +++ b/Source/ASCollectionNode.mm @@ -49,9 +49,13 @@ @interface _ASCollectionPendingState : NSObject @property (nonatomic, assign) BOOL usesSynchronousDataLoading; @property (nonatomic, assign) CGFloat leadingScreensForBatching; @property (weak, nonatomic) id layoutInspector; +@property (nonatomic, assign) BOOL alwaysBounceVertical; +@property (nonatomic, assign) BOOL alwaysBounceHorizontal; @property (nonatomic, assign) UIEdgeInsets contentInset; @property (nonatomic, assign) CGPoint contentOffset; @property (nonatomic, assign) BOOL animatesContentOffset; +@property (nonatomic, assign) BOOL showsVerticalScrollIndicator; +@property (nonatomic, assign) BOOL showsHorizontalScrollIndicator; @end @implementation _ASCollectionPendingState @@ -203,13 +207,28 @@ - (void)didLoad view.allowsMultipleSelection = pendingState.allowsMultipleSelection; view.usesSynchronousDataLoading = pendingState.usesSynchronousDataLoading; view.layoutInspector = pendingState.layoutInspector; - view.contentInset = pendingState.contentInset; - + + // Only apply these flags if they're enabled; the view might come with them turned on. + if (pendingState.alwaysBounceVertical) { + view.alwaysBounceVertical = YES; + } + if (pendingState.alwaysBounceHorizontal) { + view.alwaysBounceHorizontal = YES; + } + + UIEdgeInsets contentInset = pendingState.contentInset; + if (!UIEdgeInsetsEqualToEdgeInsets(contentInset, UIEdgeInsetsZero)) { + view.contentInset = contentInset; + } + + CGPoint contentOffset = pendingState.contentOffset; + if (!CGPointEqualToPoint(contentOffset, CGPointZero)) { + [view setContentOffset:contentOffset animated:pendingState.animatesContentOffset]; + } + if (pendingState.rangeMode != ASLayoutRangeModeUnspecified) { [view.rangeController updateCurrentRangeWithMode:pendingState.rangeMode]; } - - [view setContentOffset:pendingState.contentOffset animated:pendingState.animatesContentOffset]; // Don't need to set collectionViewLayout to the view as the layout was already used to init the view in view block. } @@ -235,10 +254,11 @@ - (void)interfaceStateDidChange:(ASInterfaceState)newState fromState:(ASInterfac - (void)didEnterPreloadState { [super didEnterPreloadState]; + // ASCollectionNode is often nested inside of other collections. In this case, ASHierarchyState's RangeManaged bit will be set. // Intentionally allocate the view here and trigger a layout pass on it, which in turn will trigger the intial data load. // We can get rid of this call later when ASDataController, ASRangeController and ASCollectionLayout can operate without the view. // TODO (ASCL) If this node supports async layout, kick off the initial data load without allocating the view - if (CGRectEqualToRect(self.bounds, CGRectZero) == NO) { + if (ASHierarchyStateIncludesRangeManaged(self.hierarchyState) && CGRectEqualToRect(self.bounds, CGRectZero) == NO) { [[self view] layoutIfNeeded]; } } @@ -435,6 +455,82 @@ - (BOOL)allowsMultipleSelection } } +- (void)setAlwaysBounceVertical:(BOOL)alwaysBounceVertical +{ + if ([self pendingState]) { + _pendingState.alwaysBounceVertical = alwaysBounceVertical; + } else { + ASDisplayNodeAssert([self isNodeLoaded], @"ASCollectionNode should be loaded if pendingState doesn't exist"); + self.view.alwaysBounceVertical = alwaysBounceVertical; + } +} + +- (BOOL)alwaysBounceVertical +{ + if ([self pendingState]) { + return _pendingState.alwaysBounceVertical; + } else { + return self.view.alwaysBounceVertical; + } +} + +- (void)setAlwaysBounceHorizontal:(BOOL)alwaysBounceHorizontal +{ + if ([self pendingState]) { + _pendingState.alwaysBounceHorizontal = alwaysBounceHorizontal; + } else { + ASDisplayNodeAssert([self isNodeLoaded], @"ASCollectionNode should be loaded if pendingState doesn't exist"); + self.view.alwaysBounceHorizontal = alwaysBounceHorizontal; + } +} + +- (BOOL)alwaysBounceHorizontal +{ + if ([self pendingState]) { + return _pendingState.alwaysBounceHorizontal; + } else { + return self.view.alwaysBounceHorizontal; + } +} + +- (void)setShowsVerticalScrollIndicator:(BOOL)showsVerticalScrollIndicator +{ + if ([self pendingState]) { + _pendingState.showsVerticalScrollIndicator = showsVerticalScrollIndicator; + } else { + ASDisplayNodeAssert([self isNodeLoaded], @"ASCollectionNode should be loaded if pendingState doesn't exist"); + self.view.showsVerticalScrollIndicator = showsVerticalScrollIndicator; + } +} + +- (BOOL)showsVerticalScrollIndicator +{ + if ([self pendingState]) { + return _pendingState.showsVerticalScrollIndicator; + } else { + return self.view.showsVerticalScrollIndicator; + } +} + +- (void)setShowsHorizontalScrollIndicator:(BOOL)showsHorizontalScrollIndicator +{ + if ([self pendingState]) { + _pendingState.showsHorizontalScrollIndicator = showsHorizontalScrollIndicator; + } else { + ASDisplayNodeAssert([self isNodeLoaded], @"ASCollectionNode should be loaded if pendingState doesn't exist"); + self.view.showsHorizontalScrollIndicator = showsHorizontalScrollIndicator; + } +} + +- (BOOL)showsHorizontalScrollIndicator +{ + if ([self pendingState]) { + return _pendingState.showsHorizontalScrollIndicator; + } else { + return self.view.showsHorizontalScrollIndicator; + } +} + - (void)setCollectionViewLayout:(UICollectionViewLayout *)layout { if ([self pendingState]) { @@ -745,8 +841,11 @@ - (BOOL)isProcessingUpdates return (self.nodeLoaded ? [self.view isProcessingUpdates] : NO); } -- (void)onDidFinishProcessingUpdates:(nullable void (^)())completion +- (void)onDidFinishProcessingUpdates:(void (^)())completion { + if (!completion) { + return; + } if (!self.nodeLoaded) { completion(); } else { @@ -754,6 +853,23 @@ - (void)onDidFinishProcessingUpdates:(nullable void (^)())completion } } +- (BOOL)isSynchronized +{ + return (self.nodeLoaded ? [self.view isSynchronized] : YES); +} + +- (void)onDidFinishSynchronizing:(void (^)())completion +{ + if (!completion) { + return; + } + if (!self.nodeLoaded) { + completion(); + } else { + [self.view onDidFinishSynchronizing:completion]; + } +} + - (void)waitUntilAllUpdatesAreProcessed { ASDisplayNodeAssertMainThread(); diff --git a/Source/ASCollectionView.h b/Source/ASCollectionView.h index 9f552e44c..594d88888 100644 --- a/Source/ASCollectionView.h +++ b/Source/ASCollectionView.h @@ -296,9 +296,15 @@ NS_ASSUME_NONNULL_BEGIN * See ASCollectionNode.h for full documentation of these methods. */ @property (nonatomic, readonly) BOOL isProcessingUpdates; -- (void)onDidFinishProcessingUpdates:(nullable void (^)(void))completion; +- (void)onDidFinishProcessingUpdates:(void (^)(void))completion; - (void)waitUntilAllUpdatesAreCommitted ASDISPLAYNODE_DEPRECATED_MSG("Use -[ASCollectionNode waitUntilAllUpdatesAreProcessed] instead."); +/** + * See ASCollectionNode.h for full documentation of these methods. + */ +@property (nonatomic, readonly, getter=isSynchronized) BOOL synchronized; +- (void)onDidFinishSynchronizing:(void (^)(void))completion; + /** * Registers the given kind of supplementary node for use in creating node-backed supplementary views. * diff --git a/Source/ASCollectionView.mm b/Source/ASCollectionView.mm index 451322564..72d8b78d4 100644 --- a/Source/ASCollectionView.mm +++ b/Source/ASCollectionView.mm @@ -374,7 +374,7 @@ - (BOOL)isProcessingUpdates return [_dataController isProcessingUpdates]; } -- (void)onDidFinishProcessingUpdates:(nullable void (^)())completion +- (void)onDidFinishProcessingUpdates:(void (^)())completion { [_dataController onDidFinishProcessingUpdates:completion]; } @@ -391,6 +391,16 @@ - (void)waitUntilAllUpdatesAreCommitted [_dataController waitUntilAllUpdatesAreProcessed]; } +- (BOOL)isSynchronized +{ + return [_dataController isSynchronized]; +} + +- (void)onDidFinishSynchronizing:(void (^)())completion +{ + [_dataController onDidFinishSynchronizing:completion]; +} + - (void)setDataSource:(id)dataSource { // UIKit can internally generate a call to this method upon changing the asyncDataSource; only assert for non-nil. We also allow this when we're doing interop. @@ -477,6 +487,7 @@ - (void)setAsyncDataSource:(id)asyncDataSource if (_layoutInspectorFlags.didChangeCollectionViewDataSource) { [layoutInspector didChangeCollectionViewDataSource:asyncDataSource]; } + [self _asyncDelegateOrDataSourceDidChange]; } - (id)asyncDelegate @@ -558,6 +569,15 @@ - (void)setAsyncDelegate:(id)asyncDelegate if (_layoutInspectorFlags.didChangeCollectionViewDelegate) { [layoutInspector didChangeCollectionViewDelegate:asyncDelegate]; } + [self _asyncDelegateOrDataSourceDidChange]; +} + +- (void)_asyncDelegateOrDataSourceDidChange +{ + ASDisplayNodeAssertMainThread(); + if (_asyncDataSource == nil && _asyncDelegate == nil) { + [_dataController clearData]; + } } - (void)setCollectionViewLayout:(nonnull UICollectionViewLayout *)collectionViewLayout @@ -644,18 +664,21 @@ - (BOOL)zeroContentInsets - (CGSize)sizeForElement:(ASCollectionElement *)element { ASDisplayNodeAssertMainThread(); - if (element == nil) { + ASCellNode *node = element.node; + if (element == nil || node == nil) { return CGSizeZero; } - ASCellNode *node = element.node; BOOL useUIKitCell = node.shouldUseUIKitCell; if (useUIKitCell) { - // 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. - ASDisplayNodeAssert([node.superclass isSubclassOfClass:[ASCellNode class]] == NO, - @"Placeholder cells for UIKit passthrough should be generic ASCellNodes: %@", node); - return node.style.preferredSize; + ASWrapperCellNode *wrapperNode = (ASWrapperCellNode *)node; + 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; + } } else { return [node layoutThatFits:element.constrainedSize].size; } @@ -781,7 +804,11 @@ - (void)invalidateFlowLayoutDelegateMetrics { // For UIKit passthrough cells of either type, re-fetch their sizes from the standard UIKit delegate methods. ASCellNode *node = element.node; if (node.shouldUseUIKitCell) { - NSIndexPath *indexPath = [self indexPathForNode:node]; + 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]; @@ -818,7 +845,7 @@ - (CGSize)_sizeForUIKitCellWithKind:(NSString *)kind atIndexPath:(NSIndexPath *) if (kind == nil) { ASDisplayNodeAssert(_asyncDataSourceFlags.interop, @"This code should not be called except for UIKit passthrough compatibility"); SEL sizeForItem = @selector(collectionView:layout:sizeForItemAtIndexPath:); - if ([_asyncDelegate respondsToSelector:sizeForItem]) { + if (indexPath && [_asyncDelegate respondsToSelector:sizeForItem]) { size = [(id)_asyncDelegate collectionView:self layout:l sizeForItemAtIndexPath:indexPath]; } else { size = ASFlowLayoutDefault(l, itemSize, CGSizeZero); @@ -826,7 +853,7 @@ - (CGSize)_sizeForUIKitCellWithKind:(NSString *)kind atIndexPath:(NSIndexPath *) } else if ([kind isEqualToString:UICollectionElementKindSectionHeader]) { ASDisplayNodeAssert(_asyncDataSourceFlags.interopViewForSupplementaryElement, @"This code should not be called except for UIKit passthrough compatibility"); SEL sizeForHeader = @selector(collectionView:layout:referenceSizeForHeaderInSection:); - if ([_asyncDelegate respondsToSelector:sizeForHeader]) { + if (indexPath && [_asyncDelegate respondsToSelector:sizeForHeader]) { size = [(id)_asyncDelegate collectionView:self layout:l referenceSizeForHeaderInSection:indexPath.section]; } else { size = ASFlowLayoutDefault(l, headerReferenceSize, CGSizeZero); @@ -834,7 +861,7 @@ - (CGSize)_sizeForUIKitCellWithKind:(NSString *)kind atIndexPath:(NSIndexPath *) } else if ([kind isEqualToString:UICollectionElementKindSectionFooter]) { ASDisplayNodeAssert(_asyncDataSourceFlags.interopViewForSupplementaryElement, @"This code should not be called except for UIKit passthrough compatibility"); SEL sizeForFooter = @selector(collectionView:layout:referenceSizeForFooterInSection:); - if ([_asyncDelegate respondsToSelector:sizeForFooter]) { + if (indexPath && [_asyncDelegate respondsToSelector:sizeForFooter]) { size = [(id)_asyncDelegate collectionView:self layout:l referenceSizeForFooterInSection:indexPath.section]; } else { size = ASFlowLayoutDefault(l, footerReferenceSize, CGSizeZero); @@ -1105,9 +1132,12 @@ - (UICollectionReusableView *)collectionView:(UICollectionView *)collectionView UICollectionReusableView *view = nil; ASCollectionElement *element = [_dataController.visibleMap supplementaryElementOfKind:kind atIndexPath:indexPath]; ASCellNode *node = element.node; + ASWrapperCellNode *wrapperNode = (node.shouldUseUIKitCell ? (ASWrapperCellNode *)node : nil); + BOOL shouldDequeueExternally = _asyncDataSourceFlags.interopAlwaysDequeue || (_asyncDataSourceFlags.interopViewForSupplementaryElement && wrapperNode); - BOOL shouldDequeueExternally = _asyncDataSourceFlags.interopViewForSupplementaryElement && (_asyncDataSourceFlags.interopAlwaysDequeue || node.shouldUseUIKitCell); - if (shouldDequeueExternally) { + if (wrapperNode.viewForSupplementaryBlock) { + view = wrapperNode.viewForSupplementaryBlock(wrapperNode); + } else if (shouldDequeueExternally) { // This codepath is used for both IGListKit mode, and app-level UICollectionView interop. view = [(id)_asyncDataSource collectionView:collectionView viewForSupplementaryElementOfKind:kind atIndexPath:indexPath]; } else { @@ -1131,15 +1161,19 @@ - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cell UICollectionViewCell *cell = nil; ASCollectionElement *element = [_dataController.visibleMap elementForItemAtIndexPath:indexPath]; ASCellNode *node = element.node; + ASWrapperCellNode *wrapperNode = (node.shouldUseUIKitCell ? (ASWrapperCellNode *)node : nil); + BOOL shouldDequeueExternally = _asyncDataSourceFlags.interopAlwaysDequeue || (_asyncDataSourceFlags.interop && wrapperNode); - BOOL shouldDequeueExternally = _asyncDataSourceFlags.interopAlwaysDequeue || (_asyncDataSourceFlags.interop && node.shouldUseUIKitCell); - if (shouldDequeueExternally) { + if (wrapperNode.cellForItemBlock) { + cell = wrapperNode.cellForItemBlock(wrapperNode); + } else if (shouldDequeueExternally) { cell = [(id)_asyncDataSource collectionView:collectionView cellForItemAtIndexPath:indexPath]; } else { cell = [self dequeueReusableCellWithReuseIdentifier:kReuseIdentifier forIndexPath:indexPath]; } ASDisplayNodeAssert(element != nil, @"Element should exist. indexPath = %@, collectionDataSource = %@", indexPath, self); + ASDisplayNodeAssert(cell != nil, @"UICollectionViewCell must not be nil. indexPath = %@, collectionDataSource = %@", indexPath, self); if (_ASCollectionViewCell *asCell = ASDynamicCastStrict(cell, _ASCollectionViewCell)) { asCell.element = element; @@ -1828,39 +1862,32 @@ - (ASCellNodeBlock)dataController:(ASDataController *)dataController nodeBlockAt if (_asyncDataSourceFlags.collectionNodeNodeBlockForItem) { GET_COLLECTIONNODE_OR_RETURN(collectionNode, ^{ return [[ASCellNode alloc] init]; }); block = [_asyncDataSource collectionNode:collectionNode nodeBlockForItemAtIndexPath:indexPath]; - } else if (_asyncDataSourceFlags.collectionNodeNodeForItem) { + } + if (!block && !cell && _asyncDataSourceFlags.collectionNodeNodeForItem) { GET_COLLECTIONNODE_OR_RETURN(collectionNode, ^{ return [[ASCellNode alloc] init]; }); cell = [_asyncDataSource collectionNode:collectionNode nodeForItemAtIndexPath:indexPath]; + } #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" - } else if (_asyncDataSourceFlags.collectionViewNodeBlockForItem) { + if (!block && !cell && _asyncDataSourceFlags.collectionViewNodeBlockForItem) { block = [_asyncDataSource collectionView:self nodeBlockForItemAtIndexPath:indexPath]; - } else if (_asyncDataSourceFlags.collectionViewNodeForItem) { + } + if (!block && !cell && _asyncDataSourceFlags.collectionViewNodeForItem) { cell = [_asyncDataSource collectionView:self nodeForItemAtIndexPath:indexPath]; } #pragma clang diagnostic pop - // Handle nil node block or cell - if (cell && [cell isKindOfClass:[ASCellNode class]]) { - block = ^{ - return cell; - }; - } - if (block == nil) { - if (_asyncDataSourceFlags.interop) { - CGSize preferredSize = [self _sizeForUIKitCellWithKind:nil atIndexPath:indexPath]; - block = ^{ - ASCellNode *node = [[ASCellNode alloc] init]; - node.shouldUseUIKitCell = YES; - node.style.preferredSize = preferredSize; - return node; - }; - } 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); - block = ^{ - return [[ASCellNode alloc] init]; - }; + if (cell == nil || ASDynamicCast(cell, ASCellNode) == nil) { + // In this case, either the client is expecting a UIKit passthrough cell to be created automatically, + // 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]; + } } } @@ -1868,17 +1895,18 @@ - (ASCellNodeBlock)dataController:(ASDataController *)dataController nodeBlockAt __weak __typeof__(self) weakSelf = self; return ^{ __typeof__(self) strongSelf = weakSelf; - ASCellNode *node = (block != nil ? block() : [[ASCellNode alloc] init]); + ASCellNode *node = (block ? block() : cell); + ASDisplayNodeAssert([node isKindOfClass:[ASCellNode class]], @"ASCollectionNode provided a non-ASCellNode! %@, %@", node, strongSelf); [node enterHierarchyState:ASHierarchyStateRangeManaged]; + if (node.interactionDelegate == nil) { node.interactionDelegate = strongSelf; } if (strongSelf.inverted) { - node.transform = CATransform3DMakeScale(1, -1, 1) ; + node.transform = CATransform3DMakeScale(1, -1, 1); } return node; }; - return block; } - (NSUInteger)dataController:(ASDataController *)dataController rowsInSection:(NSUInteger)section @@ -1923,45 +1951,50 @@ - (BOOL)dataController:(ASDataController *)dataController presentedSizeForElemen #pragma mark - ASDataControllerSource optional methods -- (ASCellNodeBlock)dataController:(ASDataController *)dataController supplementaryNodeBlockOfKind:(NSString *)kind atIndexPath:(NSIndexPath *)indexPath +- (ASCellNodeBlock)dataController:(ASDataController *)dataController supplementaryNodeBlockOfKind:(NSString *)kind atIndexPath:(NSIndexPath *)indexPath shouldAsyncLayout:(BOOL *)shouldAsyncLayout { ASDisplayNodeAssertMainThread(); - ASCellNodeBlock nodeBlock = nil; - ASCellNode *node = nil; + ASCellNodeBlock block = nil; + ASCellNode *cell = nil; if (_asyncDataSourceFlags.collectionNodeNodeBlockForSupplementaryElement) { GET_COLLECTIONNODE_OR_RETURN(collectionNode, ^{ return [[ASCellNode alloc] init]; }); - nodeBlock = [_asyncDataSource collectionNode:collectionNode nodeBlockForSupplementaryElementOfKind:kind atIndexPath:indexPath]; - } else if (_asyncDataSourceFlags.collectionNodeNodeForSupplementaryElement) { + block = [_asyncDataSource collectionNode:collectionNode nodeBlockForSupplementaryElementOfKind:kind atIndexPath:indexPath]; + } + if (!block && !cell && _asyncDataSourceFlags.collectionNodeNodeForSupplementaryElement) { GET_COLLECTIONNODE_OR_RETURN(collectionNode, ^{ return [[ASCellNode alloc] init]; }); - node = [_asyncDataSource collectionNode:collectionNode nodeForSupplementaryElementOfKind:kind atIndexPath:indexPath]; - } else if (_asyncDataSourceFlags.collectionViewNodeForSupplementaryElement) { + cell = [_asyncDataSource collectionNode:collectionNode nodeForSupplementaryElementOfKind:kind atIndexPath:indexPath]; + } + if (!block && !cell && _asyncDataSourceFlags.collectionViewNodeForSupplementaryElement) { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" - node = [_asyncDataSource collectionView:self nodeForSupplementaryElementOfKind:kind atIndexPath:indexPath]; + cell = [_asyncDataSource collectionView:self nodeForSupplementaryElementOfKind:kind atIndexPath:indexPath]; #pragma clang diagnostic pop } - if (nodeBlock == nil) { - if (node) { - nodeBlock = ^{ return node; }; - } else { + if (block == nil) { + if (cell == nil || ASDynamicCast(cell, ASCellNode) == nil) { // In this case, the app code returned nil for the node and the nodeBlock. - // If the UIKit method is implemented, then we should use it. Otherwise the CGSizeZero default will cause UIKit to not show it. - CGSize preferredSize = CGSizeZero; + // If the UIKit method is implemented, then we should use a passthrough cell. + // Otherwise the CGSizeZero default will cause UIKit to not show it (so this isn't an error like the cellForItem case). + BOOL useUIKitCell = _asyncDataSourceFlags.interopViewForSupplementaryElement; if (useUIKitCell) { - preferredSize = [self _sizeForUIKitCellWithKind:kind atIndexPath:indexPath]; + cell = [[ASWrapperCellNode alloc] init]; + cell.style.preferredSize = [self _sizeForUIKitCellWithKind:kind atIndexPath:indexPath]; + } else { + cell = [[ASCellNode alloc] init]; } - nodeBlock = ^{ - ASCellNode *node = [[ASCellNode alloc] init]; - node.shouldUseUIKitCell = useUIKitCell; - node.style.preferredSize = preferredSize; - return node; - }; } + + // This condition is intended to run for either cells received from the datasource, or created just above. + if (cell.shouldUseUIKitCell) { + *shouldAsyncLayout = NO; + } + + block = ^{ return cell; }; } - return nodeBlock; + return block; } - (NSArray *)dataController:(ASDataController *)dataController supplementaryNodeKindsInSections:(NSIndexSet *)sections @@ -2097,6 +2130,15 @@ - (void)rangeController:(ASRangeController *)rangeController updateWithChangeSet [_layoutFacilitator collectionViewWillPerformBatchUpdates]; __block NSUInteger numberOfUpdates = 0; + id 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. + [_rangeController updateIfNeeded]; + [self _scheduleCheckForBatchFetchingForNumberOfChanges:numberOfUpdates]; + [changeSet executeCompletionHandlerWithFinished:finished]; + }; + [self _superPerformBatchUpdates:^{ updates(); @@ -2129,14 +2171,8 @@ - (void)rangeController:(ASRangeController *)rangeController updateWithChangeSet [super insertItemsAtIndexPaths:change.indexPaths]; numberOfUpdates++; } - } 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. - [_rangeController updateIfNeeded]; - [self _scheduleCheckForBatchFetchingForNumberOfChanges:numberOfUpdates]; - [changeSet executeCompletionHandlerWithFinished:finished]; - }]; + } completion:completion]; + as_log_debug(ASCollectionLog(), "Completed batch update %{public}@", self.collectionNode); // Flush any range changes that happened as part of submitting the update. diff --git a/Source/ASTableNode.h b/Source/ASTableNode.h index aae4f375d..dc13110dd 100644 --- a/Source/ASTableNode.h +++ b/Source/ASTableNode.h @@ -238,7 +238,7 @@ NS_ASSUME_NONNULL_BEGIN * * Calling -waitUntilAllUpdatesAreProcessed is one way to flush any pending update completion blocks. */ -- (void)onDidFinishProcessingUpdates:(nullable void (^)(void))didFinishProcessingUpdates; +- (void)onDidFinishProcessingUpdates:(void (^)(void))didFinishProcessingUpdates; /** * Blocks execution of the main thread until all section and item updates are committed to the view. This method must be called from the main thread. diff --git a/Source/ASTableNode.mm b/Source/ASTableNode.mm index c35f90a87..6420d8f70 100644 --- a/Source/ASTableNode.mm +++ b/Source/ASTableNode.mm @@ -776,8 +776,11 @@ - (BOOL)isProcessingUpdates return (self.nodeLoaded ? [self.view isProcessingUpdates] : NO); } -- (void)onDidFinishProcessingUpdates:(nullable void (^)())completion +- (void)onDidFinishProcessingUpdates:(void (^)())completion { + if (!completion) { + return; + } if (!self.nodeLoaded) { completion(); } else { diff --git a/Source/ASTableView.h b/Source/ASTableView.h index d99ae5b21..38b5bffb1 100644 --- a/Source/ASTableView.h +++ b/Source/ASTableView.h @@ -219,7 +219,7 @@ NS_ASSUME_NONNULL_BEGIN * See ASTableNode.h for full documentation of these methods. */ @property (nonatomic, readonly) BOOL isProcessingUpdates; -- (void)onDidFinishProcessingUpdates:(nullable void (^)(void))completion; +- (void)onDidFinishProcessingUpdates:(void (^)(void))completion; - (void)waitUntilAllUpdatesAreCommitted ASDISPLAYNODE_DEPRECATED_MSG("Use -[ASTableNode waitUntilAllUpdatesAreProcessed] instead."); - (void)insertSections:(NSIndexSet *)sections withRowAnimation:(UITableViewRowAnimation)animation ASDISPLAYNODE_DEPRECATED_MSG("Use ASTableNode method instead."); diff --git a/Source/ASTableView.mm b/Source/ASTableView.mm index 5c8308444..58b3d033d 100644 --- a/Source/ASTableView.mm +++ b/Source/ASTableView.mm @@ -725,7 +725,7 @@ - (BOOL)isProcessingUpdates return [_dataController isProcessingUpdates]; } -- (void)onDidFinishProcessingUpdates:(nullable void (^)())completion +- (void)onDidFinishProcessingUpdates:(void (^)())completion { [_dataController onDidFinishProcessingUpdates:completion]; } diff --git a/Source/Details/ASDataController.h b/Source/Details/ASDataController.h index acc98bcee..8792637ba 100644 --- a/Source/Details/ASDataController.h +++ b/Source/Details/ASDataController.h @@ -91,7 +91,7 @@ extern NSString * const ASCollectionInvalidUpdateException; - (NSUInteger)dataController:(ASDataController *)dataController supplementaryNodesOfKind:(NSString *)kind inSection:(NSUInteger)section; -- (ASCellNodeBlock)dataController:(ASDataController *)dataController supplementaryNodeBlockOfKind:(NSString *)kind atIndexPath:(NSIndexPath *)indexPath; +- (ASCellNodeBlock)dataController:(ASDataController *)dataController supplementaryNodeBlockOfKind:(NSString *)kind atIndexPath:(NSIndexPath *)indexPath shouldAsyncLayout:(BOOL *)shouldAsyncLayout; /** The constrained size range for layout. Called only if no data controller layout delegate is provided. @@ -261,9 +261,15 @@ extern NSString * const ASCollectionInvalidUpdateException; * See ASCollectionNode.h for full documentation of these methods. */ @property (nonatomic, readonly) BOOL isProcessingUpdates; -- (void)onDidFinishProcessingUpdates:(nullable void (^)(void))completion; +- (void)onDidFinishProcessingUpdates:(void (^)(void))completion; - (void)waitUntilAllUpdatesAreProcessed; +/** + * See ASCollectionNode.h for full documentation of these methods. + */ +@property (nonatomic, readonly, getter=isSynchronized) BOOL synchronized; +- (void)onDidFinishSynchronizing:(void (^)(void))completion; + /** * Notifies the data controller object that its environment has changed. The object will request its environment delegate for new information * and propagate the information to all visible elements, including ones that are being prepared in background. @@ -274,6 +280,11 @@ extern NSString * const ASCollectionInvalidUpdateException; */ - (void)environmentDidChange; +/** + * Reset visibleMap and pendingMap when asyncDataSource and asyncDelegate of collection view become nil. + */ +- (void)clearData; + @end NS_ASSUME_NONNULL_END diff --git a/Source/Details/ASDataController.mm b/Source/Details/ASDataController.mm index 512b844fa..877904769 100644 --- a/Source/Details/ASDataController.mm +++ b/Source/Details/ASDataController.mm @@ -17,6 +17,8 @@ #import +#include + #import #import #import @@ -54,6 +56,8 @@ typedef dispatch_block_t ASDataControllerCompletionBlock; +typedef void (^ASDataControllerSynchronizationBlock)(); + @interface ASDataController () { id _layoutDelegate; @@ -65,10 +69,14 @@ @interface ASDataController () { ASMainSerialQueue *_mainSerialQueue; dispatch_queue_t _editingTransactionQueue; // Serial background queue. Dispatches concurrent layout and manages _editingNodes. - dispatch_group_t _editingTransactionGroup; // Group of all edit transaction blocks. Useful for waiting. + dispatch_group_t _editingTransactionGroup; // Group of all edit transaction blocks. Useful for waiting. + std::atomic _editingTransactionGroupCount; BOOL _initialReloadDataHasBeenCalled; + BOOL _synchronized; + NSMutableSet *_onDidFinishSynchronizingBlocks; + struct { unsigned int supplementaryNodeKindsInSections:1; unsigned int supplementaryNodesOfKindInSection:1; @@ -98,7 +106,7 @@ - (instancetype)initWithDataSource:(id)dataSource node:( _dataSourceFlags.supplementaryNodeKindsInSections = [_dataSource respondsToSelector:@selector(dataController:supplementaryNodeKindsInSections:)]; _dataSourceFlags.supplementaryNodesOfKindInSection = [_dataSource respondsToSelector:@selector(dataController:supplementaryNodesOfKind:inSection:)]; - _dataSourceFlags.supplementaryNodeBlockOfKindAtIndexPath = [_dataSource respondsToSelector:@selector(dataController:supplementaryNodeBlockOfKind:atIndexPath:)]; + _dataSourceFlags.supplementaryNodeBlockOfKindAtIndexPath = [_dataSource respondsToSelector:@selector(dataController:supplementaryNodeBlockOfKind:atIndexPath:shouldAsyncLayout:)]; _dataSourceFlags.constrainedSizeForNodeAtIndexPath = [_dataSource respondsToSelector:@selector(dataController:constrainedSizeForNodeAtIndexPath:)]; _dataSourceFlags.constrainedSizeForSupplementaryNodeOfKindAtIndexPath = [_dataSource respondsToSelector:@selector(dataController:constrainedSizeForSupplementaryNodeOfKind:atIndexPath:)]; _dataSourceFlags.contextForSection = [_dataSource respondsToSelector:@selector(dataController:contextForSection:)]; @@ -112,6 +120,9 @@ - (instancetype)initWithDataSource:(id)dataSource node:( _nextSectionID = 0; _mainSerialQueue = [[ASMainSerialQueue alloc] init]; + + _synchronized = YES; + _onDidFinishSynchronizingBlocks = [NSMutableSet set]; const char *queueName = [[NSString stringWithFormat:@"org.AsyncDisplayKit.ASDataController.editingTransactionQueue:%p", self] cStringUsingEncoding:NSASCIIStringEncoding]; _editingTransactionQueue = dispatch_queue_create(queueName, DISPATCH_QUEUE_SERIAL); @@ -352,7 +363,8 @@ - (void)_insertElementsIntoMap:(ASMutableElementMap *)map nodeBlock = [dataSource dataController:self nodeBlockAtIndexPath:indexPath]; } } else { - nodeBlock = [dataSource dataController:self supplementaryNodeBlockOfKind:kind atIndexPath:indexPath]; + BOOL shouldAsyncLayout = YES; + nodeBlock = [dataSource dataController:self supplementaryNodeBlockOfKind:kind atIndexPath:indexPath shouldAsyncLayout:&shouldAsyncLayout]; } ASSizeRange constrainedSize = ASSizeRangeUnconstrained; @@ -440,35 +452,71 @@ - (void)waitUntilAllUpdatesAreProcessed - (BOOL)isProcessingUpdates { ASDisplayNodeAssertMainThread(); - if (_mainSerialQueue.numberOfScheduledBlocks > 0) { - return YES; - } else if (dispatch_group_wait(_editingTransactionGroup, DISPATCH_TIME_NOW) != 0) { - // After waiting for zero duration, a nonzero value is returned if blocks are still running. - return YES; - } - // Both the _mainSerialQueue and _editingTransactionQueue are drained; we are fully quiesced. - return NO; +#if ASDISPLAYNODE_ASSERTIONS_ENABLED + // Using dispatch_group_wait is much more expensive than our manually managed count, but it's crucial they always match. + BOOL editingTransactionQueueBusy = dispatch_group_wait(_editingTransactionGroup, DISPATCH_TIME_NOW) != 0; + ASDisplayNodeAssert(editingTransactionQueueBusy == (_editingTransactionGroupCount > 0), + @"editingTransactionQueueBusy = %@, but _editingTransactionGroupCount = %d !", + editingTransactionQueueBusy ? @"YES" : @"NO", (int)_editingTransactionGroupCount); +#endif + + return _mainSerialQueue.numberOfScheduledBlocks > 0 || _editingTransactionGroupCount > 0; } -- (void)onDidFinishProcessingUpdates:(nullable void (^)())completion +- (void)onDidFinishProcessingUpdates:(void (^)())completion { ASDisplayNodeAssertMainThread(); + if (!completion) { + return; + } if ([self isProcessingUpdates] == NO) { ASPerformBlockOnMainThread(completion); } else { dispatch_async(_editingTransactionQueue, ^{ // Retry the block. If we're done processing updates, it'll run immediately, otherwise // wait again for updates to quiesce completely. - [_mainSerialQueue performBlockOnMainThread:^{ + // Don't use _mainSerialQueue so that we don't affect -isProcessingUpdates. + dispatch_async(dispatch_get_main_queue(), ^{ [self onDidFinishProcessingUpdates:completion]; - }]; + }); }); } } +- (BOOL)isSynchronized { + return _synchronized; +} + +- (void)onDidFinishSynchronizing:(void (^)())completion { + ASDisplayNodeAssertMainThread(); + if (!completion) { + return; + } + if ([self isSynchronized]) { + ASPerformBlockOnMainThread(completion); + } else { + // Hang on to the completion block so that it gets called the next time view is synchronized to data. + [_onDidFinishSynchronizingBlocks addObject:[completion copy]]; + } +} + - (void)updateWithChangeSet:(_ASHierarchyChangeSet *)changeSet { ASDisplayNodeAssertMainThread(); + + _synchronized = NO; + + [changeSet addCompletionHandler:^(BOOL finished) { + _synchronized = YES; + [self onDidFinishProcessingUpdates:^{ + if (_synchronized) { + for (ASDataControllerSynchronizationBlock block in _onDidFinishSynchronizingBlocks) { + block(); + } + [_onDidFinishSynchronizingBlocks removeAllObjects]; + } + }]; + }]; if (changeSet.includesReloadData) { if (_initialReloadDataHasBeenCalled) { @@ -558,6 +606,7 @@ - (void)updateWithChangeSet:(_ASHierarchyChangeSet *)changeSet as_log_debug(ASCollectionLog(), "New content: %@", newMap.smallDescription); Class layoutDelegateClass = [self.layoutDelegate class]; + ++_editingTransactionGroupCount; dispatch_group_async(_editingTransactionGroup, _editingTransactionQueue, ^{ __block __unused os_activity_scope_state_s preparationScope = {}; // unused if deployment target < iOS10 as_activity_scope_enter(as_activity_create("Prepare nodes for collection update", AS_ACTIVITY_CURRENT, OS_ACTIVITY_FLAG_DEFAULT), &preparationScope); @@ -577,6 +626,7 @@ - (void)updateWithChangeSet:(_ASHierarchyChangeSet *)changeSet self.visibleMap = newMap; }]; }]; + --_editingTransactionGroupCount; }; // Step 3: Call the layout delegate if possible. Otherwise, allocate and layout all elements @@ -584,9 +634,17 @@ - (void)updateWithChangeSet:(_ASHierarchyChangeSet *)changeSet [layoutDelegateClass calculateLayoutWithContext:layoutContext]; completion(); } else { - NSArray *elementsToProcess = ASArrayByFlatMapping(newMap, - ASCollectionElement *element, - (element.nodeIfAllocated.calculatedLayout == nil ? element : nil)); + NSMutableArray *elementsToProcess = [NSMutableArray array]; + for (ASCollectionElement *element in newMap) { + ASCellNode *nodeIfAllocated = element.nodeIfAllocated; + if (nodeIfAllocated.shouldUseUIKitCell) { + // If the node exists and we know it is a passthrough cell, we know it will never have a .calculatedLayout. + continue; + } else if (nodeIfAllocated.calculatedLayout == nil) { + // If the node hasn't been allocated, or it doesn't have a valid layout, let's process it. + [elementsToProcess addObject:element]; + } + } [self _allocateNodesFromElements:elementsToProcess completion:completion]; } }); @@ -822,6 +880,15 @@ - (void)environmentDidChange }); } +- (void)clearData +{ + ASDisplayNodeAssertMainThread(); + if (_initialReloadDataHasBeenCalled) { + [self waitUntilAllUpdatesAreProcessed]; + self.visibleMap = self.pendingMap = [[ASElementMap alloc] init]; + } +} + # pragma mark - Helper methods - (void)_scheduleBlockOnMainSerialQueue:(dispatch_block_t)block diff --git a/Source/Private/ASCellNode+Internal.h b/Source/Private/ASCellNode+Internal.h index 9135dd5fc..d23dc173d 100644 --- a/Source/Private/ASCellNode+Internal.h +++ b/Source/Private/ASCellNode+Internal.h @@ -63,7 +63,21 @@ NS_ASSUME_NONNULL_BEGIN @property (atomic, weak, nullable) id owningNode; -@property (nonatomic, assign) BOOL shouldUseUIKitCell; +@property (nonatomic, readonly) BOOL shouldUseUIKitCell; + +@end + +@class ASWrapperCellNode; + +typedef CGSize (^ASSizeForItemBlock)(ASWrapperCellNode *node, CGSize collectionSize); +typedef UICollectionViewCell * _Nonnull(^ASCellForItemBlock)(ASWrapperCellNode *node); +typedef UICollectionReusableView * _Nonnull(^ASViewForSupplementaryBlock)(ASWrapperCellNode *node); + +@interface ASWrapperCellNode : ASCellNode + +@property (nonatomic, copy, readonly) ASSizeForItemBlock sizeForItemBlock; +@property (nonatomic, copy, readonly) ASCellForItemBlock cellForItemBlock; +@property (nonatomic, copy, readonly) ASViewForSupplementaryBlock viewForSupplementaryBlock; @end