react-native
Version:
A framework for building native apps using React
1,359 lines (1,273 loc) • 71.1 kB
JavaScript
/**
* 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