diff --git a/AsyncDisplayKit/ASCellNode.h b/AsyncDisplayKit/ASCellNode.h index 54138de075..5496725f0f 100644 --- a/AsyncDisplayKit/ASCellNode.h +++ b/AsyncDisplayKit/ASCellNode.h @@ -21,8 +21,9 @@ typedef NSUInteger ASCellNodeAnimation; * The notification is done on main thread. * * @param node A node informing the delegate about the relayout. + * @param sizeChanged `YES` if the node's `calculatedSize` changed during the relayout, `NO` otherwise. */ -- (void)nodeDidRelayout:(ASCellNode *)node; +- (void)nodeDidRelayout:(ASCellNode *)node sizeChanged:(BOOL)sizeChanged; @end /** diff --git a/AsyncDisplayKit/ASCellNode.m b/AsyncDisplayKit/ASCellNode.m index 68a3ea92fc..27aa2bb916 100644 --- a/AsyncDisplayKit/ASCellNode.m +++ b/AsyncDisplayKit/ASCellNode.m @@ -53,11 +53,13 @@ - (void)setNeedsLayout { ASDisplayNodeAssertThreadAffinity(self); + CGSize oldSize = self.calculatedSize; [super setNeedsLayout]; - + if (_layoutDelegate != nil) { + BOOL sizeChanged = !CGSizeEqualToSize(oldSize, self.calculatedSize); ASPerformBlockOnMainThread(^{ - [_layoutDelegate nodeDidRelayout:self]; + [_layoutDelegate nodeDidRelayout:self sizeChanged:sizeChanged]; }); } } diff --git a/AsyncDisplayKit/ASCollectionView.mm b/AsyncDisplayKit/ASCollectionView.mm index d0ab712fa6..aac9507693 100644 --- a/AsyncDisplayKit/ASCollectionView.mm +++ b/AsyncDisplayKit/ASCollectionView.mm @@ -156,6 +156,7 @@ static BOOL _isInterceptedSelector(SEL sel) BOOL _asyncDelegateImplementsInsetSection; BOOL _collectionViewLayoutImplementsInsetSection; BOOL _asyncDataSourceImplementsConstrainedSizeForNode; + BOOL _queuedNodeSizeUpdate; ASBatchContext *_batchContext; @@ -911,10 +912,26 @@ static BOOL _isInterceptedSelector(SEL sel) #pragma mark - ASCellNodeDelegate -- (void)nodeDidRelayout:(ASCellNode *)node +- (void)nodeDidRelayout:(ASCellNode *)node sizeChanged:(BOOL)sizeChanged { ASDisplayNodeAssertMainThread(); - // Cause UICollectionView to requery for the new height of this node + + if (!sizeChanged || _queuedNodeSizeUpdate) { + return; + } + + _queuedNodeSizeUpdate = YES; + [self performSelector:@selector(requeryNodeSizes) + withObject:nil + afterDelay:0 + inModes:@[ NSRunLoopCommonModes ]]; +} + +// Cause UICollectionView to requery for the new size of all nodes +- (void)requeryNodeSizes +{ + _queuedNodeSizeUpdate = NO; + [super performBatchUpdates:^{} completion:nil]; } diff --git a/AsyncDisplayKit/ASDisplayNode+Subclasses.h b/AsyncDisplayKit/ASDisplayNode+Subclasses.h index e52a37bf7b..d113beb494 100644 --- a/AsyncDisplayKit/ASDisplayNode+Subclasses.h +++ b/AsyncDisplayKit/ASDisplayNode+Subclasses.h @@ -368,7 +368,7 @@ NS_ASSUME_NONNULL_BEGIN /** - * Called just before the view is added to a superview. + * Called just before the view is added to a window. */ - (void)willEnterHierarchy ASDISPLAYNODE_REQUIRES_SUPER; @@ -428,9 +428,13 @@ NS_ASSUME_NONNULL_BEGIN @end @interface ASDisplayNode (ASDisplayNodePrivate) -// This method has proven helpful in a few rare scenarios, similar to a category extension on UIView, -// but it's considered private API for now and its use should not be encouraged. -- (nullable ASDisplayNode *)_supernodeWithClass:(Class)supernodeClass; +/** + * This method has proven helpful in a few rare scenarios, similar to a category extension on UIView, + * but it's considered private API for now and its use should not be encouraged. + * @param checkViewHierarchy If YES, and no supernode can be found, method will walk up from `self.view` to find a supernode. + * If YES, this method must be called on the main thread and the node must not be layer-backed. + */ +- (nullable ASDisplayNode *)_supernodeWithClass:(Class)supernodeClass checkViewHierarchy:(BOOL)checkViewHierarchy; // The two methods below will eventually be exposed, but their names are subject to change. /** diff --git a/AsyncDisplayKit/ASDisplayNode.h b/AsyncDisplayKit/ASDisplayNode.h index b447d9f9e6..44ac1b2ca0 100644 --- a/AsyncDisplayKit/ASDisplayNode.h +++ b/AsyncDisplayKit/ASDisplayNode.h @@ -43,18 +43,28 @@ typedef void (^ASDisplayNodeDidLoadBlock)(ASDisplayNode *node); typedef NS_OPTIONS(NSUInteger, ASInterfaceState) { /** The element is not predicted to be onscreen soon and preloading should not be performed */ - ASInterfaceStateNone = 1 << 0, + ASInterfaceStateNone = 0, /** The element may be added to a view soon that could become visible. Measure the layout, including size calculation. */ - ASInterfaceStateMeasureLayout = 1 << 1, + ASInterfaceStateMeasureLayout = 1 << 0, /** The element is likely enough to come onscreen that disk and/or network data required for display should be fetched. */ - ASInterfaceStateFetchData = 1 << 2, + ASInterfaceStateFetchData = 1 << 1, /** The element is very likely to become visible, and concurrent rendering should be executed for any -setNeedsDisplay. */ - ASInterfaceStateDisplay = 1 << 3, + ASInterfaceStateDisplay = 1 << 2, /** The element is physically onscreen by at least 1 pixel. In practice, all other bit fields should also be set when this flag is set. */ - ASInterfaceStateVisible = 1 << 4, + ASInterfaceStateVisible = 1 << 3, + + /** + * The node is not contained in a cell but it is in a window. + * + * Currently we only set `interfaceState` to other values for + * nodes contained in table views or collection views. + */ + ASInterfaceStateInHierarchy = ASInterfaceStateMeasureLayout | ASInterfaceStateFetchData | ASInterfaceStateDisplay | ASInterfaceStateVisible, }; + + /** * An `ASDisplayNode` is an abstraction over `UIView` and `CALayer` that allows you to perform calculations about a view * hierarchy off the main thread, and could do rendering off the main thread as well. diff --git a/AsyncDisplayKit/ASDisplayNode.mm b/AsyncDisplayKit/ASDisplayNode.mm index 9c52f08c21..2c68b3b7e4 100644 --- a/AsyncDisplayKit/ASDisplayNode.mm +++ b/AsyncDisplayKit/ASDisplayNode.mm @@ -24,6 +24,7 @@ #import "ASInternalHelpers.h" #import "ASLayout.h" #import "ASLayoutSpec.h" +#import "ASCellNode.h" @interface ASDisplayNode () @@ -1581,6 +1582,10 @@ void recursivelyEnsureDisplayForLayer(CALayer *layer) ASDisplayNodeAssertMainThread(); ASDisplayNodeAssert(_flags.isEnteringHierarchy, @"You should never call -willEnterHierarchy directly. Appearance is automatically managed by ASDisplayNode"); ASDisplayNodeAssert(!_flags.isExitingHierarchy, @"ASDisplayNode inconsistency. __enterHierarchy and __exitHierarchy are mutually exclusive"); + + if (![self supportsInterfaceState]) { + self.interfaceState = ASInterfaceStateInHierarchy; + } } - (void)didExitHierarchy @@ -1588,6 +1593,10 @@ void recursivelyEnsureDisplayForLayer(CALayer *layer) ASDisplayNodeAssertMainThread(); ASDisplayNodeAssert(_flags.isExitingHierarchy, @"You should never call -didExitHierarchy directly. Appearance is automatically managed by ASDisplayNode"); ASDisplayNodeAssert(!_flags.isEnteringHierarchy, @"ASDisplayNode inconsistency. __enterHierarchy and __exitHierarchy are mutually exclusive"); + + if (![self supportsInterfaceState]) { + self.interfaceState = ASInterfaceStateNone; + } } - (void)clearContents @@ -1635,6 +1644,20 @@ void recursivelyEnsureDisplayForLayer(CALayer *layer) [self clearFetchedData]; } +/** + * We currently only set interface state on nodes + * in table/collection views. For other nodes, if they are + * in the hierarchy we return `Unknown`, otherwise we return `None`. + * + * TODO: Avoid traversing up node hierarchy due to possible deadlock. + * @see https://github.com/facebook/AsyncDisplayKit/issues/900 + * Possible solution is to push `isInCellNode` state downward on `addSubnode`/`removeFromSupernode`. + */ +- (BOOL)supportsInterfaceState { + return ([self isKindOfClass:ASCellNode.class] + || [self _supernodeWithClass:ASCellNode.class checkViewHierarchy:NO] != nil); +} + - (ASInterfaceState)interfaceState { ASDN::MutexLocker l(_propertyLock); @@ -1643,14 +1666,20 @@ void recursivelyEnsureDisplayForLayer(CALayer *layer) - (void)setInterfaceState:(ASInterfaceState)interfaceState { - ASDN::MutexLocker l(_propertyLock); - if (interfaceState != _interfaceState) { - if ((interfaceState & ASInterfaceStateMeasureLayout) != (_interfaceState & ASInterfaceStateMeasureLayout)) { + ASInterfaceState oldValue; + { + ASDN::MutexLocker l(_propertyLock); + oldValue = _interfaceState; + _interfaceState = interfaceState; + } + + if (interfaceState != oldValue) { + if ((interfaceState & ASInterfaceStateMeasureLayout) != (oldValue & ASInterfaceStateMeasureLayout)) { // Trigger asynchronous measurement if it is not already cached or being calculated. } // Entered or exited data loading state. - if ((interfaceState & ASInterfaceStateFetchData) != (_interfaceState & ASInterfaceStateFetchData)) { + if ((interfaceState & ASInterfaceStateFetchData) != (oldValue & ASInterfaceStateFetchData)) { if (interfaceState & ASInterfaceStateFetchData) { [self fetchData]; } else { @@ -1659,7 +1688,7 @@ void recursivelyEnsureDisplayForLayer(CALayer *layer) } // Entered or exited contents rendering state. - if ((interfaceState & ASInterfaceStateDisplay) != (_interfaceState & ASInterfaceStateDisplay)) { + if ((interfaceState & ASInterfaceStateDisplay) != (oldValue & ASInterfaceStateDisplay)) { if (interfaceState & ASInterfaceStateDisplay) { // Once the working window is eliminated (ASRangeHandlerRender), trigger display directly here. [self setDisplaySuspended:NO]; @@ -1670,15 +1699,14 @@ void recursivelyEnsureDisplayForLayer(CALayer *layer) } // Entered or exited data loading state. - if ((interfaceState & ASInterfaceStateVisible) != (_interfaceState & ASInterfaceStateVisible)) { + if ((interfaceState & ASInterfaceStateVisible) != (oldValue & ASInterfaceStateVisible)) { if (interfaceState & ASInterfaceStateVisible) { // Consider providing a -didBecomeVisible. } else { // Consider providing a -didBecomeInvisible. } } - - _interfaceState = interfaceState; + } } @@ -1871,7 +1899,7 @@ void recursivelyEnsureDisplayForLayer(CALayer *layer) // This method has proved helpful in a few rare scenarios, similar to a category extension on UIView, but assumes knowledge of _ASDisplayView. // It's considered private API for now and its use should not be encouraged. -- (ASDisplayNode *)_supernodeWithClass:(Class)supernodeClass +- (ASDisplayNode *)_supernodeWithClass:(Class)supernodeClass checkViewHierarchy:(BOOL)checkViewHierarchy { ASDisplayNode *supernode = self.supernode; while (supernode) { @@ -1879,6 +1907,9 @@ void recursivelyEnsureDisplayForLayer(CALayer *layer) return supernode; supernode = supernode.supernode; } + if (!checkViewHierarchy) { + return nil; + } UIView *view = self.view.superview; while (view) { diff --git a/AsyncDisplayKit/ASTableView.mm b/AsyncDisplayKit/ASTableView.mm index 0a3dcf85b3..530a26f638 100644 --- a/AsyncDisplayKit/ASTableView.mm +++ b/AsyncDisplayKit/ASTableView.mm @@ -181,6 +181,7 @@ static BOOL _isInterceptedSelector(SEL sel) CGFloat _nodesConstrainedWidth; BOOL _ignoreNodesConstrainedWidthChange; + BOOL _queuedNodeHeightUpdate; } @property (atomic, assign) BOOL asyncDataSourceLocked; @@ -908,10 +909,26 @@ static BOOL _isInterceptedSelector(SEL sel) #pragma mark - ASCellNodeLayoutDelegate -- (void)nodeDidRelayout:(ASCellNode *)node +- (void)nodeDidRelayout:(ASCellNode *)node sizeChanged:(BOOL)sizeChanged { ASDisplayNodeAssertMainThread(); - // Cause UITableView to requery for the new height of this node + + if (!sizeChanged || _queuedNodeHeightUpdate) { + return; + } + + _queuedNodeHeightUpdate = YES; + [self performSelector:@selector(requeryNodeHeights) + withObject:nil + afterDelay:0 + inModes:@[ NSRunLoopCommonModes ]]; +} + +// Cause UITableView to requery for the new height of this node +- (void)requeryNodeHeights +{ + _queuedNodeHeightUpdate = NO; + [super beginUpdates]; [super endUpdates]; } diff --git a/AsyncDisplayKit/ASTextNode.h b/AsyncDisplayKit/ASTextNode.h index 14bd13180a..5946fe46d4 100644 --- a/AsyncDisplayKit/ASTextNode.h +++ b/AsyncDisplayKit/ASTextNode.h @@ -247,7 +247,7 @@ typedef NS_ENUM(NSUInteger, ASTextNodeHighlightStyle) { @param attribute The attribute that was tapped. Will not be nil. @param value The value of the tapped attribute. @param point The point within textNode, in textNode's coordinate system, that was touched to trigger a highlight. - @discussion If not implemented, the default value is NO. + @discussion If not implemented, the default value is YES. @return YES if the entity attribute should be a link, NO otherwise. */ - (BOOL)textNode:(ASTextNode *)textNode shouldHighlightLinkAttribute:(NSString *)attribute value:(id)value atPoint:(CGPoint)point; diff --git a/AsyncDisplayKit/ASTextNode.mm b/AsyncDisplayKit/ASTextNode.mm index 6c797fbaba..f5d3b0381c 100644 --- a/AsyncDisplayKit/ASTextNode.mm +++ b/AsyncDisplayKit/ASTextNode.mm @@ -394,13 +394,15 @@ static NSString *ASTextNodeTruncationTokenAttributeName = @"ASTextNodeTruncation return [self _linkAttributeValueAtPoint:point attributeName:attributeNameOut range:rangeOut - inAdditionalTruncationMessage:NULL]; + inAdditionalTruncationMessage:NULL + forHighlighting:NO]; } - (id)_linkAttributeValueAtPoint:(CGPoint)point attributeName:(out NSString **)attributeNameOut range:(out NSRange *)rangeOut inAdditionalTruncationMessage:(out BOOL *)inAdditionalTruncationMessageOut + forHighlighting:(BOOL)highlighting { ASTextKitRenderer *renderer = [self _renderer]; NSRange visibleRange = renderer.visibleRanges[0]; @@ -453,10 +455,10 @@ static NSString *ASTextNodeTruncationTokenAttributeName = @"ASTextNodeTruncation continue; } - // Check if delegate implements optional method, if not assume NO. - // Should the text be highlightable/touchable? - if (![_delegate respondsToSelector:@selector(textNode:shouldHighlightLinkAttribute:value:atPoint:)] || - ![_delegate textNode:self shouldHighlightLinkAttribute:name value:value atPoint:point]) { + // If highlighting, check with delegate first. If not implemented, assume YES. + if (highlighting + && [_delegate respondsToSelector:@selector(textNode:shouldHighlightLinkAttribute:value:atPoint:)] + && ![_delegate textNode:self shouldHighlightLinkAttribute:name value:value atPoint:point]) { value = nil; name = nil; } @@ -758,7 +760,8 @@ static NSString *ASTextNodeTruncationTokenAttributeName = @"ASTextNodeTruncation id linkAttributeValue = [self _linkAttributeValueAtPoint:point attributeName:&linkAttributeName range:&range - inAdditionalTruncationMessage:&inAdditionalTruncationMessage]; + inAdditionalTruncationMessage:&inAdditionalTruncationMessage + forHighlighting:YES]; NSUInteger lastCharIndex = NSIntegerMax; BOOL linkCrossesVisibleRange = (lastCharIndex > range.location) && (lastCharIndex < NSMaxRange(range) - 1); @@ -778,11 +781,7 @@ static NSString *ASTextNodeTruncationTokenAttributeName = @"ASTextNodeTruncation ASDisplayNodeAssertMainThread(); - UITouch *touch = [touches anyObject]; - - UIView *view = touch.view; - CGPoint point = [touch locationInView:view]; - point = [self.view convertPoint:point fromView:view]; + CGPoint point = [[touches anyObject] locationInView:self.view]; NSRange range = NSMakeRange(0, 0); NSString *linkAttributeName = nil; @@ -791,7 +790,8 @@ static NSString *ASTextNodeTruncationTokenAttributeName = @"ASTextNodeTruncation id linkAttributeValue = [self _linkAttributeValueAtPoint:point attributeName:&linkAttributeName range:&range - inAdditionalTruncationMessage:&inAdditionalTruncationMessage]; + inAdditionalTruncationMessage:&inAdditionalTruncationMessage + forHighlighting:YES]; NSUInteger lastCharIndex = NSIntegerMax; BOOL linkCrossesVisibleRange = (lastCharIndex > range.location) && (lastCharIndex < NSMaxRange(range) - 1); @@ -835,7 +835,20 @@ static NSString *ASTextNodeTruncationTokenAttributeName = @"ASTextNodeTruncation { [super touchesMoved:touches withEvent:event]; - [self _clearHighlightIfNecessary]; + // If touch has moved out of the current highlight range, clear the highlight. + if (_highlightRange.length > 0) { + NSRange range = NSMakeRange(0, 0); + CGPoint point = [[touches anyObject] locationInView:self.view]; + [self _linkAttributeValueAtPoint:point + attributeName:NULL + range:&range + inAdditionalTruncationMessage:NULL + forHighlighting:YES]; + + if (!NSEqualRanges(_highlightRange, range)) { + [self _clearHighlightIfNecessary]; + } + } } - (void)_handleLongPress:(UILongPressGestureRecognizer *)longPressRecognizer diff --git a/AsyncDisplayKitTests/ASDisplayNodeTests.m b/AsyncDisplayKitTests/ASDisplayNodeTests.m index d617164650..e936c33c0a 100644 --- a/AsyncDisplayKitTests/ASDisplayNodeTests.m +++ b/AsyncDisplayKitTests/ASDisplayNodeTests.m @@ -15,6 +15,7 @@ #import "ASDisplayNode+Subclasses.h" #import "ASDisplayNodeTestsHelper.h" #import "UIView+ASConvenience.h" +#import "ASCellNode.h" // Conveniences for making nodes named a certain way #define DeclareNodeNamed(n) ASDisplayNode *n = [[ASDisplayNode alloc] init]; n.name = @#n @@ -76,11 +77,15 @@ for (ASDisplayNode *n in @[ nodes ]) {\ + (dispatch_queue_t)asyncSizingQueue; - (id)initWithViewClass:(Class)viewClass; - (id)initWithLayerClass:(Class)layerClass; + +// FIXME: Importing ASDisplayNodeInternal.h causes a heap of problems. +- (void)enterInterfaceState:(ASInterfaceState)interfaceState; @end @interface ASTestDisplayNode : ASDisplayNode @property (atomic, copy) void (^willDeallocBlock)(ASTestDisplayNode *node); @property (atomic, copy) CGSize(^calculateSizeBlock)(ASTestDisplayNode *node, CGSize size); +@property (atomic) BOOL hasFetchedData; @end @interface ASTestResponderNode : ASTestDisplayNode @@ -93,6 +98,18 @@ for (ASDisplayNode *n in @[ nodes ]) {\ return _calculateSizeBlock ? _calculateSizeBlock(self, constrainedSize) : CGSizeZero; } +- (void)fetchData +{ + [super fetchData]; + self.hasFetchedData = YES; +} + +- (void)clearFetchedData +{ + [super clearFetchedData]; + self.hasFetchedData = NO; +} + - (void)dealloc { if (_willDeallocBlock) { @@ -1666,6 +1683,48 @@ static inline BOOL _CGPointEqualToPointWithEpsilon(CGPoint point1, CGPoint point [self checkBackgroundColorOpaqueRelationshipWithViewLoaded:NO layerBacked:YES]; } +// Check that nodes who have no cell node (no range controller) +// do get their `fetchData` called, and they do report +// the fetch data interface state. +- (void)testInterfaceStateForNonCellNode +{ + ASTestWindow *window = [ASTestWindow new]; + ASTestDisplayNode *node = [ASTestDisplayNode new]; + XCTAssert(node.interfaceState == ASInterfaceStateNone); + XCTAssert(!node.hasFetchedData); + + [window addSubview:node.view]; + XCTAssert(node.hasFetchedData); + XCTAssert(node.interfaceState == ASInterfaceStateInHierarchy); + + [node.view removeFromSuperview]; + XCTAssert(!node.hasFetchedData); + XCTAssert(node.interfaceState == ASInterfaceStateNone); +} + +// Check that nodes who have no cell node (no range controller) +// do get their `fetchData` called, and they do report +// the fetch data interface state. +- (void)testInterfaceStateForCellNode +{ + ASCellNode *cellNode = [ASCellNode new]; + ASTestDisplayNode *node = [ASTestDisplayNode new]; + XCTAssert(node.interfaceState == ASInterfaceStateNone); + XCTAssert(!node.hasFetchedData); + + // Simulate range handler updating cell node. + [cellNode addSubnode:node]; + [cellNode enterInterfaceState:ASInterfaceStateFetchData]; + XCTAssert(node.hasFetchedData); + XCTAssert(node.interfaceState == ASInterfaceStateFetchData); + + // If the node goes into a view it should not adopt the `InHierarchy` state. + ASTestWindow *window = [ASTestWindow new]; + [window addSubview:cellNode.view]; + XCTAssert(node.hasFetchedData); + XCTAssert(node.interfaceState == ASInterfaceStateFetchData); +} + - (void)testInitWithViewClass { ASDisplayNode *scrollNode = [[ASDisplayNode alloc] initWithViewClass:[UIScrollView class]];