Files
react-native/Libraries/Components/ScrollView/ScrollViewStickyHeader.js
T
Mike Vitousek 8c2a4d0d26 Annotate React hooks in xplat, defaulting to any
Summary:
Add explicit annotations to React hook callbacks as required for Flow's Local Type Inference project. This codemod prepares the codebase to match Flow's new typechecking algorithm. The new algorithm will make Flow more reliable and predictable.

This diff adds `any` or `$FlowFixMe` in cases where more precise types could not be determined.

Details:
- Codemod script: `.facebook/flowd codemod annotate-react-hooks ../../xplat/js --default-any --write`
- Local Type Inference announcement: [post](https://fb.workplace.com/groups/flowlang/posts/788206301785035)
- Support group: [Flow Support](https://fb.workplace.com/groups/flow)

drop-conflicts
bypass-lint

Reviewed By: SamChou19815

Differential Revision: D41231214

fbshipit-source-id: d5f5ce8d61020baa1138292c9e9f1c69dffd324c
2022-11-11 17:04:38 -08:00

310 lines
11 KiB
JavaScript

/**
* 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 type {LayoutEvent} from '../../Types/CoreEventTypes';
import Animated from '../../Animated/Animated';
import StyleSheet from '../../StyleSheet/StyleSheet';
import Platform from '../../Utilities/Platform';
import useMergeRefs from '../../Utilities/useMergeRefs';
import * as React from 'react';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
export type Props = $ReadOnly<{
children?: React.Element<$FlowFixMe>,
nextHeaderLayoutY: ?number,
onLayout: (event: LayoutEvent) => void,
scrollAnimatedValue: Animated.Value,
// Will cause sticky headers to stick at the bottom of the ScrollView instead
// of the top.
inverted: ?boolean,
// The height of the parent ScrollView. Currently only set when inverted.
scrollViewHeight: ?number,
nativeID?: ?string,
hiddenOnScroll?: ?boolean,
}>;
type Instance = {
...React.ElementRef<typeof Animated.View>,
setNextHeaderY: number => void,
...
};
const ScrollViewStickyHeaderWithForwardedRef: React.AbstractComponent<
Props,
Instance,
> = React.forwardRef(function ScrollViewStickyHeader(props, forwardedRef) {
const {
inverted,
scrollViewHeight,
hiddenOnScroll,
scrollAnimatedValue,
nextHeaderLayoutY: _nextHeaderLayoutY,
} = props;
const [measured, setMeasured] = useState<boolean>(false);
const [layoutY, setLayoutY] = useState<number>(0);
const [layoutHeight, setLayoutHeight] = useState<number>(0);
const [translateY, setTranslateY] = useState<?number>(null);
const [nextHeaderLayoutY, setNextHeaderLayoutY] =
useState<?number>(_nextHeaderLayoutY);
const [isFabric, setIsFabric] = useState<boolean>(false);
const callbackRef = (ref: Instance | null): void => {
if (ref == null) {
return;
}
ref.setNextHeaderY = value => {
setNextHeaderLayoutY(value);
};
// Avoid dot notation because at Meta, private properties are obfuscated.
// $FlowFixMe[prop-missing]
const _internalInstanceHandler = ref['_internalInstanceHandle']; // eslint-disable-line dot-notation
setIsFabric(Boolean(_internalInstanceHandler?.stateNode?.canonical));
};
const ref: (React.ElementRef<typeof Animated.View> | null) => void =
// $FlowFixMe[incompatible-type] - Ref is mutated by `callbackRef`.
useMergeRefs<Instance | null>(callbackRef, forwardedRef);
const offset = useMemo(
() =>
hiddenOnScroll === true
? Animated.diffClamp(
scrollAnimatedValue
.interpolate({
extrapolateLeft: 'clamp',
inputRange: [layoutY, layoutY + 1],
outputRange: ([0, 1]: Array<number>),
})
.interpolate({
inputRange: [0, 1],
outputRange: ([0, -1]: Array<number>),
}),
-layoutHeight,
0,
)
: null,
[scrollAnimatedValue, layoutHeight, layoutY, hiddenOnScroll],
);
const [animatedTranslateY, setAnimatedTranslateY] = useState<Animated.Node>(
() => {
const inputRange: Array<number> = [-1, 0];
const outputRange: Array<number> = [0, 0];
const initialTranslateY = scrollAnimatedValue.interpolate({
inputRange,
outputRange,
});
if (offset != null) {
return Animated.add(initialTranslateY, offset);
}
return initialTranslateY;
},
);
const _haveReceivedInitialZeroTranslateY = useRef<boolean>(true);
const _timer = useRef<?TimeoutID>(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 (_timer.current != null) {
clearTimeout(_timer.current);
}
_timer.current = setTimeout(() => {
if (value !== translateY) {
setTranslateY(value);
}
}, _debounceTimeout);
},
[translateY],
);
useEffect(() => {
const inputRange: Array<number> = [-1, 0];
const outputRange: Array<number> = [0, 0];
if (measured) {
if (inverted === true) {
// The interpolation looks like:
// - Negative scroll: no translation
// - `stickStartPoint` is the point at which the header will start sticking.
// It is calculated using the ScrollView viewport height so it is a the bottom.
// - Headers that are in the initial viewport will never stick, `stickStartPoint`
// will be negative.
// - From 0 to `stickStartPoint` no translation. This will cause the header
// to scroll normally until it reaches the top of the scroll view.
// - From `stickStartPoint` to when the next header y hits the bottom edge of the header: translate
// equally to scroll. This will cause the header to stay at the top of the scroll view.
// - Past the collision with the next header y: no more translation. This will cause the
// header to continue scrolling up and make room for the next sticky header.
// In the case that there is no next header just translate equally to
// scroll indefinitely.
if (scrollViewHeight != null) {
const stickStartPoint = layoutY + layoutHeight - scrollViewHeight;
if (stickStartPoint > 0) {
inputRange.push(stickStartPoint);
outputRange.push(0);
inputRange.push(stickStartPoint + 1);
outputRange.push(1);
// If the next sticky header has not loaded yet (probably windowing) or is the last
// we can just keep it sticked forever.
const collisionPoint =
(nextHeaderLayoutY || 0) - layoutHeight - scrollViewHeight;
if (collisionPoint > stickStartPoint) {
inputRange.push(collisionPoint, collisionPoint + 1);
outputRange.push(
collisionPoint - stickStartPoint,
collisionPoint - stickStartPoint,
);
}
}
}
} else {
// The interpolation looks like:
// - Negative scroll: no translation
// - From 0 to the y of the header: no translation. This will cause the header
// to scroll normally until it reaches the top of the scroll view.
// - From header y to when the next header y hits the bottom edge of the header: translate
// equally to scroll. This will cause the header to stay at the top of the scroll view.
// - Past the collision with the next header y: no more translation. This will cause the
// header to continue scrolling up and make room for the next sticky header.
// In the case that there is no next header just translate equally to
// scroll indefinitely.
inputRange.push(layoutY);
outputRange.push(0);
// If the next sticky header has not loaded yet (probably windowing) or is the last
// we can just keep it sticked forever.
const collisionPoint = (nextHeaderLayoutY || 0) - layoutHeight;
if (collisionPoint >= layoutY) {
inputRange.push(collisionPoint, collisionPoint + 1);
outputRange.push(collisionPoint - layoutY, collisionPoint - layoutY);
} else {
inputRange.push(layoutY + 1);
outputRange.push(1);
}
}
}
let newAnimatedTranslateY: Animated.Node = scrollAnimatedValue.interpolate({
inputRange,
outputRange,
});
if (offset != null) {
newAnimatedTranslateY = Animated.add(newAnimatedTranslateY, offset);
}
// add the event listener
let animatedListenerId;
if (isFabric) {
animatedListenerId = newAnimatedTranslateY.addListener(
animatedValueListener,
);
}
setAnimatedTranslateY(newAnimatedTranslateY);
// clean up the event listener and timer
return () => {
if (animatedListenerId) {
newAnimatedTranslateY.removeListener(animatedListenerId);
}
if (_timer.current != null) {
clearTimeout(_timer.current);
}
};
}, [nextHeaderLayoutY, measured, layoutHeight, layoutY, scrollViewHeight, scrollAnimatedValue, inverted, offset, animatedValueListener, isFabric]);
const _onLayout = (event: LayoutEvent) => {
setLayoutY(event.nativeEvent.layout.y);
setLayoutHeight(event.nativeEvent.layout.height);
setMeasured(true);
props.onLayout(event);
const child = React.Children.only<$FlowFixMe>(props.children);
if (child.props.onLayout) {
child.props.onLayout(event);
}
};
const child = React.Children.only<$FlowFixMe>(props.children);
// TODO T68319535: remove this if NativeAnimated is rewritten for Fabric
const passthroughAnimatedPropExplicitValues =
isFabric && translateY != null
? {
style: {transform: [{translateY: translateY}]},
}
: null;
return (
/* $FlowFixMe[prop-missing] passthroughAnimatedPropExplicitValues isn't properly
included in the Animated.View flow type. */
<Animated.View
collapsable={false}
nativeID={props.nativeID}
onLayout={_onLayout}
ref={ref}
style={[
child.props.style,
styles.header,
{transform: [{translateY: animatedTranslateY}]},
]}
passthroughAnimatedPropExplicitValues={
passthroughAnimatedPropExplicitValues
}>
{React.cloneElement(child, {
style: styles.fill, // We transfer the child style to the wrapper.
onLayout: undefined, // we call this manually through our this._onLayout
})}
</Animated.View>
);
});
const styles = StyleSheet.create({
header: {
zIndex: 10,
position: 'relative',
},
fill: {
flex: 1,
},
});
export default ScrollViewStickyHeaderWithForwardedRef;