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;