Move ScrollResponder.Mixin methods into ScrollView and Remove ScrollResponder.js

Summary:
The purpose of this diff is to move all of the ScrollResponder methods into ScrollView to delete ScrollResponder.Mixin.

NOTE: ScrollResponder.Mixin uses a variable named "state" but it does not use React state correctly. Instead of calling `setState()`, state is set using `this.state.item = 123` ([example](https://www.internalfb.com/intern/diffusion/FBS/browsefile/master/xplat/js/react-native-github/Libraries/Components/ScrollResponder.js?lines=315)).  This means these are not actually React state - these are functionally just variables. In this stack, these "state" items from ScrollResponder are turned into regular internal variables.

Changelog:
[General][Removed] Moved ScrollResponder.Mixin methods into ScrollView to Remove ScrollResponder.js

Reviewed By: lunaleaps, nadiia

Differential Revision: D20715880

fbshipit-source-id: 99441434a6dc1c8ff3f435e7d6ec2840821e4e05
This commit is contained in:
Kacie Bawiec authored and Facebook GitHub Bot committed 2021-02-16 08:03:11 -08:00
1 parent 4324ca8122
commit 099f67cf8a
2 files changed
+693 -913

No files matched your search

-795
View File
@@ -1,795 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its 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
*/
const Dimensions = require('../Utilities/Dimensions');
const FrameRateLogger = require('../Interaction/FrameRateLogger');
const Keyboard = require('./Keyboard/Keyboard');
const Platform = require('../Utilities/Platform');
const React = require('react');
const ReactNative = require('../Renderer/shims/ReactNative');
const TextInputState = require('./TextInput/TextInputState');
const UIManager = require('../ReactNative/UIManager');
const invariant = require('invariant');
import type {HostComponent} from '../Renderer/shims/ReactNativeTypes';
import type {PressEvent, ScrollEvent} from '../Types/CoreEventTypes';
import {type EventSubscription} from '../vendor/emitter/EventEmitter';
import type {KeyboardEvent} from './Keyboard/Keyboard';
import typeof ScrollView from './ScrollView/ScrollView';
import type {Props as ScrollViewProps} from './ScrollView/ScrollView';
import Commands from './ScrollView/ScrollViewCommands';
/**
* Mixin that can be integrated in order to handle scrolling that plays well
* with `ResponderEventPlugin`. Integrate with your platform specific scroll
* views, or even your custom built (every-frame animating) scroll views so that
* all of these systems play well with the `ResponderEventPlugin`.
*
* 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
*
* `ScrollResponder` also includes logic for blurring a currently focused input
* if one is focused while scrolling. The `ScrollResponder` 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
*/
const IS_ANIMATING_TOUCH_START_THRESHOLD_MS = 16;
export type State = {|
isTouching: boolean,
lastMomentumScrollBeginTime: number,
lastMomentumScrollEndTime: number,
observedScrollSinceBecomingResponder: boolean,
becameResponderWhileAnimating: boolean,
|};
const ScrollResponderMixin = {
_subscriptionKeyboardWillShow: (null: ?EventSubscription),
_subscriptionKeyboardWillHide: (null: ?EventSubscription),
_subscriptionKeyboardDidShow: (null: ?EventSubscription),
_subscriptionKeyboardDidHide: (null: ?EventSubscription),
scrollResponderMixinGetInitialState: function(): State {
return {
isTouching: false,
lastMomentumScrollBeginTime: 0,
lastMomentumScrollEndTime: 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: false,
becameResponderWhileAnimating: false,
};
},
/**
* Invoke this from an `onScroll` event.
*/
scrollResponderHandleScrollShouldSetResponder: function(): boolean {
// Allow any event touch pass through if the default pan responder is disabled
if (this.props.disableScrollViewPanResponder === true) {
return false;
}
return this.state.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.
*
*/
scrollResponderHandleStartShouldSetResponder: function(
e: PressEvent,
): boolean {
// 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.scrollResponderKeyboardIsDismissible() &&
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.
*/
scrollResponderHandleStartShouldSetResponderCapture: function(
e: PressEvent,
): boolean {
// The scroll view should receive taps instead of its descendants if:
// * it is already animating/decelerating
if (this.scrollResponderIsAnimating()) {
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;
}
if (
keyboardNeverPersistTaps &&
this.scrollResponderKeyboardIsDismissible() &&
e.target != null &&
!TextInputState.isTextInput(e.target)
) {
return true;
}
return false;
},
/**
* Do we consider there to be a dismissible soft-keyboard open?
*/
scrollResponderKeyboardIsDismissible: function(): 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, except on Android where setting windowSoftInputMode to
// adjustNone leads to missing keyboard events.
const softKeyboardMayBeOpen =
this.keyboardWillOpenTo != null || Platform.OS === 'android';
return hasFocusedTextInput && softKeyboardMayBeOpen;
},
/**
* 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.
*/
scrollResponderHandleResponderReject: function() {},
/**
* 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.
*/
scrollResponderHandleTerminationRequest: function(): boolean {
return !this.state.observedScrollSinceBecomingResponder;
},
/**
* Invoke this from an `onTouchEnd` event.
*
* @param {PressEvent} e Event.
*/
scrollResponderHandleTouchEnd: function(e: PressEvent) {
const nativeEvent = e.nativeEvent;
this.state.isTouching = nativeEvent.touches.length !== 0;
this.props.onTouchEnd && this.props.onTouchEnd(e);
},
/**
* Invoke this from an `onTouchCancel` event.
*
* @param {PressEvent} e Event.
*/
scrollResponderHandleTouchCancel: function(e: PressEvent) {
this.state.isTouching = false;
this.props.onTouchCancel && this.props.onTouchCancel(e);
},
/**
* Invoke this from an `onResponderRelease` event.
*/
scrollResponderHandleResponderRelease: function(e: PressEvent) {
this.state.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 (
this.props.keyboardShouldPersistTaps !== true &&
this.props.keyboardShouldPersistTaps !== 'always' &&
this.scrollResponderKeyboardIsDismissible() &&
e.target !== currentlyFocusedTextInput &&
!this.state.observedScrollSinceBecomingResponder &&
!this.state.becameResponderWhileAnimating
) {
this.props.onScrollResponderKeyboardDismissed &&
this.props.onScrollResponderKeyboardDismissed(e);
TextInputState.blurTextInput(currentlyFocusedTextInput);
}
},
scrollResponderHandleScroll: function(e: ScrollEvent) {
(this: any).state.observedScrollSinceBecomingResponder = true;
(this: any).props.onScroll && (this: any).props.onScroll(e);
},
/**
* Invoke this from an `onResponderGrant` event.
*/
scrollResponderHandleResponderGrant: function(e: ScrollEvent) {
this.state.observedScrollSinceBecomingResponder = false;
this.props.onResponderGrant && this.props.onResponderGrant(e);
this.state.becameResponderWhileAnimating = this.scrollResponderIsAnimating();
},
/**
* 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.
*/
scrollResponderHandleScrollBeginDrag: function(e: ScrollEvent) {
FrameRateLogger.beginScroll(); // TODO: track all scrolls after implementing onScrollEndAnimation
this.props.onScrollBeginDrag && this.props.onScrollBeginDrag(e);
},
/**
* Invoke this from an `onScrollEndDrag` event.
*/
scrollResponderHandleScrollEndDrag: function(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.scrollResponderIsAnimating() &&
(!velocity || (velocity.x === 0 && velocity.y === 0))
) {
FrameRateLogger.endScroll();
}
this.props.onScrollEndDrag && this.props.onScrollEndDrag(e);
},
/**
* Invoke this from an `onMomentumScrollBegin` event.
*/
scrollResponderHandleMomentumScrollBegin: function(e: ScrollEvent) {
this.state.lastMomentumScrollBeginTime = global.performance.now();
this.props.onMomentumScrollBegin && this.props.onMomentumScrollBegin(e);
},
/**
* Invoke this from an `onMomentumScrollEnd` event.
*/
scrollResponderHandleMomentumScrollEnd: function(e: ScrollEvent) {
FrameRateLogger.endScroll();
this.state.lastMomentumScrollEndTime = global.performance.now();
this.props.onMomentumScrollEnd && this.props.onMomentumScrollEnd(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.
*/
scrollResponderHandleTouchStart: function(e: PressEvent) {
this.state.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.
*/
scrollResponderHandleTouchMove: function(e: PressEvent) {
this.props.onTouchMove && this.props.onTouchMove(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.
*/
scrollResponderIsAnimating: function(): boolean {
const now = global.performance.now();
const timeSinceLastMomentumScrollEnd =
now - this.state.lastMomentumScrollEndTime;
const isAnimating =
timeSinceLastMomentumScrollEnd < IS_ANIMATING_TOUCH_START_THRESHOLD_MS ||
this.state.lastMomentumScrollEndTime <
this.state.lastMomentumScrollBeginTime;
return isAnimating;
},
/**
* Returns the node that represents native view that can be scrolled.
* Components can pass what node to use by defining a `getScrollableNode`
* function otherwise `this` is used.
*/
scrollResponderGetScrollableNode: function(): ?number {
return this.getScrollableNode
? this.getScrollableNode()
: ReactNative.findNodeHandle(this);
},
/**
* A helper function to scroll to a specific point in the ScrollView.
* This is currently used to help focus child TextViews, but can also
* be used to quickly scroll to any element we want to focus. Syntax:
*
* `scrollResponderScrollTo(options: {x: number = 0; y: number = 0; animated: boolean = true})`
*
* Note: The weird argument signature is due to the fact that, for historical reasons,
* the function also accepts separate arguments as as alternative to the options object.
* This is deprecated due to ambiguity (y before x), and SHOULD NOT BE USED.
*/
scrollResponderScrollTo: function(
x?:
| number
| {
x?: number,
y?: number,
animated?: boolean,
...
},
y?: number,
animated?: boolean,
) {
if (typeof x === 'number') {
console.warn(
'`scrollResponderScrollTo(x, y, animated)` is deprecated. Use `scrollResponderScrollTo({x: 5, y: 5, animated: true})` instead.',
);
} else {
({x, y, animated} = x || {});
}
const that: React.ElementRef<ScrollView> = (this: any);
invariant(
that.getNativeScrollRef != null,
'Expected scrollTo to be called on a scrollViewRef. If this exception occurs it is likely a bug in React Native',
);
const nativeScrollRef = that.getNativeScrollRef();
if (nativeScrollRef == null) {
return;
}
Commands.scrollTo(nativeScrollRef, x || 0, y || 0, animated !== false);
},
/**
* Scrolls to the end of the ScrollView, either immediately or with a smooth
* animation.
*
* Example:
*
* `scrollResponderScrollToEnd({animated: true})`
*/
scrollResponderScrollToEnd: function(options?: {animated?: boolean, ...}) {
// Default to true
const animated = (options && options.animated) !== false;
const that: React.ElementRef<ScrollView> = (this: any);
invariant(
that.getNativeScrollRef != null,
'Expected scrollToEnd to be called on a scrollViewRef. If this exception occurs it is likely a bug in React Native',
);
const nativeScrollRef = that.getNativeScrollRef();
if (nativeScrollRef == null) {
return;
}
Commands.scrollToEnd(nativeScrollRef, animated);
},
/**
* 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: function(
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) {
animated = rect.animated;
delete rect.animated;
} else if (typeof animated !== 'undefined') {
console.warn(
'`scrollResponderZoomTo` `animated` argument is deprecated. Use `options.animated` instead',
);
}
const that: React.ElementRef<ScrollView> = this;
invariant(
that.getNativeScrollRef != null,
'Expected zoomToRect to be called on a scrollViewRef. If this exception occurs it is likely a bug in React Native',
);
const nativeScrollRef = that.getNativeScrollRef();
if (nativeScrollRef == null) {
return;
}
Commands.zoomToRect(nativeScrollRef, rect, animated !== false);
},
/**
* Displays the scroll indicators momentarily.
*/
scrollResponderFlashScrollIndicators: function() {
const that: React.ElementRef<ScrollView> = (this: any);
invariant(
that.getNativeScrollRef != null,
'Expected flashScrollIndicators to be called on a scrollViewRef. If this exception occurs it is likely a bug in React Native',
);
const nativeScrollRef = that.getNativeScrollRef();
if (nativeScrollRef == null) {
return;
}
Commands.flashScrollIndicators(nativeScrollRef);
},
/**
* 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: function<T>(
nodeHandle: number | React.ElementRef<HostComponent<T>>,
additionalOffset?: number,
preventNegativeScrollOffset?: boolean,
) {
this.additionalScrollOffset = additionalOffset || 0;
this.preventNegativeScrollOffset = !!preventNegativeScrollOffset;
if (typeof nodeHandle === 'number') {
UIManager.measureLayout(
nodeHandle,
ReactNative.findNodeHandle(this.getInnerViewNode()),
this.scrollResponderTextInputFocusError,
this.scrollResponderInputMeasureAndScrollToKeyboard,
);
} else {
const innerRef = this.getInnerViewRef();
if (innerRef == null) {
return;
}
nodeHandle.measureLayout(
innerRef,
this.scrollResponderInputMeasureAndScrollToKeyboard,
this.scrollResponderTextInputFocusError,
);
}
},
/**
* 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.
*/
scrollResponderInputMeasureAndScrollToKeyboard: function(
left: number,
top: number,
width: number,
height: number,
) {
let keyboardScreenY = Dimensions.get('window').height;
if (this.keyboardWillOpenTo) {
keyboardScreenY = this.keyboardWillOpenTo.endCoordinates.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) {
scrollOffsetY = Math.max(0, scrollOffsetY);
}
this.scrollResponderScrollTo({x: 0, y: scrollOffsetY, animated: true});
this.additionalOffset = 0;
this.preventNegativeScrollOffset = false;
},
scrollResponderTextInputFocusError: function() {
console.warn('Error measuring text field.');
},
/**
* `componentWillMount` is the closest thing to a standard "constructor" for
* React components.
*
* The `keyboardWillShow` is called before input focus.
*/
UNSAFE_componentWillMount: function() {
const {keyboardShouldPersistTaps} = ((this: any).props: ScrollViewProps);
if (typeof keyboardShouldPersistTaps === 'boolean') {
console.warn(
`'keyboardShouldPersistTaps={${
keyboardShouldPersistTaps === true ? 'true' : 'false'
}}' is deprecated. ` +
`Use 'keyboardShouldPersistTaps="${
keyboardShouldPersistTaps ? 'always' : 'never'
}"' instead`,
);
}
(this: any).keyboardWillOpenTo = null;
(this: any).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,
);
},
componentWillUnmount: function() {
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();
}
},
/**
* 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: function(e: KeyboardEvent) {
this.keyboardWillOpenTo = e;
this.props.onKeyboardWillShow && this.props.onKeyboardWillShow(e);
},
scrollResponderKeyboardWillHide: function(e: KeyboardEvent) {
this.keyboardWillOpenTo = null;
this.props.onKeyboardWillHide && this.props.onKeyboardWillHide(e);
},
scrollResponderKeyboardDidShow: function(e: KeyboardEvent) {
// TODO(7693961): The event for DidShow is not available on iOS yet.
// Use the one from WillShow and do not assign.
if (e) {
this.keyboardWillOpenTo = e;
}
this.props.onKeyboardDidShow && this.props.onKeyboardDidShow(e);
},
scrollResponderKeyboardDidHide: function(e: KeyboardEvent) {
this.keyboardWillOpenTo = null;
this.props.onKeyboardDidHide && this.props.onKeyboardDidHide(e);
},
};
const ScrollResponder = {
Mixin: ScrollResponderMixin,
};
module.exports = ScrollResponder;
+693 -118
View File
@@ -9,14 +9,18 @@
*/
import AnimatedImplementation from '../../Animated/AnimatedImplementation';
import Dimensions from '../../Utilities/Dimensions';
import Platform from '../../Utilities/Platform';
import * as React from 'react';
import ReactNative from '../../Renderer/shims/ReactNative';
require('../../Renderer/shims/ReactNative'); // Force side effects to prevent T55744311
import ScrollResponder from '../ScrollResponder';
import ScrollViewStickyHeader from './ScrollViewStickyHeader';
import StyleSheet from '../../StyleSheet/StyleSheet';
import View from '../View/View';
import UIManager from '../../ReactNative/UIManager';
import Keyboard from '../Keyboard/Keyboard';
import FrameRateLogger from '../../Interaction/FrameRateLogger';
import TextInputState from '../TextInput/TextInputState';
import dismissKeyboard from '../../Utilities/dismissKeyboard';
import flattenStyle from '../../StyleSheet/flattenStyle';
@@ -36,11 +40,13 @@ import type {
LayoutEvent,
} from '../../Types/CoreEventTypes';
import type {HostComponent} from '../../Renderer/shims/ReactNativeTypes';
import type {State as ScrollResponderState} from '../ScrollResponder';
import type {ViewProps} from '../View/ViewPropTypes';
import ScrollViewContext, {HORIZONTAL, VERTICAL} from './ScrollViewContext';
import type {Props as ScrollViewStickyHeaderProps} from './ScrollViewStickyHeader';
import type {KeyboardEvent} from '../Keyboard/Keyboard';
import type {EventSubscription} from '../../vendor/emitter/EventEmitter';
import Commands from './ScrollViewCommands';
import AndroidHorizontalScrollContentViewNativeComponent from './AndroidHorizontalScrollContentViewNativeComponent';
import AndroidHorizontalScrollViewNativeComponent from './AndroidHorizontalScrollViewNativeComponent';
import ScrollContentViewNativeComponent from './ScrollContentViewNativeComponent';
@@ -66,6 +72,79 @@ const {NativeHorizontalScrollViewTuple, NativeVerticalScrollViewTuple} =
],
};
/*
* 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'>,
@@ -76,14 +155,9 @@ export type ScrollViewImperativeMethods = $ReadOnly<{|
scrollTo: $PropertyType<ScrollView, 'scrollTo'>,
scrollToEnd: $PropertyType<ScrollView, 'scrollToEnd'>,
flashScrollIndicators: $PropertyType<ScrollView, 'flashScrollIndicators'>,
// ScrollResponder.Mixin public methods
scrollResponderZoomTo: $PropertyType<
typeof ScrollResponder.Mixin,
'scrollResponderZoomTo',
>,
scrollResponderZoomTo: $PropertyType<ScrollView, 'scrollResponderZoomTo'>,
scrollResponderScrollNativeHandleToKeyboard: $PropertyType<
typeof ScrollResponder.Mixin,
ScrollView,
'scrollResponderScrollNativeHandleToKeyboard',
>,
|}>;
@@ -490,7 +564,10 @@ export type Props = $ReadOnly<{|
* which this ScrollView renders.
*/
onContentSizeChange?: (contentWidth: number, contentHeight: number) => void,
onKeyboardDidShow?: (event: PressEvent) => 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
@@ -595,22 +672,9 @@ export type Props = $ReadOnly<{|
type State = {|
layoutHeight: ?number,
...ScrollResponderState,
|};
function createScrollResponder(
node: React.ElementRef<typeof ScrollView>,
): typeof ScrollResponder.Mixin {
const scrollResponder = {...ScrollResponder.Mixin};
for (const key in scrollResponder) {
if (typeof scrollResponder[key] === 'function') {
scrollResponder[key] = scrollResponder[key].bind(node);
}
}
return scrollResponder;
}
const IS_ANIMATING_TOUCH_START_THRESHOLD_MS = 16;
type ScrollViewComponentStatics = $ReadOnly<{|
Context: typeof ScrollViewContext,
@@ -653,56 +717,8 @@ type ScrollViewComponentStatics = $ReadOnly<{|
*/
class ScrollView extends React.Component<Props, State> {
static Context: typeof ScrollViewContext = ScrollViewContext;
/**
* Part 1: Removing ScrollResponder.Mixin:
*
* 1. Mixin methods should be flow typed. That's why we create a
* copy of ScrollResponder.Mixin and attach it to this._scrollResponder.
* Otherwise, we'd have to manually declare each method on the component
* class and assign it a flow type.
* 2. Mixin methods can call component methods, and access the component's
* props and state. So, we need to bind all mixin methods to the
* component instance.
* 3. Continued...
*/
_scrollResponder: typeof ScrollResponder.Mixin = createScrollResponder(this);
constructor(props: Props) {
super(props);
/**
* Part 2: Removing ScrollResponder.Mixin
*
* 3. Mixin methods access other mixin methods via dynamic dispatch using
* this. Since mixin methods are bound to the component instance, we need
* to copy all mixin methods to the component instance. This is also
* necessary because getScrollResponder() is a public method that returns
* an object that can be used to execute all scrollResponder methods.
* Since the object returned from that method is the ScrollView instance,
* we need to bind all mixin methods to the ScrollView instance.
*/
for (const key in ScrollResponder.Mixin) {
if (
typeof ScrollResponder.Mixin[key] === 'function' &&
key.startsWith('scrollResponder')
) {
// $FlowFixMe - dynamically adding properties to a class
(this: any)[key] = ScrollResponder.Mixin[key].bind(this);
}
}
/**
* Part 3: Removing ScrollResponder.Mixin
*
* 4. Mixins can initialize properties and use properties on the component
* instance.
*/
Object.keys(ScrollResponder.Mixin)
.filter(key => typeof ScrollResponder.Mixin[key] !== 'function')
.forEach(key => {
// $FlowFixMe - dynamically adding properties to a class
(this: any)[key] = ScrollResponder.Mixin[key];
});
}
_scrollAnimatedValue: AnimatedImplementation.Value = new AnimatedImplementation.Value(
@@ -715,13 +731,64 @@ class ScrollView extends React.Component<Props, State> {
> = new Map();
_headerLayoutYs: Map<string, number> = new Map();
_keyboardWillOpenTo: ?KeyboardEvent = 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 = null;
_subscriptionKeyboardWillShow: ?EventSubscription = null;
_subscriptionKeyboardWillHide: ?EventSubscription = null;
_subscriptionKeyboardDidShow: ?EventSubscription = null;
_subscriptionKeyboardDidHide: ?EventSubscription = null;
state: State = {
layoutHeight: null,
...ScrollResponder.Mixin.scrollResponderMixinGetInitialState(),
};
UNSAFE_componentWillMount() {
this._scrollResponder.UNSAFE_componentWillMount();
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._keyboardWillOpenTo = null;
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._scrollAnimatedValue = new AnimatedImplementation.Value(
this.props.contentOffset?.y ?? 0,
);
@@ -751,7 +818,19 @@ class ScrollView extends React.Component<Props, State> {
}
componentWillUnmount() {
this._scrollResponder.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();
}
@@ -780,10 +859,7 @@ class ScrollView extends React.Component<Props, State> {
ref.scrollTo = this.scrollTo;
ref.scrollToEnd = this.scrollToEnd;
ref.flashScrollIndicators = this.flashScrollIndicators;
// $FlowFixMe - This method was manually bound from ScrollResponder.mixin
ref.scrollResponderZoomTo = this.scrollResponderZoomTo;
// $FlowFixMe - This method was manually bound from ScrollResponder.mixin
ref.scrollResponderScrollNativeHandleToKeyboard = this.scrollResponderScrollNativeHandleToKeyboard;
}
},
@@ -796,7 +872,7 @@ class ScrollView extends React.Component<Props, State> {
* to the underlying scroll responder's methods.
*/
getScrollResponder: () => ScrollResponderType = () => {
// $FlowFixMe - overriding type to include ScrollResponder.Mixin
// $FlowFixMe
return ((this: any): ScrollResponderType);
};
@@ -864,11 +940,10 @@ class ScrollView extends React.Component<Props, State> {
x = options.x;
animated = options.animated;
}
this._scrollResponder.scrollResponderScrollTo({
x: x || 0,
y: y || 0,
animated: animated !== false,
});
if (this._scrollViewRef == null) {
return;
}
Commands.scrollTo(this._scrollViewRef, x || 0, y || 0, animated !== false);
};
/**
@@ -884,9 +959,10 @@ class ScrollView extends React.Component<Props, State> {
) => {
// Default to true
const animated = (options && options.animated) !== false;
this._scrollResponder.scrollResponderScrollToEnd({
animated: animated,
});
if (this._scrollViewRef == null) {
return;
}
Commands.scrollToEnd(this._scrollViewRef, animated);
};
/**
@@ -895,7 +971,133 @@ class ScrollView extends React.Component<Props, State> {
* @platform ios
*/
flashScrollIndicators: () => void = () => {
this._scrollResponder.scrollResponderFlashScrollIndicators();
if (this._scrollViewRef == null) {
return;
}
Commands.flashScrollIndicators(this._scrollViewRef);
};
/**
* 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._innerViewRef == null) {
return;
}
if (typeof nodeHandle === 'number') {
UIManager.measureLayout(
nodeHandle,
ReactNative.findNodeHandle(this),
this._textInputFocusError,
this._inputMeasureAndScrollToKeyboard,
);
} else {
nodeHandle.measureLayout(
this._innerViewRef,
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, // 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._scrollViewRef == null) {
return;
}
Commands.zoomToRect(this._scrollViewRef, 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;
if (this._keyboardWillOpenTo != null) {
keyboardScreenY = this._keyboardWillOpenTo.endCoordinates.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;
};
_getKeyForIndex(index, childArray) {
@@ -973,14 +1175,12 @@ class ScrollView extends React.Component<Props, State> {
}
}
if (Platform.OS === 'android') {
if (
this.props.keyboardDismissMode === 'on-drag' &&
this.state.isTouching
) {
if (this.props.keyboardDismissMode === 'on-drag' && this._isTouching) {
dismissKeyboard();
}
}
this._scrollResponder.scrollResponderHandleScroll(e);
this._observedScrollSinceBecomingResponder = true;
this.props.onScroll && this.props.onScroll(e);
};
_handleLayout = (e: LayoutEvent) => {
@@ -1008,6 +1208,390 @@ class ScrollView extends React.Component<Props, State> {
},
});
/**
* 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._keyboardWillOpenTo = e;
this.props.onKeyboardWillShow && this.props.onKeyboardWillShow(e);
};
scrollResponderKeyboardWillHide: (e: KeyboardEvent) => void = (
e: KeyboardEvent,
) => {
this._keyboardWillOpenTo = null;
this.props.onKeyboardWillHide && this.props.onKeyboardWillHide(e);
};
scrollResponderKeyboardDidShow: (e: KeyboardEvent) => void = (
e: KeyboardEvent,
) => {
// TODO(7693961): The event for DidShow is not available on iOS yet.
// Use the one from WillShow and do not assign.
if (e) {
this._keyboardWillOpenTo = e;
}
this.props.onKeyboardDidShow && this.props.onKeyboardDidShow(e);
};
scrollResponderKeyboardDidHide: (e: KeyboardEvent) => void = (
e: KeyboardEvent,
) => {
this._keyboardWillOpenTo = 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
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 (
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;
}
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();
// 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, except on Android where setting windowSoftInputMode to
// adjustNone leads to missing keyboard events.
const softKeyboardMayBeOpen =
this._keyboardWillOpenTo != null || Platform.OS === 'android';
return hasFocusedTextInput && softKeyboardMayBeOpen;
};
/**
* 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;
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
@@ -1121,31 +1705,22 @@ class ScrollView extends React.Component<Props, State> {
// bubble up from TextInputs
onContentSizeChange: null,
onLayout: this._handleLayout,
onMomentumScrollBegin: this._scrollResponder
.scrollResponderHandleMomentumScrollBegin,
onMomentumScrollEnd: this._scrollResponder
.scrollResponderHandleMomentumScrollEnd,
onResponderGrant: this._scrollResponder
.scrollResponderHandleResponderGrant,
onResponderReject: this._scrollResponder
.scrollResponderHandleResponderReject,
onResponderRelease: this._scrollResponder
.scrollResponderHandleResponderRelease,
onResponderTerminationRequest: this._scrollResponder
.scrollResponderHandleTerminationRequest,
onScrollBeginDrag: this._scrollResponder
.scrollResponderHandleScrollBeginDrag,
onScrollEndDrag: this._scrollResponder.scrollResponderHandleScrollEndDrag,
onScrollShouldSetResponder: this._scrollResponder
.scrollResponderHandleScrollShouldSetResponder,
onStartShouldSetResponder: this._scrollResponder
.scrollResponderHandleStartShouldSetResponder,
onStartShouldSetResponderCapture: this._scrollResponder
.scrollResponderHandleStartShouldSetResponderCapture,
onTouchEnd: this._scrollResponder.scrollResponderHandleTouchEnd,
onTouchMove: this._scrollResponder.scrollResponderHandleTouchMove,
onTouchStart: this._scrollResponder.scrollResponderHandleTouchStart,
onTouchCancel: this._scrollResponder.scrollResponderHandleTouchCancel,
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,
scrollBarThumbImage: resolveAssetSource(this.props.scrollBarThumbImage),
scrollEventThrottle: hasStickyHeaders