diff --git a/package-lock.json b/package-lock.json
index 811af9f2c77b3..6c8dfd26b78c4 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 b562daedcf30c..02c79360b567a 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 8aacaad95b296..82e7a96ec8733 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 04343773a04c6..0000000000000
--- 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 41f4e2ea9ac2c..0000000000000
--- 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 bb5597178f0de..49d10aacb9fb1 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 ffdd97dd5acbb..eccb80f3903e5 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 05ff4c8bb6519..90fda81d05b2f 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 0000000000000..18265682b305a
--- /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 0000000000000..98c33cd4750fb
--- /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 0000000000000..6cb9bd5be81aa
--- /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 0000000000000..850b8c09a03b9
--- /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 0000000000000..f12b254dd9469
--- /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 0000000000000..3bdaba837a60b
--- /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 0000000000000..49aa873fc66ef
--- /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 0000000000000..1c69cbcc48c45
--- /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 886e56e493938..b23b73c334e75 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 166d86834bc13..c6e928b396440 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 973b8179ea5ec..8a1737118d1d1 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 8ac7884dd89c6..4d90d13974c8e 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 e425af19ec089..1eedaf1467ddc 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 e5b34078a0e45..b413f3b6a42ce 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 6604c9ba6f32b..aa398d1f24e26 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 cbedc7fc8a955..6cad729d26ecb 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 010f3add38639..79a27e1e61846 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 562fa507f3f94..af952e19906d1 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 9c979c300f1c0..8a143fe97b288 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 134a3fe6666ba..2d48d1263cb61 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 fab58b9d3157d..0000000000000
--- 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 ) => {