diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollViewStickyHeader.js b/packages/react-native/Libraries/Components/ScrollView/ScrollViewStickyHeader.js index 6caef6b5066a..1d8e18bab78a 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollViewStickyHeader.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollViewStickyHeader.js @@ -118,46 +118,8 @@ const ScrollViewStickyHeader: component( }, ); - const haveReceivedInitialZeroTranslateY = useRef(true); const translateYDebounceTimer = useRef>(null); - useEffect(() => { - if (translateY !== 0 && translateY != null) { - haveReceivedInitialZeroTranslateY.current = false; - } - }, [translateY]); - - // This is called whenever the (Interpolated) Animated Value - // updates, which is several times per frame during scrolling. - // To ensure that the Fabric ShadowTree has the most recent - // translate style of this node, we debounce the value and then - // pass it through to the underlying node during render. - // This is: - // 1. Only an issue in Fabric. - // 2. Worse in Android than iOS. In Android, but not iOS, you - // can touch and move your finger slightly and still trigger - // a "tap" event. In iOS, moving will cancel the tap in - // both Fabric and non-Fabric. On Android when you move - // your finger, the hit-detection moves from the Android - // platform to JS, so we need the ShadowTree to have knowledge - // of the current position. - const animatedValueListener = useCallback(({value}: $FlowFixMe) => { - const debounceTimeout: number = Platform.OS === 'android' ? 15 : 64; - // When the AnimatedInterpolation is recreated, it always initializes - // to a value of zero and emits a value change of 0 to its listeners. - if (value === 0 && !haveReceivedInitialZeroTranslateY.current) { - haveReceivedInitialZeroTranslateY.current = true; - return; - } - if (translateYDebounceTimer.current != null) { - clearTimeout(translateYDebounceTimer.current); - } - translateYDebounceTimer.current = setTimeout( - () => setTranslateY(value), - debounceTimeout, - ); - }, []); - useEffect(() => { const inputRange: Array = [-1, 0]; const outputRange: Array = [0, 0]; @@ -233,6 +195,30 @@ const ScrollViewStickyHeader: component( newAnimatedTranslateY = Animated.add(newAnimatedTranslateY, offset); } + // The interpolation emits a zero when it is first created. Ignore only + // that initial value; a later zero is the actual position when the header + // returns from its sticky position. + let isInitialValue = true; + // Keep the Fabric ShadowTree's transform in sync with the native animation + // for hit testing and measurement. Debounce updates while scrolling to + // avoid rendering on every frame. + const animatedValueListener = ({value}: {value: number}) => { + if (isInitialValue) { + isInitialValue = false; + if (value === 0) { + return; + } + } + const debounceTimeout: number = Platform.OS === 'android' ? 15 : 64; + if (translateYDebounceTimer.current != null) { + clearTimeout(translateYDebounceTimer.current); + } + translateYDebounceTimer.current = setTimeout( + () => setTranslateY(value), + debounceTimeout, + ); + }; + // add the event listener let animatedListenerId; if (isFabric) { @@ -261,7 +247,6 @@ const ScrollViewStickyHeader: component( scrollAnimatedValue, inverted, offset, - animatedValueListener, isFabric, ]); diff --git a/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollViewStickyHeader-test.js b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollViewStickyHeader-test.js new file mode 100644 index 000000000000..f397662d4d9a --- /dev/null +++ b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollViewStickyHeader-test.js @@ -0,0 +1,108 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import NativeAnimatedHelper from '../../../../src/private/animated/NativeAnimatedHelper'; +import Animated from '../../../Animated/Animated'; +import View from '../../View/View'; +import ScrollViewStickyHeader from '../ScrollViewStickyHeader'; +import nullthrows from 'nullthrows'; +import * as React from 'react'; +import TestRenderer from 'react-test-renderer'; + +// Jest host refs are not Fabric public instances, so enable the Fabric path. +jest.mock( + '../../../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstanceUtils', + () => ({ + isPublicInstance: () => true, + }), +); + +describe('ScrollViewStickyHeader', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('updates its Fabric transform while sticky and after returning to its layout position', async () => { + const scrollAnimatedValue = new Animated.Value(0, { + useNativeDriver: true, + }); + let renderer; + + await TestRenderer.act(async () => { + renderer = TestRenderer.create( + {}} + scrollAnimatedValue={scrollAnimatedValue} + inverted={false} + scrollViewHeight={null}> + + , + { + createNodeMock: () => ({__nativeTag: 2, setNativeProps: () => {}}), + }, + ); + }); + + const header = () => + nullthrows( + renderer.root + .findAllByProps({nativeID: 'sticky-header'}) + .filter(node => node.props.style != null) + .pop(), + ); + await TestRenderer.act(async () => { + header().props.onLayout({ + nativeEvent: {layout: {y: 100, height: 20}}, + }); + }); + + const animatedHeader = nullthrows( + nullthrows(renderer) + .root.findAllByProps({nativeID: 'sticky-header'}) + .find(node => Array.isArray(node.props.style)), + ); + const animatedTranslateY = + animatedHeader.props.style[2].transform[0].translateY; + const animatedTag = animatedTranslateY.__getNativeTag(); + const emitNativeTranslation = (value: number) => { + NativeAnimatedHelper.nativeEventEmitter.emit('onAnimatedValueUpdate', { + tag: animatedTag, + value, + }); + }; + + await TestRenderer.act(async () => { + emitNativeTranslation(0); + emitNativeTranslation(50); + jest.advanceTimersByTime(65); + }); + expect(header().props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({transform: [{translateY: 50}]}), + ]), + ); + + await TestRenderer.act(async () => { + emitNativeTranslation(0); + jest.advanceTimersByTime(65); + }); + expect(header().props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({transform: [{translateY: 0}]}), + ]), + ); + }); +});