From 4454e829372874130c29b62ba9f04e03ea180480 Mon Sep 17 00:00:00 2001 From: Gerardo Pacheco Date: Mon, 10 Apr 2023 14:01:01 +0200 Subject: [PATCH] =?UTF-8?q?[Mobile]=C2=A0Remove=20Keyboard=20Aware=20Flatl?= =?UTF-8?q?ist=20(#48791)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Remove react-native-keyboard-aware-scroll-view dependency * Mobile - AztecView/AztecInputState: - Remove usage of onCaretVerticalPositionChange - Add support for adding a listener when the caret Y coordinate position changes * Mobile - VisualEditor - Remove keyboard listeners and the usage of isAutoScrollEnabled in the state of the component * Mobile - RichText - Remove usage useNativeProps which used to handle the caret position's changes for iOS * Mobile - BlockList and Keyboard Aware FlatList for iOS: - Removes usage of the react-native-keyboard-aware-scroll-view library - Adds custom implementation to scroll to the focused TextInput element * Mobile - Remove patch for react-native-keyboard-aware-scroll-view * Mobile - Update Block insertion E2E test * Mobile - Block List - Update isFloatingToolbarVisible logic * Mobile - KeyboardAwareFlatList - Remove usage of inputAccessoryViewHeight * Mobile - KeyboardAwareFlatList - Prevent adding listeners on re-renders and prevent scrolling if the scroll reference doesn't exist * Mobile - E2E - Improve editor paragraph test * Mobile - KeyboardAwareFlatList - Refactors the code to split it in several hooks, it also fixes a few cases that weren't working correctly. * Mobile - useScrollToTextInput - Fix typo in type * Mobile - RCTAztecView - Send caret's height along with its coords * Mobile - Keyboard Aware Flat list - Rewrite hooks to take into account the measurement of the TextInput to its parent, use the caret's height to add extra padding and correct calculation of the top and bottom offsets * Mobile - Keyboard Aware Flatlist - Update hooks to fix several bugs * Mobile - Keyboard Aware Flatlist - Add tests for new hooks * Mobile - Keyboard Aware FlatList - Remove duplicated condition and adds comments * Mobile - Keyboard Aware Flatlist - Update useScrollToTextInput to fix bugs and simplify logic * Mobile - Keyboard Aware Flatlist - Add missing styles for inner blocks * Mobile - Keyboard Aware Flatlist - Fix issue when the title is focused and it shouldn't scroll down * Mobile - Keyboard Aware Flatlist - Don't take into account null values * Mobile - AztecView - iOS: Pass the caret data when the TextInput is focused * Mobile - Keyboard Aware FlatList - Remove unused shouldPreventAutomaticScroll prop * Mobile - Keyboard Aware FlatList - useKeyboardOffset - Add case where it should remove listeners if the scrollEnabled prop changes from true to false * Mobile - Keyboard Aware FlatList - useTextInputOffset: Prevent measuring the TextInput if the caretY is null since we are no longer taking that as a valid value * Mobile - Keyboard Aware FlatList - useScrollToTextInput: Add documentation for scrollToTextInputOffset * Mobile - Keyboard Aware FlatList - useScrollToTextInput: Update "does not scroll when the ScrollView ref is not available" test to check scrollTo wasn't called * Mobile - AztecInputState - Fix spacing in comment * Mobile - E2E Tests - Paragraph and Block Insertion: Remove adding isAndroid conditions * Revert "Mobile - Keyboard Aware FlatList - useScrollToTextInput: Update "does not scroll when the ScrollView ref is not available" test to check scrollTo wasn't called" This reverts commit fd42475f80cf7e270d05dfba95d22e6f490f874e. * Mobile - KeyboardAwareFlatList - Reset scrollViewMeasurements to null everythime the dependecies change * Mobile - useKeyboardOffset - Remove if condition if there's an AztecView currently focused, it is not needed anymore * Mobile - AztecView - Pass caret data when the content size of the TextInput changes e.g the orientation changes. Also update the caret data if the AztecView is focused * Mobile - AztecInputState - Don't notify caret change listeners if there's no caret data (avoid triggering them with null values) * Mobile - useKeyboardOffset: Remove usage of keyboardWillShow and just rely on keyboardDidShow and keyboardDidHide, this will be useful when this logic is shared with Android. It also updates the hook to just store the current keyboard offset avoiding storing the keyboard visibility as well. * Mobile - KeyboardAwareFlatList - Remove usage of isKeboardVisible since we just need to know if there's a keyboard offset set or not. It also removes measureScrollView from the useEffect that listens to device orientation changes * Mobile - BlockList - Restore usage of shouldFlatListPreventAutomaticScroll * Mobile -useKeyboardOffset: Update hook to use a setTiemout to remove the keyboard offset, it also updates the unit test * Mobile - useScrollToTextInput: Remove dash * Mobile - KeyboardAwareFlatList: Remove dashes * Mobile - useKeyboardOffset - Reset timeout when willShowSubscription is called * Mobile - useTextInputOffset - Add example when a caretY value would be -1 * Mobile - useKeyboardOffset - Remove duplicated test and use a different keyboard height end coordinates to check different offset value * Mobile - useKeyboardOffset - Update test to also remove the keyboardWillShow listener * Mobile - Update Changelog * Components - Update changelog to include the KeyboardAwareFlatList mobile refactor * Mobile - useTextInputOffset - Update comment to use permanent link --- package-lock.json | 9 - .../src/components/block-list/index.native.js | 57 ++--- .../src/components/rich-text/index.native.js | 5 +- .../components/rich-text/use-native-props.js | 3 - .../rich-text/use-native-props.native.js | 17 -- packages/components/CHANGELOG.md | 4 + .../keyboard-aware-flat-list/index.android.js | 4 - .../keyboard-aware-flat-list/index.ios.js | 185 ++++++++++------ .../test/use-keyboard-offset.native.js | 203 ++++++++++++++++++ .../test/use-scroll-to-text-input.native.js | 140 ++++++++++++ .../use-text-input-caret-position.native.js | 82 +++++++ .../test/use-text-input-offset.native.js | 147 +++++++++++++ .../use-keyboard-offset.native.js | 87 ++++++++ .../use-scroll-to-text-input.native.js | 105 +++++++++ .../use-text-input-caret-position.native.js | 36 ++++ .../use-text-input-offset.native.js | 54 +++++ .../components/visual-editor/index.native.js | 37 +--- .../ios/RNTAztecView/RCTAztecView.swift | 9 +- .../react-native-aztec/src/AztecInputState.js | 55 +++++ packages/react-native-aztec/src/AztecView.js | 19 +- packages/react-native-editor/CHANGELOG.md | 1 + ...erg-editor-block-insertion-@canary.test.js | 6 +- .../gutenberg-editor-paragraph.test.js | 3 + .../__device-tests__/pages/editor-page.js | 13 +- .../GutenbergDemo.xcodeproj/project.pbxproj | 4 - packages/react-native-editor/ios/Podfile.lock | 6 - packages/react-native-editor/package.json | 1 - .../rich-text/src/component/index.native.js | 3 - ...eyboard-aware-scroll-view+0.8.8-wp-1.patch | 76 ------- 29 files changed, 1089 insertions(+), 282 deletions(-) delete mode 100644 packages/block-editor/src/components/rich-text/use-native-props.js delete mode 100644 packages/block-editor/src/components/rich-text/use-native-props.native.js create mode 100644 packages/components/src/mobile/keyboard-aware-flat-list/test/use-keyboard-offset.native.js create mode 100644 packages/components/src/mobile/keyboard-aware-flat-list/test/use-scroll-to-text-input.native.js create mode 100644 packages/components/src/mobile/keyboard-aware-flat-list/test/use-text-input-caret-position.native.js create mode 100644 packages/components/src/mobile/keyboard-aware-flat-list/test/use-text-input-offset.native.js create mode 100644 packages/components/src/mobile/keyboard-aware-flat-list/use-keyboard-offset.native.js create mode 100644 packages/components/src/mobile/keyboard-aware-flat-list/use-scroll-to-text-input.native.js create mode 100644 packages/components/src/mobile/keyboard-aware-flat-list/use-text-input-caret-position.native.js create mode 100644 packages/components/src/mobile/keyboard-aware-flat-list/use-text-input-offset.native.js delete mode 100644 patches/react-native-keyboard-aware-scroll-view+0.8.8-wp-1.patch diff --git a/package-lock.json b/package-lock.json index 811af9f2c77b3a..6c8dfd26b78c4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18030,7 +18030,6 @@ "react-native-get-random-values": "1.4.0", "react-native-hr": "https://raw.githubusercontent.com/wordpress-mobile/react-native-hr/1.1.3-wp-1/react-native-hr-1.1.3.tgz", "react-native-hsv-color-picker": "https://raw.githubusercontent.com/wordpress-mobile/react-native-hsv-color-picker/v1.0.1-wp-3/react-native-hsv-color-picker-1.0.1-wp-3.tgz", - "react-native-keyboard-aware-scroll-view": "https://raw.githubusercontent.com/wordpress-mobile/react-native-keyboard-aware-scroll-view/v0.8.8-wp-1/react-native-keyboard-aware-scroll-view-0.8.8-wp-1.tgz", "react-native-linear-gradient": "https://raw.githubusercontent.com/wordpress-mobile/react-native-linear-gradient/v2.5.6-wp-3/react-native-linear-gradient-2.5.6-wp-3.tgz", "react-native-modal": "^11.10.0", "react-native-prompt-android": "https://raw.githubusercontent.com/wordpress-mobile/react-native-prompt-android/v1.0.0-wp-3/react-native-prompt-android-1.0.0-wp-3.tgz", @@ -49374,14 +49373,6 @@ "resolved": "https://registry.npmjs.org/react-native-iphone-x-helper/-/react-native-iphone-x-helper-1.3.1.tgz", "integrity": "sha512-HOf0jzRnq2/aFUcdCJ9w9JGzN3gdEg0zFE4FyYlp4jtidqU03D5X7ZegGKfT1EWteR0gPBGp9ye5T5FvSWi9Yg==" }, - "react-native-keyboard-aware-scroll-view": { - "version": "https://raw.githubusercontent.com/wordpress-mobile/react-native-keyboard-aware-scroll-view/v0.8.8-wp-1/react-native-keyboard-aware-scroll-view-0.8.8-wp-1.tgz", - "integrity": "sha512-gp0xGZvr4TKFW5K5HM2uPsSsbSdONOlajEZohWVxLJJyVSToyaptt/amJrvkpWFqlqtcE9k22iEO/vzpnAgNRw==", - "requires": { - "prop-types": "^15.6.2", - "react-native-iphone-x-helper": "^1.0.3" - } - }, "react-native-linear-gradient": { "version": "https://raw.githubusercontent.com/wordpress-mobile/react-native-linear-gradient/v2.5.6-wp-3/react-native-linear-gradient-2.5.6-wp-3.tgz", "integrity": "sha512-Xq/ABki6/zz6ejut2wPWrh2ZV9Cw5NhHsFcB1adhY/Z2YIVyAVnpApwhMWVV6BxbtKcl17eMPR6vpOI5Q76BjA==" diff --git a/packages/block-editor/src/components/block-list/index.native.js b/packages/block-editor/src/components/block-list/index.native.js index b562daedcf30cd..02c79360b567aa 100644 --- a/packages/block-editor/src/components/block-list/index.native.js +++ b/packages/block-editor/src/components/block-list/index.native.js @@ -6,7 +6,7 @@ import { View, Platform, TouchableWithoutFeedback } from 'react-native'; /** * WordPress dependencies */ -import { Component, createContext } from '@wordpress/element'; +import { Component } from '@wordpress/element'; import { withDispatch, withSelect } from '@wordpress/data'; import { compose, withPreferredColorScheme } from '@wordpress/compose'; import { createBlock } from '@wordpress/blocks'; @@ -33,7 +33,6 @@ import { import { BlockDraggableWrapper } from '../block-draggable'; import { store as blockEditorStore } from '../../store'; -export const OnCaretVerticalPositionChange = createContext(); const identity = ( x ) => x; const stylesMemo = {}; @@ -70,8 +69,6 @@ export class BlockList extends Component { }; this.renderItem = this.renderItem.bind( this ); this.renderBlockListFooter = this.renderBlockListFooter.bind( this ); - this.onCaretVerticalPositionChange = - this.onCaretVerticalPositionChange.bind( this ); this.scrollViewInnerRef = this.scrollViewInnerRef.bind( this ); this.addBlockToEndOfPost = this.addBlockToEndOfPost.bind( this ); this.shouldFlatListPreventAutomaticScroll = @@ -94,15 +91,6 @@ export class BlockList extends Component { this.props.insertBlock( newBlock, this.props.blockCount ); } - onCaretVerticalPositionChange( targetId, caretY, previousCaretY ) { - KeyboardAwareFlatList.handleCaretVerticalPositionChange( - this.scrollViewRef, - targetId, - caretY, - previousCaretY - ); - } - scrollViewInnerRef( ref ) { this.scrollViewRef = ref; } @@ -209,13 +197,7 @@ export class BlockList extends Component { ); - return ( - - { blockList } - - ); + return blockList; } renderList( extraProps = {} ) { @@ -237,8 +219,7 @@ export class BlockList extends Component { } = this.props; const { parentScrollRef, onScroll } = extraProps; - const { blockToolbar, blockBorder, headerToolbar, floatingToolbar } = - styles; + const { blockToolbar, headerToolbar, floatingToolbar } = styles; const containerStyle = { flex: isRootList ? 1 : 0, @@ -250,6 +231,15 @@ export class BlockList extends Component { const isContentStretch = contentResizeMode === 'stretch'; const isMultiBlocks = blockClientIds.length > 1; const { isWider } = alignmentHelpers; + const extraScrollHeight = + headerToolbar.height + + blockToolbar.height + + ( isFloatingToolbarVisible ? floatingToolbar.height : 0 ); + + const scrollViewStyle = [ + { flex: isRootList ? 1 : 0 }, + ! isRootList && styles.overflowVisible, + ]; return ( { this.scrollViewInnerRef( parentScrollRef || ref ); } } - extraScrollHeight={ - blockToolbar.height + blockBorder.width - } - inputAccessoryViewHeight={ - headerToolbar.height + - ( isFloatingToolbarVisible - ? floatingToolbar.height - : 0 ) - } + extraScrollHeight={ extraScrollHeight } keyboardShouldPersistTaps="always" - scrollViewStyle={ [ - { flex: isRootList ? 1 : 0 }, - ! isRootList && styles.overflowVisible, - ] } + scrollViewStyle={ scrollViewStyle } extraData={ this.getExtraData() } scrollEnabled={ isRootList } contentContainerStyle={ [ @@ -407,6 +385,7 @@ export default compose( [ ( select, { rootClientId, orientation, filterInnerBlocks } ) => { const { getBlockCount, + getBlockHierarchyRootClientId, getBlockOrder, getSelectedBlockClientId, isBlockInsertionPointVisible, @@ -427,10 +406,12 @@ export default compose( [ const isReadOnly = getSettings().readOnly; const blockCount = getBlockCount(); - const hasRootInnerBlocks = !! blockCount; + const rootBlockId = getBlockHierarchyRootClientId( + selectedBlockClientId + ); const isFloatingToolbarVisible = - !! selectedBlockClientId && hasRootInnerBlocks; + !! selectedBlockClientId && !! getBlockCount( rootBlockId ); const isRTL = getSettings().isRTL; return { diff --git a/packages/block-editor/src/components/rich-text/index.native.js b/packages/block-editor/src/components/rich-text/index.native.js index 8aacaad95b2960..82e7a96ec8733a 100644 --- a/packages/block-editor/src/components/rich-text/index.native.js +++ b/packages/block-editor/src/components/rich-text/index.native.js @@ -46,7 +46,6 @@ import { useBlockEditContext } from '../block-edit'; import { RemoveBrowserShortcuts } from './remove-browser-shortcuts'; import { filePasteHandler } from './file-paste-handler'; import FormatToolbarContainer from './format-toolbar-container'; -import { useNativeProps } from './use-native-props'; import { store as blockEditorStore } from '../../store'; import { addActiveFormats, @@ -120,7 +119,6 @@ function RichTextWrapper( const fallbackRef = useRef(); const { clientId, isSelected: blockIsSelected } = useBlockEditContext(); - const nativeProps = useNativeProps(); const embedHandlerPickerRef = useRef(); const selector = ( select ) => { const { @@ -219,6 +217,7 @@ function RichTextWrapper( selectionChangeEnd ); }, + // eslint-disable-next-line react-hooks/exhaustive-deps [ clientId, identifier ] ); @@ -372,6 +371,7 @@ function RichTextWrapper( } } }, + // eslint-disable-next-line react-hooks/exhaustive-deps [ onReplace, onSplit, @@ -614,7 +614,6 @@ function RichTextWrapper( } __unstableMultilineRootTag={ __unstableMultilineRootTag } // Native props. - { ...nativeProps } blockIsSelected={ originalIsSelected !== undefined ? originalIsSelected diff --git a/packages/block-editor/src/components/rich-text/use-native-props.js b/packages/block-editor/src/components/rich-text/use-native-props.js deleted file mode 100644 index 04343773a04c60..00000000000000 --- a/packages/block-editor/src/components/rich-text/use-native-props.js +++ /dev/null @@ -1,3 +0,0 @@ -export function useNativeProps() { - return {}; -} diff --git a/packages/block-editor/src/components/rich-text/use-native-props.native.js b/packages/block-editor/src/components/rich-text/use-native-props.native.js deleted file mode 100644 index 41f4e2ea9ac2cb..00000000000000 --- a/packages/block-editor/src/components/rich-text/use-native-props.native.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * WordPress dependencies - */ -import { useContext } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { OnCaretVerticalPositionChange } from '../block-list'; - -export function useNativeProps() { - return { - onCaretVerticalPositionChange: useContext( - OnCaretVerticalPositionChange - ), - }; -} diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index bb5597178f0de9..49d10aacb9fb1b 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Internal + +- `Mobile` Refactor of the KeyboardAwareFlatList component. + ### Enhancements - `DropZone`: Smooth animation ([#49517](https://github.com/WordPress/gutenberg/pull/49517)). diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/index.android.js b/packages/components/src/mobile/keyboard-aware-flat-list/index.android.js index ffdd97dd5acbb7..eccb80f3903e5d 100644 --- a/packages/components/src/mobile/keyboard-aware-flat-list/index.android.js +++ b/packages/components/src/mobile/keyboard-aware-flat-list/index.android.js @@ -24,8 +24,4 @@ export const KeyboardAwareFlatList = ( { innerRef, onScroll, ...props } ) => { ); }; -KeyboardAwareFlatList.handleCaretVerticalPositionChange = () => { - // no need to handle on Android, it is system managed -}; - export default KeyboardAwareFlatList; diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/index.ios.js b/packages/components/src/mobile/keyboard-aware-flat-list/index.ios.js index 05ff4c8bb65191..90fda81d05b2f6 100644 --- a/packages/components/src/mobile/keyboard-aware-flat-list/index.ios.js +++ b/packages/components/src/mobile/keyboard-aware-flat-list/index.ios.js @@ -1,9 +1,8 @@ /** * External dependencies */ -import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; -import { FlatList } from 'react-native'; -import fastDeepEqual from 'fast-deep-equal/es6'; + +import { ScrollView, FlatList, useWindowDimensions } from 'react-native'; import Animated, { useAnimatedScrollHandler, useSharedValue, @@ -12,36 +11,123 @@ import Animated, { /** * WordPress dependencies */ -import { memo, useCallback, useRef } from '@wordpress/element'; +import { useCallback, useEffect, useRef } from '@wordpress/element'; +import { useThrottle } from '@wordpress/compose'; -const List = memo( FlatList, fastDeepEqual ); -const AnimatedKeyboardAwareScrollView = Animated.createAnimatedComponent( - KeyboardAwareScrollView -); +/** + * Internal dependencies + */ +import useTextInputOffset from './use-text-input-offset'; +import useKeyboardOffset from './use-keyboard-offset'; +import useScrollToTextInput from './use-scroll-to-text-input'; +import useTextInputCaretPosition from './use-text-input-caret-position'; +const AnimatedScrollView = Animated.createAnimatedComponent( ScrollView ); + +/** + * React component that provides a FlatList that is aware of the keyboard state and can scroll + * to the currently focused TextInput. + * + * @param {Object} props Component props. + * @param {number} props.extraScrollHeight Extra scroll height for the content. + * @param {Function} props.innerRef Function to pass the ScrollView ref to the parent component. + * @param {Function} props.onScroll Function to be called when the list is scrolled. + * @param {boolean} props.scrollEnabled Whether the list can be scrolled. + * @param {Object} props.scrollViewStyle Additional style for the ScrollView component. + * @param {boolean} props.shouldPreventAutomaticScroll Whether to prevent scrolling when there's a Keyboard offset set. + * @param {Object} props... Other props to pass to the FlatList component. + * @return {WPComponent} KeyboardAwareFlatList component. + */ export const KeyboardAwareFlatList = ( { extraScrollHeight, - shouldPreventAutomaticScroll, innerRef, - autoScroll, - scrollViewStyle, - inputAccessoryViewHeight, onScroll, - ...listProps + scrollEnabled, + scrollViewStyle, + shouldPreventAutomaticScroll, + ...props } ) => { const scrollViewRef = useRef(); - const keyboardWillShowIndicator = useRef(); + const scrollViewMeasurements = useRef(); + const scrollViewYOffset = useSharedValue( -1 ); + + const { height: windowHeight, width: windowWidth } = useWindowDimensions(); + const isLandscape = windowWidth >= windowHeight; + + const [ keyboardOffset ] = useKeyboardOffset( + scrollEnabled, + shouldPreventAutomaticScroll + ); + + const [ currentCaretData ] = useTextInputCaretPosition( scrollEnabled ); + + const [ getTextInputOffset ] = useTextInputOffset( + scrollEnabled, + scrollViewRef + ); + + const [ scrollToTextInputOffset ] = useScrollToTextInput( + extraScrollHeight, + keyboardOffset, + scrollEnabled, + scrollViewMeasurements, + scrollViewRef, + scrollViewYOffset + ); - const latestContentOffsetY = useSharedValue( -1 ); + const onScrollToTextInput = useThrottle( + useCallback( + async ( caret ) => { + const textInputOffset = await getTextInputOffset( caret ); + const hasTextInputOffset = textInputOffset !== null; + + if ( hasTextInputOffset ) { + scrollToTextInputOffset( caret, textInputOffset ); + } + }, + [ getTextInputOffset, scrollToTextInputOffset ] + ), + 200, + { leading: false } + ); + + useEffect( () => { + onScrollToTextInput( currentCaretData ); + }, [ currentCaretData, onScrollToTextInput ] ); + + // When the orientation changes, the ScrollView measurements + // need to be re-calculated. + useEffect( () => { + scrollViewMeasurements.current = null; + }, [ isLandscape ] ); const scrollHandler = useAnimatedScrollHandler( { onScroll: ( event ) => { const { contentOffset } = event; - latestContentOffsetY.value = contentOffset.y; + scrollViewYOffset.value = contentOffset.y; onScroll( event ); }, } ); + const measureScrollView = useCallback( () => { + if ( scrollViewRef.current ) { + const scrollRef = scrollViewRef.current.getNativeScrollRef(); + + scrollRef.measureInWindow( ( _x, y, width, height ) => { + scrollViewMeasurements.current = { y, width, height }; + } ); + } + }, [] ); + + const onContentSizeChange = useCallback( () => { + onScrollToTextInput( currentCaretData ); + + // Sets the first values when the content size changes. + if ( ! scrollViewMeasurements.current ) { + measureScrollView(); + } + }, [ measureScrollView, onScrollToTextInput, currentCaretData ] ); + const getRef = useCallback( ( ref ) => { scrollViewRef.current = ref; @@ -49,63 +135,28 @@ export const KeyboardAwareFlatList = ( { }, [ innerRef ] ); - const onKeyboardWillHide = useCallback( () => { - keyboardWillShowIndicator.current = false; - }, [] ); - const onKeyboardDidHide = useCallback( () => { - setTimeout( () => { - if ( - ! keyboardWillShowIndicator.current && - latestContentOffsetY.value !== -1 && - ! shouldPreventAutomaticScroll() - ) { - // Reset the content position if keyboard is still closed. - scrollViewRef.current?.scrollToPosition( - 0, - latestContentOffsetY.value, - true - ); - } - }, 50 ); - }, [ latestContentOffsetY, shouldPreventAutomaticScroll ] ); - const onKeyboardWillShow = useCallback( () => { - keyboardWillShowIndicator.current = true; - }, [] ); + + // Adds content insets when the keyboard is opened to have + // extra padding at the bottom. + const contentInset = { bottom: keyboardOffset }; + + const style = [ { flex: 1 }, scrollViewStyle ]; return ( - - - + + ); }; -KeyboardAwareFlatList.handleCaretVerticalPositionChange = ( - scrollView, - targetId, - caretY, - previousCaretY -) => { - if ( previousCaretY ) { - // If this is not the first tap. - scrollView.refreshScrollForField( targetId ); - } -}; - export default KeyboardAwareFlatList; diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/test/use-keyboard-offset.native.js b/packages/components/src/mobile/keyboard-aware-flat-list/test/use-keyboard-offset.native.js new file mode 100644 index 00000000000000..18265682b305a5 --- /dev/null +++ b/packages/components/src/mobile/keyboard-aware-flat-list/test/use-keyboard-offset.native.js @@ -0,0 +1,203 @@ +/** + * External dependencies + */ +import { act, renderHook } from '@testing-library/react-native'; +import { Keyboard } from 'react-native'; +import RCTDeviceEventEmitter from 'react-native/Libraries/EventEmitter/RCTDeviceEventEmitter'; + +/** + * Internal dependencies + */ +import useKeyboardOffset from '../use-keyboard-offset'; + +jest.useFakeTimers(); + +describe( 'useKeyboardOffset', () => { + beforeEach( () => { + Keyboard.removeAllListeners( 'keyboardDidShow' ); + Keyboard.removeAllListeners( 'keyboardDidHide' ); + Keyboard.removeAllListeners( 'keyboardWillShow' ); + } ); + + it( 'returns the initial state', () => { + // Arrange + const { result } = renderHook( () => useKeyboardOffset( true ) ); + const [ keyboardOffset ] = result.current; + + // Assert + expect( keyboardOffset ).toBe( 0 ); + } ); + + it( 'updates keyboard visibility and offset when the keyboard is shown', () => { + // Arrange + const { result } = renderHook( () => useKeyboardOffset( true ) ); + + // Act + act( () => { + RCTDeviceEventEmitter.emit( 'keyboardDidShow', { + endCoordinates: { height: 250 }, + } ); + } ); + + // Assert + const [ keyboardOffset ] = result.current; + expect( keyboardOffset ).toBe( 250 ); + } ); + + it( 'updates keyboard visibility and offset when the keyboard is hidden', () => { + // Arrange + const shouldPreventAutomaticScroll = jest.fn().mockReturnValue( false ); + const { result } = renderHook( () => + useKeyboardOffset( true, shouldPreventAutomaticScroll ) + ); + + // Act + act( () => { + RCTDeviceEventEmitter.emit( 'keyboardDidShow', { + endCoordinates: { height: 250 }, + } ); + } ); + + act( () => { + RCTDeviceEventEmitter.emit( 'keyboardDidHide' ); + jest.runAllTimers(); + } ); + + // Assert + const [ keyboardOffset ] = result.current; + expect( keyboardOffset ).toBe( 0 ); + } ); + + it( 'removes all keyboard listeners when scrollEnabled changes to false', () => { + // Arrange + const { result, rerender } = renderHook( + ( { scrollEnabled } ) => useKeyboardOffset( scrollEnabled ), + { + initialProps: { scrollEnabled: true }, + } + ); + const [ keyboardOffset ] = result.current; + + // Act + rerender( { scrollEnabled: false } ); + + // Assert + expect( keyboardOffset ).toBe( 0 ); + expect( RCTDeviceEventEmitter.listenerCount( 'keyboardDidHide' ) ).toBe( + 0 + ); + expect( RCTDeviceEventEmitter.listenerCount( 'keyboardDidShow' ) ).toBe( + 0 + ); + } ); + + it( 'adds all keyboard listeners when scrollEnabled changes to true', () => { + // Arrange + const { result, rerender } = renderHook( + ( { scrollEnabled } ) => useKeyboardOffset( scrollEnabled ), + { + initialProps: { scrollEnabled: false }, + } + ); + // Act + act( () => { + rerender( { scrollEnabled: true } ); + } ); + + act( () => { + RCTDeviceEventEmitter.emit( 'keyboardDidShow', { + endCoordinates: { height: 250 }, + } ); + } ); + + const [ keyboardOffset ] = result.current; + + // Assert + expect( keyboardOffset ).toBe( 250 ); + expect( RCTDeviceEventEmitter.listenerCount( 'keyboardDidShow' ) ).toBe( + 1 + ); + expect( RCTDeviceEventEmitter.listenerCount( 'keyboardDidHide' ) ).toBe( + 1 + ); + } ); + + it( 'does not set keyboard offset to 0 when keyboard is hidden and shouldPreventAutomaticScroll is true', () => { + // Arrange + const shouldPreventAutomaticScroll = jest.fn().mockReturnValue( true ); + const { result } = renderHook( () => + useKeyboardOffset( true, shouldPreventAutomaticScroll ) + ); + + // Act + act( () => { + RCTDeviceEventEmitter.emit( 'keyboardDidShow', { + endCoordinates: { height: 250 }, + } ); + } ); + act( () => { + RCTDeviceEventEmitter.emit( 'keyboardDidHide' ); + jest.runAllTimers(); + } ); + + // Assert + expect( result.current[ 0 ] ).toBe( 250 ); + } ); + + it( 'handles updates to shouldPreventAutomaticScroll', () => { + // Arrange + const preventScrollTrue = jest.fn( () => true ); + const preventScrollFalse = jest.fn( () => false ); + + // Act + const { result, rerender } = renderHook( + ( { shouldPreventAutomaticScroll } ) => + useKeyboardOffset( true, shouldPreventAutomaticScroll ), + { + initialProps: { + shouldPreventAutomaticScroll: preventScrollFalse, + }, + } + ); + + // Assert + expect( result.current[ 0 ] ).toBe( 0 ); + + // Act + act( () => { + RCTDeviceEventEmitter.emit( 'keyboardDidShow', { + endCoordinates: { height: 150 }, + } ); + } ); + + // Assert + expect( result.current[ 0 ] ).toBe( 150 ); + + // Act + act( () => { + rerender( { shouldPreventAutomaticScroll: preventScrollTrue } ); + } ); + + act( () => { + RCTDeviceEventEmitter.emit( 'keyboardDidHide' ); + jest.runAllTimers(); + } ); + + // Assert + expect( result.current[ 0 ] ).toBe( 150 ); + + // Act + act( () => { + rerender( { shouldPreventAutomaticScroll: preventScrollFalse } ); + } ); + + act( () => { + RCTDeviceEventEmitter.emit( 'keyboardDidShow', { + endCoordinates: { height: 250 }, + } ); + } ); + + // Assert + expect( result.current[ 0 ] ).toBe( 250 ); + } ); +} ); diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/test/use-scroll-to-text-input.native.js b/packages/components/src/mobile/keyboard-aware-flat-list/test/use-scroll-to-text-input.native.js new file mode 100644 index 00000000000000..98c33cd4750fbb --- /dev/null +++ b/packages/components/src/mobile/keyboard-aware-flat-list/test/use-scroll-to-text-input.native.js @@ -0,0 +1,140 @@ +/** + * External dependencies + */ + +import { renderHook } from '@testing-library/react-native'; + +/** + * Internal dependencies + */ +import useScrollToTextInput from '../use-scroll-to-text-input'; + +describe( 'useScrollToTextInput', () => { + it( 'scrolls up to the current TextInput offset', () => { + // Arrange + const currentCaretData = { caretHeight: 10 }; + const extraScrollHeight = 50; + const keyboardOffset = 100; + const scrollEnabled = true; + const scrollViewRef = { current: { scrollTo: jest.fn() } }; + const scrollViewMeasurements = { current: { height: 600 } }; + const scrollViewYOffset = { value: 150 }; + const textInputOffset = 50; + + const { result } = renderHook( () => + useScrollToTextInput( + extraScrollHeight, + keyboardOffset, + scrollEnabled, + scrollViewMeasurements, + scrollViewRef, + scrollViewYOffset + ) + ); + + // Act + result.current[ 0 ]( currentCaretData, textInputOffset ); + + // Assert + expect( scrollViewRef.current.scrollTo ).toHaveBeenCalledWith( { + y: textInputOffset, + animated: true, + } ); + } ); + + it( 'scrolls down to the current TextInput offset', () => { + // Arrange + const currentCaretData = { caretHeight: 10 }; + const extraScrollHeight = 50; + const keyboardOffset = 100; + const scrollEnabled = true; + const scrollViewRef = { current: { scrollTo: jest.fn() } }; + const scrollViewMeasurements = { current: { height: 600 } }; + const scrollViewYOffset = { value: 250 }; + const textInputOffset = 750; + + const { result } = renderHook( () => + useScrollToTextInput( + extraScrollHeight, + keyboardOffset, + scrollEnabled, + scrollViewMeasurements, + scrollViewRef, + scrollViewYOffset + ) + ); + + // Act + result.current[ 0 ]( currentCaretData, textInputOffset ); + + // Assert + const expectedYOffset = + textInputOffset - + ( scrollViewMeasurements.current.height - + ( keyboardOffset + + extraScrollHeight + + currentCaretData.caretHeight ) ); + expect( scrollViewRef.current.scrollTo ).toHaveBeenCalledWith( { + y: expectedYOffset, + animated: true, + } ); + } ); + + it( 'does not scroll when the ScrollView ref is not available', () => { + // Arrange + const currentCaretData = { caretHeight: 10 }; + const extraScrollHeight = 50; + const keyboardOffset = 100; + const scrollEnabled = true; + const scrollViewRef = { current: null }; + const scrollViewMeasurements = { current: { height: 600 } }; + const scrollViewYOffset = { value: 0 }; + const textInputOffset = 50; + + const { result } = renderHook( () => + useScrollToTextInput( + extraScrollHeight, + keyboardOffset, + scrollEnabled, + scrollViewMeasurements, + scrollViewRef, + scrollViewYOffset + ) + ); + + // Act + result.current[ 0 ]( currentCaretData, textInputOffset ); + + // Assert + expect( scrollViewRef.current ).toBeNull(); + } ); + + it( 'does not scroll when the scroll is not enabled', () => { + // Arrange + const currentCaretData = { caretHeight: 10 }; + const extraScrollHeight = 50; + const keyboardOffset = 100; + const scrollEnabled = false; + const scrollViewRef = { current: { scrollTo: jest.fn() } }; + const scrollViewMeasurements = { current: { height: 600 } }; + const scrollViewYOffset = { value: 0 }; + const textInputOffset = 50; + + const { result } = renderHook( () => + useScrollToTextInput( + extraScrollHeight, + keyboardOffset, + scrollEnabled, + scrollViewMeasurements, + scrollViewRef, + scrollViewYOffset + ) + ); + + // Act + result.current[ 0 ]( currentCaretData, textInputOffset ); + + // Assert + expect( scrollViewRef.current.scrollTo ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/test/use-text-input-caret-position.native.js b/packages/components/src/mobile/keyboard-aware-flat-list/test/use-text-input-caret-position.native.js new file mode 100644 index 00000000000000..6cb9bd5be81aab --- /dev/null +++ b/packages/components/src/mobile/keyboard-aware-flat-list/test/use-text-input-caret-position.native.js @@ -0,0 +1,82 @@ +/** + * External dependencies + */ +import { renderHook } from '@testing-library/react-native'; + +/** + * WordPress dependencies + */ +import RCTAztecView from '@wordpress/react-native-aztec'; + +/** + * Internal dependencies + */ +import useTextInputCaretPosition from '../use-text-input-caret-position'; + +describe( 'useTextInputCaretPosition', () => { + let addCaretChangeListenerSpy; + let removeCaretChangeListenerSpy; + + beforeAll( () => { + addCaretChangeListenerSpy = jest.spyOn( + RCTAztecView.InputState, + 'addCaretChangeListener' + ); + removeCaretChangeListenerSpy = jest.spyOn( + RCTAztecView.InputState, + 'removeCaretChangeListener' + ); + } ); + + beforeEach( () => { + addCaretChangeListenerSpy.mockClear(); + removeCaretChangeListenerSpy.mockClear(); + } ); + + it( 'should add and remove caret change listener correctly', () => { + // Arrange + const scrollEnabled = true; + + // Act + const { unmount } = renderHook( () => + useTextInputCaretPosition( scrollEnabled ) + ); + unmount(); + + // Assert + expect( addCaretChangeListenerSpy ).toHaveBeenCalledTimes( 1 ); + expect( removeCaretChangeListenerSpy ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'should add caret change listener when scroll is enabled', () => { + // Arrange + const scrollEnabled = true; + + // Act + renderHook( () => useTextInputCaretPosition( scrollEnabled ) ); + + // Assert + expect( addCaretChangeListenerSpy ).toHaveBeenCalledTimes( 1 ); + expect( removeCaretChangeListenerSpy ).not.toHaveBeenCalled(); + } ); + + it( 'should remove caret change listener when scroll is enabled and then changed to disabled', () => { + // Arrange + const { rerender } = renderHook( + ( props ) => useTextInputCaretPosition( props.scrollEnabled ), + { + initialProps: { scrollEnabled: true }, + } + ); + + // Assert + expect( addCaretChangeListenerSpy ).toHaveBeenCalled(); + + // Act + rerender( { scrollEnabled: false } ); + + // Assert + expect( removeCaretChangeListenerSpy ).toHaveBeenCalled(); + expect( addCaretChangeListenerSpy ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/test/use-text-input-offset.native.js b/packages/components/src/mobile/keyboard-aware-flat-list/test/use-text-input-offset.native.js new file mode 100644 index 00000000000000..850b8c09a03b91 --- /dev/null +++ b/packages/components/src/mobile/keyboard-aware-flat-list/test/use-text-input-offset.native.js @@ -0,0 +1,147 @@ +/** + * External dependencies + */ +import { renderHook } from '@testing-library/react-native'; + +/** + * WordPress dependencies + */ +import RCTAztecView from '@wordpress/react-native-aztec'; + +/** + * Internal dependencies + */ +import useTextInputOffset from '../use-text-input-offset'; + +jest.mock( '@wordpress/react-native-aztec', () => ( { + InputState: { + getCurrentFocusedElement: jest.fn(), + }, +} ) ); + +describe( 'useTextInputOffset', () => { + afterEach( () => { + jest.clearAllMocks(); + } ); + + it( 'should return a function', () => { + // Arrange + const scrollViewRef = { current: {} }; + const scrollEnabled = true; + + // Act + const { result } = renderHook( () => + useTextInputOffset( scrollEnabled, scrollViewRef ) + ); + + // Assert + expect( result.current[ 0 ] ).toBeInstanceOf( Function ); + } ); + + it( 'should return null when scrollViewRef.current is null', async () => { + // Arrange + const scrollViewRef = { current: null }; + const scrollEnabled = true; + + // Act + const { result } = renderHook( () => + useTextInputOffset( scrollEnabled, scrollViewRef ) + ); + const getTextInputOffset = result.current[ 0 ]; + + // Assert + const offset = await getTextInputOffset(); + expect( offset ).toBeNull(); + } ); + + it( 'should return null when textInput is null', async () => { + // Arrange + const scrollViewRef = { current: {} }; + const scrollEnabled = true; + RCTAztecView.InputState.getCurrentFocusedElement.mockReturnValue( + null + ); + + // Act + const { result } = renderHook( () => + useTextInputOffset( scrollEnabled, scrollViewRef ) + ); + const getTextInputOffset = result.current[ 0 ]; + + // Assert + const offset = await getTextInputOffset(); + expect( offset ).toBeNull(); + } ); + + it( 'should return null when scroll is not enabled', async () => { + // Arrange + const scrollViewRef = { current: {} }; + const scrollEnabled = false; + + // Act + const { result } = renderHook( () => + useTextInputOffset( scrollEnabled, scrollViewRef ) + ); + const getTextInputOffset = result.current[ 0 ]; + + // Assert + const offset = await getTextInputOffset(); + expect( offset ).toBeNull(); + } ); + + it( 'should return correct offset value when caretY is not null', async () => { + // Arrange + const scrollViewRef = { current: {} }; + const scrollEnabled = true; + const x = 0; + const y = 10; + const width = 0; + const height = 100; + const textInput = { + measureLayout: jest.fn( ( _, callback ) => { + callback( x, y, width, height ); + } ), + }; + RCTAztecView.InputState.getCurrentFocusedElement.mockReturnValue( + textInput + ); + + // Act + const { result } = renderHook( () => + useTextInputOffset( scrollEnabled, scrollViewRef ) + ); + const getTextInputOffset = result.current[ 0 ]; + + // Assert + const offset = await getTextInputOffset( { caretY: 10 } ); + expect( offset ).toBe( 20 ); + } ); + + it( 'should return correct offset value when caretY is -1', async () => { + // Arrange + const scrollViewRef = { current: {} }; + const scrollEnabled = true; + const x = 0; + const y = 10; + const width = 0; + const height = 100; + const textInput = { + measureLayout: jest.fn( ( _, callback ) => { + callback( x, y, width, height ); + } ), + }; + RCTAztecView.InputState.getCurrentFocusedElement.mockReturnValue( + textInput + ); + + // Act + const { result } = renderHook( () => + useTextInputOffset( scrollEnabled, scrollViewRef ) + ); + const getTextInputOffset = result.current[ 0 ]; + + // Assert + const offset = await getTextInputOffset( { caretY: -1 } ); + expect( offset ).toBe( 110 ); + } ); +} ); diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/use-keyboard-offset.native.js b/packages/components/src/mobile/keyboard-aware-flat-list/use-keyboard-offset.native.js new file mode 100644 index 00000000000000..f12b254dd9469b --- /dev/null +++ b/packages/components/src/mobile/keyboard-aware-flat-list/use-keyboard-offset.native.js @@ -0,0 +1,87 @@ +/** + * External dependencies + */ + +import { Keyboard } from 'react-native'; + +/** + * WordPress dependencies + */ +import { useEffect, useCallback, useState, useRef } from '@wordpress/element'; + +/** + * Hook that adds Keyboard listeners to get the offset space + * when the keyboard is opened, taking into account focused AztecViews. + * + * @param {boolean} scrollEnabled Whether the scroll is enabled or not. + * @param {Function} shouldPreventAutomaticScroll Whether to prevent scrolling when there's a Keyboard offset set. + * @return {[number]} Keyboard offset. + */ +export default function useKeyboardOffset( + scrollEnabled, + shouldPreventAutomaticScroll +) { + const [ keyboardOffset, setKeyboardOffset ] = useState( 0 ); + const timeoutRef = useRef(); + + const onKeyboardDidHide = useCallback( () => { + if ( shouldPreventAutomaticScroll() ) { + clearTimeout( timeoutRef.current ); + return; + } + + // A timeout is being used to delay resetting the offset in cases + // where the focus is changed to a different TextInput. + clearTimeout( timeoutRef.current ); + timeoutRef.current = setTimeout( () => { + setKeyboardOffset( 0 ); + }, 200 ); + }, [ shouldPreventAutomaticScroll ] ); + + const onKeyboardDidShow = useCallback( ( { endCoordinates } ) => { + clearTimeout( timeoutRef.current ); + setKeyboardOffset( endCoordinates.height ); + }, [] ); + + const onKeyboardWillShow = useCallback( () => { + clearTimeout( timeoutRef.current ); + }, [] ); + + useEffect( () => { + let willShowSubscription; + let showSubscription; + let hideSubscription; + + if ( scrollEnabled ) { + willShowSubscription = Keyboard.addListener( + 'keyboardWillShow', + onKeyboardWillShow + ); + showSubscription = Keyboard.addListener( + 'keyboardDidShow', + onKeyboardDidShow + ); + hideSubscription = Keyboard.addListener( + 'keyboardDidHide', + onKeyboardDidHide + ); + } else { + willShowSubscription?.remove(); + showSubscription?.remove(); + hideSubscription?.remove(); + } + + return () => { + clearTimeout( timeoutRef.current ); + willShowSubscription?.remove(); + showSubscription?.remove(); + hideSubscription?.remove(); + }; + }, [ + onKeyboardDidHide, + onKeyboardDidShow, + onKeyboardWillShow, + scrollEnabled, + ] ); + return [ keyboardOffset ]; +} diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/use-scroll-to-text-input.native.js b/packages/components/src/mobile/keyboard-aware-flat-list/use-scroll-to-text-input.native.js new file mode 100644 index 00000000000000..3bdaba837a60b3 --- /dev/null +++ b/packages/components/src/mobile/keyboard-aware-flat-list/use-scroll-to-text-input.native.js @@ -0,0 +1,105 @@ +/** + * External dependencies + */ +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +/** + * WordPress dependencies + */ +import { useCallback } from '@wordpress/element'; + +const DEFAULT_FONT_SIZE = 16; + +/** @typedef {import('@wordpress/element').RefObject} RefObject */ +/** @typedef {import('react-native-reanimated').SharedValue} SharedValue */ +/** + * Hook to scroll to the currently focused TextInput + * depending on where the caret is placed taking into + * account the Keyboard and the Header. + * + * @param {number} extraScrollHeight Extra space to not overlap the content. + * @param {number} keyboardOffset Keyboard space offset. + * @param {boolean} scrollEnabled Whether the scroll is enabled or not. + * @param {RefObject} scrollViewMeasurements ScrollView Layout measurements. + * @param {RefObject} scrollViewRef ScrollView reference. + * @param {SharedValue} scrollViewYOffset Current offset position of the ScrollView. + * @return {Function[]} Function to scroll to the current TextInput's offset. + */ +export default function useScrollToTextInput( + extraScrollHeight, + keyboardOffset, + scrollEnabled, + scrollViewMeasurements, + scrollViewRef, + scrollViewYOffset +) { + const { top, bottom } = useSafeAreaInsets(); + const insets = top + bottom; + + /** + * Function to scroll to the current TextInput's offset. + * + * @param {Object} caret The caret position data of the currently focused TextInput. + * @param {number} caret.caretHeight The height of the caret. + * @param {number} textInputOffset The offset calculated with the caret's Y coordinate + the + * TextInput's Y coord or height value. + */ + const scrollToTextInputOffset = useCallback( + ( caret, textInputOffset ) => { + const { caretHeight = DEFAULT_FONT_SIZE } = caret ?? {}; + + if ( + ! scrollViewRef.current || + ! scrollEnabled || + ! scrollViewMeasurements.current + ) { + return; + } + const currentScrollViewYOffset = Math.max( + 0, + scrollViewYOffset.value + ); + + // Scroll up. + if ( textInputOffset < currentScrollViewYOffset ) { + scrollViewRef.current.scrollTo( { + y: textInputOffset, + animated: true, + } ); + return; + } + + const availableScreenSpace = Math.abs( + Math.floor( + scrollViewMeasurements.current.height - + ( keyboardOffset + extraScrollHeight + caretHeight ) + ) + ); + const maxOffset = Math.floor( + currentScrollViewYOffset + availableScreenSpace + ); + + const isAtTheTop = + textInputOffset < scrollViewMeasurements.current.y + insets; + + // Scroll down. + if ( textInputOffset > maxOffset && ! isAtTheTop ) { + scrollViewRef.current.scrollTo( { + y: textInputOffset - availableScreenSpace, + animated: true, + } ); + } + }, + [ + extraScrollHeight, + insets, + keyboardOffset, + scrollEnabled, + scrollViewMeasurements, + scrollViewRef, + scrollViewYOffset, + ] + ); + + return [ scrollToTextInputOffset ]; +} diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/use-text-input-caret-position.native.js b/packages/components/src/mobile/keyboard-aware-flat-list/use-text-input-caret-position.native.js new file mode 100644 index 00000000000000..49aa873fc66efc --- /dev/null +++ b/packages/components/src/mobile/keyboard-aware-flat-list/use-text-input-caret-position.native.js @@ -0,0 +1,36 @@ +/** + * WordPress dependencies + */ +import RCTAztecView from '@wordpress/react-native-aztec'; +import { useCallback, useEffect, useState } from '@wordpress/element'; + +/** + * Hook that listens to caret changes from AztecView TextInputs. + * + * @param {boolean} scrollEnabled Whether the scroll is enabled or not. + * @return {[number]} Current caret's data. + */ +export default function useTextInputCaretPosition( scrollEnabled ) { + const [ currentCaretData, setCurrentCaretData ] = useState(); + + const onCaretChange = useCallback( ( caret ) => { + setCurrentCaretData( caret ); + }, [] ); + + useEffect( () => { + if ( scrollEnabled ) { + RCTAztecView.InputState.addCaretChangeListener( onCaretChange ); + } else { + RCTAztecView.InputState.removeCaretChangeListener( onCaretChange ); + } + + return () => { + if ( scrollEnabled ) { + RCTAztecView.InputState.removeCaretChangeListener( + onCaretChange + ); + } + }; + }, [ scrollEnabled, onCaretChange ] ); + return [ currentCaretData ]; +} diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/use-text-input-offset.native.js b/packages/components/src/mobile/keyboard-aware-flat-list/use-text-input-offset.native.js new file mode 100644 index 00000000000000..1c69cbcc48c45f --- /dev/null +++ b/packages/components/src/mobile/keyboard-aware-flat-list/use-text-input-offset.native.js @@ -0,0 +1,54 @@ +/** + * WordPress dependencies + */ +import RCTAztecView from '@wordpress/react-native-aztec'; +import { useCallback } from '@wordpress/element'; + +/** @typedef {import('@wordpress/element').RefObject} RefObject */ +/** + * Hook that calculates the currently focused TextInput's current + * caret Y coordinate position. + * + * @param {boolean} scrollEnabled Whether the scroll is enabled or not. + * @param {RefObject} scrollViewRef ScrollView reference. + * @return {[Function]} Function to get the current TextInput's offset. + */ +export default function useTextInputOffset( scrollEnabled, scrollViewRef ) { + const getTextInputOffset = useCallback( + async ( caret ) => { + const { caretY = null } = caret ?? {}; + const textInput = + RCTAztecView.InputState.getCurrentFocusedElement(); + + return new Promise( ( resolve ) => { + if ( + scrollViewRef.current && + textInput && + scrollEnabled && + caretY !== null + ) { + textInput.measureLayout( + scrollViewRef.current, + ( _x, y, _width, height ) => { + const caretYOffset = + // For cases where the caretY value is -1 + // we use the y + height value, e.g the current + // character index is not valid or out of bounds + // see https://github.com/wordpress-mobile/AztecEditor-iOS/blob/4d0522d67b0056ac211466caaa76936cc5b4f947/Aztec/Classes/TextKit/TextView.swift#L762 + caretY >= 0 && caretY < height + ? y + caretY + : y + height; + resolve( Math.round( Math.abs( caretYOffset ) ) ); + }, + () => resolve( null ) + ); + } else { + resolve( null ); + } + } ); + }, + [ scrollEnabled, scrollViewRef ] + ); + + return [ getTextInputOffset ]; +} diff --git a/packages/edit-post/src/components/visual-editor/index.native.js b/packages/edit-post/src/components/visual-editor/index.native.js index 886e56e493938b..b23b73c334e75a 100644 --- a/packages/edit-post/src/components/visual-editor/index.native.js +++ b/packages/edit-post/src/components/visual-editor/index.native.js @@ -3,10 +3,7 @@ */ import { Component } from '@wordpress/element'; import { BlockList } from '@wordpress/block-editor'; -/** - * External dependencies - */ -import { Keyboard } from 'react-native'; + /** * Internal dependencies */ @@ -16,36 +13,6 @@ export default class VisualEditor extends Component { constructor( props ) { super( props ); this.renderHeader = this.renderHeader.bind( this ); - this.keyboardDidShow = this.keyboardDidShow.bind( this ); - this.keyboardDidHide = this.keyboardDidHide.bind( this ); - - this.state = { - isAutoScrollEnabled: true, - }; - } - - componentDidMount() { - this.keyboardDidShow = Keyboard.addListener( - 'keyboardDidShow', - this.keyboardDidShow - ); - this.keyboardDidHideListener = Keyboard.addListener( - 'keyboardDidHide', - this.keyboardDidHide - ); - } - - componentWillUnmount() { - this.keyboardDidShow.remove(); - this.keyboardDidHideListener.remove(); - } - - keyboardDidShow() { - this.setState( { isAutoScrollEnabled: false } ); - } - - keyboardDidHide() { - this.setState( { isAutoScrollEnabled: true } ); } renderHeader() { @@ -55,13 +22,11 @@ export default class VisualEditor extends Component { render() { const { safeAreaBottomInset } = this.props; - const { isAutoScrollEnabled } = this.state; return ( ); } diff --git a/packages/react-native-aztec/ios/RNTAztecView/RCTAztecView.swift b/packages/react-native-aztec/ios/RNTAztecView/RCTAztecView.swift index 166d86834bc131..c6e928b3964404 100644 --- a/packages/react-native-aztec/ios/RNTAztecView/RCTAztecView.swift +++ b/packages/react-native-aztec/ios/RNTAztecView/RCTAztecView.swift @@ -234,7 +234,10 @@ class RCTAztecView: Aztec.TextView { previousContentSize = newSize let body = packForRN(newSize, withName: "contentSize") - onContentSizeChange(body) + let caretData = packCaretDataForRN() + var result = body + result.merge(caretData) { (_, new) in new } + onContentSizeChange(result) } // MARK: - Paste handling @@ -479,6 +482,7 @@ class RCTAztecView: Aztec.TextView { if !(caretEndRect.isInfinite || caretEndRect.isNull) { result["selectionEndCaretX"] = caretEndRect.origin.x result["selectionEndCaretY"] = caretEndRect.origin.y + result["selectionEndCaretHeight"] = caretEndRect.size.height } } @@ -792,7 +796,8 @@ extension RCTAztecView: UITextViewDelegate { override func becomeFirstResponder() -> Bool { if !isFirstResponder && canBecomeFirstResponder { - onFocus?([:]) + let caretData = packCaretDataForRN() + onFocus?(caretData) } return super.becomeFirstResponder() } diff --git a/packages/react-native-aztec/src/AztecInputState.js b/packages/react-native-aztec/src/AztecInputState.js index 973b8179ea5ec9..8a1737118d1d13 100644 --- a/packages/react-native-aztec/src/AztecInputState.js +++ b/packages/react-native-aztec/src/AztecInputState.js @@ -6,8 +6,10 @@ import TextInputState from 'react-native/Libraries/Components/TextInput/TextInpu /** @typedef {import('@wordpress/element').RefObject} RefObject */ const focusChangeListeners = []; +const caretChangeListeners = []; let currentFocusedElement = null; +let currentCaretData = null; /** * Adds a listener that will be called in the following cases: @@ -47,6 +49,37 @@ const notifyListeners = ( { isFocused } ) => { } ); }; +/** + * Adds a listener that will be called when the caret's Y position + * changes for the focused Aztec view. + * + * @param {Function} listener + */ +export const addCaretChangeListener = ( listener ) => { + caretChangeListeners.push( listener ); +}; + +/** + * Removes a listener from the caret change listeners list. + * + * @param {Function} listener + */ +export const removeCaretChangeListener = ( listener ) => { + const itemIndex = caretChangeListeners.indexOf( listener ); + if ( itemIndex !== -1 ) { + caretChangeListeners.splice( itemIndex, 1 ); + } +}; + +/** + * Notifies listeners about caret changes in focused Aztec view. + */ +const notifyCaretChangeListeners = () => { + caretChangeListeners.forEach( ( listener ) => { + listener( getCurrentCaretData() ); + } ); +}; + /** * Determines if any Aztec view is focused. * @@ -100,6 +133,7 @@ export const focus = ( element ) => { */ export const blur = ( element ) => { TextInputState.blurTextInput( element ); + setCurrentCaretData( null ); notifyInputChange(); }; @@ -111,3 +145,24 @@ export const blurCurrentFocusedElement = () => { blur( getCurrentFocusedElement() ); } }; + +/** + * Sets the current focused element caret's data. + * + * @param {Object} caret Caret's data. + */ +export const setCurrentCaretData = ( caret ) => { + if ( isFocused() && caret ) { + currentCaretData = caret; + notifyCaretChangeListeners(); + } +}; + +/** + * Get the current focused element caret's data. + * + * @return {Object} Current caret's data. + */ +export const getCurrentCaretData = () => { + return currentCaretData; +}; diff --git a/packages/react-native-aztec/src/AztecView.js b/packages/react-native-aztec/src/AztecView.js index 8ac7884dd89c63..4d90d13974c8ec 100644 --- a/packages/react-native-aztec/src/AztecView.js +++ b/packages/react-native-aztec/src/AztecView.js @@ -79,6 +79,8 @@ class AztecView extends Component { } _onContentSizeChange( event ) { + this.updateCaretData( event ); + if ( ! this.props.onContentSizeChange ) { return; } @@ -197,16 +199,19 @@ class AztecView extends Component { onSelectionChange( selectionStart, selectionEnd, text, event ); } + this.updateCaretData( event ); + } + + updateCaretData( event ) { if ( - this.props.onCaretVerticalPositionChange && - this.selectionEndCaretY !== event.nativeEvent.selectionEndCaretY + this.isFocused() && + this.selectionEndCaretY !== event?.nativeEvent?.selectionEndCaretY ) { const caretY = event.nativeEvent.selectionEndCaretY; - this.props.onCaretVerticalPositionChange( - event.nativeEvent.target, + AztecInputState.setCurrentCaretData( { caretY, - this.selectionEndCaretY - ); + caretHeight: event.nativeEvent?.selectionEndCaretHeight, + } ); this.selectionEndCaretY = caretY; } } @@ -237,6 +242,8 @@ class AztecView extends Component { // combination generate an infinite loop as described in https://github.com/wordpress-mobile/gutenberg-mobile/issues/302 // For iOS, this is necessary to let the system know when Aztec was focused programatically. if ( Platform.OS === 'ios' ) { + this.updateCaretData( event ); + this._onPress( event ); } } diff --git a/packages/react-native-editor/CHANGELOG.md b/packages/react-native-editor/CHANGELOG.md index e425af19ec0890..1eedaf1467ddcd 100644 --- a/packages/react-native-editor/CHANGELOG.md +++ b/packages/react-native-editor/CHANGELOG.md @@ -12,6 +12,7 @@ For each user feature we should also add a importance categorization label to i ## Unreleased - [*] Support POST requests [#49371] - [*] Avoid empty Gallery block error [#49557] +- [***] [iOS] Fixed iOS scroll jumping issue by refactoring KeyboardAwareFlatList improving writing flow and caret focus handling. [#48791] ## 1.92.0 * No User facing changes * diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-block-insertion-@canary.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-block-insertion-@canary.test.js index e5b34078a0e453..b413f3b6a42cea 100644 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-block-insertion-@canary.test.js +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-block-insertion-@canary.test.js @@ -65,14 +65,14 @@ describe( 'Gutenberg Editor tests for Block insertion', () => { } ); await titleElement.click(); + // Wait for editor to finish scrolling to the title + await editorPage.driver.sleep( 2000 ); + await editorPage.addNewBlock( blockNames.paragraph ); const emptyParagraphBlock = await editorPage.getBlockAtPosition( blockNames.paragraph ); expect( emptyParagraphBlock ).toBeTruthy(); - const emptyParagraphBlockElement = - await editorPage.getTextBlockAtPosition( blockNames.paragraph ); - expect( emptyParagraphBlockElement ).toBeTruthy(); await editorPage.sendTextToParagraphBlock( 1, testData.mediumText ); const html = await editorPage.getHtmlContent(); diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-paragraph.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-paragraph.test.js index 6604c9ba6f32b7..aa398d1f24e265 100644 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-paragraph.test.js +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-paragraph.test.js @@ -86,6 +86,9 @@ describe( 'Gutenberg Editor tests for Paragraph Block', () => { await editorPage.sendTextToParagraphBlock( 1, testData.longText ); for ( let i = 3; i > 0; i-- ) { + const paragraphBlockElement = + await editorPage.getTextBlockAtPosition( blockNames.paragraph ); + await paragraphBlockElement.click(); await editorPage.removeBlock(); } } ); diff --git a/packages/react-native-editor/__device-tests__/pages/editor-page.js b/packages/react-native-editor/__device-tests__/pages/editor-page.js index cbedc7fc8a9552..6cad729d26ecb9 100644 --- a/packages/react-native-editor/__device-tests__/pages/editor-page.js +++ b/packages/react-native-editor/__device-tests__/pages/editor-page.js @@ -200,14 +200,19 @@ class EditorPage { const titleElement = isAndroid() ? 'Post title. Welcome to Gutenberg!, Updates the title.' : 'post-title'; + + if ( options.autoscroll ) { + await swipeDown( this.driver ); + } + const elements = await this.driver.elementsByAccessibilityId( titleElement ); - if ( elements.length === 0 || ! elements[ 0 ].isDisplayed() ) { - if ( options.autoscroll ) { - await swipeDown( this.driver ); - } + if ( + elements.length === 0 || + ! ( await elements[ 0 ].isDisplayed() ) + ) { return await this.getTitleElement( options ); } return elements[ 0 ]; diff --git a/packages/react-native-editor/ios/GutenbergDemo.xcodeproj/project.pbxproj b/packages/react-native-editor/ios/GutenbergDemo.xcodeproj/project.pbxproj index 010f3add38639f..79a27e1e618468 100644 --- a/packages/react-native-editor/ios/GutenbergDemo.xcodeproj/project.pbxproj +++ b/packages/react-native-editor/ios/GutenbergDemo.xcodeproj/project.pbxproj @@ -370,7 +370,6 @@ "${BUILT_PRODUCTS_DIR}/libwebp/libwebp.framework", "${BUILT_PRODUCTS_DIR}/react-native-blur/react_native_blur.framework", "${BUILT_PRODUCTS_DIR}/react-native-get-random-values/react_native_get_random_values.framework", - "${BUILT_PRODUCTS_DIR}/react-native-keyboard-aware-scroll-view/react_native_keyboard_aware_scroll_view.framework", "${BUILT_PRODUCTS_DIR}/react-native-safe-area/react_native_safe_area.framework", "${BUILT_PRODUCTS_DIR}/react-native-safe-area-context/react_native_safe_area_context.framework", "${BUILT_PRODUCTS_DIR}/react-native-slider/react_native_slider.framework", @@ -419,7 +418,6 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libwebp.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_blur.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_get_random_values.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_keyboard_aware_scroll_view.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_safe_area.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_safe_area_context.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_slider.framework", @@ -500,7 +498,6 @@ "${BUILT_PRODUCTS_DIR}/libwebp/libwebp.framework", "${BUILT_PRODUCTS_DIR}/react-native-blur/react_native_blur.framework", "${BUILT_PRODUCTS_DIR}/react-native-get-random-values/react_native_get_random_values.framework", - "${BUILT_PRODUCTS_DIR}/react-native-keyboard-aware-scroll-view/react_native_keyboard_aware_scroll_view.framework", "${BUILT_PRODUCTS_DIR}/react-native-safe-area/react_native_safe_area.framework", "${BUILT_PRODUCTS_DIR}/react-native-safe-area-context/react_native_safe_area_context.framework", "${BUILT_PRODUCTS_DIR}/react-native-slider/react_native_slider.framework", @@ -549,7 +546,6 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libwebp.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_blur.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_get_random_values.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_keyboard_aware_scroll_view.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_safe_area.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_safe_area_context.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_slider.framework", diff --git a/packages/react-native-editor/ios/Podfile.lock b/packages/react-native-editor/ios/Podfile.lock index 562fa507f3f944..af952e19906d1e 100644 --- a/packages/react-native-editor/ios/Podfile.lock +++ b/packages/react-native-editor/ios/Podfile.lock @@ -240,8 +240,6 @@ PODS: - React-Core - react-native-get-random-values (1.4.0): - React-Core - - react-native-keyboard-aware-scroll-view (0.8.8-wp-1): - - React-Core - react-native-safe-area (0.5.1): - React-Core - react-native-safe-area-context (3.2.0): @@ -399,7 +397,6 @@ DEPENDENCIES: - React-logger (from `../../../node_modules/react-native/ReactCommon/logger`) - "react-native-blur (from `../../../node_modules/@react-native-community/blur`)" - react-native-get-random-values (from `../../../node_modules/react-native-get-random-values`) - - react-native-keyboard-aware-scroll-view (from `../../../node_modules/react-native-keyboard-aware-scroll-view`) - react-native-safe-area (from `../../../node_modules/react-native-safe-area`) - react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`) - "react-native-slider (from `../../../node_modules/@react-native-community/slider`)" @@ -482,8 +479,6 @@ EXTERNAL SOURCES: :path: "../../../node_modules/@react-native-community/blur" react-native-get-random-values: :path: "../../../node_modules/react-native-get-random-values" - react-native-keyboard-aware-scroll-view: - :path: "../../../node_modules/react-native-keyboard-aware-scroll-view" react-native-safe-area: :path: "../../../node_modules/react-native-safe-area" react-native-safe-area-context: @@ -563,7 +558,6 @@ SPEC CHECKSUMS: React-logger: 1088859f145b8f6dd0d3ed051a647ef0e3e80fad react-native-blur: 3e9c8e8e9f7d17fa1b94e1a0ae9fd816675f5382 react-native-get-random-values: b6fb85e7169b9822976793e467458c151c3e8b69 - react-native-keyboard-aware-scroll-view: 0bc6c2dfe9056935a40dc1a70e764b7a1bbf6568 react-native-safe-area: c9cf765aa2dd96159476a99633e7d462ce5bb94f react-native-safe-area-context: f0906bf8bc9835ac9a9d3f97e8bde2a997d8da79 react-native-slider: a433f1c13c5da3c17a587351bff7371f65cc9a07 diff --git a/packages/react-native-editor/package.json b/packages/react-native-editor/package.json index 9c979c300f1c04..8a143fe97b288a 100644 --- a/packages/react-native-editor/package.json +++ b/packages/react-native-editor/package.json @@ -62,7 +62,6 @@ "react-native-get-random-values": "1.4.0", "react-native-hr": "https://raw.githubusercontent.com/wordpress-mobile/react-native-hr/1.1.3-wp-1/react-native-hr-1.1.3.tgz", "react-native-hsv-color-picker": "https://raw.githubusercontent.com/wordpress-mobile/react-native-hsv-color-picker/v1.0.1-wp-3/react-native-hsv-color-picker-1.0.1-wp-3.tgz", - "react-native-keyboard-aware-scroll-view": "https://raw.githubusercontent.com/wordpress-mobile/react-native-keyboard-aware-scroll-view/v0.8.8-wp-1/react-native-keyboard-aware-scroll-view-0.8.8-wp-1.tgz", "react-native-linear-gradient": "https://raw.githubusercontent.com/wordpress-mobile/react-native-linear-gradient/v2.5.6-wp-3/react-native-linear-gradient-2.5.6-wp-3.tgz", "react-native-modal": "^11.10.0", "react-native-prompt-android": "https://raw.githubusercontent.com/wordpress-mobile/react-native-prompt-android/v1.0.0-wp-3/react-native-prompt-android-1.0.0-wp-3.tgz", diff --git a/packages/rich-text/src/component/index.native.js b/packages/rich-text/src/component/index.native.js index 134a3fe6666bad..2d48d1263cb61a 100644 --- a/packages/rich-text/src/component/index.native.js +++ b/packages/rich-text/src/component/index.native.js @@ -1218,9 +1218,6 @@ export class RichText extends Component { onPaste={ this.onPaste } activeFormats={ this.getActiveFormatNames( record ) } onContentSizeChange={ this.onContentSizeChange } - onCaretVerticalPositionChange={ - this.props.onCaretVerticalPositionChange - } onSelectionChange={ this.onSelectionChangeFromAztec } blockType={ { tag: tagName } } color={ diff --git a/patches/react-native-keyboard-aware-scroll-view+0.8.8-wp-1.patch b/patches/react-native-keyboard-aware-scroll-view+0.8.8-wp-1.patch deleted file mode 100644 index fab58b9d3157d9..00000000000000 --- a/patches/react-native-keyboard-aware-scroll-view+0.8.8-wp-1.patch +++ /dev/null @@ -1,76 +0,0 @@ -diff --git a/node_modules/react-native-keyboard-aware-scroll-view/lib/KeyboardAwareHOC.js b/node_modules/react-native-keyboard-aware-scroll-view/lib/KeyboardAwareHOC.js -index 30f62c9..83a6920 100644 ---- a/node_modules/react-native-keyboard-aware-scroll-view/lib/KeyboardAwareHOC.js -+++ b/node_modules/react-native-keyboard-aware-scroll-view/lib/KeyboardAwareHOC.js -@@ -264,9 +264,13 @@ function KeyboardAwareHOC( - }) - } - -- componentWillReceiveProps(nextProps: KeyboardAwareHOCProps) { -- if (nextProps.viewIsInsideTabBar !== this.props.viewIsInsideTabBar) { -- const keyboardSpace: number = nextProps.viewIsInsideTabBar -+ // This patch changed from the deprecated `componentWillReceiveProps` to -+ // `componentDidUpdate`. We can remove this patch when we upgrade to -+ // `react-native-keyboard-aware-scroll-view@^0.9.2` -+ // https://git.io/JPbOK -+ componentDidUpdate(prevProps: KeyboardAwareHOCProps) { -+ if (this.props.viewIsInsideTabBar !== prevProps.viewIsInsideTabBar) { -+ const keyboardSpace: number = this.props.viewIsInsideTabBar - ? _KAM_DEFAULT_TAB_BAR_HEIGHT - : 0 - if (this.state.keyboardSpace !== keyboardSpace) { -@@ -293,12 +297,33 @@ function KeyboardAwareHOC( - - scrollToPosition = (x: number, y: number, animated: boolean = true) => { - const responder = this.getScrollResponder() -- responder && responder.scrollResponderScrollTo({ x, y, animated }) -+ // Patch applied to avoid invoking the removed `scrollResponderScrollTo` -+ // method. This patch could be removed if we upgrade to -+ // `react-native-keyboard-aware-view@^0.9.5` https://git.io/JPb6a -+ if (!responder) return; -+ if (responder.scrollResponderScrollTo) { -+ // React Native < 0.65 -+ responder.scrollResponderScrollTo({ x, y, animated }) -+ } else if (responder.scrollTo) { -+ // React Native >= 0.65 -+ responder.scrollTo({ x, y, animated }) -+ } - } - - scrollToEnd = (animated?: boolean = true) => { - const responder = this.getScrollResponder() -- responder && responder.scrollResponderScrollToEnd({ animated }) -+ // Patch applied to avoid invoking the removed -+ // `scrollResponderScrollToEnd` method. This patch could be removed if we -+ // upgrade to `react-native-keyboard-aware-view@^0.9.5` -+ // https://git.io/JPb6a -+ if (!responder) return; -+ if (responder.scrollResponderScrollToEnd) { -+ // React Native < 0.65 -+ responder.scrollResponderScrollToEnd({ animated }) -+ } else if (responder.scrollToEnd) { -+ // React Native >= 0.65 -+ responder.scrollToEnd({ animated }) -+ } - } - - scrollForExtraHeightOnAndroid = (extraHeight: number) => { -@@ -553,7 +578,17 @@ function KeyboardAwareHOC( - - scrollOffsetY = Math.max(0, scrollOffsetY); //prevent negative scroll offset - const responder = this.getScrollResponder(); -- responder && responder.scrollResponderScrollTo( { x: 0, y: scrollOffsetY, animated: true } ); -+ // Patch applied to avoid invoking the removed `scrollResponderScrollTo` -+ // method. This patch could be removed if we upgrade to -+ // `react-native-keyboard-aware-view@^0.9.5` https://git.io/JPb6a -+ if (!responder) return; -+ if (responder.scrollResponderScrollTo) { -+ // React Native < 0.65 -+ responder.scrollResponderScrollTo( { x: 0, y: scrollOffsetY, animated: true } ) -+ } else if (responder.scrollTo) { -+ // React Native >= 0.65 -+ responder.scrollTo( { x: 0, y: scrollOffsetY, animated: true } ) -+ } - } - - const measureLayoutErrorHandler = ( e: Object ) => {