diff --git a/CHANGELOG.md b/CHANGELOG.md index e2311fb0e1..e27918bad5 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 b846e549d2..87f72cf5cc 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 cd3bed4476..c93da2db63 100644 --- a/Source/ASCellNode.mm +++ b/Source/ASCellNode.mm @@ -352,6 +352,25 @@ 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 952227896f..2c8163b71e 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 4a56217cf5..b25372efc5 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 d0cc5f2084..a7f9e9bf7a 100644 --- a/Source/ASCollectionNode.mm +++ b/Source/ASCollectionNode.mm @@ -49,9 +49,13 @@ @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 @@ 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)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 @@ } } +- (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 @@ 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 @@ } } +- (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 9f552e44c6..594d888888 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 451322564b..72d8b78d41 100644 --- a/Source/ASCollectionView.mm +++ b/Source/ASCollectionView.mm @@ -374,7 +374,7 @@ static NSString * const kReuseIdentifier = @"_ASCollectionReuseIdentifier"; return [_dataController isProcessingUpdates]; } -- (void)onDidFinishProcessingUpdates:(nullable void (^)())completion +- (void)onDidFinishProcessingUpdates:(void (^)())completion { [_dataController onDidFinishProcessingUpdates:completion]; } @@ -391,6 +391,16 @@ static NSString * const kReuseIdentifier = @"_ASCollectionReuseIdentifier"; [_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 @@ static NSString * const kReuseIdentifier = @"_ASCollectionReuseIdentifier"; if (_layoutInspectorFlags.didChangeCollectionViewDataSource) { [layoutInspector didChangeCollectionViewDataSource:asyncDataSource]; } + [self _asyncDelegateOrDataSourceDidChange]; } - (id)asyncDelegate @@ -558,6 +569,15 @@ static NSString * const kReuseIdentifier = @"_ASCollectionReuseIdentifier"; 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 @@ static NSString * const kReuseIdentifier = @"_ASCollectionReuseIdentifier"; - (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 @@ static NSString * const kReuseIdentifier = @"_ASCollectionReuseIdentifier"; // 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 @@ static NSString * const kReuseIdentifier = @"_ASCollectionReuseIdentifier"; 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 @@ static NSString * const kReuseIdentifier = @"_ASCollectionReuseIdentifier"; } 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 @@ static NSString * const kReuseIdentifier = @"_ASCollectionReuseIdentifier"; } 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 @@ static NSString * const kReuseIdentifier = @"_ASCollectionReuseIdentifier"; 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 @@ static NSString * const kReuseIdentifier = @"_ASCollectionReuseIdentifier"; 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 @@ static NSString * const kReuseIdentifier = @"_ASCollectionReuseIdentifier"; 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 @@ static NSString * const kReuseIdentifier = @"_ASCollectionReuseIdentifier"; __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 @@ static NSString * const kReuseIdentifier = @"_ASCollectionReuseIdentifier"; #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 @@ static NSString * const kReuseIdentifier = @"_ASCollectionReuseIdentifier"; [_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 @@ static NSString * const kReuseIdentifier = @"_ASCollectionReuseIdentifier"; [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 aae4f375d8..dc13110dd7 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 c35f90a878..6420d8f708 100644 --- a/Source/ASTableNode.mm +++ b/Source/ASTableNode.mm @@ -776,8 +776,11 @@ ASLayoutElementCollectionTableSetTraitCollection(_environmentStateLock) 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 d99ae5b217..38b5bffb1f 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 5c83084448..58b3d033de 100644 --- a/Source/ASTableView.mm +++ b/Source/ASTableView.mm @@ -725,7 +725,7 @@ static NSString * const kCellReuseIdentifier = @"_ASTableViewCell"; 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 acc98bceea..8792637ba1 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 512b844fa5..8779047694 100644 --- a/Source/Details/ASDataController.mm +++ b/Source/Details/ASDataController.mm @@ -17,6 +17,8 @@ #import +#include + #import #import #import @@ -54,6 +56,8 @@ NSString * const ASCollectionInvalidUpdateException = @"ASCollectionInvalidUpdat typedef dispatch_block_t ASDataControllerCompletionBlock; +typedef void (^ASDataControllerSynchronizationBlock)(); + @interface ASDataController () { id _layoutDelegate; @@ -65,10 +69,14 @@ typedef dispatch_block_t ASDataControllerCompletionBlock; 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 @@ typedef dispatch_block_t ASDataControllerCompletionBlock; _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 @@ typedef dispatch_block_t ASDataControllerCompletionBlock; _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 @@ typedef dispatch_block_t ASDataControllerCompletionBlock; 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 @@ typedef dispatch_block_t ASDataControllerCompletionBlock; - (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 @@ typedef dispatch_block_t ASDataControllerCompletionBlock; 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 @@ typedef dispatch_block_t ASDataControllerCompletionBlock; self.visibleMap = newMap; }]; }]; + --_editingTransactionGroupCount; }; // Step 3: Call the layout delegate if possible. Otherwise, allocate and layout all elements @@ -584,9 +634,17 @@ typedef dispatch_block_t ASDataControllerCompletionBlock; [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 @@ typedef dispatch_block_t ASDataControllerCompletionBlock; }); } +- (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 9135dd5fce..d23dc173dc 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