From 099f67cf8aa290592092cfa0cb4e938d0543b696 Mon Sep 17 00:00:00 2001 From: Kacie Bawiec Date: Tue, 16 Feb 2021 08:00:09 -0800 Subject: [PATCH] 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 --- Libraries/Components/ScrollResponder.js | 795 ----------------- Libraries/Components/ScrollView/ScrollView.js | 811 +++++++++++++++--- 2 files changed, 693 insertions(+), 913 deletions(-) delete mode 100644 Libraries/Components/ScrollResponder.js diff --git a/Libraries/Components/ScrollResponder.js b/Libraries/Components/ScrollResponder.js deleted file mode 100644 index 5f37877163cc54..00000000000000 --- a/Libraries/Components/ScrollResponder.js +++ /dev/null @@ -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 = (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 = (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 = 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 = (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( - nodeHandle: number | React.ElementRef>, - 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; diff --git a/Libraries/Components/ScrollView/ScrollView.js b/Libraries/Components/ScrollView/ScrollView.js index 584bf47d375ec0..bcc6e80c2d8b24 100644 --- a/Libraries/Components/ScrollView/ScrollView.js +++ b/Libraries/Components/ScrollView/ScrollView.js @@ -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, @@ -76,14 +155,9 @@ export type ScrollViewImperativeMethods = $ReadOnly<{| scrollTo: $PropertyType, scrollToEnd: $PropertyType, flashScrollIndicators: $PropertyType, - - // ScrollResponder.Mixin public methods - scrollResponderZoomTo: $PropertyType< - typeof ScrollResponder.Mixin, - 'scrollResponderZoomTo', - >, + scrollResponderZoomTo: $PropertyType, 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 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 { 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 { > = new Map(); _headerLayoutYs: Map = 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 { } 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 { 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 { * 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 { 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 { ) => { // 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 { * @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: ( + nodeHandle: number | React.ElementRef>, + additionalOffset?: number, + preventNegativeScrollOffset?: boolean, + ) => void = ( + nodeHandle: number | React.ElementRef>, + 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 { } } 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 { }, }); + /** + * 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 { const [NativeDirectionalScrollView, NativeDirectionalScrollContentView] = this.props.horizontal === true @@ -1121,31 +1705,22 @@ class ScrollView extends React.Component { // 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