Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @fantom_flags useSharedAnimatedBackend:*
* @fantom_flags useSharedAnimatedBackend:* animatedDeferStartOfTimingAnimations:*
* @flow strict-local
* @format
*/
Expand All @@ -21,6 +21,12 @@ import {Animated, View, useAnimatedValue} from 'react-native';
import {allowStyleProp} from 'react-native/Libraries/Animated/NativeAnimatedAllowlist';
import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement';

// Deferred start outputs the initial value on the first animation frame and
// re-anchors timing on the second. This delays animation progress by one
// frame interval (~16ms at 60 fps).
const DEFERRED_START_MS =
ReactNativeFeatureFlags.animatedDeferStartOfTimingAnimations() ? 16 : 0;

test('moving box by 100 points', () => {
let _translateX;
const viewRef = createRef<HostInstance>();
Expand Down Expand Up @@ -60,7 +66,7 @@ test('moving box by 100 points', () => {
}).start();
});

Fantom.unstable_produceFramesForDuration(500);
Fantom.unstable_produceFramesForDuration(500 + DEFERRED_START_MS);

// shadow tree is not synchronised yet, position X is still 0.
expect(viewElement.getBoundingClientRect().x).toBe(0);
Expand All @@ -81,6 +87,85 @@ test('moving box by 100 points', () => {
expect(viewElement.getBoundingClientRect().x).toBe(100);
});

// Validate that a `useNativeDriver` timing animation does not begin progressing
// until the end of the event loop tick it was started in.
//
// Tested different behavior introduced by `animatedDeferStartOfTimingAnimations`,
// the behavioral difference is animated prop value on the first frame after the tick:
// flag ON -> deferred, not progressed yet, flag OFF -> already progressing.
function startTimingAnimationAndGetTranslateXAfterFirstFrame(): number {
let _translateX;
const viewRef = createRef<HostInstance>();

function MyApp() {
const translateX = useAnimatedValue(0);
_translateX = translateX;
return (
<Animated.View
ref={viewRef}
style={[{width: 100, height: 100}, {transform: [{translateX}]}]}
/>
);
}

const root = Fantom.createRoot();

Fantom.runTask(() => {
root.render(<MyApp />);
});

const viewElement = ensureInstance(viewRef.current, ReactNativeElement);

Fantom.runTask(() => {
Animated.timing(_translateX, {
toValue: 100,
duration: 1000,
useNativeDriver: true,
}).start();

Fantom.unstable_produceFramesForDuration(500);

// The UI thread advances while we are still inside the js tick. The animation
// must not produce any direct manipulation yet, because its mount
// operations have not been flushed. This holds regardless of the flag.
expect(() =>
Fantom.unstable_getDirectManipulationProps(viewElement),
).toThrow();
});

// Produce the first frame after the tick (~16ms rounds to frame 1).
Fantom.unstable_produceFramesForDuration(16);
const translateXAfterFirstFrame =
// $FlowFixMe[incompatible-use]
Fantom.unstable_getDirectManipulationProps(viewElement).transform[0]
.translateX;

// Drain the animation so it completes and the message queue is empty for the
// next test.
Fantom.unstable_produceFramesForDuration(1000);
Fantom.runWorkLoop();
expect(viewElement.getBoundingClientRect().x).toBe(100);

return translateXAfterFirstFrame;
}

if (ReactNativeFeatureFlags.animatedDeferStartOfTimingAnimations()) {
test('animation does not start before the end of the current event loop tick', () => {
// With deferred start, the first frame after the tick outputs the initial
// value and re-anchors timing, so the animation has not progressed yet —
// no frames were skipped despite the UI thread advancing inside the tick.
expect(startTimingAnimationAndGetTranslateXAfterFirstFrame()).toBe(0);
});
} else {
test('animation might start before the end of the current event loop tick', () => {
// Without deferred start, the animation begins progressing immediately — it
// has effectively started before the end of the tick.
expect(
startTimingAnimationAndGetTranslateXAfterFirstFrame(),
).toBeGreaterThan(0);
});
}

test('animation driven by onScroll event', () => {
const scrollViewRef = createRef<HostInstance>();
const viewRef = createRef<HostInstance>();
Expand Down Expand Up @@ -248,7 +333,7 @@ test('animated opacity', () => {
}).start();
});

Fantom.unstable_produceFramesForDuration(30);
Fantom.unstable_produceFramesForDuration(30 + DEFERRED_START_MS);
expect(Fantom.unstable_getDirectManipulationProps(viewElement).opacity).toBe(
0,
);
Expand Down Expand Up @@ -559,7 +644,7 @@ test('animate layout props', () => {
}).start();
});

Fantom.unstable_produceFramesForDuration(10);
Fantom.unstable_produceFramesForDuration(10 + DEFERRED_START_MS);

// TODO: this shouldn't be necessary since animation should be stopped after duration
Fantom.runTask(() => {
Expand Down Expand Up @@ -712,7 +797,7 @@ test('Animated.sequence', () => {
});
});

Fantom.unstable_produceFramesForDuration(500);
Fantom.unstable_produceFramesForDuration(500 + DEFERRED_START_MS);

expect(
// $FlowFixMe[incompatible-use]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type AnimatedValue from '../nodes/AnimatedValue';
import type AnimatedValueXY from '../nodes/AnimatedValueXY';
import type {AnimationConfig, EndCallback} from './Animation';

import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags';
import AnimatedColor from '../nodes/AnimatedColor';
import Animation from './Animation';

Expand Down Expand Up @@ -69,6 +70,7 @@ export default class TimingAnimation extends Animation {
_animationFrame: ?AnimationFrameID;
_timeout: ?TimeoutID;
_platformConfig: ?PlatformConfig;
_deferredStart: boolean;

constructor(config: TimingAnimationConfigSingle) {
super(config);
Expand All @@ -78,6 +80,7 @@ export default class TimingAnimation extends Animation {
this._duration = config.duration ?? 500;
this._delay = config.delay ?? 0;
this._platformConfig = config.platformConfig;
this._deferredStart = false;
}

__getNativeAnimationConfig(): Readonly<{
Expand All @@ -102,6 +105,7 @@ export default class TimingAnimation extends Animation {
iterations: this.__iterations,
platformConfig: this._platformConfig,
debugID: this.__getDebugID(),
deferredStart: this._deferredStart,
};
}

Expand All @@ -116,6 +120,10 @@ export default class TimingAnimation extends Animation {

this._fromValue = fromValue;
this._onUpdate = onUpdate;
if (ReactNativeFeatureFlags.animatedDeferStartOfTimingAnimations()) {
this._deferredStart = animatedValue.__deferAnimationStart;
animatedValue.__deferAnimationStart = false;
}

const start = () => {
this._startTime = Date.now();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* @format
*/

import type {EventSubscription} from '../../vendor/emitter/EventEmitter';

Check warning on line 11 in packages/react-native/Libraries/Animated/nodes/AnimatedValue.js

View workflow job for this annotation

GitHub Actions / test_js (24)

Requires should be sorted alphabetically, with at least one line between imports/requires and code

Check warning on line 11 in packages/react-native/Libraries/Animated/nodes/AnimatedValue.js

View workflow job for this annotation

GitHub Actions / test_js (22.13.0)

Requires should be sorted alphabetically, with at least one line between imports/requires and code
import type {PlatformConfig} from '../AnimatedPlatformConfig';
import type Animation from '../animations/Animation';
import type {EndCallback} from '../animations/Animation';
Expand All @@ -20,6 +20,7 @@
import type {AnimatedNodeConfig} from './AnimatedNode';
import type AnimatedTracking from './AnimatedTracking';

import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags';
import NativeAnimatedHelper from '../../../src/private/animated/NativeAnimatedHelper';
import AnimatedInterpolation from './AnimatedInterpolation';
import AnimatedWithChildren from './AnimatedWithChildren';
Expand Down Expand Up @@ -95,6 +96,7 @@
_offset: number;
_animation: ?Animation;
_tracking: ?AnimatedTracking;
__deferAnimationStart: boolean;

constructor(value: number, config?: ?AnimatedValueConfig) {
super(config);
Expand All @@ -107,6 +109,8 @@

this._startingValue = this._value = value;
this._offset = 0;
this.__deferAnimationStart =
ReactNativeFeatureFlags.animatedDeferStartOfTimingAnimations();
this._animation = null;
if (config && config.useNativeDriver) {
this.__makeNative();
Expand Down Expand Up @@ -327,6 +331,10 @@
result => {
this._animation = null;
callback && callback(result);
if (this._animation == null) {
this.__deferAnimationStart =
ReactNativeFeatureFlags.animatedDeferStartOfTimingAnimations();
}
},
previousAnimation,
this,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,34 @@ void FrameAnimationDriver::onConfigChanged() {
frames_.push_back(frameValue);
}
toValue_ = config_["toValue"].asDouble();
auto deferIt = config_.find("deferredStart");
deferredStart_ = deferIt != config_.items().end() && deferIt->second.asBool();
}

bool FrameAnimationDriver::update(double timeDeltaMs, bool /*restarting*/) {
bool FrameAnimationDriver::update(double timeDeltaMs, bool restarting) {
if (auto node =
manager_->getAnimatedNode<ValueAnimatedNode>(animatedValueTag_)) {
if (!startValue_) {
startValue_ = node->getRawValue();
}

if (deferredStart_ && restarting) {
// On the very first update after start: output the starting value
// (frame 0) and defer the time anchor. The base class will re-anchor
// startFrameTimeMs_ on the next call, so elapsed time is measured
// from the first frame that has actually been rendered — not from
// when startAnimatingNode was dispatched.
//
// This prevents skipping initial frames when the UI thread is busy
// with layout/mount work between animation start and first composite.
node->setRawValue(
startValue_.value() + frames_[0] * (toValue_ - startValue_.value()));
markNodeUpdated(node->tag());
startFrameTimeMs_ = -1;
deferredStart_ = false;
return false;
}

const auto startIndex =
static_cast<size_t>(std::round(timeDeltaMs / SingleFrameIntervalMs));
assert(startIndex >= 0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class FrameAnimationDriver : public AnimationDriver {
std::vector<double> frames_{};
double toValue_{0};
std::optional<double> startValue_{};
bool deferredStart_{false};
};

} // namespace facebook::react
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,52 @@ TEST_F(AnimationDriverTests, framesAnimationReconfigurationClearsFrames) {
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), toValue2);
}

TEST_F(AnimationDriverTests, framesAnimationDeferredStart) {
// Deferred start outputs frame 0 on the first update and re-anchors
// startFrameTimeMs_ so the second update also sees timeDelta=0.
// Without the defer the second frame would already be at value 25.
initNodesManager();

auto rootTag = getNextRootViewTag();

auto valueNodeTag = ++rootTag;
nodesManager_->createAnimatedNode(
valueNodeTag,
folly::dynamic::object("type", "value")("value", 0)("offset", 0));

const auto animationId = 1;
const auto frames = folly::dynamic::array(0.0f, 0.25f, 0.5f, 0.75f, 1.0f);
const auto toValue = 100;
nodesManager_->startAnimatingNode(
animationId,
valueNodeTag,
folly::dynamic::object("type", "frames")("frames", frames)(
"toValue", toValue)("deferredStart", true),
std::nullopt);

const double t = 12345;

// Frame 1: both with and without deferredStart, timeDelta=0 → value=0
runAnimationFrame(t);
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0);

// Frame 2: WITHOUT deferredStart timeDelta=SI → value≈25.
// WITH deferredStart the deferred start re-anchored startFrameTimeMs_, so
// timeDelta=0 → value=0. This assertion fails without deferredStart.
runAnimationFrame(t + SingleFrameIntervalMs);
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0);

// Frame 3: now timeDelta=SI from the re-anchored start
runAnimationFrame(t + SingleFrameIntervalMs * 2);
EXPECT_NEAR(nodesManager_->getValue(valueNodeTag).value(), 25, 0.01);

// Frame 4
runAnimationFrame(t + SingleFrameIntervalMs * 3);
EXPECT_NEAR(nodesManager_->getValue(valueNodeTag).value(), 50, 0.01);

// Complete
runAnimationFrame(t + SingleFrameIntervalMs * 5);
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), toValue);
}

} // namespace facebook::react
Loading
Loading