diff --git a/packages/react-native/Libraries/NativeAnimation/RCTNativeAnimatedNodesManager.h b/packages/react-native/Libraries/NativeAnimation/RCTNativeAnimatedNodesManager.h index 932c873eb6cf..60798329cd8d 100644 --- a/packages/react-native/Libraries/NativeAnimation/RCTNativeAnimatedNodesManager.h +++ b/packages/react-native/Libraries/NativeAnimation/RCTNativeAnimatedNodesManager.h @@ -87,6 +87,8 @@ NS_ASSUME_NONNULL_BEGIN - (void)stopListeningToAnimatedNodeValue:(NSNumber *)tag; +- (NSSet *)getTagsOfConnectedNodesFrom:(NSNumber *)tag andEvent:(NSString *)eventName; + @end NS_ASSUME_NONNULL_END diff --git a/packages/react-native/Libraries/NativeAnimation/RCTNativeAnimatedNodesManager.mm b/packages/react-native/Libraries/NativeAnimation/RCTNativeAnimatedNodesManager.mm index b3669766ea07..db3e2f8b4e1a 100644 --- a/packages/react-native/Libraries/NativeAnimation/RCTNativeAnimatedNodesManager.mm +++ b/packages/react-native/Libraries/NativeAnimation/RCTNativeAnimatedNodesManager.mm @@ -368,6 +368,15 @@ - (void)addAnimatedEventToView:(NSNumber *)viewTag [drivers addObject:driver]; _eventDrivers[key] = drivers; } + + // Handle onScrollEnded special events. + // These are triggered when the user stops dragging or when the + // scroll view stops decelerating after the user swiped + // The goal is to use this event to force a resync of the Shadow Tree + // with the Native tree + if ([eventName isEqualToString:@"onScroll"]) { + [self addAnimatedEventToView:viewTag eventName:@"onScrollEnded" eventMapping:eventMapping]; + } } - (void)removeAnimatedEventFromView:(NSNumber *)viewTag @@ -470,6 +479,24 @@ - (void)stepAnimations:(CADisplayLink *)displaylink [self stopAnimationLoopIfNeeded]; } +- (NSSet *)getTagsOfConnectedNodesFrom:(NSNumber *)tag andEvent:(NSString *)eventName +{ + NSMutableSet *tags = [NSMutableSet new]; + NSString *key = [NSString stringWithFormat:@"%@%@", tag, RCTNormalizeAnimatedEventName(eventName)]; + NSArray *eventAnimations = _eventDrivers[key]; + for (RCTEventAnimation *animation in eventAnimations) { + NSNumber *nodeTag = [animation.valueNode nodeTag]; + if (nodeTag) { + [tags addObject:nodeTag]; + } + for (NSNumber *childNodeKey in [animation.valueNode childNodes]) { + [tags addObject:childNodeKey]; + } + } + + return tags; +} + #pragma mark-- Updates - (void)updateAnimations diff --git a/packages/react-native/Libraries/NativeAnimation/RCTNativeAnimatedTurboModule.mm b/packages/react-native/Libraries/NativeAnimation/RCTNativeAnimatedTurboModule.mm index cdf38d90dcd2..4d16e5009254 100644 --- a/packages/react-native/Libraries/NativeAnimation/RCTNativeAnimatedTurboModule.mm +++ b/packages/react-native/Libraries/NativeAnimation/RCTNativeAnimatedTurboModule.mm @@ -25,6 +25,11 @@ @implementation RCTNativeAnimatedTurboModule { NSMutableArray *_operations; // Operations called before views have been updated. NSMutableArray *_preOperations; + + NSSet *_userDrivenAnimationEndedEvents; + + // TODO: Remove this when https://github.com/facebook/react-native/pull/45457 lands + BOOL _shouldEmitEvent; } RCT_EXPORT_MODULE(); @@ -39,6 +44,8 @@ - (instancetype)init if (self = [super init]) { _operations = [NSMutableArray new]; _preOperations = [NSMutableArray new]; + _userDrivenAnimationEndedEvents = [NSSet setWithArray:@[ @"onScrollEnded" ]]; + _shouldEmitEvent = NO; } return self; } @@ -364,7 +371,7 @@ - (void)didMountComponentsWithRootTag:(NSInteger)rootTag - (NSArray *)supportedEvents { - return @[ @"onAnimatedValueUpdate" ]; + return @[ @"onAnimatedValueUpdate", @"onUserDrivenAnimationEnded" ]; } - (void)animatedNode:(RCTValueAnimatedNode *)node didUpdateValue:(CGFloat)value @@ -372,12 +379,44 @@ - (void)animatedNode:(RCTValueAnimatedNode *)node didUpdateValue:(CGFloat)value [self sendEventWithName:@"onAnimatedValueUpdate" body:@{@"tag" : node.nodeTag, @"value" : @(value)}]; } +// TODO: Remove this when https://github.com/facebook/react-native/pull/45457 lands +- (void)startObserving +{ + [super startObserving]; + _shouldEmitEvent = YES; +} + +- (void)stopObserving +{ + [super stopObserving]; + _shouldEmitEvent = NO; +} + +// ---- + +- (void)userDrivenAnimationEnded:(NSArray *)nodes +{ + if (!_shouldEmitEvent) { + return; + } + + [self sendEventWithName:@"onUserDrivenAnimationEnded" body:@{@"tags" : nodes}]; +} + - (void)eventDispatcherWillDispatchEvent:(id)event { // Events can be dispatched from any queue so we have to make sure handleAnimatedEvent // is run from the main queue. RCTExecuteOnMainQueue(^{ [self->_nodesManager handleAnimatedEvent:event]; + + if ([self->_userDrivenAnimationEndedEvents containsObject:event.eventName]) { + NSSet *tags = [self->_nodesManager getTagsOfConnectedNodesFrom:event.viewTag + andEvent:event.eventName]; + if (tags.count > 0) { + [self userDrivenAnimationEnded:[tags allObjects]]; + } + } }); } diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index 98cf04959323..cc81ad3a45cd 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -26,6 +26,10 @@ using namespace facebook::react; +static NSString *kOnScrollEvent = @"onScroll"; + +static NSString *kOnScrollEndEvent = @"onScrollEnded"; + static const CGFloat kClippingLeeway = 44.0; static UIScrollViewKeyboardDismissMode RCTUIKeyboardDismissModeFromProps(const ScrollViewProps &props) @@ -56,10 +60,11 @@ static UIScrollViewIndicatorStyle RCTUIScrollViewIndicatorStyleFromProps(const S // This is just a workaround to allow animations based on onScroll event. // This is only used to animate sticky headers in ScrollViews, and only the contentOffset and tag is used. // TODO: T116850910 [Fabric][iOS] Make Fabric not use legacy RCTEventDispatcher for native-driven AnimatedEvents -static void RCTSendScrollEventForNativeAnimations_DEPRECATED(UIScrollView *scrollView, NSInteger tag) +static void +RCTSendScrollEventForNativeAnimations_DEPRECATED(UIScrollView *scrollView, NSInteger tag, NSString *eventName) { static uint16_t coalescingKey = 0; - RCTScrollEvent *scrollEvent = [[RCTScrollEvent alloc] initWithEventName:@"onScroll" + RCTScrollEvent *scrollEvent = [[RCTScrollEvent alloc] initWithEventName:eventName reactTag:[NSNumber numberWithInt:tag] scrollViewContentOffset:scrollView.contentOffset scrollViewContentInset:scrollView.contentInset @@ -507,7 +512,7 @@ - (void)scrollViewDidScroll:(UIScrollView *)scrollView static_cast(*_eventEmitter).onScroll(scrollMetrics); } - RCTSendScrollEventForNativeAnimations_DEPRECATED(scrollView, self.tag); + RCTSendScrollEventForNativeAnimations_DEPRECATED(scrollView, self.tag, kOnScrollEvent); } } @@ -564,6 +569,7 @@ - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL // ScrollView will not decelerate and `scrollViewDidEndDecelerating` will not be called. // `_isUserTriggeredScrolling` must be set to NO here. _isUserTriggeredScrolling = NO; + RCTSendScrollEventForNativeAnimations_DEPRECATED(scrollView, self.tag, kOnScrollEndEvent); } } @@ -589,6 +595,8 @@ - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView static_cast(*_eventEmitter).onMomentumScrollEnd([self _scrollViewMetrics]); [self _updateStateWithContentOffset]; _isUserTriggeredScrolling = NO; + + RCTSendScrollEventForNativeAnimations_DEPRECATED(scrollView, self.tag, kOnScrollEndEvent); } - (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView