diff --git a/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js b/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js index df8472d8fe90..a20d9cdb0a90 100644 --- a/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js +++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js @@ -912,3 +912,103 @@ test('animate width with memo and rerender (js sync test)', () => { , ); }); + +// A native scroll event pushes its values outside the frame loop. It must not +// step running time-based animations: frames step them with the frame time, so +// stepping them at the push's own time as well would move them whenever the +// two times differ. +test('native scroll event does not step a running timing animation', () => { + const scrollViewRef = createRef(); + const viewRef = createRef(); + const scrollFollowerRef = createRef(); + let _translateX; + + component MyApp() { + const scrollY = useAnimatedValue(0); + const translateX = useAnimatedValue(0); + _translateX = translateX; + return ( + + + + + + + + ); + } + + // Stamp pushes far later than the frame clock, as on device, where a push + // between frames runs later than the next frame's vsync time. + const mockClock = Fantom.installHighResTimeStampMock(); + mockClock.setTime(1e12); + let animation; + try { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + const scrollViewElement = nullthrows(scrollViewRef.current); + const viewElement = nullthrows(viewRef.current); + const scrollFollowerElement = nullthrows(scrollFollowerRef.current); + + const getTransformValue = (element: HostInstance, key: string): number => { + const transform = + Fantom.unstable_getDirectManipulationProps(element).transform; + const first = Array.isArray(transform) ? transform[0] : null; + if ( + first == null || + typeof first !== 'object' || + typeof first[key] !== 'number' + ) { + throw new Error(`Expected a numeric ${key} transform`); + } + return first[key]; + }; + const getTranslateX = (): number => + getTransformValue(viewElement, 'translateX'); + + Fantom.runTask(() => { + animation = Animated.timing(nullthrows(_translateX), { + toValue: 100, + duration: 1000, + easing: Easing.linear, + useNativeDriver: true, + }); + animation.start(); + }); + Fantom.unstable_produceFramesForDuration(500); + + const beforeScroll = getTranslateX(); + expect(beforeScroll).toBeGreaterThan(0); + expect(beforeScroll).toBeLessThan(100); + + Fantom.scrollTo(scrollViewElement, {x: 0, y: 10}); + // The scroll event reached the native graph... + expect(getTransformValue(scrollFollowerElement, 'translateY')).toBe(10); + // ...and left the running timing animation where the frames put it. + expect(getTranslateX()).toBe(beforeScroll); + + Fantom.unstable_produceFramesForDuration(100); + expect(getTranslateX()).toBeGreaterThan(beforeScroll); + expect(getTranslateX()).toBeLessThan(100); + } finally { + Fantom.runTask(() => { + animation?.stop(); + }); + // Flush the work queued by stopping the animation. + Fantom.runWorkLoop(); + mockClock.uninstall(); + } +}); diff --git a/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.cpp b/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.cpp index 5c4d70b1ef5f..de87a75b0c5a 100644 --- a/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.cpp +++ b/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.cpp @@ -555,9 +555,14 @@ void NativeAnimatedNodesManager::handleAnimatedEvent( // frames. if (useSharedAnimatedBackend_) { if (auto animationBackend = animationBackend_.lock()) { + // Pass stepAnimations false so this push only propagates event values. + // Frames step time-based drivers with the vsync time, which can be + // earlier than this push's time, so stepping them here would move them + // backward on the next frame. animationBackend->pushAnimationMutations( [this](AnimationTimestamp timestamp) -> AnimationMutations { - return pullAnimationMutations(timestamp); + return pullAnimationMutations( + timestamp, /* stepAnimations */ false); }); } } else { @@ -1038,14 +1043,17 @@ void NativeAnimatedNodesManager::insertMutations( AnimationMutations NativeAnimatedNodesManager::onAnimationFrameForBackend( AnimatedPropsBuilder& propsBuilder, - AnimationTimestamp timestamp) { + AnimationTimestamp timestamp, + bool stepAnimations) { AnimationMutations mutations{}; auto timestampMs = timestamp.count(); // Run all active animations auto hasFinishedAnimations = false; std::set finishedAnimationValueNodes; for (const auto& [_id, driver] : activeAnimations_) { - driver->runAnimationStep(timestampMs); + if (stepAnimations) { + driver->runAnimationStep(timestampMs); + } if (driver->getIsComplete()) { hasFinishedAnimations = true; @@ -1083,7 +1091,8 @@ AnimationMutations NativeAnimatedNodesManager::onAnimationFrameForBackend( } AnimationMutations NativeAnimatedNodesManager::pullAnimationMutations( - AnimationTimestamp timestamp) { + AnimationTimestamp timestamp, + bool stepAnimations) { if (!useSharedAnimatedBackend_) { return {}; } @@ -1113,7 +1122,8 @@ AnimationMutations NativeAnimatedNodesManager::pullAnimationMutations( // Step through the animation loop if (isAnimationUpdateNeeded()) { AnimatedPropsBuilder propsBuilder; - mutations = onAnimationFrameForBackend(propsBuilder, timestamp); + mutations = + onAnimationFrameForBackend(propsBuilder, timestamp, stepAnimations); if (mutations.batch.empty()) { // The last animation tick didn't result in any changes to the UI. diff --git a/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.h b/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.h index ff630b31209f..a85cbf4e6d95 100644 --- a/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.h +++ b/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.h @@ -133,8 +133,11 @@ class NativeAnimatedNodesManager : public std::enable_shared_from_this #include #include namespace facebook::react { -class EventAnimationDriverTests : public AnimationTestsBase {}; +namespace { + +// Runs event-driven updates at a timestamp the test sets, like a platform +// whose current time is later than the vsync time of the next frame. +class FakeAnimationBackend : public UIManagerAnimationBackend { + public: + AnimationTimestamp pushTimestamp{0}; + + void onAnimationFrame(AnimationTimestamp /*timestamp*/) override {} + CallbackId start(const Callback& /*callback*/) override { + return 0; + } + void stop(CallbackId /*callbackId*/) override {} + void clearRegistry(SurfaceId /*surfaceId*/) override {} + void clearRegistryOnSurfaceStop(SurfaceId /*surfaceId*/) override {} + void trigger() override {} + void pushAnimationMutations(const Callback& callback) override { + callback(pushTimestamp); + } + void registerJSInvoker(std::shared_ptr /*jsInvoker*/) override {} +}; + +} // namespace + +class EventAnimationDriverTests : public AnimationTestsBase { + protected: + static constexpr double kStartTime = 1000; + + std::shared_ptr backend_ = + std::make_shared(); + Tag tag_{getNextRootViewTag()}; + Tag viewTag_{++tag_}; + Tag scrollValueTag_{++tag_}; + Tag animatedValueTag_{++tag_}; + + // A shared-backend manager with a scroll event mapped to scrollValueTag_ and + // the given animation running on animatedValueTag_. + void startSharedBackendAnimation(const folly::dynamic& animationConfig) { + // Marks this thread as the render thread. + initNodesManager(); + nodesManager_ = std::make_shared(backend_); + + const folly::dynamic valueNodeConfig = + folly::dynamic::object("type", "value")("value", 0)("offset", 0); + nodesManager_->createAnimatedNode(scrollValueTag_, valueNodeConfig); + nodesManager_->addAnimatedEventToView( + viewTag_, + "scroll", + folly::dynamic::object("animatedValueTag", scrollValueTag_)( + "nativeEventPath", folly::dynamic::array("contentOffset", "y"))); + nodesManager_->createAnimatedNode(animatedValueTag_, valueNodeConfig); + nodesManager_->startAnimatingNode( + 1, animatedValueTag_, animationConfig, std::nullopt); + } + + void runBackendFrame(double timestamp) { + nodesManager_->pullAnimationMutations(AnimationTimestamp{timestamp}); + } + + void dispatchScrollAt(double timestamp, double offsetY) { + backend_->pushTimestamp = AnimationTimestamp{timestamp}; + auto scrollEvent = std::make_shared(); + scrollEvent->contentOffset = {.x = 0, .y = static_cast(offsetY)}; + (*nodesManager_->getEventEmitterListener())( + viewTag_, "scroll", *scrollEvent); + } + + double animatedValue() { + return nodesManager_->getValue(animatedValueTag_).value(); + } +}; TEST_F(EventAnimationDriverTests, subscribeToViewEvent) { initNodesManager(); @@ -67,4 +138,41 @@ TEST_F(EventAnimationDriverTests, subscribeToViewEvent) { EXPECT_EQ(nodesManager_->getValue(animatedValueTag2), 11); } +TEST_F( + EventAnimationDriverTests, + sharedBackendEventDoesNotStepTimingAnimationAheadOfFrames) { + startSharedBackendAnimation( + folly::dynamic::object("type", "frames")( + "frames", folly::dynamic::array(0.0, 0.25, 0.5, 0.75, 1.0))( + "toValue", 100)); + + runBackendFrame(kStartTime); + runBackendFrame(kStartTime + SingleFrameIntervalMs); + const auto valueBeforeEvent = animatedValue(); + EXPECT_GT(valueBeforeEvent, 0); + + // The event is handled later than the next frame's vsync time. + dispatchScrollAt(kStartTime + SingleFrameIntervalMs * 3, 4); + EXPECT_EQ(nodesManager_->getValue(scrollValueTag_), 4); + EXPECT_EQ(animatedValue(), valueBeforeEvent); + + runBackendFrame(kStartTime + SingleFrameIntervalMs * 2); + EXPECT_GT(animatedValue(), valueBeforeEvent); +} + +TEST_F(EventAnimationDriverTests, sharedBackendEventDoesNotEndDecayAnimation) { + startSharedBackendAnimation( + folly::dynamic::object("type", "decay")("velocity", 1.0)( + "deceleration", 0.998)("iterations", 1)); + + runBackendFrame(kStartTime); + runBackendFrame(kStartTime + SingleFrameIntervalMs); + dispatchScrollAt(kStartTime + SingleFrameIntervalMs * 2, 4); + runBackendFrame(kStartTime + SingleFrameIntervalMs * 2); + const auto valueAfterEvent = animatedValue(); + + runBackendFrame(kStartTime + SingleFrameIntervalMs * 3); + EXPECT_GT(animatedValue(), valueAfterEvent); +} + } // namespace facebook::react diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api index 2c501ad938e5..5e5dad5f5ce5 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api @@ -2839,8 +2839,8 @@ class facebook::react::NativeAnimatedNodesManager : public std::enable_shared_fr public bool hasManagedProps() const noexcept; public bool isOnRenderThread() const noexcept; public bool useSharedAnimatedBackend() const noexcept; - public facebook::react::AnimationMutations onAnimationFrameForBackend(facebook::react::AnimatedPropsBuilder& propsBuilder, facebook::react::AnimationTimestamp timestamp); - public facebook::react::AnimationMutations pullAnimationMutations(facebook::react::AnimationTimestamp timestamp); + public facebook::react::AnimationMutations onAnimationFrameForBackend(facebook::react::AnimatedPropsBuilder& propsBuilder, facebook::react::AnimationTimestamp timestamp, bool stepAnimations = true); + public facebook::react::AnimationMutations pullAnimationMutations(facebook::react::AnimationTimestamp timestamp, bool stepAnimations = true); public facebook::react::NativeAnimatedNodesManager& operator=(const facebook::react::NativeAnimatedNodesManager&) = delete; public facebook::react::NativeAnimatedNodesManager& operator=(facebook::react::NativeAnimatedNodesManager&&) = delete; public folly::dynamic getManagedProps(facebook::react::Tag tag) const noexcept; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api index b6fcaa20bde3..cb8ca685d4f2 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api @@ -5038,8 +5038,8 @@ class facebook::react::NativeAnimatedNodesManager : public std::enable_shared_fr public bool hasManagedProps() const noexcept; public bool isOnRenderThread() const noexcept; public bool useSharedAnimatedBackend() const noexcept; - public facebook::react::AnimationMutations onAnimationFrameForBackend(facebook::react::AnimatedPropsBuilder& propsBuilder, facebook::react::AnimationTimestamp timestamp); - public facebook::react::AnimationMutations pullAnimationMutations(facebook::react::AnimationTimestamp timestamp); + public facebook::react::AnimationMutations onAnimationFrameForBackend(facebook::react::AnimatedPropsBuilder& propsBuilder, facebook::react::AnimationTimestamp timestamp, bool stepAnimations = true); + public facebook::react::AnimationMutations pullAnimationMutations(facebook::react::AnimationTimestamp timestamp, bool stepAnimations = true); public facebook::react::NativeAnimatedNodesManager& operator=(const facebook::react::NativeAnimatedNodesManager&) = delete; public facebook::react::NativeAnimatedNodesManager& operator=(facebook::react::NativeAnimatedNodesManager&&) = delete; public folly::dynamic getManagedProps(facebook::react::Tag tag) const noexcept; diff --git a/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api index e1cce2df0794..cb97eccc7f11 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api @@ -1703,8 +1703,8 @@ class facebook::react::NativeAnimatedNodesManager : public std::enable_shared_fr public bool hasManagedProps() const noexcept; public bool isOnRenderThread() const noexcept; public bool useSharedAnimatedBackend() const noexcept; - public facebook::react::AnimationMutations onAnimationFrameForBackend(facebook::react::AnimatedPropsBuilder& propsBuilder, facebook::react::AnimationTimestamp timestamp); - public facebook::react::AnimationMutations pullAnimationMutations(facebook::react::AnimationTimestamp timestamp); + public facebook::react::AnimationMutations onAnimationFrameForBackend(facebook::react::AnimatedPropsBuilder& propsBuilder, facebook::react::AnimationTimestamp timestamp, bool stepAnimations = true); + public facebook::react::AnimationMutations pullAnimationMutations(facebook::react::AnimationTimestamp timestamp, bool stepAnimations = true); public facebook::react::NativeAnimatedNodesManager& operator=(const facebook::react::NativeAnimatedNodesManager&) = delete; public facebook::react::NativeAnimatedNodesManager& operator=(facebook::react::NativeAnimatedNodesManager&&) = delete; public folly::dynamic getManagedProps(facebook::react::Tag tag) const noexcept;