* 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
*/
* MIT License
*
* Copyright (C) 2026 Huawei Device Co., Ltd.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import type { HostComponent } from "react-native/Libraries/Renderer/shims/ReactNativeTypes";
import type { EdgeInsetsProp } from "react-native/Libraries/StyleSheet/EdgeInsetsPropType";
import type { PointProp } from "react-native/Libraries/StyleSheet/PointPropType";
import type { ViewStyleProp } from "react-native/Libraries/StyleSheet/StyleSheet";
import type { ColorValue } from "react-native/Libraries/StyleSheet/StyleSheet";
import type {
LayoutEvent,
PressEvent,
ScrollEvent,
} from "react-native/Libraries/Types/CoreEventTypes";
import type { EventSubscription } from "react-native/Libraries/vendor/emitter/EventEmitter";
import type {
KeyboardEvent,
KeyboardMetrics,
} from "react-native/Libraries/Components/Keyboard/Keyboard";
import type { ViewProps } from "react-native/Libraries/Components/View/ViewPropTypes";
import type { Props as ScrollViewStickyHeaderProps } from "react-native/Libraries/Components/ScrollView/ScrollViewStickyHeader";
import AnimatedImplementation from "react-native/Libraries/Animated/AnimatedImplementation";
import FrameRateLogger from "react-native/Libraries/Interaction/FrameRateLogger";
import { findNodeHandle } from "react-native/Libraries/ReactNative/RendererProxy";
import UIManager from "react-native/Libraries/ReactNative/UIManager";
import flattenStyle from "react-native/Libraries/StyleSheet/flattenStyle";
import splitLayoutProps from "react-native/Libraries/StyleSheet/splitLayoutProps";
import StyleSheet from "react-native/Libraries/StyleSheet/StyleSheet";
import Dimensions from "react-native/Libraries/Utilities/Dimensions";
import dismissKeyboard from "react-native/Libraries/Utilities/dismissKeyboard";
import Platform from "@react-native-oh/react-native-harmony/Libraries/Utilities/Platform";
import Keyboard from "react-native/Libraries/Components/Keyboard/Keyboard";
import TextInputState from "@react-native-oh/react-native-harmony/Libraries/Components/TextInput/TextInputState";
import View from "react-native/Libraries/Components/View/View";
import AndroidHorizontalScrollContentViewNativeComponent from "react-native/Libraries/Components/ScrollView/AndroidHorizontalScrollContentViewNativeComponent";
import AndroidHorizontalScrollViewNativeComponent from "react-native/Libraries/Components/ScrollView/AndroidHorizontalScrollViewNativeComponent";
import processDecelerationRate from "@react-native-oh/react-native-harmony/Libraries/Components/ScrollView/processDecelerationRate.harmony";
import ScrollContentViewNativeComponent from "react-native/Libraries/Components/ScrollView/ScrollContentViewNativeComponent";
import Commands from "react-native/Libraries/Components/ScrollView/ScrollViewCommands";
import ScrollViewContext, {
HORIZONTAL,
VERTICAL,
} from "react-native/Libraries/Components/ScrollView/ScrollViewContext";
import ScrollViewNativeComponent from "react-native/Libraries/Components/ScrollView/ScrollViewNativeComponent";
import ScrollViewStickyHeader from "react-native/Libraries/Components/ScrollView/ScrollViewStickyHeader";
import invariant from "invariant";
import memoize from "memoize-one";
import nullthrows from "nullthrows";
import * as React from "react";
if (Platform.OS === "ios") {
require("react-native/Libraries/Renderer/shims/ReactNative");
}
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
*/
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?: ?
("none" | "on-drag" | "interactive"),
* 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).
*/
* 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,
showScrollIndicator: boolean,
|};
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 MjScrollView 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;
_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,
showScrollIndicator: false,
};
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 = () => {
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, ... }
) => {
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 = () => {
this.state.showScrollIndicator = true;
setTimeout(() => {
this.state.showScrollIndicator = false;
this.forceUpdate();
}, 500);
this.forceUpdate();
};
* 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)),
this._textInputFocusError,
this._inputMeasureAndScrollToKeyboard
);
} else {
nodeHandle.measureLayout(
this._innerView.nativeInstance,
this._inputMeasureAndScrollToKeyboard,
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
) => void = (
rect: {|
x: number,
y: number,
width: number,
height: number,
animated?: boolean,
|},
animated?: boolean
) => {
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;
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) {
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)) {
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 => {
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();
if (
(Platform.OS === "android" || Platform.OS === "harmony") &&
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 (
!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;
}
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 = () => {
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
) => {
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
) => {
if (this._isAnimating()) {
return true;
}
if (this.props.disableScrollViewPanResponder === true) {
return false;
}
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;
}
if (this._softKeyboardIsDetached()) {
return false;
}
if (
keyboardNeverPersistTaps &&
this._keyboardIsDismissible() &&
e.target != null &&
!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();
const hasFocusedTextInput =
currentlyFocusedInput != null &&
TextInputState.isTextInput(currentlyFocusedInput);
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 = () => {
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";
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) {
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),
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,
snapToStart: this.props.snapToStart !== false,
snapToEnd: this.props.snapToEnd !== false,
pagingEnabled:
this.props.pagingEnabled === true &&
this.props.snapToInterval == null &&
this.props.snapToOffsets == null,
showsVerticalScrollIndicator: (this.props.showsVerticalScrollIndicator ?? true) || this.state.showScrollIndicator,
showsHorizontalScrollIndicator: (this.props.showsHorizontalScrollIndicator ?? true) || this.state.showScrollIndicator,
persistentScrollbar: this.props.persistentScrollbar || this.state.showScrollIndicator,
};
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) {
const { outer, inner } = splitLayoutProps(flattenStyle(props.style));
const children = [
<NativeDirectionalScrollView
key="scrollview"
{...props}
style={StyleSheet.compose(baseStyle, inner)}
ref={scrollViewRef}
>
{contentContainer}
</NativeDirectionalScrollView>,
];
if (refreshControl.props.children) {
children.push(
React.cloneElement(refreshControl.props.children, {
key: "refresh-indicator",
style: [
refreshControl.props.children.props.style,
{
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 1000,
},
],
})
);
}
return React.cloneElement(refreshControl, {
style: StyleSheet.compose(baseStyle, outer),
children: children,
});
}
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;
}
module.exports = (Platform.OS === 'ios' || Platform.OS === 'harmony') ? MjScrollView : ScrollView;