diff --git a/Libraries/Components/ScrollView/ScrollViewStickyHeader.js b/Libraries/Components/ScrollView/ScrollViewStickyHeader.js index a17d6350c65abc..f530be611602e1 100644 --- a/Libraries/Components/ScrollView/ScrollViewStickyHeader.js +++ b/Libraries/Components/ScrollView/ScrollViewStickyHeader.js @@ -4,31 +4,25 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow + * @flow strict-local * @format */ -import AnimatedImplementation from '../../Animated/AnimatedImplementation'; -import AnimatedAddition from '../../Animated/nodes/AnimatedAddition'; -import AnimatedDiffClamp from '../../Animated/nodes/AnimatedDiffClamp'; -import AnimatedNode from '../../Animated/nodes/AnimatedNode'; - -import * as React from 'react'; -import StyleSheet from '../../StyleSheet/StyleSheet'; -import View from '../View/View'; -import Platform from '../../Utilities/Platform'; - import type {LayoutEvent} from '../../Types/CoreEventTypes'; +import setAndForwardRef from 'react-native/Libraries/Utilities/setAndForwardRef'; +import Platform from '../../Utilities/Platform'; +import StyleSheet from '../../StyleSheet/StyleSheet'; +import Animated from '../../Animated/Animated'; +import * as React from 'react'; +import {useEffect, useMemo, useRef, useCallback} from 'react'; -import ScrollViewStickyHeaderInjection from './ScrollViewStickyHeaderInjection'; - -const AnimatedView = AnimatedImplementation.createAnimatedComponent(View); +const AnimatedView = Animated.View; export type Props = $ReadOnly<{ - children?: React.Element, + children?: React.Element<$FlowFixMe>, nextHeaderLayoutY: ?number, onLayout: (event: LayoutEvent) => void, - scrollAnimatedValue: AnimatedImplementation.Value, + scrollAnimatedValue: Animated.Value, // Will cause sticky headers to stick at the bottom of the ScrollView instead // of the top. inverted: ?boolean, @@ -38,287 +32,275 @@ export type Props = $ReadOnly<{ hiddenOnScroll?: ?boolean, }>; -type State = { - measured: boolean, - layoutY: number, - layoutHeight: number, - nextHeaderLayoutY: ?number, - translateY: ?number, - ... -}; +const ScrollViewStickyHeaderWithForwardedRef: React.AbstractComponent< + Props, + $ReadOnly<{ + setNextHeaderY: number => void, + ...$Exact>, + }>, +> = React.forwardRef(function ScrollViewStickyHeader(props, forwardedRef) { + const { + inverted, + scrollViewHeight, + hiddenOnScroll, + scrollAnimatedValue, + nextHeaderLayoutY: _nextHeaderLayoutY, + } = props; -class ScrollViewStickyHeader extends React.Component { - state: State = { - measured: false, - layoutY: 0, - layoutHeight: 0, - nextHeaderLayoutY: this.props.nextHeaderLayoutY, - translateY: null, - }; + const [measured, setMeasured] = React.useState(false); + const [layoutY, setLayoutY] = React.useState(0); + const [layoutHeight, setLayoutHeight] = React.useState(0); + const [translateY, setTranslateY] = React.useState(null); + const [nextHeaderLayoutY, setNextHeaderLayoutY] = React.useState( + _nextHeaderLayoutY, + ); + const [isFabric, setIsFabric] = React.useState(false); - _translateY: ?AnimatedNode = null; - _shouldRecreateTranslateY: boolean = true; - _haveReceivedInitialZeroTranslateY: boolean = true; - _ref: any; // TODO T53738161: flow type this, and the whole file + const componentRef = React.useRef>(); + const _setNativeRef = setAndForwardRef({ + getForwardedRef: () => forwardedRef, + setLocalRef: ref => { + componentRef.current = ref; + if (ref) { + ref.setNextHeaderY = value => { + setNextHeaderLayoutY(value); + }; + setIsFabric( + !!( + // An internal transform mangles variables with leading "_" as private. + // eslint-disable-next-line dot-notation + ref['_internalInstanceHandle']?.stateNode?.canonical + ), + ); + } + }, + }); - // Fabric-only: - _timer: ?TimeoutID; - _animatedValueListenerId: string; - _animatedValueListener: (valueObject: $ReadOnly<{|value: number|}>) => void; - _debounceTimeout: number = Platform.OS === 'android' ? 15 : 64; + const offset = useMemo( + () => + hiddenOnScroll === true + ? Animated.diffClamp( + scrollAnimatedValue + .interpolate({ + extrapolateLeft: 'clamp', + inputRange: [layoutY, layoutY + 1], + outputRange: ([0, 1]: Array), + }) + .interpolate({ + inputRange: [0, 1], + outputRange: ([0, -1]: Array), + }), + -layoutHeight, + 0, + ) + : null, + [scrollAnimatedValue, layoutHeight, layoutY, hiddenOnScroll], + ); - setNextHeaderY(y: number) { - this._shouldRecreateTranslateY = true; - this.setState({nextHeaderLayoutY: y}); - } + const [ + animatedTranslateY, + setAnimatedTranslateY, + ] = React.useState(() => { + const inputRange: Array = [-1, 0]; + const outputRange: Array = [0, 0]; + const initialTranslateY: Animated.Interpolation = scrollAnimatedValue.interpolate( + { + inputRange, + outputRange, + }, + ); - componentWillUnmount() { - if (this._translateY != null && this._animatedValueListenerId != null) { - this._translateY.removeListener(this._animatedValueListenerId); + if (offset != null) { + return Animated.add(initialTranslateY, offset); } - if (this._timer) { - clearTimeout(this._timer); - } - } + return initialTranslateY; + }); - UNSAFE_componentWillReceiveProps(nextProps: Props) { - if ( - nextProps.scrollViewHeight !== this.props.scrollViewHeight || - nextProps.scrollAnimatedValue !== this.props.scrollAnimatedValue || - nextProps.inverted !== this.props.inverted - ) { - this._shouldRecreateTranslateY = true; - } - } + const _haveReceivedInitialZeroTranslateY = useRef(true); + const _timer = useRef(null); - updateTranslateListener( - translateY: AnimatedImplementation.Interpolation, - isFabric: boolean, - offset: AnimatedDiffClamp | null, - ) { - if (this._translateY != null && this._animatedValueListenerId != null) { - this._translateY.removeListener(this._animatedValueListenerId); + useEffect(() => { + if (translateY !== 0 && translateY != null) { + _haveReceivedInitialZeroTranslateY.current = false; } - offset - ? (this._translateY = new AnimatedAddition(translateY, offset)) - : (this._translateY = translateY); + }, [translateY]); - this._shouldRecreateTranslateY = false; + // 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}) => { + 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], + ); - if (!isFabric) { - return; - } + useEffect(() => { + const inputRange: Array = [-1, 0]; + const outputRange: Array = [0, 0]; - if (!this._animatedValueListener) { - // 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. - this._animatedValueListener = ({value}) => { - // 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 && !this._haveReceivedInitialZeroTranslateY) { - this._haveReceivedInitialZeroTranslateY = true; - return; + 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, + ); + } + } } - if (this._timer) { - clearTimeout(this._timer); + } 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); } - this._timer = setTimeout(() => { - if (value !== this.state.translateY) { - this.setState({ - translateY: value, - }); - } - }, this._debounceTimeout); - }; - } - if (this.state.translateY !== 0 && this.state.translateY != null) { - this._haveReceivedInitialZeroTranslateY = false; - } - this._animatedValueListenerId = translateY.addListener( - this._animatedValueListener, - ); - } - - _onLayout = event => { - const layoutY = event.nativeEvent.layout.y; - const layoutHeight = event.nativeEvent.layout.height; - const measured = true; - - if ( - layoutY !== this.state.layoutY || - layoutHeight !== this.state.layoutHeight || - measured !== this.state.measured - ) { - this._shouldRecreateTranslateY = true; + } } - this.setState({ - measured, - layoutY, - layoutHeight, + let newAnimatedTranslateY: Animated.Node = scrollAnimatedValue.interpolate({ + inputRange, + outputRange, }); - this.props.onLayout(event); - const child = React.Children.only(this.props.children); - if (child.props.onLayout) { - child.props.onLayout(event); + if (offset != null) { + newAnimatedTranslateY = Animated.add(newAnimatedTranslateY, offset); } - }; - _setComponentRef = ref => { - this._ref = ref; - }; + // add the event listener + let animatedListenerId; + if (isFabric) { + animatedListenerId = newAnimatedTranslateY.addListener( + animatedValueListener, + ); + } - render(): React.Node { - // Fabric Detection - const isFabric = !!( - // An internal transform mangles variables with leading "_" as private. - // eslint-disable-next-line dot-notation - (this._ref && this._ref['_internalInstanceHandle']?.stateNode?.canonical) - ); - // Initially and in the case of updated props or layout, we - // recreate this interpolated value. Otherwise, we do not recreate - // when there are state changes. - if (this._shouldRecreateTranslateY) { - const {inverted, scrollViewHeight} = this.props; - const {measured, layoutHeight, layoutY, nextHeaderLayoutY} = this.state; - const inputRange: Array = [-1, 0]; - const outputRange: Array = [0, 0]; + setAnimatedTranslateY(newAnimatedTranslateY); - if (measured) { - if (inverted) { - // 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); - } - } + // 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]); - this.updateTranslateListener( - this.props.scrollAnimatedValue.interpolate({ - inputRange, - outputRange, - }), - isFabric, - this.props.hiddenOnScroll - ? new AnimatedDiffClamp( - this.props.scrollAnimatedValue - .interpolate({ - extrapolateLeft: 'clamp', - inputRange: [layoutY, layoutY + 1], - outputRange: ([0, 1]: Array), - }) - .interpolate({ - inputRange: [0, 1], - outputRange: ([0, -1]: Array), - }), - -this.state.layoutHeight, - 0, - ) - : null, - ); + const _onLayout = (event: LayoutEvent) => { + setLayoutY(event.nativeEvent.layout.y); + setLayoutHeight(event.nativeEvent.layout.height); + setMeasured(true); + + props.onLayout(event); + const child = React.Children.only(props.children); + if (child.props.onLayout) { + child.props.onLayout(event); } + }; - const child = React.Children.only(this.props.children); + const child = React.Children.only(props.children); - // TODO T68319535: remove this if NativeAnimated is rewritten for Fabric - const passthroughAnimatedPropExplicitValues = - isFabric && this.state.translateY != null - ? { - style: {transform: [{translateY: this.state.translateY}]}, - } - : null; + // TODO T68319535: remove this if NativeAnimated is rewritten for Fabric + const passthroughAnimatedPropExplicitValues = + isFabric && translateY != null + ? { + style: {transform: [{translateY: translateY}]}, + } + : null; - return ( - - {React.cloneElement(child, { - style: styles.fill, // We transfer the child style to the wrapper. - onLayout: undefined, // we call this manually through our this._onLayout - })} - - ); - } -} + return ( + /* $FlowFixMe[prop-missing] passthroughAnimatedPropExplicitValues isn't properly + included in the Animated.View flow type. */ + + {React.cloneElement(child, { + style: styles.fill, // We transfer the child style to the wrapper. + onLayout: undefined, // we call this manually through our this._onLayout + })} + + ); +}); const styles = StyleSheet.create({ header: { @@ -330,8 +312,4 @@ const styles = StyleSheet.create({ }, }); -const SHToExport: React.AbstractComponent< - Props, - $ReadOnly<{setNextHeaderY: number => void, ...}>, -> = ScrollViewStickyHeaderInjection.unstable_SH ?? ScrollViewStickyHeader; -module.exports = SHToExport; +export default ScrollViewStickyHeaderWithForwardedRef; diff --git a/Libraries/Components/ScrollView/ScrollViewStickyHeaderInjection.js b/Libraries/Components/ScrollView/ScrollViewStickyHeaderInjection.js deleted file mode 100644 index ab8684ffb6818a..00000000000000 --- a/Libraries/Components/ScrollView/ScrollViewStickyHeaderInjection.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @format - * @flow - */ - -'use strict'; - -import typeof ScrollViewStickyHeader from './ScrollViewStickyHeader'; - -export default { - unstable_SH: (null: ?ScrollViewStickyHeader), -};