ScrollViewStickyHeader.js 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. /**
  2. * Copyright (c) Facebook, Inc. and its affiliates.
  3. *
  4. * This source code is licensed under the MIT license found in the
  5. * LICENSE file in the root directory of this source tree.
  6. *
  7. * @flow
  8. * @format
  9. */
  10. 'use strict';
  11. const AnimatedImplementation = require('../../Animated/src/AnimatedImplementation');
  12. const React = require('react');
  13. const StyleSheet = require('../../StyleSheet/StyleSheet');
  14. const View = require('../View/View');
  15. import type {LayoutEvent} from '../../Types/CoreEventTypes';
  16. const AnimatedView = AnimatedImplementation.createAnimatedComponent(View);
  17. export type Props = {
  18. children?: React.Element<any>,
  19. nextHeaderLayoutY: ?number,
  20. onLayout: (event: LayoutEvent) => void,
  21. scrollAnimatedValue: AnimatedImplementation.Value,
  22. // Will cause sticky headers to stick at the bottom of the ScrollView instead
  23. // of the top.
  24. inverted: ?boolean,
  25. // The height of the parent ScrollView. Currently only set when inverted.
  26. scrollViewHeight: ?number,
  27. ...
  28. };
  29. type State = {
  30. measured: boolean,
  31. layoutY: number,
  32. layoutHeight: number,
  33. nextHeaderLayoutY: ?number,
  34. ...
  35. };
  36. class ScrollViewStickyHeader extends React.Component<Props, State> {
  37. state: State = {
  38. measured: false,
  39. layoutY: 0,
  40. layoutHeight: 0,
  41. nextHeaderLayoutY: this.props.nextHeaderLayoutY,
  42. };
  43. setNextHeaderY(y: number) {
  44. this.setState({nextHeaderLayoutY: y});
  45. }
  46. _onLayout = event => {
  47. this.setState({
  48. measured: true,
  49. layoutY: event.nativeEvent.layout.y,
  50. layoutHeight: event.nativeEvent.layout.height,
  51. });
  52. this.props.onLayout(event);
  53. const child = React.Children.only(this.props.children);
  54. if (child.props.onLayout) {
  55. child.props.onLayout(event);
  56. }
  57. };
  58. render(): React.Node {
  59. const {inverted, scrollViewHeight} = this.props;
  60. const {measured, layoutHeight, layoutY, nextHeaderLayoutY} = this.state;
  61. const inputRange: Array<number> = [-1, 0];
  62. const outputRange: Array<number> = [0, 0];
  63. if (measured) {
  64. if (inverted) {
  65. // The interpolation looks like:
  66. // - Negative scroll: no translation
  67. // - `stickStartPoint` is the point at which the header will start sticking.
  68. // It is calculated using the ScrollView viewport height so it is a the bottom.
  69. // - Headers that are in the initial viewport will never stick, `stickStartPoint`
  70. // will be negative.
  71. // - From 0 to `stickStartPoint` no translation. This will cause the header
  72. // to scroll normally until it reaches the top of the scroll view.
  73. // - From `stickStartPoint` to when the next header y hits the bottom edge of the header: translate
  74. // equally to scroll. This will cause the header to stay at the top of the scroll view.
  75. // - Past the collision with the next header y: no more translation. This will cause the
  76. // header to continue scrolling up and make room for the next sticky header.
  77. // In the case that there is no next header just translate equally to
  78. // scroll indefinitely.
  79. if (scrollViewHeight != null) {
  80. const stickStartPoint = layoutY + layoutHeight - scrollViewHeight;
  81. if (stickStartPoint > 0) {
  82. inputRange.push(stickStartPoint);
  83. outputRange.push(0);
  84. inputRange.push(stickStartPoint + 1);
  85. outputRange.push(1);
  86. // If the next sticky header has not loaded yet (probably windowing) or is the last
  87. // we can just keep it sticked forever.
  88. const collisionPoint =
  89. (nextHeaderLayoutY || 0) - layoutHeight - scrollViewHeight;
  90. if (collisionPoint > stickStartPoint) {
  91. inputRange.push(collisionPoint, collisionPoint + 1);
  92. outputRange.push(
  93. collisionPoint - stickStartPoint,
  94. collisionPoint - stickStartPoint,
  95. );
  96. }
  97. }
  98. }
  99. } else {
  100. // The interpolation looks like:
  101. // - Negative scroll: no translation
  102. // - From 0 to the y of the header: no translation. This will cause the header
  103. // to scroll normally until it reaches the top of the scroll view.
  104. // - From header y to when the next header y hits the bottom edge of the header: translate
  105. // equally to scroll. This will cause the header to stay at the top of the scroll view.
  106. // - Past the collision with the next header y: no more translation. This will cause the
  107. // header to continue scrolling up and make room for the next sticky header.
  108. // In the case that there is no next header just translate equally to
  109. // scroll indefinitely.
  110. inputRange.push(layoutY);
  111. outputRange.push(0);
  112. // If the next sticky header has not loaded yet (probably windowing) or is the last
  113. // we can just keep it sticked forever.
  114. const collisionPoint = (nextHeaderLayoutY || 0) - layoutHeight;
  115. if (collisionPoint >= layoutY) {
  116. inputRange.push(collisionPoint, collisionPoint + 1);
  117. outputRange.push(collisionPoint - layoutY, collisionPoint - layoutY);
  118. } else {
  119. inputRange.push(layoutY + 1);
  120. outputRange.push(1);
  121. }
  122. }
  123. }
  124. const translateY = this.props.scrollAnimatedValue.interpolate({
  125. inputRange,
  126. outputRange,
  127. });
  128. const child = React.Children.only(this.props.children);
  129. return (
  130. <AnimatedView
  131. collapsable={false}
  132. onLayout={this._onLayout}
  133. style={[child.props.style, styles.header, {transform: [{translateY}]}]}>
  134. {React.cloneElement(child, {
  135. style: styles.fill, // We transfer the child style to the wrapper.
  136. onLayout: undefined, // we call this manually through our this._onLayout
  137. })}
  138. </AnimatedView>
  139. );
  140. }
  141. }
  142. const styles = StyleSheet.create({
  143. header: {
  144. zIndex: 10,
  145. },
  146. fill: {
  147. flex: 1,
  148. },
  149. });
  150. module.exports = ScrollViewStickyHeader;