From 77b8c097277b5cf248d08e772ea8bb8d8583e9a1 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Thu, 2 Mar 2017 15:09:39 -0800 Subject: [PATCH] Implement sticky headers in JS using Native Animated Summary: This re-implements sticky headers in JS to make it work on Android. The only change that was needed was to expose a way to attach a an animated value to an event manually since we can't use the Animated wrapper and `Animated.event` to do it for us because this is implemented directly in the `ScrollView` component. Simply exposed `attachNativeEvent` that takes a ref, event name and event object mapping. This is what is used by `Animated.event`. TODO: - Need to check why momentum scrolling isn't triggering scroll events properly on Android. - Remove native iOS implementation - cleanup / fix flow **Test plan** Test the example list in UIExplorer, test the ListViewPaging example. Closes https://github.com/facebook/react-native/pull/11315 Differential Revision: D4450278 Pulled By: sahrens fbshipit-source-id: fec8da2cffce9807d74f8e518ebdefeb6a708667 --- .../UIExplorer/js/UIExplorerExampleList.js | 1 + Libraries/Animated/src/Animated.js | 18 +- .../Animated/src/AnimatedImplementation.js | 86 ++++++---- Libraries/Components/ScrollView/ScrollView.js | 154 +++++++++++++++--- .../ScrollView/ScrollViewStickyHeader.js | 101 ++++++++++++ 5 files changed, 297 insertions(+), 63 deletions(-) create mode 100644 Libraries/Components/ScrollView/ScrollViewStickyHeader.js diff --git a/Examples/UIExplorer/js/UIExplorerExampleList.js b/Examples/UIExplorer/js/UIExplorerExampleList.js index aea6911313c8c5..b805291832d9fc 100644 --- a/Examples/UIExplorer/js/UIExplorerExampleList.js +++ b/Examples/UIExplorer/js/UIExplorerExampleList.js @@ -201,6 +201,7 @@ const styles = StyleSheet.create({ backgroundColor: '#eeeeee', }, sectionHeader: { + backgroundColor: '#eeeeee', padding: 5, fontWeight: '500', fontSize: 11, diff --git a/Libraries/Animated/src/Animated.js b/Libraries/Animated/src/Animated.js index a0c0482aea1589..1eacd03c48c66e 100644 --- a/Libraries/Animated/src/Animated.js +++ b/Libraries/Animated/src/Animated.js @@ -16,12 +16,22 @@ var AnimatedImplementation = require('AnimatedImplementation'); var Image = require('Image'); var Text = require('Text'); var View = require('View'); -var ScrollView = require('ScrollView'); -module.exports = { - ...AnimatedImplementation, +let AnimatedScrollView; + +const Animated = { View: AnimatedImplementation.createAnimatedComponent(View), Text: AnimatedImplementation.createAnimatedComponent(Text), Image: AnimatedImplementation.createAnimatedComponent(Image), - ScrollView: AnimatedImplementation.createAnimatedComponent(ScrollView), + get ScrollView() { + // Make this lazy to avoid circular reference. + if (!AnimatedScrollView) { + AnimatedScrollView = AnimatedImplementation.createAnimatedComponent(require('ScrollView')); + } + return AnimatedScrollView; + }, }; + +Object.assign((Animated: Object), AnimatedImplementation); + +module.exports = ((Animated: any): (typeof AnimatedImplementation) & typeof Animated); diff --git a/Libraries/Animated/src/AnimatedImplementation.js b/Libraries/Animated/src/AnimatedImplementation.js index 550c8062db3fc7..5cb8743faeed81 100644 --- a/Libraries/Animated/src/AnimatedImplementation.js +++ b/Libraries/Animated/src/AnimatedImplementation.js @@ -2151,9 +2151,53 @@ type EventConfig = { useNativeDriver?: bool, }; +function attachNativeEvent(viewRef: any, eventName: string, argMapping: Array) { + // Find animated values in `argMapping` and create an array representing their + // key path inside the `nativeEvent` object. Ex.: ['contentOffset', 'x']. + const eventMappings = []; + + const traverse = (value, path) => { + if (value instanceof AnimatedValue) { + value.__makeNative(); + + eventMappings.push({ + nativeEventPath: path, + animatedValueTag: value.__getNativeTag(), + }); + } else if (typeof value === 'object') { + for (const key in value) { + traverse(value[key], path.concat(key)); + } + } + }; + + invariant( + argMapping[0] && argMapping[0].nativeEvent, + 'Native driven events only support animated values contained inside `nativeEvent`.' + ); + + // Assume that the event containing `nativeEvent` is always the first argument. + traverse(argMapping[0].nativeEvent, []); + + const viewTag = ReactNative.findNodeHandle(viewRef); + + eventMappings.forEach((mapping) => { + NativeAnimatedAPI.addAnimatedEventToView(viewTag, eventName, mapping); + }); + + return { + detach() { + NativeAnimatedAPI.removeAnimatedEventFromView(viewTag, eventName); + }, + }; +} + class AnimatedEvent { _argMapping: Array; _listener: ?Function; + _attachedEvent: ?{ + detach: () => void, + }; __isNative: bool; constructor( @@ -2162,6 +2206,7 @@ class AnimatedEvent { ) { this._argMapping = argMapping; this._listener = config.listener; + this._attachedEvent = null; this.__isNative = shouldUseNativeDriver(config); if (__DEV__) { @@ -2172,44 +2217,13 @@ class AnimatedEvent { __attach(viewRef, eventName) { invariant(this.__isNative, 'Only native driven events need to be attached.'); - // Find animated values in `argMapping` and create an array representing their - // key path inside the `nativeEvent` object. Ex.: ['contentOffset', 'x']. - const eventMappings = []; - - const traverse = (value, path) => { - if (value instanceof AnimatedValue) { - value.__makeNative(); - - eventMappings.push({ - nativeEventPath: path, - animatedValueTag: value.__getNativeTag(), - }); - } else if (typeof value === 'object') { - for (const key in value) { - traverse(value[key], path.concat(key)); - } - } - }; - - invariant( - this._argMapping[0] && this._argMapping[0].nativeEvent, - 'Native driven events only support animated values contained inside `nativeEvent`.' - ); - - // Assume that the event containing `nativeEvent` is always the first argument. - traverse(this._argMapping[0].nativeEvent, []); - - const viewTag = ReactNative.findNodeHandle(viewRef); - - eventMappings.forEach((mapping) => { - NativeAnimatedAPI.addAnimatedEventToView(viewTag, eventName, mapping); - }); + this._attachedEvent = attachNativeEvent(viewRef, eventName, this._argMapping); } __detach(viewTag, eventName) { invariant(this.__isNative, 'Only native driven events need to be detached.'); - NativeAnimatedAPI.removeAnimatedEventFromView(viewTag, eventName); + this._attachedEvent && this._attachedEvent.detach(); } __getHandler() { @@ -2582,5 +2596,11 @@ module.exports = { */ createAnimatedComponent, + /** + * Imperative API to attach an animated value to an event on a view. Prefer using + * `Animated.event` with `useNativeDrive: true` if possible. + */ + attachNativeEvent, + __PropsOnlyForTests: AnimatedProps, }; diff --git a/Libraries/Components/ScrollView/ScrollView.js b/Libraries/Components/ScrollView/ScrollView.js index 68ac9b987a175a..a8587a180e249f 100644 --- a/Libraries/Components/ScrollView/ScrollView.js +++ b/Libraries/Components/ScrollView/ScrollView.js @@ -11,6 +11,7 @@ */ 'use strict'; +const Animated = require('Animated'); const ColorPropType = require('ColorPropType'); const EdgeInsetsPropType = require('EdgeInsetsPropType'); const Platform = require('Platform'); @@ -18,6 +19,7 @@ const PointPropType = require('PointPropType'); const React = require('React'); const ReactNative = require('ReactNative'); const ScrollResponder = require('ScrollResponder'); +const ScrollViewStickyHeader = require('ScrollViewStickyHeader'); const StyleSheet = require('StyleSheet'); const StyleSheetPropType = require('StyleSheetPropType'); const View = require('View'); @@ -50,7 +52,7 @@ const requireNativeComponent = require('requireNativeComponent'); * ScrollView simply renders all its react child components at once. That * makes it very easy to understand and use. * On the other hand, this has a performance downside. Imagine you have a very - * long list of items you want to display, worth of couple of your ScrollView’s + * long list of items you want to display, worth of couple of your ScrollView's * heights. Creating JS components and native views upfront for all its items, * which may not even be shown, will contribute to slow rendering of your * screen and increased memory usage. @@ -157,8 +159,8 @@ const ScrollView = React.createClass({ /** * The style of the scroll indicators. * - `default` (the default), same as `black`. - * - `black`, scroll indicator is black. This style is good against a white content background. - * - `white`, scroll indicator is white. This style is good against a black content background. + * - `black`, scroll indicator is black. This style is good against a light background. + * - `white`, scroll indicator is white. This style is good against a dark background. * @platform ios */ indicatorStyle: PropTypes.oneOf([ @@ -227,7 +229,8 @@ const ScrollView = React.createClass({ /** * Called when scrollable content view of the ScrollView changes. * - * Handler function is passed the content width and content height as parameters: `(contentWidth, contentHeight)` + * Handler function is passed the content width and content height as parameters: + * `(contentWidth, contentHeight)` * * It's implemented using onLayout handler attached to the content container * which this ScrollView renders. @@ -372,10 +375,33 @@ const ScrollView = React.createClass({ mixins: [ScrollResponder.Mixin], + _scrollAnimatedValue: (new Animated.Value(0): Animated.Value), + _scrollAnimatedValueAttachment: (null: ?{detach: () => void}), + _stickyHeaderRefs: (new Map(): Map), + getInitialState: function() { return this.scrollResponderMixinGetInitialState(); }, + componentWillMount: function() { + this._scrollAnimatedValue = new Animated.Value(0); + this._stickyHeaderRefs = new Map(); + }, + + componentDidMount: function() { + this._updateAnimatedNodeAttachment(); + }, + + componentDidUpdate: function() { + this._updateAnimatedNodeAttachment(); + }, + + componentWillUnmount: function() { + if (this._scrollAnimatedValueAttachment) { + this._scrollAnimatedValueAttachment.detach(); + } + }, + setNativeProps: function(props: Object) { this._scrollViewRef && this._scrollViewRef.setNativeProps(props); }, @@ -415,11 +441,14 @@ const ScrollView = React.createClass({ animated?: boolean ) { if (typeof y === 'number') { - console.warn('`scrollTo(y, x, animated)` is deprecated. Use `scrollTo({x: 5, y: 5, animated: true})` instead.'); + console.warn('`scrollTo(y, x, animated)` is deprecated. Use `scrollTo({x: 5, y: 5, ' + + 'animated: true})` instead.'); } else { ({x, y, animated} = y || {}); } - this.getScrollResponder().scrollResponderScrollTo({x: x || 0, y: y || 0, animated: animated !== false}); + this.getScrollResponder().scrollResponderScrollTo( + {x: x || 0, y: y || 0, animated: animated !== false} + ); }, /** @@ -448,6 +477,42 @@ const ScrollView = React.createClass({ this.scrollTo({x, y, animated: false}); }, + _updateAnimatedNodeAttachment: function() { + if (this.props.stickyHeaderIndices && this.props.stickyHeaderIndices.length > 0) { + if (!this._scrollAnimatedValueAttachment) { + this._scrollAnimatedValueAttachment = Animated.attachNativeEvent( + this._scrollViewRef, + 'onScroll', + [{nativeEvent: {contentOffset: {y: this._scrollAnimatedValue}}}] + ); + } + } else { + if (this._scrollAnimatedValueAttachment) { + this._scrollAnimatedValueAttachment.detach(); + } + } + }, + + _setStickyHeaderRef: function(index, ref) { + this._stickyHeaderRefs.set(index, ref); + }, + + _onStickyHeaderLayout: function(index, event) { + if (!this.props.stickyHeaderIndices) { + return; + } + + const previousHeaderIndex = this.props.stickyHeaderIndices[ + this.props.stickyHeaderIndices.indexOf(index) - 1 + ]; + if (previousHeaderIndex != null) { + const previousHeader = this._stickyHeaderRefs.get(previousHeaderIndex); + previousHeader && previousHeader.setNextHeaderY( + event.nativeEvent.layout.y - event.nativeEvent.layout.height, + ); + } + }, + _handleScroll: function(e: Object) { if (__DEV__) { if (this.props.onScroll && this.props.scrollEventThrottle == null && Platform.OS === 'ios') { @@ -531,14 +596,36 @@ const ScrollView = React.createClass({ }; } - const contentContainer = + const {stickyHeaderIndices} = this.props; + const hasStickyHeaders = stickyHeaderIndices && stickyHeaderIndices.length > 0; + const children = stickyHeaderIndices && hasStickyHeaders ? + React.Children.toArray(this.props.children).map((child, index) => { + const stickyHeaderIndex = stickyHeaderIndices.indexOf(index); + if (child && stickyHeaderIndex >= 0) { + return ( + this._setStickyHeaderRef(index, ref)} + onLayout={(event) => this._onStickyHeaderLayout(index, event)} + scrollAnimatedValue={this._scrollAnimatedValue}> + {child} + + ); + } else { + return child; + } + }) : + this.props.children; + const contentContainer = - {this.props.children} + {children} ; const alwaysBounceHorizontal = @@ -560,23 +647,26 @@ const ScrollView = React.createClass({ // Override the onContentSizeChange from props, since this event can // bubble up from TextInputs onContentSizeChange: null, - onTouchStart: this.scrollResponderHandleTouchStart, - onTouchMove: this.scrollResponderHandleTouchMove, - onTouchEnd: this.scrollResponderHandleTouchEnd, - onScrollBeginDrag: this.scrollResponderHandleScrollBeginDrag, - onScrollEndDrag: this.scrollResponderHandleScrollEndDrag, onMomentumScrollBegin: this.scrollResponderHandleMomentumScrollBegin, onMomentumScrollEnd: this.scrollResponderHandleMomentumScrollEnd, - onStartShouldSetResponder: this.scrollResponderHandleStartShouldSetResponder, - onStartShouldSetResponderCapture: this.scrollResponderHandleStartShouldSetResponderCapture, - onScrollShouldSetResponder: this.scrollResponderHandleScrollShouldSetResponder, - onScroll: this._handleScroll, onResponderGrant: this.scrollResponderHandleResponderGrant, - onResponderTerminationRequest: this.scrollResponderHandleTerminationRequest, - onResponderTerminate: this.scrollResponderHandleTerminate, - onResponderRelease: this.scrollResponderHandleResponderRelease, onResponderReject: this.scrollResponderHandleResponderReject, - sendMomentumEvents: (this.props.onMomentumScrollBegin || this.props.onMomentumScrollEnd) ? true : false, + onResponderRelease: this.scrollResponderHandleResponderRelease, + onResponderTerminate: this.scrollResponderHandleTerminate, + onResponderTerminationRequest: this.scrollResponderHandleTerminationRequest, + onScroll: this._handleScroll, + onScrollBeginDrag: this.scrollResponderHandleScrollBeginDrag, + onScrollEndDrag: this.scrollResponderHandleScrollEndDrag, + onScrollShouldSetResponder: this.scrollResponderHandleScrollShouldSetResponder, + onStartShouldSetResponder: this.scrollResponderHandleStartShouldSetResponder, + onStartShouldSetResponderCapture: this.scrollResponderHandleStartShouldSetResponderCapture, + onTouchEnd: this.scrollResponderHandleTouchEnd, + onTouchMove: this.scrollResponderHandleTouchMove, + onTouchStart: this.scrollResponderHandleTouchStart, + scrollEventThrottle: hasStickyHeaders ? 1 : this.props.scrollEventThrottle, + sendMomentumEvents: (this.props.onMomentumScrollBegin || this.props.onMomentumScrollEnd) ? + true : false, + stickyHeaderIndices: null, }; const { decelerationRate } = this.props; @@ -636,17 +726,25 @@ const styles = StyleSheet.create({ }, }); -let nativeOnlyProps, AndroidScrollView, AndroidHorizontalScrollView, RCTScrollView, RCTScrollContentView; +let nativeOnlyProps, + AndroidScrollView, + AndroidHorizontalScrollView, + RCTScrollView, + RCTScrollContentView; if (Platform.OS === 'android') { nativeOnlyProps = { nativeOnly: { sendMomentumEvents: true, } }; - AndroidScrollView = requireNativeComponent('RCTScrollView', ScrollView, nativeOnlyProps); + AndroidScrollView = requireNativeComponent( + 'RCTScrollView', + (ScrollView: ReactClass<*>), + nativeOnlyProps + ); AndroidHorizontalScrollView = requireNativeComponent( 'AndroidHorizontalScrollView', - ScrollView, + (ScrollView: ReactClass<*>), nativeOnlyProps ); } else if (Platform.OS === 'ios') { @@ -658,7 +756,11 @@ if (Platform.OS === 'android') { onScrollEndDrag: true, } }; - RCTScrollView = requireNativeComponent('RCTScrollView', ScrollView, nativeOnlyProps); + RCTScrollView = requireNativeComponent( + 'RCTScrollView', + (ScrollView: ReactClass<*>), + nativeOnlyProps, + ); RCTScrollContentView = requireNativeComponent('RCTScrollContentView', View); } diff --git a/Libraries/Components/ScrollView/ScrollViewStickyHeader.js b/Libraries/Components/ScrollView/ScrollViewStickyHeader.js new file mode 100644 index 00000000000000..04040492b00f8d --- /dev/null +++ b/Libraries/Components/ScrollView/ScrollViewStickyHeader.js @@ -0,0 +1,101 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule ScrollViewStickyHeader + * @flow + */ +'use strict'; + +const Animated = require('Animated'); +const React = require('React'); +const StyleSheet = require('StyleSheet'); + +type Props = { + children?: React.Element<*>, + scrollAnimatedValue: Animated.Value, + onLayout: (event: Object) => void, +}; + +class ScrollViewStickyHeader extends React.Component { + props: Props; + state = { + measured: false, + layoutY: 0, + nextHeaderLayoutY: (null: ?number), + }; + + setNextHeaderY(y: number) { + this.setState({ nextHeaderLayoutY: y }); + } + + _onLayout = (event) => { + this.setState({ + measured: true, + layoutY: event.nativeEvent.layout.y, + }); + + this.props.onLayout(event); + }; + + render() { + const {measured, layoutY, nextHeaderLayoutY} = this.state; + + let translateY; + if (measured) { + // 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 the header y to the next header y: translate equally to scroll. + // This will cause the header to stay at the top of the scroll view. + // - Past the 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 indefinetly. + const inputRange = [-1, 0, layoutY]; + const outputRange: Array = [0, 0, 0]; + if (nextHeaderLayoutY != null) { + inputRange.push(nextHeaderLayoutY, nextHeaderLayoutY + 1); + outputRange.push(nextHeaderLayoutY - layoutY, nextHeaderLayoutY - layoutY); + } else { + inputRange.push(layoutY + 1); + outputRange.push(1); + } + translateY = this.props.scrollAnimatedValue.interpolate({ + inputRange, + outputRange, + }); + } else { + translateY = 0; + } + + const child = React.Children.only(this.props.children); + + return ( + + {React.cloneElement(child, { + style: styles.fill, + })} + + ); + } +} + +const styles = StyleSheet.create({ + header: { + zIndex: 10, + }, + fill: { + flex: 1, + }, +}); + +module.exports = ScrollViewStickyHeader;