Skip to content

Commit 96dd5fb

Browse files
zeyapmeta-codesync[bot]
authored andcommitted
Defer animation start time in FrameAnimationDriver (#56929)
Summary: Pull Request resolved: #56929 ## Changelog: [Internal] [Fixed] - Defer animation start time in FrameAnimationDriver **Problem**: In complex apps, if animation is started in commit phase (the case if animation starts in useLayoutEffect, or from ViewTransition event handlers), it'll skip initial frames — the user sees the animation snap to an intermediate position. This happens because `FrameAnimationDriver` anchors its start time on the first `runAnimationStep` call, but the UI thread may be busy with layout/mount work for several frames before the view actually composites. The elapsed wall-clock time advances, causing `frameIndex` to jump ahead. **Why**: `startFrameTimeMs_` is set to the Choreographer frame time on the first tick. If the UI thread is blocked processing a heavy tree (many views mounting), subsequent ticks arrive much later — `timeDeltaMs` jumps and the animation skips to a mid-point. - Every major framework solves this: Flutter uses lazy start (`_startTime ??= timeStamp` on first actual tick), Android native uses `CALLBACK_COMMIT` to adjust post-traversal, and CSS View Transitions spec defers start until post-composite. **Fix**: On the very first `update()` call, output the starting value (frame 0) and reset `startFrameTimeMs_ = -1`. This causes the base class to re-anchor on the next `runAnimationStep`, so elapsed time is measured from the first frame that has actually been rendered — not from when `startAnimatingNode` was dispatched. The flag disables itself after one use, so all subsequent frames use pure elapsed-time with no behavioral change. Differential Revision: D106007152
1 parent d4ae881 commit 96dd5fb

8 files changed

Lines changed: 292 additions & 21 deletions

File tree

packages/react-native/Libraries/Animated/__tests__/Animated-itest.js

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @fantom_flags useSharedAnimatedBackend:*
7+
* @fantom_flags useSharedAnimatedBackend:* animatedDeferStartOfTimingAnimations:*
88
* @flow strict-local
99
* @format
1010
*/
@@ -21,6 +21,12 @@ import {Animated, View, useAnimatedValue} from 'react-native';
2121
import {allowStyleProp} from 'react-native/Libraries/Animated/NativeAnimatedAllowlist';
2222
import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement';
2323

24+
// Deferred start outputs the initial value on the first animation frame and
25+
// re-anchors timing on the second. This delays animation progress by one
26+
// frame interval (~16ms at 60 fps).
27+
const DEFERRED_START_MS =
28+
ReactNativeFeatureFlags.animatedDeferStartOfTimingAnimations() ? 16 : 0;
29+
2430
test('moving box by 100 points', () => {
2531
let _translateX;
2632
const viewRef = createRef<HostInstance>();
@@ -60,7 +66,7 @@ test('moving box by 100 points', () => {
6066
}).start();
6167
});
6268

63-
Fantom.unstable_produceFramesForDuration(500);
69+
Fantom.unstable_produceFramesForDuration(500 + DEFERRED_START_MS);
6470

6571
// shadow tree is not synchronised yet, position X is still 0.
6672
expect(viewElement.getBoundingClientRect().x).toBe(0);
@@ -248,7 +254,7 @@ test('animated opacity', () => {
248254
}).start();
249255
});
250256

251-
Fantom.unstable_produceFramesForDuration(30);
257+
Fantom.unstable_produceFramesForDuration(30 + DEFERRED_START_MS);
252258
expect(Fantom.unstable_getDirectManipulationProps(viewElement).opacity).toBe(
253259
0,
254260
);
@@ -559,7 +565,7 @@ test('animate layout props', () => {
559565
}).start();
560566
});
561567

562-
Fantom.unstable_produceFramesForDuration(10);
568+
Fantom.unstable_produceFramesForDuration(10 + DEFERRED_START_MS);
563569

564570
// TODO: this shouldn't be necessary since animation should be stopped after duration
565571
Fantom.runTask(() => {
@@ -712,7 +718,7 @@ test('Animated.sequence', () => {
712718
});
713719
});
714720

715-
Fantom.unstable_produceFramesForDuration(500);
721+
Fantom.unstable_produceFramesForDuration(500 + DEFERRED_START_MS);
716722

717723
expect(
718724
// $FlowFixMe[incompatible-use]

packages/react-native/Libraries/Animated/animations/TimingAnimation.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type AnimatedValue from '../nodes/AnimatedValue';
1515
import type AnimatedValueXY from '../nodes/AnimatedValueXY';
1616
import type {AnimationConfig, EndCallback} from './Animation';
1717

18+
import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags';
1819
import AnimatedColor from '../nodes/AnimatedColor';
1920
import Animation from './Animation';
2021

@@ -69,6 +70,7 @@ export default class TimingAnimation extends Animation {
6970
_animationFrame: ?AnimationFrameID;
7071
_timeout: ?TimeoutID;
7172
_platformConfig: ?PlatformConfig;
73+
_deferredStart: boolean;
7274

7375
constructor(config: TimingAnimationConfigSingle) {
7476
super(config);
@@ -78,6 +80,7 @@ export default class TimingAnimation extends Animation {
7880
this._duration = config.duration ?? 500;
7981
this._delay = config.delay ?? 0;
8082
this._platformConfig = config.platformConfig;
83+
this._deferredStart = false;
8184
}
8285

8386
__getNativeAnimationConfig(): Readonly<{
@@ -102,6 +105,7 @@ export default class TimingAnimation extends Animation {
102105
iterations: this.__iterations,
103106
platformConfig: this._platformConfig,
104107
debugID: this.__getDebugID(),
108+
deferredStart: this._deferredStart,
105109
};
106110
}
107111

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

117121
this._fromValue = fromValue;
118122
this._onUpdate = onUpdate;
123+
if (ReactNativeFeatureFlags.animatedDeferStartOfTimingAnimations()) {
124+
this._deferredStart = animatedValue.__deferAnimationStart;
125+
animatedValue.__deferAnimationStart = false;
126+
}
119127

120128
const start = () => {
121129
this._startTime = Date.now();

packages/react-native/Libraries/Animated/nodes/AnimatedValue.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type AnimatedNode from './AnimatedNode';
2020
import type {AnimatedNodeConfig} from './AnimatedNode';
2121
import type AnimatedTracking from './AnimatedTracking';
2222

23+
import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags';
2324
import NativeAnimatedHelper from '../../../src/private/animated/NativeAnimatedHelper';
2425
import AnimatedInterpolation from './AnimatedInterpolation';
2526
import AnimatedWithChildren from './AnimatedWithChildren';
@@ -95,6 +96,7 @@ export default class AnimatedValue extends AnimatedWithChildren {
9596
_offset: number;
9697
_animation: ?Animation;
9798
_tracking: ?AnimatedTracking;
99+
__deferAnimationStart: boolean;
98100

99101
constructor(value: number, config?: ?AnimatedValueConfig) {
100102
super(config);
@@ -107,6 +109,8 @@ export default class AnimatedValue extends AnimatedWithChildren {
107109

108110
this._startingValue = this._value = value;
109111
this._offset = 0;
112+
this.__deferAnimationStart =
113+
ReactNativeFeatureFlags.animatedDeferStartOfTimingAnimations();
110114
this._animation = null;
111115
if (config && config.useNativeDriver) {
112116
this.__makeNative();
@@ -327,6 +331,10 @@ export default class AnimatedValue extends AnimatedWithChildren {
327331
result => {
328332
this._animation = null;
329333
callback && callback(result);
334+
if (this._animation == null) {
335+
this.__deferAnimationStart =
336+
ReactNativeFeatureFlags.animatedDeferStartOfTimingAnimations();
337+
}
330338
},
331339
previousAnimation,
332340
this,

packages/react-native/ReactCommon/react/renderer/animated/drivers/FrameAnimationDriver.cpp

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,34 @@ void FrameAnimationDriver::onConfigChanged() {
4949
frames_.push_back(frameValue);
5050
}
5151
toValue_ = config_["toValue"].asDouble();
52+
auto deferIt = config_.find("deferredStart");
53+
deferredStart_ = deferIt == config_.items().end() || deferIt->second.asBool();
5254
}
5355

54-
bool FrameAnimationDriver::update(double timeDeltaMs, bool /*restarting*/) {
56+
bool FrameAnimationDriver::update(double timeDeltaMs, bool restarting) {
5557
if (auto node =
5658
manager_->getAnimatedNode<ValueAnimatedNode>(animatedValueTag_)) {
5759
if (!startValue_) {
5860
startValue_ = node->getRawValue();
5961
}
6062

63+
if (deferredStart_ && restarting) {
64+
// On the very first update after start: output the starting value
65+
// (frame 0) and defer the time anchor. The base class will re-anchor
66+
// startFrameTimeMs_ on the next call, so elapsed time is measured
67+
// from the first frame that has actually been rendered — not from
68+
// when startAnimatingNode was dispatched.
69+
//
70+
// This prevents skipping initial frames when the UI thread is busy
71+
// with layout/mount work between animation start and first composite.
72+
node->setRawValue(
73+
startValue_.value() + frames_[0] * (toValue_ - startValue_.value()));
74+
markNodeUpdated(node->tag());
75+
startFrameTimeMs_ = -1;
76+
deferredStart_ = false;
77+
return false;
78+
}
79+
6180
const auto startIndex =
6281
static_cast<size_t>(std::round(timeDeltaMs / SingleFrameIntervalMs));
6382
assert(startIndex >= 0);

packages/react-native/ReactCommon/react/renderer/animated/drivers/FrameAnimationDriver.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class FrameAnimationDriver : public AnimationDriver {
3535
std::vector<double> frames_{};
3636
double toValue_{0};
3737
std::optional<double> startValue_{};
38+
bool deferredStart_{true};
3839
};
3940

4041
} // namespace facebook::react

0 commit comments

Comments
 (0)