From 925e81ab86c9807b66d405d914e857b978b194fd Mon Sep 17 00:00:00 2001 From: Samuel Susla Date: Wed, 26 Oct 2022 08:17:49 -0700 Subject: [PATCH] Rewrite of ScrollViewStickyHeader for concurrent rendering Summary: changelog: [general][Added] - Concurrent rendering safe implementation of ScrollViewStickyHeader This is a re-land of ScrollViewStickyHeader from Kacie Bawiec. Reviewed By: yungsters Differential Revision: D40380217 fbshipit-source-id: 60dc86086a4d9d97eef71c4ed2e26536f7e72889 --- .../ScrollView/ScrollViewStickyHeader.js | 507 +++++++++--------- 1 file changed, 241 insertions(+), 266 deletions(-) diff --git a/Libraries/Components/ScrollView/ScrollViewStickyHeader.js b/Libraries/Components/ScrollView/ScrollViewStickyHeader.js index 4a445dc53d562b..6b42d2e63dad2a 100644 --- a/Libraries/Components/ScrollView/ScrollViewStickyHeader.js +++ b/Libraries/Components/ScrollView/ScrollViewStickyHeader.js @@ -4,28 +4,24 @@ * 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 type {LayoutEvent} from '../../Types/CoreEventTypes'; -import AnimatedImplementation from '../../Animated/AnimatedImplementation'; -import AnimatedAddition from '../../Animated/nodes/AnimatedAddition'; -import AnimatedDiffClamp from '../../Animated/nodes/AnimatedDiffClamp'; -import AnimatedNode from '../../Animated/nodes/AnimatedNode'; +import Animated from '../../Animated/Animated'; import StyleSheet from '../../StyleSheet/StyleSheet'; import Platform from '../../Utilities/Platform'; -import View from '../View/View'; +import useMergeRefs from '../../Utilities/useMergeRefs'; import * as React from 'react'; - -const AnimatedView = AnimatedImplementation.createAnimatedComponent(View); +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; 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, @@ -35,291 +31,270 @@ export type Props = $ReadOnly<{ hiddenOnScroll?: ?boolean, }>; -type State = { - measured: boolean, - layoutY: number, - layoutHeight: number, - nextHeaderLayoutY: ?number, - translateY: ?number, +type Instance = { + ...React.ElementRef, + setNextHeaderY: number => void, ... }; -class ScrollViewStickyHeader extends React.Component { - state: State = { - measured: false, - layoutY: 0, - layoutHeight: 0, - nextHeaderLayoutY: this.props.nextHeaderLayoutY, - translateY: null, - }; - - _translateY: ?AnimatedNode = null; - _shouldRecreateTranslateY: boolean = true; - _haveReceivedInitialZeroTranslateY: boolean = true; - _ref: any; // TODO T53738161: flow type this, and the whole file +const ScrollViewStickyHeaderWithForwardedRef: React.AbstractComponent< + Props, + Instance, +> = React.forwardRef(function ScrollViewStickyHeader(props, forwardedRef) { + const { + inverted, + scrollViewHeight, + hiddenOnScroll, + scrollAnimatedValue, + nextHeaderLayoutY: _nextHeaderLayoutY, + } = props; - // Fabric-only: - _timer: ?TimeoutID; - _animatedValueListenerId: string; - _animatedValueListener: (valueObject: $ReadOnly<{|value: number|}>) => void; - _debounceTimeout: number = Platform.OS === 'android' ? 15 : 64; + const [measured, setMeasured] = useState(false); + const [layoutY, setLayoutY] = useState(0); + const [layoutHeight, setLayoutHeight] = useState(0); + const [translateY, setTranslateY] = useState(null); + const [nextHeaderLayoutY, setNextHeaderLayoutY] = + useState(_nextHeaderLayoutY); + const [isFabric, setIsFabric] = useState(false); - setNextHeaderY: (y: number) => void = (y: number): void => { - this._shouldRecreateTranslateY = true; - this.setState({nextHeaderLayoutY: y}); + 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 | null) => void = + // $FlowFixMe[incompatible-type] - Ref is mutated by `callbackRef`. + useMergeRefs(callbackRef, forwardedRef); - componentWillUnmount() { - if (this._translateY != null && this._animatedValueListenerId != null) { - this._translateY.removeListener(this._animatedValueListenerId); - } - if (this._timer) { - clearTimeout(this._timer); - } - } + 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], + ); - UNSAFE_componentWillReceiveProps(nextProps: Props) { - if ( - nextProps.scrollViewHeight !== this.props.scrollViewHeight || - nextProps.scrollAnimatedValue !== this.props.scrollAnimatedValue || - nextProps.inverted !== this.props.inverted - ) { - this._shouldRecreateTranslateY = true; - } - } + const [animatedTranslateY, setAnimatedTranslateY] = useState( + () => { + const inputRange: Array = [-1, 0]; + const outputRange: Array = [0, 0]; + const initialTranslateY = scrollAnimatedValue.interpolate({ + inputRange, + outputRange, + }); - updateTranslateListener( - translateY: AnimatedNode, - isFabric: boolean, - offset: AnimatedDiffClamp | null, - ) { - if (this._translateY != null && this._animatedValueListenerId != null) { - this._translateY.removeListener(this._animatedValueListenerId); - } - offset - ? (this._translateY = new AnimatedAddition(translateY, offset)) - : (this._translateY = translateY); + if (offset != null) { + return Animated.add(initialTranslateY, offset); + } + return initialTranslateY; + }, + ); - this._shouldRecreateTranslateY = false; + const _haveReceivedInitialZeroTranslateY = useRef(true); + const _timer = useRef(null); - if (!isFabric) { - return; + useEffect(() => { + if (translateY !== 0 && translateY != null) { + _haveReceivedInitialZeroTranslateY.current = false; } + }, [translateY]); - 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 (this._timer) { - clearTimeout(this._timer); + // 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); } - 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, - ); - } + }, _debounceTimeout); + }, + [translateY], + ); - _onLayout = (event: any) => { - const layoutY = event.nativeEvent.layout.y; - const layoutHeight = event.nativeEvent.layout.height; - const measured = true; + useEffect(() => { + const inputRange: Array = [-1, 0]; + const outputRange: Array = [0, 0]; - if ( - layoutY !== this.state.layoutY || - layoutHeight !== this.state.layoutHeight || - measured !== this.state.measured - ) { - this._shouldRecreateTranslateY = true; + 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); + } + } } - 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.onCellLayout) { - child.props.onCellLayout(event, child.props.cellKey, child.props.index); - } else if (child.props.onLayout) { - child.props.onLayout(event); + if (offset != null) { + newAnimatedTranslateY = Animated.add(newAnimatedTranslateY, offset); } - }; - /* $FlowFixMe[missing-local-annot] The type annotation(s) required by Flow's - * LTI update could not be added via codemod */ - _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], - }) - .interpolate({ - inputRange: [0, 1], - outputRange: [0, -1], - }), - -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: { @@ -331,4 +306,4 @@ const styles = StyleSheet.create({ }, }); -module.exports = ScrollViewStickyHeader; +export default ScrollViewStickyHeaderWithForwardedRef;