Skip to content
Closed
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 @@ -118,46 +118,8 @@ const ScrollViewStickyHeader: component(
},
);

const haveReceivedInitialZeroTranslateY = useRef<boolean>(true);
const translateYDebounceTimer = useRef<?ReturnType<typeof setTimeout>>(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<number> = [-1, 0];
const outputRange: Array<number> = [0, 0];
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -261,7 +247,6 @@ const ScrollViewStickyHeader: component(
scrollAnimatedValue,
inverted,
offset,
animatedValueListener,
isFabric,
]);

Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<ScrollViewStickyHeader
nativeID="sticky-header"
nextHeaderLayoutY={null}
onLayout={() => {}}
scrollAnimatedValue={scrollAnimatedValue}
inverted={false}
scrollViewHeight={null}>
<View style={{height: 20}} />
</ScrollViewStickyHeader>,
{
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}]}),
]),
);
});
});
Loading