UNPKG

react-native

Version:

A framework for building native apps using React

1,359 lines (1,273 loc) • 71.1 kB
/** * 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. * * @format * @flow strict-local */ import type {HostInstance} from '../../../src/private/types/HostInstance'; import type {EdgeInsetsProp} from '../../StyleSheet/EdgeInsetsPropType'; import type {PointProp} from '../../StyleSheet/PointPropType'; import type {ViewStyleProp} from '../../StyleSheet/StyleSheet'; import type {ColorValue} from '../../StyleSheet/StyleSheet'; import type { GestureResponderEvent, LayoutChangeEvent, ScrollEvent, } from '../../Types/CoreEventTypes'; import type {EventSubscription} from '../../vendor/emitter/EventEmitter'; import type {KeyboardEvent, KeyboardMetrics} from '../Keyboard/Keyboard'; import type {ViewProps} from '../View/ViewPropTypes'; import type {ScrollViewStickyHeaderProps} from './ScrollViewStickyHeader'; import { HScrollContentViewNativeComponent, HScrollViewNativeComponent, } from '../../../src/private/components/scrollview/HScrollViewNativeComponents'; import { VScrollContentViewNativeComponent, VScrollViewNativeComponent, } from '../../../src/private/components/scrollview/VScrollViewNativeComponents'; import AnimatedImplementation from '../../Animated/AnimatedImplementation'; import FrameRateLogger from '../../Interaction/FrameRateLogger'; import {findNodeHandle} from '../../ReactNative/RendererProxy'; import UIManager from '../../ReactNative/UIManager'; import flattenStyle from '../../StyleSheet/flattenStyle'; import splitLayoutProps from '../../StyleSheet/splitLayoutProps'; import StyleSheet from '../../StyleSheet/StyleSheet'; import Dimensions from '../../Utilities/Dimensions'; import dismissKeyboard from '../../Utilities/dismissKeyboard'; import Platform from '../../Utilities/Platform'; import Keyboard from '../Keyboard/Keyboard'; import TextInputState from '../TextInput/TextInputState'; import View from '../View/View'; import processDecelerationRate from './processDecelerationRate'; import Commands from './ScrollViewCommands'; import ScrollViewContext, {HORIZONTAL, VERTICAL} from './ScrollViewContext'; import ScrollViewStickyHeader from './ScrollViewStickyHeader'; import invariant from 'invariant'; import memoize from 'memoize-one'; import nullthrows from 'nullthrows'; import * as React from 'react'; /* * iOS scroll event timing nuances: * =============================== * * * Scrolling without bouncing, if you touch down: * ------------------------------- * * 1. `onMomentumScrollBegin` (when animation begins after letting up) * ... physical touch starts ... * 2. `onTouchStartCapture` (when you press down to stop the scroll) * 3. `onTouchStart` (same, but bubble phase) * 4. `onResponderRelease` (when lifting up - you could pause forever before * lifting) * 5. `onMomentumScrollEnd` * * * Scrolling with bouncing, if you touch down: * ------------------------------- * * 1. `onMomentumScrollBegin` (when animation begins after letting up) * ... bounce begins ... * ... some time elapses ... * ... physical touch during bounce ... * 2. `onMomentumScrollEnd` (Makes no sense why this occurs first during bounce) * 3. `onTouchStartCapture` (immediately after `onMomentumScrollEnd`) * 4. `onTouchStart` (same, but bubble phase) * 5. `onTouchEnd` (You could hold the touch start for a long time) * 6. `onMomentumScrollBegin` (When releasing the view starts bouncing back) * * So when we receive an `onTouchStart`, how can we tell if we are touching * *during* an animation (which then causes the animation to stop)? The only way * to tell is if the `touchStart` occurred immediately after the * `onMomentumScrollEnd`. * * This is abstracted out for you, so you can just call this.scrollResponderIsAnimating() if * necessary * * `ScrollView` also includes logic for blurring a currently focused input * if one is focused while scrolling. This is a natural place * to put this logic since it can support not dismissing the keyboard while * scrolling, unless a recognized "tap"-like gesture has occurred. * * The public lifecycle API includes events for keyboard interaction, responder * interaction, and scrolling (among others). The keyboard callbacks * `onKeyboardWill/Did/*` are *global* events, but are invoked on scroll * responder's props so that you can guarantee that the scroll responder's * internal state has been updated accordingly (and deterministically) by * the time the props callbacks are invoke. Otherwise, you would always wonder * if the scroll responder is currently in a state where it recognizes new * keyboard positions etc. If coordinating scrolling with keyboard movement, * *always* use these hooks instead of listening to your own global keyboard * events. * * Public keyboard lifecycle API: (props callbacks) * * Standard Keyboard Appearance Sequence: * * this.props.onKeyboardWillShow * this.props.onKeyboardDidShow * * `onScrollResponderKeyboardDismissed` will be invoked if an appropriate * tap inside the scroll responder's scrollable region was responsible * for the dismissal of the keyboard. There are other reasons why the * keyboard could be dismissed. * * this.props.onScrollResponderKeyboardDismissed * * Standard Keyboard Hide Sequence: * * this.props.onKeyboardWillHide * this.props.onKeyboardDidHide */ export interface ScrollViewScrollToOptions { x?: number; y?: number; animated?: boolean; } // Public methods for ScrollView export interface ScrollViewImperativeMethods { +getScrollResponder: () => ScrollResponderType; +getScrollableNode: () => ?number; +getInnerViewNode: () => ?number; +getInnerViewRef: () => InnerViewInstance | null; +getNativeScrollRef: () => HostInstance | null; +scrollTo: ( options?: ScrollViewScrollToOptions | number, deprecatedX?: number, deprecatedAnimated?: boolean, ) => void; +scrollToEnd: (options?: ?ScrollViewScrollToOptions) => void; +flashScrollIndicators: () => void; +scrollResponderZoomTo: ( rect: { x: number, y: number, width: number, height: number, animated?: boolean, }, animated?: boolean, // deprecated, put this inside the rect argument instead ) => void; +scrollResponderScrollNativeHandleToKeyboard: ( nodeHandle: number | HostInstance, additionalOffset?: number, preventNegativeScrollOffset?: boolean, ) => void; } export type DecelerationRateType = 'fast' | 'normal' | number; export type ScrollResponderType = ScrollViewImperativeMethods; export interface PublicScrollViewInstance extends HostInstance, ScrollViewImperativeMethods {} type InnerViewInstance = React.ElementRef<typeof View>; export type ScrollViewPropsIOS = $ReadOnly<{ /** * Controls whether iOS should automatically adjust the content inset * for scroll views that are placed behind a navigation bar or * tab bar/ toolbar. The default value is true. * @platform ios */ automaticallyAdjustContentInsets?: ?boolean, /** * Controls whether the ScrollView should automatically adjust its `contentInset` * and `scrollViewInsets` when the Keyboard changes its size. The default value is false. * @platform ios */ automaticallyAdjustKeyboardInsets?: ?boolean, /** * Controls whether iOS should automatically adjust the scroll indicator * insets. The default value is true. Available on iOS 13 and later. * @platform ios */ automaticallyAdjustsScrollIndicatorInsets?: ?boolean, /** * The amount by which the scroll view content is inset from the edges * of the scroll view. Defaults to `{top: 0, left: 0, bottom: 0, right: 0}`. * @platform ios */ contentInset?: ?EdgeInsetsProp, /** * When true, the scroll view bounces when it reaches the end of the * content if the content is larger then the scroll view along the axis of * the scroll direction. When false, it disables all bouncing even if * the `alwaysBounce*` props are true. The default value is true. * @platform ios */ bounces?: ?boolean, /** * By default, ScrollView has an active pan responder that hijacks panresponders * deeper in the render tree in order to prevent accidental touches while scrolling. * However, in certain occasions (such as when using snapToInterval) in a vertical scrollview * You may want to disable this behavior in order to prevent the ScrollView from blocking touches */ disableScrollViewPanResponder?: ?boolean, /** * When true, gestures can drive zoom past min/max and the zoom will animate * to the min/max value at gesture end, otherwise the zoom will not exceed * the limits. * @platform ios */ bouncesZoom?: ?boolean, /** * When true, the scroll view bounces horizontally when it reaches the end * even if the content is smaller than the scroll view itself. The default * value is true when `horizontal={true}` and false otherwise. * @platform ios */ alwaysBounceHorizontal?: ?boolean, /** * When true, the scroll view bounces vertically when it reaches the end * even if the content is smaller than the scroll view itself. The default * value is false when `horizontal={true}` and true otherwise. * @platform ios */ alwaysBounceVertical?: ?boolean, /** * When true, the scroll view automatically centers the content when the * content is smaller than the scroll view bounds; when the content is * larger than the scroll view, this property has no effect. The default * value is false. * @platform ios */ centerContent?: ?boolean, /** * The style of the scroll indicators. * * - `'default'` (the default), same as `black`. * - `'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?: ?('default' | 'black' | 'white'), /** * When true, the ScrollView will try to lock to only vertical or horizontal * scrolling while dragging. The default value is false. * @platform ios */ directionalLockEnabled?: ?boolean, /** * When false, once tracking starts, won't try to drag if the touch moves. * The default value is true. * @platform ios */ canCancelContentTouches?: ?boolean, /** * The maximum allowed zoom scale. The default value is 1.0. * @platform ios */ maximumZoomScale?: ?number, /** * The minimum allowed zoom scale. The default value is 1.0. * @platform ios */ minimumZoomScale?: ?number, /** * When true, ScrollView allows use of pinch gestures to zoom in and out. * The default value is true. * @platform ios */ pinchGestureEnabled?: ?boolean, /** * The amount by which the scroll view indicators are inset from the edges * of the scroll view. This should normally be set to the same value as * the `contentInset`. Defaults to `{0, 0, 0, 0}`. * @platform ios */ scrollIndicatorInsets?: ?EdgeInsetsProp, /** * When true, the scroll view can be programmatically scrolled beyond its * content size. The default value is false. * @platform ios */ scrollToOverflowEnabled?: ?boolean, /** * When true, the scroll view scrolls to top when the status bar is tapped. * The default value is true. * @platform ios */ scrollsToTop?: ?boolean, /** * Fires when the scroll view scrolls to top after the status bar has been tapped * @platform ios */ onScrollToTop?: (event: ScrollEvent) => void, /** * When true, shows a horizontal scroll indicator. * The default value is true. */ showsHorizontalScrollIndicator?: ?boolean, /** * The current scale of the scroll view content. The default value is 1.0. * @platform ios */ zoomScale?: ?number, /** * This property specifies how the safe area insets are used to modify the * content area of the scroll view. The default value of this property is * "never". * @platform ios */ contentInsetAdjustmentBehavior?: ?( | 'automatic' | 'scrollableAxes' | 'never' | 'always' ), }>; export type ScrollViewPropsAndroid = $ReadOnly<{ /** * Enables nested scrolling for Android API level 21+. * Nested scrolling is supported by default on iOS * @platform android */ nestedScrollEnabled?: ?boolean, /** * Sometimes a scrollview takes up more space than its content fills. When this is * the case, this prop will fill the rest of the scrollview with a color to avoid setting * a background and creating unnecessary overdraw. This is an advanced optimization * that is not needed in the general case. * @platform android */ endFillColor?: ?ColorValue, /** * Tag used to log scroll performance on this scroll view. Will force * momentum events to be turned on (see sendMomentumEvents). This doesn't do * anything out of the box and you need to implement a custom native * FpsListener for it to be useful. * @platform android */ scrollPerfTag?: ?string, /** * Used to override default value of overScroll mode. * * Possible values: * * - `'auto'` - Default value, allow a user to over-scroll * this view only if the content is large enough to meaningfully scroll. * - `'always'` - Always allow a user to over-scroll this view. * - `'never'` - Never allow a user to over-scroll this view. * * @platform android */ overScrollMode?: ?('auto' | 'always' | 'never'), /** * Causes the scrollbars not to turn transparent when they are not in use. * The default value is false. * * @platform android */ persistentScrollbar?: ?boolean, /** * Fades out the edges of the scroll content. * * If the value is greater than 0, the fading edges will be set accordingly * to the current scroll direction and position, * indicating if there is more content to show. * * The default value is 0. * * @platform android */ fadingEdgeLength?: ?number, }>; type StickyHeaderComponentType = component( ref?: React.RefSetter<$ReadOnly<interface {setNextHeaderY: number => void}>>, ...ScrollViewStickyHeaderProps ); type ScrollViewBaseProps = $ReadOnly<{ /** * These styles will be applied to the scroll view content container which * wraps all of the child views. Example: * * ``` * return ( * <ScrollView contentContainerStyle={styles.contentContainer}> * </ScrollView> * ); * ... * const styles = StyleSheet.create({ * contentContainer: { * paddingVertical: 20 * } * }); * ``` */ contentContainerStyle?: ?ViewStyleProp, /** * Used to manually set the starting scroll offset. * The default value is `{x: 0, y: 0}`. */ contentOffset?: ?PointProp, /** * When true, the scroll view stops on the next index (in relation to scroll * position at release) regardless of how fast the gesture is. This can be * used for pagination when the page is less than the width of the * horizontal ScrollView or the height of the vertical ScrollView. The default value is false. */ disableIntervalMomentum?: ?boolean, /** * A floating-point number that determines how quickly the scroll view * decelerates after the user lifts their finger. You may also use string * shortcuts `"normal"` and `"fast"` which match the underlying iOS settings * for `UIScrollViewDecelerationRateNormal` and * `UIScrollViewDecelerationRateFast` respectively. * * - `'normal'`: 0.998 on iOS, 0.985 on Android (the default) * - `'fast'`: 0.99 on iOS, 0.9 on Android */ decelerationRate?: ?DecelerationRateType, /** * *Experimental, iOS Only*. The API is experimental and will change in future releases. * * Controls how much distance is travelled after user stops scrolling. * Value greater than 1 will increase the distance travelled. * Value less than 1 will decrease the distance travelled. * * @deprecated * * The default value is 1. */ experimental_endDraggingSensitivityMultiplier?: ?number, /** * When true, the scroll view's children are arranged horizontally in a row * instead of vertically in a column. The default value is false. */ horizontal?: ?boolean, /** * If sticky headers should stick at the bottom instead of the top of the * ScrollView. This is usually used with inverted ScrollViews. */ invertStickyHeaders?: ?boolean, /** * Determines whether the keyboard gets dismissed in response to a drag. * * *Cross platform* * * - `'none'` (the default), drags do not dismiss the keyboard. * - `'on-drag'`, the keyboard is dismissed when a drag begins. * * *iOS Only* * * - `'interactive'`, the keyboard is dismissed interactively with the drag and moves in * synchrony with the touch; dragging upwards cancels the dismissal. * On android this is not supported and it will have the same behavior as 'none'. */ keyboardDismissMode?: ?// default // cross-platform ('none' | 'on-drag' | 'interactive'), // ios only /** * Determines when the keyboard should stay visible after a tap. * * - `'never'` (the default), tapping outside of the focused text input when the keyboard * is up dismisses the keyboard. When this happens, children won't receive the tap. * - `'always'`, the keyboard will not dismiss automatically, and the scroll view will not * catch taps, but children of the scroll view can catch taps. * - `'handled'`, the keyboard will not dismiss automatically when the tap was handled by * a children, (or captured by an ancestor). * - `false`, deprecated, use 'never' instead * - `true`, deprecated, use 'always' instead */ keyboardShouldPersistTaps?: ?('always' | 'never' | 'handled' | true | false), /** * When set, the scroll view will adjust the scroll position so that the first child that is * partially or fully visible and at or beyond `minIndexForVisible` will not change position. * This is useful for lists that are loading content in both directions, e.g. a chat thread, * where new messages coming in might otherwise cause the scroll position to jump. A value of 0 * is common, but other values such as 1 can be used to skip loading spinners or other content * that should not maintain position. * * The optional `autoscrollToTopThreshold` can be used to make the content automatically scroll * to the top after making the adjustment if the user was within the threshold of the top before * the adjustment was made. This is also useful for chat-like applications where you want to see * new messages scroll into place, but not if the user has scrolled up a ways and it would be * disruptive to scroll a bunch. * * Caveat 1: Reordering elements in the scrollview with this enabled will probably cause * jumpiness and jank. It can be fixed, but there are currently no plans to do so. For now, * don't re-order the content of any ScrollViews or Lists that use this feature. * * Caveat 2: This simply uses `contentOffset` and `frame.origin` in native code to compute * visibility. Occlusion, transforms, and other complexity won't be taken into account as to * whether content is "visible" or not. * */ maintainVisibleContentPosition?: ?$ReadOnly<{ minIndexForVisible: number, autoscrollToTopThreshold?: ?number, }>, /** * Called when the momentum scroll starts (scroll which occurs as the ScrollView glides to a stop). */ onMomentumScrollBegin?: ?(event: ScrollEvent) => void, /** * Called when the momentum scroll ends (scroll which occurs as the ScrollView glides to a stop). */ onMomentumScrollEnd?: ?(event: ScrollEvent) => void, /** * Fires at most once per frame during scrolling. */ onScroll?: ?(event: ScrollEvent) => void, /** * Called when the user begins to drag the scroll view. */ onScrollBeginDrag?: ?(event: ScrollEvent) => void, /** * Called when the user stops dragging the scroll view and it either stops * or begins to glide. */ onScrollEndDrag?: ?(event: ScrollEvent) => void, /** * Called when scrollable content view of the ScrollView changes. * * 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. */ onContentSizeChange?: (contentWidth: number, contentHeight: number) => void, onKeyboardDidShow?: (event: KeyboardEvent) => void, onKeyboardDidHide?: (event: KeyboardEvent) => void, onKeyboardWillShow?: (event: KeyboardEvent) => void, onKeyboardWillHide?: (event: KeyboardEvent) => void, /** * When true, the scroll view stops on multiples of the scroll view's size * when scrolling. This can be used for horizontal pagination. The default * value is false. */ pagingEnabled?: ?boolean, /** * When false, the view cannot be scrolled via touch interaction. * The default value is true. * * Note that the view can always be scrolled by calling `scrollTo`. */ scrollEnabled?: ?boolean, /** * Limits how often scroll events will be fired while scrolling, specified as * a time interval in ms. This may be useful when expensive work is performed * in response to scrolling. Values <= `16` will disable throttling, * regardless of the refresh rate of the device. */ scrollEventThrottle?: ?number, /** * When true, shows a vertical scroll indicator. * The default value is true. */ showsVerticalScrollIndicator?: ?boolean, /** * When true, Sticky header is hidden when scrolling down, and dock at the top * when scrolling up */ stickyHeaderHiddenOnScroll?: ?boolean, /** * An array of child indices determining which children get docked to the * top of the screen when scrolling. For example, passing * `stickyHeaderIndices={[0]}` will cause the first child to be fixed to the * top of the scroll view. This property is not supported in conjunction * with `horizontal={true}`. */ stickyHeaderIndices?: ?$ReadOnlyArray<number>, /** * A React Component that will be used to render sticky headers. * To be used together with `stickyHeaderIndices` or with `SectionList`, defaults to `ScrollViewStickyHeader`. * You may need to set this if your sticky header uses custom transforms (eg. translation), * for example when you want your list to have an animated hidable header. */ StickyHeaderComponent?: StickyHeaderComponentType, /** * When `snapToInterval` is set, `snapToAlignment` will define the relationship * of the snapping to the scroll view. * * - `'start'` (the default) will align the snap at the left (horizontal) or top (vertical) * - `'center'` will align the snap in the center * - `'end'` will align the snap at the right (horizontal) or bottom (vertical) */ snapToAlignment?: ?('start' | 'center' | 'end'), /** * When set, causes the scroll view to stop at multiples of the value of * `snapToInterval`. This can be used for paginating through children * that have lengths smaller than the scroll view. Typically used in * combination with `snapToAlignment` and `decelerationRate="fast"`. * * Overrides less configurable `pagingEnabled` prop. */ snapToInterval?: ?number, /** * When set, causes the scroll view to stop at the defined offsets. * This can be used for paginating through variously sized children * that have lengths smaller than the scroll view. Typically used in * combination with `decelerationRate="fast"`. * * Overrides less configurable `pagingEnabled` and `snapToInterval` props. */ snapToOffsets?: ?$ReadOnlyArray<number>, /** * Use in conjunction with `snapToOffsets`. By default, the beginning * of the list counts as a snap offset. Set `snapToStart` to false to disable * this behavior and allow the list to scroll freely between its start and * the first `snapToOffsets` offset. * The default value is true. */ snapToStart?: ?boolean, /** * Use in conjunction with `snapToOffsets`. By default, the end * of the list counts as a snap offset. Set `snapToEnd` to false to disable * this behavior and allow the list to scroll freely between its end and * the last `snapToOffsets` offset. * The default value is true. */ snapToEnd?: ?boolean, /** * Experimental: When true, offscreen child views (whose `overflow` value is * `hidden`) are removed from their native backing superview when offscreen. * This can improve scrolling performance on long lists. The default value is * true. */ removeClippedSubviews?: ?boolean, /** * A RefreshControl component, used to provide pull-to-refresh * functionality for the ScrollView. Only works for vertical ScrollViews * (`horizontal` prop must be `false`). * * See [RefreshControl](docs/refreshcontrol.html). */ /* $FlowFixMe[unclear-type] - how to handle generic type without existential * operator? */ refreshControl?: ?React.MixedElement, children?: React.Node, /** * A ref to the inner View element of the ScrollView. This should be used * instead of calling `getInnerViewRef`. */ innerViewRef?: React.RefSetter<InnerViewInstance>, /** * A ref to the Native ScrollView component. This ref can be used to call * all of ScrollView's public methods, in addition to native methods like * measure, measureLayout, etc. */ scrollViewRef?: React.RefSetter<PublicScrollViewInstance>, }>; export type ScrollViewProps = $ReadOnly<{ ...ViewProps, ...ScrollViewPropsIOS, ...ScrollViewPropsAndroid, ...ScrollViewBaseProps, }>; type ScrollViewState = { layoutHeight: ?number, }; const IS_ANIMATING_TOUCH_START_THRESHOLD_MS = 16; export type ScrollViewComponentStatics = $ReadOnly<{ Context: typeof ScrollViewContext, }>; /** * Component that wraps platform ScrollView while providing * integration with touch locking "responder" system. * * Keep in mind that ScrollViews must have a bounded height in order to work, * since they contain unbounded-height children into a bounded container (via * a scroll interaction). In order to bound the height of a ScrollView, either * set the height of the view directly (discouraged) or make sure all parent * views have bounded height. Forgetting to transfer `{flex: 1}` down the * view stack can lead to errors here, which the element inspector makes * easy to debug. * * Doesn't yet support other contained responders from blocking this scroll * view from becoming the responder. * * * `<ScrollView>` vs [`<FlatList>`](https://reactnative.dev/docs/flatlist) - which one to use? * * `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, maybe several screens worth of * content. Creating JS components and native views for everything all at once, * much of which may not even be shown, will contribute to slow rendering and * increased memory usage. * * This is where `FlatList` comes into play. `FlatList` renders items lazily, * just when they are about to appear, and removes items that scroll way off * screen to save memory and processing time. * * `FlatList` is also handy if you want to render separators between your items, * multiple columns, infinite scroll loading, or any number of other features it * supports out of the box. */ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> { static Context: typeof ScrollViewContext = ScrollViewContext; constructor(props: ScrollViewProps) { super(props); this._scrollAnimatedValue = new AnimatedImplementation.Value( this.props.contentOffset?.y ?? 0, ); this._scrollAnimatedValue.setOffset(this.props.contentInset?.top ?? 0); } _scrollAnimatedValue: AnimatedImplementation.Value; _scrollAnimatedValueAttachment: ?{detach: () => void, ...} = null; _stickyHeaderRefs: Map< React.Key, React.ElementRef<StickyHeaderComponentType>, > = new Map(); _headerLayoutYs: Map<React.Key, number> = new Map(); _keyboardMetrics: ?KeyboardMetrics = null; _additionalScrollOffset: number = 0; _isTouching: boolean = false; _lastMomentumScrollBeginTime: number = 0; _lastMomentumScrollEndTime: number = 0; // Reset to false every time becomes responder. This is used to: // - Determine if the scroll view has been scrolled and therefore should // refuse to give up its responder lock. // - Determine if releasing should dismiss the keyboard when we are in // tap-to-dismiss mode (this.props.keyboardShouldPersistTaps !== 'always'). _observedScrollSinceBecomingResponder: boolean = false; _becameResponderWhileAnimating: boolean = false; _preventNegativeScrollOffset: ?boolean = null; _animated: ?boolean = null; _subscriptionKeyboardWillShow: ?EventSubscription = null; _subscriptionKeyboardWillHide: ?EventSubscription = null; _subscriptionKeyboardDidShow: ?EventSubscription = null; _subscriptionKeyboardDidHide: ?EventSubscription = null; state: ScrollViewState = { layoutHeight: null, }; componentDidMount() { if (typeof this.props.keyboardShouldPersistTaps === 'boolean') { console.warn( `'keyboardShouldPersistTaps={${ this.props.keyboardShouldPersistTaps === true ? 'true' : 'false' }}' is deprecated. ` + `Use 'keyboardShouldPersistTaps="${ this.props.keyboardShouldPersistTaps ? 'always' : 'never' }"' instead`, ); } this._keyboardMetrics = Keyboard.metrics(); this._additionalScrollOffset = 0; this._subscriptionKeyboardWillShow = Keyboard.addListener( 'keyboardWillShow', this.scrollResponderKeyboardWillShow, ); this._subscriptionKeyboardWillHide = Keyboard.addListener( 'keyboardWillHide', this.scrollResponderKeyboardWillHide, ); this._subscriptionKeyboardDidShow = Keyboard.addListener( 'keyboardDidShow', this.scrollResponderKeyboardDidShow, ); this._subscriptionKeyboardDidHide = Keyboard.addListener( 'keyboardDidHide', this.scrollResponderKeyboardDidHide, ); this._updateAnimatedNodeAttachment(); } componentDidUpdate(prevProps: ScrollViewProps) { const prevContentInsetTop = prevProps.contentInset ? prevProps.contentInset.top : 0; const newContentInsetTop = this.props.contentInset ? this.props.contentInset.top : 0; if (prevContentInsetTop !== newContentInsetTop) { this._scrollAnimatedValue.setOffset(newContentInsetTop || 0); } this._updateAnimatedNodeAttachment(); } componentWillUnmount() { if (this._subscriptionKeyboardWillShow != null) { this._subscriptionKeyboardWillShow.remove(); } if (this._subscriptionKeyboardWillHide != null) { this._subscriptionKeyboardWillHide.remove(); } if (this._subscriptionKeyboardDidShow != null) { this._subscriptionKeyboardDidShow.remove(); } if (this._subscriptionKeyboardDidHide != null) { this._subscriptionKeyboardDidHide.remove(); } if (this._scrollAnimatedValueAttachment) { this._scrollAnimatedValueAttachment.detach(); } } /** * Returns a reference to the underlying scroll responder, which supports * operations like `scrollTo`. All ScrollView-like components should * implement this method so that they can be composed while providing access * to the underlying scroll responder's methods. */ getScrollResponder: ScrollViewImperativeMethods['getScrollResponder'] = () => { // $FlowFixMe[unclear-type] return ((this: any): ScrollResponderType); }; getScrollableNode: ScrollViewImperativeMethods['getScrollableNode'] = () => { return findNodeHandle<$FlowFixMe>(this.getNativeScrollRef()); }; getInnerViewNode: ScrollViewImperativeMethods['getInnerViewNode'] = () => { return findNodeHandle<$FlowFixMe>(this._innerView.nativeInstance); }; getInnerViewRef: ScrollViewImperativeMethods['getInnerViewRef'] = () => { return this._innerView.nativeInstance; }; getNativeScrollRef: ScrollViewImperativeMethods['getNativeScrollRef'] = () => { return this._scrollView.nativeInstance; }; /** * Scrolls to a given x, y offset, either immediately or with a smooth animation. * * Example: * * `scrollTo({x: 0, y: 0, animated: true})` * * Note: The weird function signature is due to the fact that, for historical reasons, * the function also accepts separate arguments as an alternative to the options object. * This is deprecated due to ambiguity (y before x), and SHOULD NOT BE USED. */ scrollTo: ScrollViewImperativeMethods['scrollTo'] = ( options, deprecatedX, deprecatedAnimated, ) => { let x, y, animated; if (typeof options === 'number') { console.warn( '`scrollTo(y, x, animated)` is deprecated. Use `scrollTo({x: 5, y: 5, ' + 'animated: true})` instead.', ); y = options; x = deprecatedX; animated = deprecatedAnimated; } else if (options) { y = options.y; x = options.x; animated = options.animated; } const component = this.getNativeScrollRef(); if (component == null) { return; } Commands.scrollTo(component, x || 0, y || 0, animated !== false); }; /** * If this is a vertical ScrollView scrolls to the bottom. * If this is a horizontal ScrollView scrolls to the right. * * Use `scrollToEnd({animated: true})` for smooth animated scrolling, * `scrollToEnd({animated: false})` for immediate scrolling. * If no options are passed, `animated` defaults to true. */ scrollToEnd: ScrollViewImperativeMethods['scrollToEnd'] = options => { // Default to true const animated = (options && options.animated) !== false; const component = this.getNativeScrollRef(); if (component == null) { return; } Commands.scrollToEnd(component, animated); }; /** * Displays the scroll indicators momentarily. * * @platform ios */ flashScrollIndicators: ScrollViewImperativeMethods['flashScrollIndicators'] = () => { const component = this.getNativeScrollRef(); if (component == null) { return; } Commands.flashScrollIndicators(component); }; /** * This method should be used as the callback to onFocus in a TextInputs' * parent view. Note that any module using this mixin needs to return * the parent view's ref in getScrollViewRef() in order to use this method. * @param {number} nodeHandle The TextInput node handle * @param {number} additionalOffset The scroll view's bottom "contentInset". * Default is 0. * @param {bool} preventNegativeScrolling Whether to allow pulling the content * down to make it meet the keyboard's top. Default is false. */ scrollResponderScrollNativeHandleToKeyboard: ScrollViewImperativeMethods['scrollResponderScrollNativeHandleToKeyboard'] = ( nodeHandle: number | HostInstance, additionalOffset?: number, preventNegativeScrollOffset?: boolean, ) => { this._additionalScrollOffset = additionalOffset || 0; this._preventNegativeScrollOffset = !!preventNegativeScrollOffset; if (this._innerView.nativeInstance == null) { return; } if (typeof nodeHandle === 'number') { UIManager.measureLayout( nodeHandle, nullthrows(findNodeHandle<$FlowFixMe>(this)), // $FlowFixMe[method-unbinding] added when improving typing for this parameters this._textInputFocusError, this._inputMeasureAndScrollToKeyboard, ); } else { nodeHandle.measureLayout( this._innerView.nativeInstance, this._inputMeasureAndScrollToKeyboard, // $FlowFixMe[method-unbinding] added when improving typing for this parameters this._textInputFocusError, ); } }; /** * A helper function to zoom to a specific rect in the scrollview. The argument has the shape * {x: number; y: number; width: number; height: number; animated: boolean = true} * * @platform ios */ scrollResponderZoomTo: ScrollViewImperativeMethods['scrollResponderZoomTo'] = ( rect: { x: number, y: number, width: number, height: number, animated?: boolean, }, animated?: boolean, // deprecated, put this inside the rect argument instead ) => { invariant(Platform.OS === 'ios', 'zoomToRect is not implemented'); if ('animated' in rect) { this._animated = rect.animated; delete rect.animated; } else if (typeof animated !== 'undefined') { console.warn( '`scrollResponderZoomTo` `animated` argument is deprecated. Use `options.animated` instead', ); } const component = this.getNativeScrollRef(); if (component == null) { return; } Commands.zoomToRect(component, rect, animated !== false); }; _textInputFocusError() { console.warn('Error measuring text field.'); } /** * The calculations performed here assume the scroll view takes up the entire * screen - even if has some content inset. We then measure the offsets of the * keyboard, and compensate both for the scroll view's "contentInset". * * @param {number} left Position of input w.r.t. table view. * @param {number} top Position of input w.r.t. table view. * @param {number} width Width of the text input. * @param {number} height Height of the text input. */ _inputMeasureAndScrollToKeyboard: ( left: number, top: number, width: number, height: number, ) => void = (left: number, top: number, width: number, height: number) => { let keyboardScreenY = Dimensions.get('window').height; const scrollTextInputIntoVisibleRect = () => { if (this._keyboardMetrics != null) { keyboardScreenY = this._keyboardMetrics.screenY; } let scrollOffsetY = top - keyboardScreenY + height + this._additionalScrollOffset; // By default, this can scroll with negative offset, pulling the content // down so that the target component's bottom meets the keyboard's top. // If requested otherwise, cap the offset at 0 minimum to avoid content // shifting down. if (this._preventNegativeScrollOffset === true) { scrollOffsetY = Math.max(0, scrollOffsetY); } this.scrollTo({x: 0, y: scrollOffsetY, animated: true}); this._additionalScrollOffset = 0; this._preventNegativeScrollOffset = false; }; if (this._keyboardMetrics == null) { // `_keyboardMetrics` is set inside `scrollResponderKeyboardWillShow` which // is not guaranteed to be called before `_inputMeasureAndScrollToKeyboard` but native has already scheduled it. // In case it was not called before `_inputMeasureAndScrollToKeyboard`, we postpone scrolling to // text input. setTimeout(() => { scrollTextInputIntoVisibleRect(); }, 0); } else { scrollTextInputIntoVisibleRect(); } }; _getKeyForIndex( index: number, // $FlowFixMe[unclear-type] - The children and its key is unknown. childArray: any, ): React.Key { const child = childArray[index]; return child && child.key; } _updateAnimatedNodeAttachment() { if (this._scrollAnimatedValueAttachment) { this._scrollAnimatedValueAttachment.detach(); } if ( this.props.stickyHeaderIndices && this.props.stickyHeaderIndices.length > 0 ) { this._scrollAnimatedValueAttachment = AnimatedImplementation.attachNativeEvent( this.getNativeScrollRef(), 'onScroll', [{nativeEvent: {contentOffset: {y: this._scrollAnimatedValue}}}], ); } } _setStickyHeaderRef( key: string, ref: ?React.ElementRef<StickyHeaderComponentType>, ) { if (ref) { this._stickyHeaderRefs.set(key, ref); } else { this._stickyHeaderRefs.delete(key); } } _onStickyHeaderLayout( index: number, event: LayoutChangeEvent, key: React.Key, ) { const {stickyHeaderIndices} = this.props; if (!stickyHeaderIndices) { return; } const childArray = React.Children.toArray<$FlowFixMe>(this.props.children); if (key !== this._getKeyForIndex(index, childArray)) { // ignore stale layout update return; } const layoutY = event.nativeEvent.layout.y; this._headerLayoutYs.set(key, layoutY); const indexOfIndex = stickyHeaderIndices.indexOf(index); const previousHeaderIndex = stickyHeaderIndices[indexOfIndex - 1]; if (previousHeaderIndex != null) { const previousHeader = this._stickyHeaderRefs.get( this._getKeyForIndex(previousHeaderIndex, childArray), ); previousHeader && previousHeader.setNextHeaderY && previousHeader.setNextHeaderY(layoutY); } } _handleScroll = (e: ScrollEvent) => { this._observedScrollSinceBecomingResponder = true; this.props.onScroll && this.props.onScroll(e); }; _handleLayout = (e: LayoutChangeEvent) => { if (this.props.invertStickyHeaders === true) { this.setState({layoutHeight: e.nativeEvent.layout.height}); } if (this.props.onLayout) { this.props.onLayout(e); } }; _handleContentOnLayout = (e: LayoutChangeEvent) => { const {width, height} = e.nativeEvent.layout; this.props.onContentSizeChange && this.props.onContentSizeChange(width, height); }; _innerView: RefForwarder<InnerViewInstance, InnerViewInstance> = createRefForwarder( (instance: InnerViewInstance): InnerViewInstance => instance, ); _scrollView: RefForwarder<HostInstance, PublicScrollViewInstance | null> = createRefForwarder(nativeInstance => { // This is a hack. Ideally we would forwardRef to the underlying // host component. However, since ScrollView has it's own methods that can be // called as well, if we used the standard forwardRef then these // methods wouldn't be accessible and thus be a breaking change. // // Therefore we edit ref to include ScrollView's public methods so that // they are callable from the ref. // $FlowFixMe[prop-missing] - Known issue with appending custom methods. // $FlowFixMe[unsafe-object-assign] const publicInstance: PublicScrollViewInstance = Object.assign( nativeInstance, { getScrollResponder: this.getScrollResponder, getScrollableNode: this.getScrollableNode, getInnerViewNode: this.getInnerViewNode, getInnerViewRef: this.getInnerViewRef, getNativeScrollRef: this.getNativeScrollRef, scrollTo: this.scrollTo, scrollToEnd: this.scrollToEnd, flashScrollIndicators: this.flashScrollIndicators, scrollResponderZoomTo: this.scrollResponderZoomTo, scrollResponderScrollNativeHandleToKeyboard: this.scrollResponderScrollNativeHandleToKeyboard, }, ); return publicInstance; }); /** * Warning, this may be called several times for a single keyboard opening. * It's best to store the information in this method and then take any action * at a later point (either in `keyboardDidShow` or other). * * Here's the order that events occur in: * - focus * - willShow {startCoordinates, endCoordinates} several times * - didShow several times * - blur * - willHide {startCoordinates, endCoordinates} several times * - didHide several times * * The `ScrollResponder` module callbacks for each of these events. * Even though any user could have easily listened to keyboard events * themselves, using these `props` callbacks ensures that ordering of events * is consistent - and not dependent on the order that the keyboard events are * subscribed to. This matters when telling the scroll view to scroll to where * the keyboard is headed - the scroll responder better have been notified of * the keyboard destination before being instructed to scroll to where the * keyboard will be. Stick to the `ScrollResponder` callbacks, and everything * will work. * * WARNING: These callbacks will fire even if a keyboard is displayed in a * different navigation pane. Filter out the events to determine if they are * relevant to you. (For example, only if you receive these callbacks after * you had explicitly focused a node etc). */ scrollResponderKeyboardWillShow: (e: KeyboardEvent) => void = ( e: KeyboardEvent, ) => { this._keyboardMetrics = e.endCoordinates; this.props.onKeyboardWillShow && this.props.onKeyboardWillShow(e); }; scrollResponderKeyboardWillHide: (e: KeyboardEvent) => void = ( e: KeyboardEvent, ) => { this._keyboardMetrics = null; this.props.onKeyboardWillHide && this.props.onKeyboardWillHide(e); }; scrollResponderKeyboardDidShow: (e: KeyboardEvent) => void = ( e: KeyboardEvent, ) => { this._keyboardMetrics = e.endCoordinates; this.props.onKeyboardDidShow && this.props.onKeyboardDidShow(e); }; scrollResponderKeyboardDidHide: (e: KeyboardEvent) => void = ( e: KeyboardEvent, ) => { this._keyboardMetrics = null; this.props.onKeyboardDidHide && this.props.onKeyboardDidHide(e); }; /** * Invoke this from an `onMomentumScrollBegin` event. */ _handleMomentumScrollBegin: (e: ScrollEvent) => void = (e: ScrollEvent) => { this._lastMomentumScrollBeginTime = global.performance.now(); this.props.onMomentumScrollBegin && this.props.onMomentumScrollBegin(e); }; /** * Invoke this from an `onMomentumScrollEnd` event. */ _handleMomentumScrollEnd: (e: ScrollEvent) => void = (e: ScrollEvent) => { FrameRateLogger.endScroll(); this._lastMomentumScrollEndTime = global.performance.now(); this.props.onMomentumScrollEnd && this.props.onMomentumScrollEnd(e); }; /** * Unfortunately, `onScrollBeginDrag` also fires when *stopping* the scroll * animation, and there's not an easy way to distinguish a drag vs. stopping * momentum. * * Invoke this from an `onScrollBeginDrag` event. */ _handleScrollBeginDrag: (e: ScrollEvent) => void = (e: ScrollEvent) => { FrameRateLogger.beginScroll(); // TODO: track all scrolls after implementing onScrollEndAnimation if ( Platform.OS === 'android' && this.props.keyboardDismissMode === 'on-drag' ) { dismissKeyboard(); } this.props.onScrollBeginDrag && this.props.onScrollBeginDrag(e); }; /** * Invoke this from an `onScrollEndDrag` event. */ _handleScrollEndDrag: (e: ScrollEvent) => void = (e: ScrollEvent) => { const {velocity} = e.nativeEvent; // - If we are animating, then this is a "drag" that is stopping the scrollview and momentum end // will fire. // - If velocity is non-zero, then the interaction will stop when momentum scroll ends or // another drag starts and ends. // - If we don't get velocity, better to stop the interaction twice than not stop it. if ( !this._isAnimating() && (!velocity || (velocity.x === 0 && velocity.y === 0)) ) { FrameRateLogger.endScroll(); } this.props.onScrollEndDrag && this.props.onScrollEndDrag(e); }; /** * A helper function for this class that lets us quickly determine if the * view is currently animating. This is particularly useful to know when * a touch has just started or ended. */ _isAnimating: () => boolean = () => { const now = global.performance.now(); const timeSinceLastMomentumScrollEnd = now - this._lastMomentumScrollEndTime; const isAnimating = timeSinceLastMomentumScrollEnd < IS_ANIMATING_TOUCH_START_THRESHOLD_MS || this._lastMomentumScrollEndTime < this._lastMomentumScrollBeginTime; return isAnimating; }; /** * Invoke this from an `onResponderGrant` event. */ _handleResponderGrant: (e: GestureResponderEvent) => void = ( e: GestureResponderEvent, ) => { this._observedScrollSinceBecomingResponder = false; this.props.onResponderGrant && this.props.onResponderGrant(e); this._becameResponderWhileAnimating = this._isAnimating(); }; /** * Invoke this from an `onResponderReject` event. * * Some other element is not yielding its role as responder. Normally, we'd * just disable the `UIScrollView`, but a touch has already began on it, the * `UIScrollView` will not accept being disabled after that. The easiest * solution for now is to accept the limitation of disallowing this * altogether. To improve this, find a way to disable the `UIScrollView` after * a touch has already started. */ _handleResponderReject: () => void = () => {}; /** * Invoke this from an `onResponderRelease` event. */ _handleResponderRelease: (e: GestureResponderEvent) => void = ( e: GestureResponderEvent, ) => { this._isTouching = e.nativeEvent.touches.length !== 0; this.props.onResponderRelease && this.props.onResponderRelease(e); if (typeof e.target === 'number') { if (__DEV__) { console.error( 'Did not expect event target to be a number. Should have been a native component', ); } return; } // By def