amis-rpc-design/node_modules/react-native/Libraries/Components/ScrollView/ScrollView.js

1939 lines
70 KiB
JavaScript
Raw Normal View History

2023-10-07 19:42:30 +08:00
/**
* 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 {HostComponent} from '../../Renderer/shims/ReactNativeTypes';
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 {
LayoutEvent,
PressEvent,
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 {Props as ScrollViewStickyHeaderProps} from './ScrollViewStickyHeader';
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 AndroidHorizontalScrollContentViewNativeComponent from './AndroidHorizontalScrollContentViewNativeComponent';
import AndroidHorizontalScrollViewNativeComponent from './AndroidHorizontalScrollViewNativeComponent';
import processDecelerationRate from './processDecelerationRate';
import ScrollContentViewNativeComponent from './ScrollContentViewNativeComponent';
import Commands from './ScrollViewCommands';
import ScrollViewContext, {HORIZONTAL, VERTICAL} from './ScrollViewContext';
import ScrollViewNativeComponent from './ScrollViewNativeComponent';
import ScrollViewStickyHeader from './ScrollViewStickyHeader';
import invariant from 'invariant';
import memoize from 'memoize-one';
import nullthrows from 'nullthrows';
import * as React from 'react';
if (Platform.OS === 'ios') {
require('../../Renderer/shims/ReactNative'); // Force side effects to prevent T55744311
}
const {NativeHorizontalScrollViewTuple, NativeVerticalScrollViewTuple} =
Platform.OS === 'android'
? {
NativeHorizontalScrollViewTuple: [
AndroidHorizontalScrollViewNativeComponent,
AndroidHorizontalScrollContentViewNativeComponent,
],
NativeVerticalScrollViewTuple: [ScrollViewNativeComponent, View],
}
: {
NativeHorizontalScrollViewTuple: [
ScrollViewNativeComponent,
ScrollContentViewNativeComponent,
],
NativeVerticalScrollViewTuple: [
ScrollViewNativeComponent,
ScrollContentViewNativeComponent,
],
};
/*
* 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
*/
// Public methods for ScrollView
export type ScrollViewImperativeMethods = $ReadOnly<{|
getScrollResponder: $PropertyType<ScrollView, 'getScrollResponder'>,
getScrollableNode: $PropertyType<ScrollView, 'getScrollableNode'>,
getInnerViewNode: $PropertyType<ScrollView, 'getInnerViewNode'>,
getInnerViewRef: $PropertyType<ScrollView, 'getInnerViewRef'>,
getNativeScrollRef: $PropertyType<ScrollView, 'getNativeScrollRef'>,
scrollTo: $PropertyType<ScrollView, 'scrollTo'>,
scrollToEnd: $PropertyType<ScrollView, 'scrollToEnd'>,
flashScrollIndicators: $PropertyType<ScrollView, 'flashScrollIndicators'>,
scrollResponderZoomTo: $PropertyType<ScrollView, 'scrollResponderZoomTo'>,
scrollResponderScrollNativeHandleToKeyboard: $PropertyType<
ScrollView,
'scrollResponderScrollNativeHandleToKeyboard',
>,
|}>;
export type DecelerationRateType = 'fast' | 'normal' | number;
export type ScrollResponderType = ScrollViewImperativeMethods;
type NativeScrollViewInstance = React.ElementRef<HostComponent<mixed>>;
type PublicScrollViewInstance = $ReadOnly<{|
...$Exact<NativeScrollViewInstance>,
...ScrollViewImperativeMethods,
|}>;
type InnerViewInstance = React.ElementRef<typeof View>;
type IOSProps = $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,
/**
* When set, the scroll view will adjust the scroll position so that the first child that is
* currently 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,
|}>,
/**
* 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". Available on iOS 11 and later.
* @platform ios
*/
contentInsetAdjustmentBehavior?: ?(
| 'automatic'
| 'scrollableAxes'
| 'never'
| 'always'
),
|}>;
type AndroidProps = $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 = React.AbstractComponent<
ScrollViewStickyHeaderProps,
$ReadOnly<interface {setNextHeaderY: number => void}>,
>;
export type Props = $ReadOnly<{|
...ViewProps,
...IOSProps,
...AndroidProps,
/**
* 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,
/**
* 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),
/**
* 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. The frequency of the
* events can be controlled using the `scrollEventThrottle` prop.
*/
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.
*
* Note: Vertical pagination is not supported on Android.
*/
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,
/**
* This controls how often the scroll event will be fired while scrolling
* (as a time interval in ms). A lower number yields better accuracy for code
* that is tracking the scroll position, but can lead to scroll performance
* problems due to the volume of information being send over the bridge.
*
* Values between 0 and 17ms indicate 60fps updates are needed and throttling
* will be disabled.
*
* If you do not need precise scroll position tracking, set this value higher
* to limit the information being sent across the bridge.
*
* The default value is zero, which results in the scroll event being sent only
* once each time the view is scrolled.
*/
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.Element<any>,
children?: React.Node,
/**
* A ref to the inner View element of the ScrollView. This should be used
* instead of calling `getInnerViewRef`.
*/
innerViewRef?: ForwardedRef<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?: ForwardedRef<PublicScrollViewInstance>,
|}>;
type State = {|
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<Props, State> {
static Context: typeof ScrollViewContext = ScrollViewContext;
constructor(props: Props) {
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<string, React.ElementRef<StickyHeaderComponentType>> =
new Map();
_headerLayoutYs: Map<string, 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: State = {
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: Props) {
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: () => ScrollResponderType = () => {
// $FlowFixMe[unclear-type]
return ((this: any): ScrollResponderType);
};
getScrollableNode: () => ?number = () => {
return findNodeHandle(this._scrollView.nativeInstance);
};
getInnerViewNode: () => ?number = () => {
return findNodeHandle(this._innerView.nativeInstance);
};
getInnerViewRef: () => InnerViewInstance | null = () => {
return this._innerView.nativeInstance;
};
getNativeScrollRef: () => NativeScrollViewInstance | null = () => {
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: (
options?:
| {
x?: number,
y?: number,
animated?: boolean,
...
}
| number,
deprecatedX?: number,
deprecatedAnimated?: boolean,
) => void = (
options?:
| {
x?: number,
y?: number,
animated?: boolean,
...
}
| number,
deprecatedX?: number,
deprecatedAnimated?: boolean,
) => {
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;
}
if (this._scrollView.nativeInstance == null) {
return;
}
Commands.scrollTo(
this._scrollView.nativeInstance,
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: (options?: ?{animated?: boolean, ...}) => void = (
options?: ?{animated?: boolean, ...},
) => {
// Default to true
const animated = (options && options.animated) !== false;
if (this._scrollView.nativeInstance == null) {
return;
}
Commands.scrollToEnd(this._scrollView.nativeInstance, animated);
};
/**
* Displays the scroll indicators momentarily.
*
* @platform ios
*/
flashScrollIndicators: () => void = () => {
if (this._scrollView.nativeInstance == null) {
return;
}
Commands.flashScrollIndicators(this._scrollView.nativeInstance);
};
/**
* 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: <T>(
nodeHandle: number | React.ElementRef<HostComponent<T>>,
additionalOffset?: number,
preventNegativeScrollOffset?: boolean,
) => void = <T>(
nodeHandle: number | React.ElementRef<HostComponent<T>>,
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(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: (
rect: {|
x: number,
y: number,
width: number,
height: number,
animated?: boolean,
|},
animated?: boolean, // deprecated, put this inside the rect argument instead
) => void = (
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',
);
}
if (this._scrollView.nativeInstance == null) {
return;
}
Commands.zoomToRect(
this._scrollView.nativeInstance,
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: $FlowFixMe, childArray: $FlowFixMe): $FlowFixMe {
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._scrollView.nativeInstance,
'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: $FlowFixMe, event: $FlowFixMe, key: $FlowFixMe) {
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) => {
if (__DEV__) {
if (
this.props.onScroll &&
this.props.scrollEventThrottle == null &&
Platform.OS === 'ios'
) {
console.log(
'You specified `onScroll` on a <ScrollView> but not ' +
'`scrollEventThrottle`. You will only receive one event. ' +
'Using `16` you get all the events but be aware that it may ' +
"cause frame drops, use a bigger number if you don't need as " +
'much precision.',
);
}
}
this._observedScrollSinceBecomingResponder = true;
this.props.onScroll && this.props.onScroll(e);
};
_handleLayout = (e: LayoutEvent) => {
if (this.props.invertStickyHeaders === true) {
this.setState({layoutHeight: e.nativeEvent.layout.height});
}
if (this.props.onLayout) {
this.props.onLayout(e);
}
};
_handleContentOnLayout = (e: LayoutEvent) => {
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<
NativeScrollViewInstance,
PublicScrollViewInstance,
> = createRefForwarder(
(nativeInstance: NativeScrollViewInstance): PublicScrollViewInstance => {
// 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.
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: PressEvent) => void = (e: PressEvent) => {
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: PressEvent) => void = (e: PressEvent) => {
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 default scroll views will unfocus a textField
// if another touch occurs outside of it
const currentlyFocusedTextInput = TextInputState.currentlyFocusedInput();
if (
currentlyFocusedTextInput != null &&
this.props.keyboardShouldPersistTaps !== true &&
this.props.keyboardShouldPersistTaps !== 'always' &&
this._keyboardIsDismissible() &&
e.target !== currentlyFocusedTextInput &&
!this._observedScrollSinceBecomingResponder &&
!this._becameResponderWhileAnimating
) {
TextInputState.blurTextInput(currentlyFocusedTextInput);
}
};
/**
* We will allow the scroll view to give up its lock iff it acquired the lock
* during an animation. This is a very useful default that happens to satisfy
* many common user experiences.
*
* - Stop a scroll on the left edge, then turn that into an outer view's
* backswipe.
* - Stop a scroll mid-bounce at the top, continue pulling to have the outer
* view dismiss.
* - However, without catching the scroll view mid-bounce (while it is
* motionless), if you drag far enough for the scroll view to become
* responder (and therefore drag the scroll view a bit), any backswipe
* navigation of a swipe gesture higher in the view hierarchy, should be
* rejected.
*/
_handleResponderTerminationRequest: () => boolean = () => {
return !this._observedScrollSinceBecomingResponder;
};
/**
* Invoke this from an `onScroll` event.
*/
_handleScrollShouldSetResponder: () => boolean = () => {
// Allow any event touch pass through if the default pan responder is disabled
if (this.props.disableScrollViewPanResponder === true) {
return false;
}
return this._isTouching;
};
/**
* Merely touch starting is not sufficient for a scroll view to become the
* responder. Being the "responder" means that the very next touch move/end
* event will result in an action/movement.
*
* Invoke this from an `onStartShouldSetResponder` event.
*
* `onStartShouldSetResponder` is used when the next move/end will trigger
* some UI movement/action, but when you want to yield priority to views
* nested inside of the view.
*
* There may be some cases where scroll views actually should return `true`
* from `onStartShouldSetResponder`: Any time we are detecting a standard tap
* that gives priority to nested views.
*
* - If a single tap on the scroll view triggers an action such as
* recentering a map style view yet wants to give priority to interaction
* views inside (such as dropped pins or labels), then we would return true
* from this method when there is a single touch.
*
* - Similar to the previous case, if a two finger "tap" should trigger a
* zoom, we would check the `touches` count, and if `>= 2`, we would return
* true.
*
*/
_handleStartShouldSetResponder: (e: PressEvent) => boolean = (
e: PressEvent,
) => {
// Allow any event touch pass through if the default pan responder is disabled
if (this.props.disableScrollViewPanResponder === true) {
return false;
}
const currentlyFocusedInput = TextInputState.currentlyFocusedInput();
if (
this.props.keyboardShouldPersistTaps === 'handled' &&
this._keyboardIsDismissible() &&
e.target !== currentlyFocusedInput
) {
return true;
}
return false;
};
/**
* There are times when the scroll view wants to become the responder
* (meaning respond to the next immediate `touchStart/touchEnd`), in a way
* that *doesn't* give priority to nested views (hence the capture phase):
*
* - Currently animating.
* - Tapping anywhere that is not a text input, while the keyboard is
* up (which should dismiss the keyboard).
*
* Invoke this from an `onStartShouldSetResponderCapture` event.
*/
_handleStartShouldSetResponderCapture: (e: PressEvent) => boolean = (
e: PressEvent,
) => {
// The scroll view should receive taps instead of its descendants if:
// * it is already animating/decelerating
if (this._isAnimating()) {
return true;
}
// Allow any event touch pass through if the default pan responder is disabled
if (this.props.disableScrollViewPanResponder === true) {
return false;
}
// * the keyboard is up, keyboardShouldPersistTaps is 'never' (the default),
// and a new touch starts with a non-textinput target (in which case the
// first tap should be sent to the scroll view and dismiss the keyboard,
// then the second tap goes to the actual interior view)
const {keyboardShouldPersistTaps} = this.props;
const keyboardNeverPersistTaps =
!keyboardShouldPersistTaps || keyboardShouldPersistTaps === 'never';
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 false;
}
// Let presses through if the soft keyboard is detached from the viewport
if (this._softKeyboardIsDetached()) {
return false;
}
if (
keyboardNeverPersistTaps &&
this._keyboardIsDismissible() &&
e.target != null &&
// $FlowFixMe[incompatible-call]
!TextInputState.isTextInput(e.target)
) {
return true;
}
return false;
};
/**
* Do we consider there to be a dismissible soft-keyboard open?
*/
_keyboardIsDismissible: () => boolean = () => {
const currentlyFocusedInput = TextInputState.currentlyFocusedInput();
// We cannot dismiss the keyboard without an input to blur, even if a soft
// keyboard is open (e.g. when keyboard is open due to a native component
// not participating in TextInputState). It's also possible that the
// currently focused input isn't a TextInput (such as by calling ref.focus
// on a non-TextInput).
const hasFocusedTextInput =
currentlyFocusedInput != null &&
TextInputState.isTextInput(currentlyFocusedInput);
// Even if an input is focused, we may not have a keyboard to dismiss. E.g
// when using a physical keyboard. Ensure we have an event for an opened
// keyboard.
const softKeyboardMayBeOpen =
this._keyboardMetrics != null || this._keyboardEventsAreUnreliable();
return hasFocusedTextInput && softKeyboardMayBeOpen;
};
/**
* Whether an open soft keyboard is present which does not overlap the
* viewport. E.g. for a VR soft-keyboard which is detached from the app
* viewport.
*/
_softKeyboardIsDetached: () => boolean = () => {
return this._keyboardMetrics != null && this._keyboardMetrics.height === 0;
};
_keyboardEventsAreUnreliable: () => boolean = () => {
// Android versions prior to API 30 rely on observing layout changes when
// `android:windowSoftInputMode` is set to `adjustResize` or `adjustPan`.
return Platform.OS === 'android' && Platform.Version < 30;
};
/**
* Invoke this from an `onTouchEnd` event.
*
* @param {PressEvent} e Event.
*/
_handleTouchEnd: (e: PressEvent) => void = (e: PressEvent) => {
const nativeEvent = e.nativeEvent;
this._isTouching = nativeEvent.touches.length !== 0;
const {keyboardShouldPersistTaps} = this.props;
const keyboardNeverPersistsTaps =
!keyboardShouldPersistTaps || keyboardShouldPersistTaps === 'never';
// Dismiss the keyboard now if we didn't become responder in capture phase
// to eat presses, but still want to dismiss on interaction.
// Don't do anything if the target of the touch event is the current input.
const currentlyFocusedTextInput = TextInputState.currentlyFocusedInput();
if (
currentlyFocusedTextInput != null &&
e.target !== currentlyFocusedTextInput &&
this._softKeyboardIsDetached() &&
this._keyboardIsDismissible() &&
keyboardNeverPersistsTaps
) {
TextInputState.blurTextInput(currentlyFocusedTextInput);
}
this.props.onTouchEnd && this.props.onTouchEnd(e);
};
/**
* Invoke this from an `onTouchCancel` event.
*
* @param {PressEvent} e Event.
*/
_handleTouchCancel: (e: PressEvent) => void = (e: PressEvent) => {
this._isTouching = false;
this.props.onTouchCancel && this.props.onTouchCancel(e);
};
/**
* Invoke this from an `onTouchStart` event.
*
* Since we know that the `SimpleEventPlugin` occurs later in the plugin
* order, after `ResponderEventPlugin`, we can detect that we were *not*
* permitted to be the responder (presumably because a contained view became
* responder). The `onResponderReject` won't fire in that case - it only
* fires when a *current* responder rejects our request.
*
* @param {PressEvent} e Touch Start event.
*/
_handleTouchStart: (e: PressEvent) => void = (e: PressEvent) => {
this._isTouching = true;
this.props.onTouchStart && this.props.onTouchStart(e);
};
/**
* Invoke this from an `onTouchMove` event.
*
* Since we know that the `SimpleEventPlugin` occurs later in the plugin
* order, after `ResponderEventPlugin`, we can detect that we were *not*
* permitted to be the responder (presumably because a contained view became
* responder). The `onResponderReject` won't fire in that case - it only
* fires when a *current* responder rejects our request.
*
* @param {PressEvent} e Touch Start event.
*/
_handleTouchMove: (e: PressEvent) => void = (e: PressEvent) => {
this.props.onTouchMove && this.props.onTouchMove(e);
};
render(): React.Node | React.Element<string> {
const [NativeDirectionalScrollView, NativeDirectionalScrollContentView] =
this.props.horizontal === true
? NativeHorizontalScrollViewTuple
: NativeVerticalScrollViewTuple;
const contentContainerStyle = [
this.props.horizontal === true && styles.contentContainerHorizontal,
this.props.contentContainerStyle,
];
if (__DEV__ && this.props.style !== undefined) {
// $FlowFixMe[underconstrained-implicit-instantiation]
const style = flattenStyle(this.props.style);
const childLayoutProps = ['alignItems', 'justifyContent'].filter(
prop => style && style[prop] !== undefined,
);
invariant(
childLayoutProps.length === 0,
'ScrollView child layout (' +
JSON.stringify(childLayoutProps) +
') must be applied through the contentContainerStyle prop.',
);
}
const contentSizeChangeProps =
this.props.onContentSizeChange == null
? null
: {
onLayout: this._handleContentOnLayout,
};
const {stickyHeaderIndices} = this.props;
let children = this.props.children;
if (stickyHeaderIndices != null && stickyHeaderIndices.length > 0) {
const childArray = React.Children.toArray<$FlowFixMe>(
this.props.children,
);
children = childArray.map((child, index) => {
const indexOfIndex = child ? stickyHeaderIndices.indexOf(index) : -1;
if (indexOfIndex > -1) {
const key = child.key;
const nextIndex = stickyHeaderIndices[indexOfIndex + 1];
const StickyHeaderComponent =
this.props.StickyHeaderComponent || ScrollViewStickyHeader;
return (
<StickyHeaderComponent
key={key}
nativeID={'StickyHeader-' + key} /* TODO: T68258846. */
ref={ref => this._setStickyHeaderRef(key, ref)}
nextHeaderLayoutY={this._headerLayoutYs.get(
this._getKeyForIndex(nextIndex, childArray),
)}
onLayout={event => this._onStickyHeaderLayout(index, event, key)}
scrollAnimatedValue={this._scrollAnimatedValue}
inverted={this.props.invertStickyHeaders}
hiddenOnScroll={this.props.stickyHeaderHiddenOnScroll}
scrollViewHeight={this.state.layoutHeight}>
{child}
</StickyHeaderComponent>
);
} else {
return child;
}
});
}
children = (
<ScrollViewContext.Provider
value={this.props.horizontal === true ? HORIZONTAL : VERTICAL}>
{children}
</ScrollViewContext.Provider>
);
const hasStickyHeaders =
Array.isArray(stickyHeaderIndices) && stickyHeaderIndices.length > 0;
const contentContainer = (
<NativeDirectionalScrollContentView
{...contentSizeChangeProps}
ref={this._innerView.getForwardingRef(this.props.innerViewRef)}
style={contentContainerStyle}
removeClippedSubviews={
// Subview clipping causes issues with sticky headers on Android and
// would be hard to fix properly in a performant way.
Platform.OS === 'android' && hasStickyHeaders
? false
: this.props.removeClippedSubviews
}
collapsable={false}>
{children}
</NativeDirectionalScrollContentView>
);
const alwaysBounceHorizontal =
this.props.alwaysBounceHorizontal !== undefined
? this.props.alwaysBounceHorizontal
: this.props.horizontal;
const alwaysBounceVertical =
this.props.alwaysBounceVertical !== undefined
? this.props.alwaysBounceVertical
: !this.props.horizontal;
const baseStyle =
this.props.horizontal === true
? styles.baseHorizontal
: styles.baseVertical;
const props = {
...this.props,
alwaysBounceHorizontal,
alwaysBounceVertical,
style: StyleSheet.compose(baseStyle, this.props.style),
// Override the onContentSizeChange from props, since this event can
// bubble up from TextInputs
onContentSizeChange: null,
onLayout: this._handleLayout,
onMomentumScrollBegin: this._handleMomentumScrollBegin,
onMomentumScrollEnd: this._handleMomentumScrollEnd,
onResponderGrant: this._handleResponderGrant,
onResponderReject: this._handleResponderReject,
onResponderRelease: this._handleResponderRelease,
onResponderTerminationRequest: this._handleResponderTerminationRequest,
onScrollBeginDrag: this._handleScrollBeginDrag,
onScrollEndDrag: this._handleScrollEndDrag,
onScrollShouldSetResponder: this._handleScrollShouldSetResponder,
onStartShouldSetResponder: this._handleStartShouldSetResponder,
onStartShouldSetResponderCapture:
this._handleStartShouldSetResponderCapture,
onTouchEnd: this._handleTouchEnd,
onTouchMove: this._handleTouchMove,
onTouchStart: this._handleTouchStart,
onTouchCancel: this._handleTouchCancel,
onScroll: this._handleScroll,
scrollEventThrottle: hasStickyHeaders
? 1
: this.props.scrollEventThrottle,
sendMomentumEvents:
this.props.onMomentumScrollBegin || this.props.onMomentumScrollEnd
? true
: false,
// default to true
snapToStart: this.props.snapToStart !== false,
// default to true
snapToEnd: this.props.snapToEnd !== false,
// pagingEnabled is overridden by snapToInterval / snapToOffsets
pagingEnabled: Platform.select({
// on iOS, pagingEnabled must be set to false to have snapToInterval / snapToOffsets work
ios:
this.props.pagingEnabled === true &&
this.props.snapToInterval == null &&
this.props.snapToOffsets == null,
// on Android, pagingEnabled must be set to true to have snapToInterval / snapToOffsets work
android:
this.props.pagingEnabled === true ||
this.props.snapToInterval != null ||
this.props.snapToOffsets != null,
}),
};
const {decelerationRate} = this.props;
if (decelerationRate != null) {
props.decelerationRate = processDecelerationRate(decelerationRate);
}
const refreshControl = this.props.refreshControl;
const scrollViewRef = this._scrollView.getForwardingRef(
this.props.scrollViewRef,
);
if (refreshControl) {
if (Platform.OS === 'ios') {
// On iOS the RefreshControl is a child of the ScrollView.
return (
<NativeDirectionalScrollView {...props} ref={scrollViewRef}>
{refreshControl}
{contentContainer}
</NativeDirectionalScrollView>
);
} else if (Platform.OS === 'android') {
// On Android wrap the ScrollView with a AndroidSwipeRefreshLayout.
// Since the ScrollView is wrapped add the style props to the
// AndroidSwipeRefreshLayout and use flex: 1 for the ScrollView.
// Note: we should split props.style on the inner and outer props
// however, the ScrollView still needs the baseStyle to be scrollable
// $FlowFixMe[underconstrained-implicit-instantiation]
const {outer, inner} = splitLayoutProps(flattenStyle(props.style));
return React.cloneElement(
refreshControl,
{style: StyleSheet.compose(baseStyle, outer)},
<NativeDirectionalScrollView
{...props}
style={StyleSheet.compose(baseStyle, inner)}
ref={scrollViewRef}>
{contentContainer}
</NativeDirectionalScrollView>,
);
}
}
return (
<NativeDirectionalScrollView {...props} ref={scrollViewRef}>
{contentContainer}
</NativeDirectionalScrollView>
);
}
}
const styles = StyleSheet.create({
baseVertical: {
flexGrow: 1,
flexShrink: 1,
flexDirection: 'column',
overflow: 'scroll',
},
baseHorizontal: {
flexGrow: 1,
flexShrink: 1,
flexDirection: 'row',
overflow: 'scroll',
},
contentContainerHorizontal: {
flexDirection: 'row',
},
});
type ForwardedRef<T> = {current: null | T, ...} | ((null | T) => mixed);
type RefForwarder<TNativeInstance, TPublicInstance> = {
getForwardingRef: (
?ForwardedRef<TPublicInstance>,
) => (TNativeInstance | null) => void,
nativeInstance: TNativeInstance | null,
publicInstance: TPublicInstance | null,
};
/**
* Helper function that should be replaced with `useCallback` and `useMergeRefs`
* once `ScrollView` is reimplemented as a functional component.
*/
function createRefForwarder<TNativeInstance, TPublicInstance>(
mutator: TNativeInstance => TPublicInstance,
): RefForwarder<TNativeInstance, TPublicInstance> {
const state: RefForwarder<TNativeInstance, TPublicInstance> = {
getForwardingRef: memoize(forwardedRef => {
return (nativeInstance: TNativeInstance | null): void => {
const publicInstance =
nativeInstance == null ? null : mutator(nativeInstance);
state.nativeInstance = nativeInstance;
state.publicInstance = publicInstance;
if (forwardedRef != null) {
if (typeof forwardedRef === 'function') {
forwardedRef(publicInstance);
} else {
forwardedRef.current = publicInstance;
}
}
};
}),
nativeInstance: null,
publicInstance: null,
};
return state;
}
/* $FlowFixMe[missing-local-annot] The type annotation(s) required by Flow's
* LTI update could not be added via codemod */
function Wrapper(props, ref: (mixed => mixed) | {current: mixed, ...}) {
return <ScrollView {...props} scrollViewRef={ref} />;
}
Wrapper.displayName = 'ScrollView';
const ForwardedScrollView = React.forwardRef(Wrapper);
// $FlowFixMe[prop-missing] Add static context to ForwardedScrollView
ForwardedScrollView.Context = ScrollViewContext;
ForwardedScrollView.displayName = 'ScrollView';
module.exports = ((ForwardedScrollView: $FlowFixMe): React.AbstractComponent<
React.ElementConfig<typeof ScrollView>,
PublicScrollViewInstance,
> &
ScrollViewComponentStatics);