diff --git a/packages/react-native/Libraries/Animated/__tests__/AnimatedValue-test.js b/packages/react-native/Libraries/Animated/__tests__/AnimatedValue-test.js index d5309151065..ece374cd86c 100644 --- a/packages/react-native/Libraries/Animated/__tests__/AnimatedValue-test.js +++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedValue-test.js @@ -68,15 +68,22 @@ describe('AnimatedValue', () => { expect(callback).toBeCalledTimes(1); }); - it('creates a native node on attach', () => { + it('creates a native node when adding a listener', () => { const node = createNativeAnimatedValue(); node.__attach(); + expect(NativeAnimatedHelper.API.createAnimatedNode).not.toBeCalled(); + + const id = node.addListener(jest.fn()); + node.removeListener(id); expect(NativeAnimatedHelper.API.createAnimatedNode).toBeCalledTimes(1); }); it('drops a created native node on detach', () => { const node = createNativeAnimatedValue(); node.__attach(); + expect(NativeAnimatedHelper.API.createAnimatedNode).toBeCalledTimes(0); + + node.addListener(jest.fn()); expect(NativeAnimatedHelper.API.createAnimatedNode).toBeCalledTimes(1); expect(NativeAnimatedHelper.API.dropAnimatedNode).toBeCalledTimes(0); diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js b/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js index 889bfa51825..44122d9fe5b 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js @@ -9,6 +9,7 @@ */ import type {EventSubscription} from '../../vendor/emitter/EventEmitter'; +import type {PlatformConfig} from '../AnimatedPlatformConfig'; import type Animation, {EndCallback} from '../animations/Animation'; import type {InterpolationConfigType} from './AnimatedInterpolation'; import type AnimatedNode from './AnimatedNode'; @@ -84,6 +85,7 @@ function _executeAsAnimatedBatch(id: string, operation: () => void) { * See https://reactnative.dev/docs/animatedvalue */ export default class AnimatedValue extends AnimatedWithChildren { + #listenerCount: number = 0; #updateSubscription: ?EventSubscription = null; _value: number; @@ -105,20 +107,8 @@ export default class AnimatedValue extends AnimatedWithChildren { } } - __attach(): void { + __detach() { if (this.__isNative) { - // NOTE: In theory, we should only need to call this when any listeners - // are added. However, there is a global `onUserDrivenAnimationEnded` - // listener that relies on `onAnimatedValueUpdate` having fired to update - // the values in JavaScript. If that listener is removed, this could be - // re-optimized. - this.#ensureUpdateSubscriptionExists(); - } - } - - __detach(): void { - if (this.__isNative) { - this.#updateSubscription?.remove(); NativeAnimatedAPI.getValue(this.__getNativeTag(), value => { this._value = value - this._offset; }); @@ -131,6 +121,38 @@ export default class AnimatedValue extends AnimatedWithChildren { return this._value + this._offset; } + __makeNative(platformConfig: ?PlatformConfig): void { + super.__makeNative(platformConfig); + if (this.#listenerCount > 0) { + this.#ensureUpdateSubscriptionExists(); + } + } + + addListener(callback: (value: any) => mixed): string { + const id = super.addListener(callback); + this.#listenerCount++; + if (this.__isNative) { + this.#ensureUpdateSubscriptionExists(); + } + return id; + } + + removeListener(id: string): void { + super.removeListener(id); + this.#listenerCount--; + if (this.__isNative && this.#listenerCount === 0) { + this.#updateSubscription?.remove(); + } + } + + removeAllListeners(): void { + super.removeAllListeners(); + this.#listenerCount = 0; + if (this.__isNative) { + this.#updateSubscription?.remove(); + } + } + #ensureUpdateSubscriptionExists(): void { if (this.#updateSubscription != null) { return; diff --git a/packages/react-native/Libraries/Animated/useAnimatedProps.js b/packages/react-native/Libraries/Animated/useAnimatedProps.js index 98fc5678373..5130964f7ed 100644 --- a/packages/react-native/Libraries/Animated/useAnimatedProps.js +++ b/packages/react-native/Libraries/Animated/useAnimatedProps.js @@ -17,7 +17,9 @@ import * as ReactNativeFeatureFlags from '../../src/private/featureflags/ReactNa import {isPublicInstance as isFabricPublicInstance} from '../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstanceUtils'; import useRefEffect from '../Utilities/useRefEffect'; import {AnimatedEvent} from './AnimatedEvent'; +import AnimatedNode from './nodes/AnimatedNode'; import AnimatedProps from './nodes/AnimatedProps'; +import AnimatedValue from './nodes/AnimatedValue'; import { useCallback, useEffect, @@ -37,6 +39,11 @@ type CallbackRef = T => mixed; type UpdateCallback = () => void; +type AnimatedValueListeners = Array<{ + propValue: AnimatedValue, + listenerId: string, +}>; + const useMemoOrAnimatedPropsMemo = ReactNativeFeatureFlags.enableAnimatedPropsMemo() ? useAnimatedPropsMemo @@ -162,6 +169,7 @@ export default function useAnimatedProps( const target = getEventTarget(instance); const events = []; + const animatedValueListeners: AnimatedValueListeners = []; for (const propName in props) { // $FlowFixMe[invalid-computed-prop] @@ -169,6 +177,8 @@ export default function useAnimatedProps( if (propValue instanceof AnimatedEvent && propValue.__isNative) { propValue.__attach(target, propName); events.push([propName, propValue]); + // $FlowFixMe[incompatible-call] - the `addListenersToPropsValue` drills down the propValue. + addListenersToPropsValue(propValue, animatedValueListeners); } } @@ -178,6 +188,10 @@ export default function useAnimatedProps( for (const [propName, propValue] of events) { propValue.__detach(target, propName); } + + for (const {propValue, listenerId} of animatedValueListeners) { + propValue.removeListener(listenerId); + } }; }, [node, useNativePropsInFabric, props], @@ -201,6 +215,35 @@ function reduceAnimatedProps( }; } +function addListenersToPropsValue( + propValue: AnimatedValue, + accumulator: AnimatedValueListeners, +) { + // propValue can be a scalar value, an array or an object. + if (propValue instanceof AnimatedValue) { + const listenerId = propValue.addListener(() => {}); + accumulator.push({propValue, listenerId}); + } else if (Array.isArray(propValue)) { + // An array can be an array of scalar values, arrays of arrays, or arrays of objects + for (const prop of propValue) { + addListenersToPropsValue(prop, accumulator); + } + } else if (propValue instanceof Object) { + addAnimatedValuesListenersToProps(propValue, accumulator); + } +} + +function addAnimatedValuesListenersToProps( + props: AnimatedNode, + accumulator: AnimatedValueListeners, +) { + for (const propName in props) { + // $FlowFixMe[prop-missing] - This is an object contained in a prop, but we don't know the exact type. + const propValue = props[propName]; + addListenersToPropsValue(propValue, accumulator); + } +} + /** * Manages the lifecycle of the supplied `AnimatedProps` by invoking `__attach` * and `__detach`. However, this is more complicated because `AnimatedProps` diff --git a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap index e6364898cbb..94730a0a06f 100644 --- a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap +++ b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap @@ -1106,9 +1106,12 @@ declare export default class AnimatedValue extends AnimatedWithChildren { _animation: ?Animation; _tracking: ?AnimatedTracking; constructor(value: number, config?: ?AnimatedValueConfig): void; - __attach(): void; __detach(): void; __getValue(): number; + __makeNative(platformConfig: ?PlatformConfig): void; + addListener(callback: (value: any) => mixed): string; + removeListener(id: string): void; + removeAllListeners(): void; setValue(value: number): void; setOffset(offset: number): void; flattenOffset(): void; diff --git a/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js b/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js index a6f51e17561..3d9f4ac2208 100644 --- a/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js +++ b/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js @@ -208,21 +208,15 @@ describe('Native Animated', () => { const value1 = new Animated.Value(0); value1.__makeNative(); - const nativeTag = value1.__getNativeTag(); - - value1.__attach(); const listener = jest.fn(); const id = value1.addListener(listener); expect( NativeAnimatedModule.startListeningToAnimatedNodeValue, - ).toHaveBeenCalledTimes(1); - expect( - NativeAnimatedModule.startListeningToAnimatedNodeValue, - ).toHaveBeenCalledWith(nativeTag); + ).toHaveBeenCalledWith(value1.__getNativeTag()); NativeAnimatedHelper.nativeEventEmitter.emit('onAnimatedValueUpdate', { value: 42, - tag: nativeTag, + tag: value1.__getNativeTag(), }); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toBeCalledWith({value: 42}); @@ -230,24 +224,20 @@ describe('Native Animated', () => { NativeAnimatedHelper.nativeEventEmitter.emit('onAnimatedValueUpdate', { value: 7, - tag: nativeTag, + tag: value1.__getNativeTag(), }); expect(listener).toHaveBeenCalledTimes(2); expect(listener).toBeCalledWith({value: 7}); expect(value1.__getValue()).toBe(7); value1.removeListener(id); - value1.__detach(); expect( NativeAnimatedModule.stopListeningToAnimatedNodeValue, - ).toHaveBeenCalledTimes(1); - expect( - NativeAnimatedModule.stopListeningToAnimatedNodeValue, - ).toHaveBeenCalledWith(nativeTag); + ).toHaveBeenCalledWith(value1.__getNativeTag()); NativeAnimatedHelper.nativeEventEmitter.emit('onAnimatedValueUpdate', { value: 1492, - tag: nativeTag, + tag: value1.__getNativeTag(), }); expect(listener).toHaveBeenCalledTimes(2); expect(value1.__getValue()).toBe(7); @@ -258,37 +248,27 @@ describe('Native Animated', () => { const value1 = new Animated.Value(0); value1.__makeNative(); - const nativeTag = value1.__getNativeTag(); - - value1.__attach(); const listener = jest.fn(); [1, 2, 3, 4].forEach(() => value1.addListener(listener)); expect( NativeAnimatedModule.startListeningToAnimatedNodeValue, - ).toHaveBeenCalledTimes(1); - expect( - NativeAnimatedModule.startListeningToAnimatedNodeValue, - ).toHaveBeenCalledWith(nativeTag); + ).toHaveBeenCalledWith(value1.__getNativeTag()); NativeAnimatedHelper.nativeEventEmitter.emit('onAnimatedValueUpdate', { value: 42, - tag: nativeTag, + tag: value1.__getNativeTag(), }); expect(listener).toHaveBeenCalledTimes(4); expect(listener).toBeCalledWith({value: 42}); value1.removeAllListeners(); - value1.__detach(); expect( NativeAnimatedModule.stopListeningToAnimatedNodeValue, - ).toHaveBeenCalledTimes(1); - expect( - NativeAnimatedModule.stopListeningToAnimatedNodeValue, - ).toHaveBeenCalledWith(nativeTag); + ).toHaveBeenCalledWith(value1.__getNativeTag()); NativeAnimatedHelper.nativeEventEmitter.emit('onAnimatedValueUpdate', { value: 7, - tag: nativeTag, + tag: value1.__getNativeTag(), }); expect(listener).toHaveBeenCalledTimes(4); });