From f364b684613f89456bb4771d3c6a9b4a026a680e Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Tue, 2 Jan 2024 11:45:25 +0100 Subject: [PATCH] [RNMobile] Auto-scroll upon block insertion (#57273) * Add `useScrollToSection` hook * Add `useScrollToElement` hook * Update logic to retrieve `KeyboardAwareFlatList` ref using `forwardRef` * Replace `useScrollToTextInput` with `useScrollToSection` * Expose `scrollToSection` and `scrollToElement` in Android implementation of `KeyboardAwareFlatList ` * Add `useScrollUponInsertion` hook * Add `useScroll` hook to abstract common logic in `KeyboardAwareFlatList` * Calculate `nativeScrollRef` based on platform in `useScroll` hook * Update unit test to adapt `useScrollToSection` hook * Remove `nativeScrollRef` in favor of calculating the ref in `KeyboardAwareFlatList` platform-specific components * Add unit test for `useScroll` hook * Ensure layout event of `BlockListItemCell` is triggered in tests * Avoid triggering auto-scroll for the same client ID * Update `react-native-editor` changelog --- .../block-draggable/index.native.js | 5 - .../block-draggable/test/helpers.native.js | 15 +- .../use-scroll-when-dragging.native.js | 2 +- .../block-list/block-list-item-cell.native.js | 6 +- .../src/components/block-list/block.native.js | 17 +- .../src/components/block-list/index.native.js | 8 +- .../use-scroll-upon-insertion.native.js | 52 ++++++ .../keyboard-aware-flat-list/index.android.js | 55 +++++- .../keyboard-aware-flat-list/index.ios.js | 156 ++++++++---------- ...ive.js => use-scroll-to-section.native.js} | 52 +++--- .../test/use-scroll.native.js | 71 ++++++++ .../use-scroll-to-element.native.js | 41 +++++ ...ive.js => use-scroll-to-section.native.js} | 49 +++--- .../use-scroll.native.js | 100 +++++++++++ packages/react-native-editor/CHANGELOG.md | 1 + 15 files changed, 462 insertions(+), 168 deletions(-) create mode 100644 packages/block-editor/src/components/block-list/use-scroll-upon-insertion.native.js rename packages/components/src/mobile/keyboard-aware-flat-list/test/{use-scroll-to-text-input.native.js => use-scroll-to-section.native.js} (71%) create mode 100644 packages/components/src/mobile/keyboard-aware-flat-list/test/use-scroll.native.js create mode 100644 packages/components/src/mobile/keyboard-aware-flat-list/use-scroll-to-element.native.js rename packages/components/src/mobile/keyboard-aware-flat-list/{use-scroll-to-text-input.native.js => use-scroll-to-section.native.js} (56%) create mode 100644 packages/components/src/mobile/keyboard-aware-flat-list/use-scroll.native.js diff --git a/packages/block-editor/src/components/block-draggable/index.native.js b/packages/block-editor/src/components/block-draggable/index.native.js index d153843cae6a4..6128bf0cb179a 100644 --- a/packages/block-editor/src/components/block-draggable/index.native.js +++ b/packages/block-editor/src/components/block-draggable/index.native.js @@ -9,7 +9,6 @@ import { import Animated, { runOnJS, runOnUI, - useAnimatedRef, useAnimatedStyle, useSharedValue, withDelay, @@ -39,7 +38,6 @@ import RCTAztecView from '@wordpress/react-native-aztec'; import useScrollWhenDragging from './use-scroll-when-dragging'; import DraggableChip from './draggable-chip'; import { store as blockEditorStore } from '../../store'; -import { useBlockListContext } from '../block-list/block-list-context'; import DroppingInsertionPoint from './dropping-insertion-point'; import useBlockDropZone from '../use-block-drop-zone'; import styles from './style.scss'; @@ -74,13 +72,10 @@ const BlockDraggableWrapper = ( { children, isRTL } ) => { const { selectBlock, startDraggingBlocks, stopDraggingBlocks } = useDispatch( blockEditorStore ); - const { scrollRef } = useBlockListContext(); - const animatedScrollRef = useAnimatedRef(); const { left, right } = useSafeAreaInsets(); const { width } = useSafeAreaFrame(); const safeAreaOffset = left + right; const contentWidth = width - safeAreaOffset; - animatedScrollRef( scrollRef ); const scroll = { offsetY: useSharedValue( 0 ), diff --git a/packages/block-editor/src/components/block-draggable/test/helpers.native.js b/packages/block-editor/src/components/block-draggable/test/helpers.native.js index 1d8e4fbb2afb6..eb0689c13cfa1 100644 --- a/packages/block-editor/src/components/block-draggable/test/helpers.native.js +++ b/packages/block-editor/src/components/block-draggable/test/helpers.native.js @@ -3,11 +3,12 @@ */ import { act, + advanceAnimationByFrames, fireEvent, initializeEditor, + screen, waitForStoreResolvers, within, - advanceAnimationByFrames, } from 'test/helpers'; import { fireGestureHandler } from 'react-native-gesture-handler/jest-utils'; import { State } from 'react-native-gesture-handler'; @@ -52,15 +53,15 @@ const DEFAULT_TOUCH_EVENTS = [ export const initializeWithBlocksLayouts = async ( blocks ) => { const initialHtml = blocks.map( ( block ) => block.html ).join( '\n' ); - const screen = await initializeEditor( { initialHtml } ); - const { getAllByLabelText } = screen; + await initializeEditor( { initialHtml } ); const waitPromises = []; + const blockListItems = screen.getAllByTestId( 'block-list-item-cell' ); + // Check that rendered block list items match expected block count. + expect( blockListItems.length ).toBe( blocks.length ); + blocks.forEach( ( block, index ) => { - const a11yLabel = new RegExp( - `${ block.name } Block\\. Row ${ index + 1 }` - ); - const [ element ] = getAllByLabelText( a11yLabel ); + const element = blockListItems[ index ]; // "onLayout" event will populate the blocks layouts data. fireEvent( element, 'layout', { nativeEvent: { layout: block.layout }, diff --git a/packages/block-editor/src/components/block-draggable/use-scroll-when-dragging.native.js b/packages/block-editor/src/components/block-draggable/use-scroll-when-dragging.native.js index ef5c437206bbd..75e6c04ffa33b 100644 --- a/packages/block-editor/src/components/block-draggable/use-scroll-when-dragging.native.js +++ b/packages/block-editor/src/components/block-draggable/use-scroll-when-dragging.native.js @@ -34,7 +34,7 @@ const VELOCITY_MULTIPLIER = 5000; export default function useScrollWhenDragging() { const { scrollRef } = useBlockListContext(); const animatedScrollRef = useAnimatedRef(); - animatedScrollRef( scrollRef ); + animatedScrollRef( scrollRef?.scrollViewRef ); const { height: windowHeight } = useWindowDimensions(); diff --git a/packages/block-editor/src/components/block-list/block-list-item-cell.native.js b/packages/block-editor/src/components/block-list/block-list-item-cell.native.js index 80aa2589eb64f..e15a3e1063cad 100644 --- a/packages/block-editor/src/components/block-list/block-list-item-cell.native.js +++ b/packages/block-editor/src/components/block-list/block-list-item-cell.native.js @@ -52,7 +52,11 @@ function BlockListItemCell( { children, item: clientId, onLayout } ) { [ clientId, rootClientId, updateBlocksLayouts, onLayout ] ); - return { children }; + return ( + + { children } + + ); } export default BlockListItemCell; diff --git a/packages/block-editor/src/components/block-list/block.native.js b/packages/block-editor/src/components/block-list/block.native.js index 027ed12a7483a..f58251ee66583 100644 --- a/packages/block-editor/src/components/block-list/block.native.js +++ b/packages/block-editor/src/components/block-list/block.native.js @@ -7,7 +7,7 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { useCallback, useMemo, useState } from '@wordpress/element'; +import { useCallback, useMemo, useState, useRef } from '@wordpress/element'; import { GlobalStylesContext, getMergedGlobalStyles, @@ -40,6 +40,7 @@ import BlockInvalidWarning from './block-invalid-warning'; import BlockOutline from './block-outline'; import { store as blockEditorStore } from '../../store'; import { useLayout } from './layout'; +import useScrollUponInsertion from './use-scroll-upon-insertion'; import { useSettings } from '../use-settings'; const EMPTY_ARRAY = []; @@ -103,6 +104,18 @@ function BlockWrapper( { ]; const accessible = ! ( isSelected || isDescendentBlockSelected ); + const ref = useRef(); + const [ isLayoutCalculated, setIsLayoutCalculated ] = useState(); + useScrollUponInsertion( { + clientId, + isSelected, + isLayoutCalculated, + elementRef: ref, + } ); + const onLayout = useCallback( () => { + setIsLayoutCalculated( true ); + }, [] ); + return ( blockInsertionPointIsVisible; @@ -239,7 +239,7 @@ export default function BlockList( { @@ -249,9 +249,7 @@ export default function BlockList( { ? { removeClippedSubviews: false } : {} ) } // Disable clipping on Android to fix focus losing. See https://github.com/wordpress-mobile/gutenberg-mobile/pull/741#issuecomment-472746541 accessibilityLabel="block-list" - innerRef={ ( ref ) => { - scrollViewRef.current = ref; - } } + ref={ scrollRef } extraScrollHeight={ extraScrollHeight } keyboardShouldPersistTaps="always" scrollViewStyle={ { flex: 1 } } diff --git a/packages/block-editor/src/components/block-list/use-scroll-upon-insertion.native.js b/packages/block-editor/src/components/block-list/use-scroll-upon-insertion.native.js new file mode 100644 index 0000000000000..d224c4c777671 --- /dev/null +++ b/packages/block-editor/src/components/block-list/use-scroll-upon-insertion.native.js @@ -0,0 +1,52 @@ +/** + * WordPress dependencies + */ +import { useEffect } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { useBlockListContext } from './block-list-context'; +import { store as blockEditorStore } from '../../store'; + +const useScrollUponInsertion = ( { + clientId, + isSelected, + isLayoutCalculated, + elementRef, +} ) => { + const { scrollRef } = useBlockListContext(); + const wasBlockJustInserted = useSelect( + ( select ) => + !! select( blockEditorStore ).wasBlockJustInserted( + clientId, + 'inserter_menu' + ), + [ clientId ] + ); + useEffect( () => { + const lastScrollTo = scrollRef?.lastScrollTo.current; + const alreadyScrolledTo = lastScrollTo?.clientId === clientId; + if ( + alreadyScrolledTo || + ! isSelected || + ! scrollRef || + ! wasBlockJustInserted || + ! isLayoutCalculated + ) { + return; + } + scrollRef.scrollToElement( elementRef ); + lastScrollTo.clientId = clientId; + }, [ + isSelected, + scrollRef, + wasBlockJustInserted, + elementRef, + isLayoutCalculated, + clientId, + ] ); +}; + +export default useScrollUponInsertion; 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 eccb80f3903e5..e66a0ffc28b54 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 @@ -2,26 +2,71 @@ * External dependencies */ import { FlatList } from 'react-native'; -import Animated, { useAnimatedScrollHandler } from 'react-native-reanimated'; +import Animated from 'react-native-reanimated'; + +/** + * WordPress dependencies + */ +import { + forwardRef, + useCallback, + useImperativeHandle, +} from '@wordpress/element'; /** * Internal dependencies */ +import useScroll from './use-scroll'; import KeyboardAvoidingView from '../keyboard-avoiding-view'; const AnimatedFlatList = Animated.createAnimatedComponent( FlatList ); -export const KeyboardAwareFlatList = ( { innerRef, onScroll, ...props } ) => { - const scrollHandler = useAnimatedScrollHandler( { onScroll } ); +export const KeyboardAwareFlatList = ( { onScroll, ...props }, ref ) => { + const { extraScrollHeight, scrollEnabled, shouldPreventAutomaticScroll } = + props; + + const { + scrollViewRef, + scrollHandler, + scrollToSection, + scrollToElement, + onContentSizeChange, + lastScrollTo, + } = useScroll( { + scrollEnabled, + shouldPreventAutomaticScroll, + extraScrollHeight, + onScroll, + } ); + + const getFlatListRef = useCallback( + ( flatListRef ) => { + // On Android, we get the ref of the associated scroll + // view to the FlatList. + scrollViewRef.current = flatListRef?.getNativeScrollRef(); + }, + [ scrollViewRef ] + ); + + useImperativeHandle( ref, () => { + return { + scrollViewRef: scrollViewRef.current, + scrollToSection, + scrollToElement, + lastScrollTo, + }; + } ); + return ( ); }; -export default KeyboardAwareFlatList; +export default forwardRef( 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 8dc58008a3600..ac2c89188cbf6 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 @@ -2,139 +2,104 @@ * External dependencies */ -import { ScrollView, FlatList, useWindowDimensions } from 'react-native'; -import Animated, { - useAnimatedScrollHandler, - useSharedValue, -} from 'react-native-reanimated'; +import { ScrollView, FlatList } from 'react-native'; +import Animated from 'react-native-reanimated'; /** * WordPress dependencies */ -import { useCallback, useEffect, useRef } from '@wordpress/element'; +import { + useCallback, + useEffect, + forwardRef, + useImperativeHandle, +} from '@wordpress/element'; import { useThrottle } from '@wordpress/compose'; /** * Internal dependencies */ +import useScroll from './use-scroll'; 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 DEFAULT_FONT_SIZE = 16; const AnimatedScrollView = Animated.createAnimatedComponent( ScrollView ); +/** @typedef {import('@wordpress/element').RefObject} RefObject */ /** * 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. + * @param {Object} props Component props. + * @param {number} props.extraScrollHeight Extra scroll height for the content. + * @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. + * @param {RefObject} ref * @return {Component} KeyboardAwareFlatList component. */ -export const KeyboardAwareFlatList = ( { - extraScrollHeight, - innerRef, - onScroll, - scrollEnabled, - scrollViewStyle, - shouldPreventAutomaticScroll, - ...props -} ) => { - const scrollViewRef = useRef(); - const scrollViewMeasurements = useRef(); - const scrollViewYOffset = useSharedValue( -1 ); - - const { height: windowHeight, width: windowWidth } = useWindowDimensions(); - const isLandscape = windowWidth >= windowHeight; - - const [ keyboardOffset ] = useKeyboardOffset( +export const KeyboardAwareFlatList = ( + { + extraScrollHeight, + onScroll, scrollEnabled, - shouldPreventAutomaticScroll - ); - - const [ currentCaretData ] = useTextInputCaretPosition( scrollEnabled ); + scrollViewStyle, + shouldPreventAutomaticScroll, + ...props + }, + ref +) => { + const { + scrollViewRef, + scrollHandler, + keyboardOffset, + scrollToSection, + scrollToElement, + onContentSizeChange, + lastScrollTo, + } = useScroll( { + scrollEnabled, + shouldPreventAutomaticScroll, + extraScrollHeight, + onScroll, + onSizeChange, + } ); const [ getTextInputOffset ] = useTextInputOffset( scrollEnabled, scrollViewRef ); - const [ scrollToTextInputOffset ] = useScrollToTextInput( - extraScrollHeight, - keyboardOffset, - scrollEnabled, - scrollViewMeasurements, - scrollViewRef, - scrollViewYOffset - ); - const onScrollToTextInput = useThrottle( useCallback( async ( caret ) => { + const { caretHeight = DEFAULT_FONT_SIZE } = caret ?? {}; const textInputOffset = await getTextInputOffset( caret ); const hasTextInputOffset = textInputOffset !== null; if ( hasTextInputOffset ) { - scrollToTextInputOffset( caret, textInputOffset ); + scrollToSection( textInputOffset, caretHeight ); } }, - [ getTextInputOffset, scrollToTextInputOffset ] + [ getTextInputOffset, scrollToSection ] ), 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; - scrollViewYOffset.value = contentOffset.y; - onScroll( event ); - }, - } ); - - const measureScrollView = useCallback( () => { - if ( scrollViewRef.current ) { - const scrollRef = scrollViewRef.current.getNativeScrollRef(); + const [ currentCaretData ] = useTextInputCaretPosition( scrollEnabled ); - scrollRef.measureInWindow( ( _x, y, width, height ) => { - scrollViewMeasurements.current = { y, width, height }; - } ); - } - }, [] ); + const onSizeChange = useCallback( + () => onScrollToTextInput( currentCaretData ), + [ currentCaretData, onScrollToTextInput ] + ); - const onContentSizeChange = useCallback( () => { + useEffect( () => { 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; - innerRef( ref ); - }, - [ innerRef ] - ); + }, [ currentCaretData, onScrollToTextInput ] ); // Adds content insets when the keyboard is opened to have // extra padding at the bottom. @@ -142,6 +107,15 @@ export const KeyboardAwareFlatList = ( { const style = [ { flex: 1 }, scrollViewStyle ]; + useImperativeHandle( ref, () => { + return { + scrollViewRef: scrollViewRef.current, + scrollToSection, + scrollToElement, + lastScrollTo, + }; + } ); + return ( { - it( 'scrolls up to the current TextInput offset', () => { +describe( 'useScrollToSection', () => { + it( 'scrolls up to the section', () => { // Arrange - const currentCaretData = { caretHeight: 10 }; + const sectionY = 50; + const sectionHeight = 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( + useScrollToSection( extraScrollHeight, keyboardOffset, scrollEnabled, @@ -33,28 +34,29 @@ describe( 'useScrollToTextInput', () => { ); // Act - result.current[ 0 ]( currentCaretData, textInputOffset ); + result.current[ 0 ]( sectionY, sectionHeight ); // Assert expect( scrollViewRef.current.scrollTo ).toHaveBeenCalledWith( { - y: textInputOffset, + y: sectionY, animated: true, } ); } ); - it( 'scrolls down to the current TextInput offset', () => { + it( 'scrolls down to the section', () => { // Arrange - const currentCaretData = { caretHeight: 10 }; + const sectionY = 750; + const sectionHeight = 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( + useScrollToSection( extraScrollHeight, keyboardOffset, scrollEnabled, @@ -65,15 +67,13 @@ describe( 'useScrollToTextInput', () => { ); // Act - result.current[ 0 ]( currentCaretData, textInputOffset ); + result.current[ 0 ]( sectionY, sectionHeight ); // Assert const expectedYOffset = - textInputOffset - + sectionY - ( scrollViewMeasurements.current.height - - ( keyboardOffset + - extraScrollHeight + - currentCaretData.caretHeight ) ); + ( keyboardOffset + extraScrollHeight + sectionHeight ) ); expect( scrollViewRef.current.scrollTo ).toHaveBeenCalledWith( { y: expectedYOffset, animated: true, @@ -82,17 +82,18 @@ describe( 'useScrollToTextInput', () => { it( 'does not scroll when the ScrollView ref is not available', () => { // Arrange - const currentCaretData = { caretHeight: 10 }; + const sectionY = 50; + const sectionHeight = 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( + useScrollToSection( extraScrollHeight, keyboardOffset, scrollEnabled, @@ -103,7 +104,7 @@ describe( 'useScrollToTextInput', () => { ); // Act - result.current[ 0 ]( currentCaretData, textInputOffset ); + result.current[ 0 ]( sectionY, sectionHeight ); // Assert expect( scrollViewRef.current ).toBeNull(); @@ -111,17 +112,18 @@ describe( 'useScrollToTextInput', () => { it( 'does not scroll when the scroll is not enabled', () => { // Arrange - const currentCaretData = { caretHeight: 10 }; + const sectionY = 50; + const sectionHeight = 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( + useScrollToSection( extraScrollHeight, keyboardOffset, scrollEnabled, @@ -132,7 +134,7 @@ describe( 'useScrollToTextInput', () => { ); // Act - result.current[ 0 ]( currentCaretData, textInputOffset ); + result.current[ 0 ]( sectionY, sectionHeight ); // Assert expect( scrollViewRef.current.scrollTo ).not.toHaveBeenCalled(); diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/test/use-scroll.native.js b/packages/components/src/mobile/keyboard-aware-flat-list/test/use-scroll.native.js new file mode 100644 index 0000000000000..ab34bb59b3a4e --- /dev/null +++ b/packages/components/src/mobile/keyboard-aware-flat-list/test/use-scroll.native.js @@ -0,0 +1,71 @@ +/** + * External dependencies + */ + +import { renderHook } from 'test/helpers'; + +/** + * Internal dependencies + */ +import useScroll from '../use-scroll'; + +// Mock Reanimated with default mock +jest.mock( 'react-native-reanimated', () => ( { + ...require( 'react-native-reanimated/mock' ), + useAnimatedScrollHandler: jest.fn( ( args ) => args ), +} ) ); + +describe( 'useScroll', () => { + it( 'scrolls using current scroll position', () => { + const sectionY = 50; + const sectionHeight = 10; + const scrollViewMeasurements = { x: 0, y: 0, width: 0, height: 600 }; + const extraScrollHeight = 50; + const scrollEnabled = true; + const shouldPreventAutomaticScroll = false; + + const scrollTo = jest.fn(); + const measureInWindow = jest.fn( ( callback ) => + callback( ...Object.values( scrollViewMeasurements ) ) + ); + const scrollRef = { scrollTo, measureInWindow }; + const onScroll = jest.fn(); + const onSizeChange = jest.fn(); + + const { result } = renderHook( () => + useScroll( { + scrollEnabled, + shouldPreventAutomaticScroll, + onScroll, + onSizeChange, + extraScrollHeight, + } ) + ); + const { + scrollViewRef, + onContentSizeChange, + scrollHandler, + scrollToSection, + } = result.current; + + // Assign ref + scrollViewRef.current = scrollRef; + + // Check content size changes + onContentSizeChange(); + expect( measureInWindow ).toHaveBeenCalled(); + expect( onSizeChange ).toHaveBeenCalled(); + + // Set up initial scroll offset + scrollHandler.onScroll( { contentOffset: { y: 150 } } ); + + // Scroll to section + scrollToSection( sectionY, sectionHeight ); + + // Assert + expect( scrollTo ).toHaveBeenCalledWith( { + y: sectionY, + animated: true, + } ); + } ); +} ); diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/use-scroll-to-element.native.js b/packages/components/src/mobile/keyboard-aware-flat-list/use-scroll-to-element.native.js new file mode 100644 index 0000000000000..048b6815cdec2 --- /dev/null +++ b/packages/components/src/mobile/keyboard-aware-flat-list/use-scroll-to-element.native.js @@ -0,0 +1,41 @@ +/** + * WordPress dependencies + */ +import { useCallback } from '@wordpress/element'; + +/** @typedef {import('@wordpress/element').RefObject} RefObject */ +/** + * Hook to scroll to a specified element by taking into account the Keyboard + * and the Header. + * + * @param {RefObject} scrollViewRef Scroll view reference. + * @param {Function} scrollToSection Function to scroll. + * @return {Function[]} Function to scroll to an element. + */ +export default function useScrollToElement( scrollViewRef, scrollToSection ) { + /** + * Function to scroll to an element. + * + * @param {RefObject} elementRef Ref of the element. + */ + const scrollToElement = useCallback( + ( elementRef ) => { + if ( ! scrollViewRef.current || ! elementRef ) { + return; + } + + elementRef.current.measureLayout( + scrollViewRef.current, + ( _x, y, _width, height ) => { + if ( height || y ) { + scrollToSection( Math.round( y ), height ); + } + }, + () => {} + ); + }, + [ scrollViewRef, scrollToSection ] + ); + + return [ scrollToElement ]; +} 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-section.native.js similarity index 56% rename from packages/components/src/mobile/keyboard-aware-flat-list/use-scroll-to-text-input.native.js rename to packages/components/src/mobile/keyboard-aware-flat-list/use-scroll-to-section.native.js index 3bdaba837a60b..e889e09972cf4 100644 --- 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-section.native.js @@ -8,24 +8,21 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; */ 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. + * Hook to scroll to a specified section by 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 {RefObject} scrollViewRef Scroll view reference. * @param {SharedValue} scrollViewYOffset Current offset position of the ScrollView. - * @return {Function[]} Function to scroll to the current TextInput's offset. + * @return {Function[]} Function to scroll to a section. */ -export default function useScrollToTextInput( +export default function useScrollToSection( extraScrollHeight, keyboardOffset, scrollEnabled, @@ -37,33 +34,31 @@ export default function useScrollToTextInput( const insets = top + bottom; /** - * Function to scroll to the current TextInput's offset. + * Function to scroll to a section. * - * @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. + * @param {Object} section Section data to scroll to. + * @param {number} section.y Y-coordinate of of the section. + * @param {number} section.height Height of the section. */ - const scrollToTextInputOffset = useCallback( - ( caret, textInputOffset ) => { - const { caretHeight = DEFAULT_FONT_SIZE } = caret ?? {}; - + const scrollToSection = useCallback( + ( sectionY, sectionHeight ) => { if ( ! scrollViewRef.current || ! scrollEnabled || - ! scrollViewMeasurements.current + ! scrollViewMeasurements ) { return; } + const currentScrollViewYOffset = Math.max( 0, scrollViewYOffset.value ); - // Scroll up. - if ( textInputOffset < currentScrollViewYOffset ) { + // Scroll to the top of the section. + if ( sectionY < currentScrollViewYOffset ) { scrollViewRef.current.scrollTo( { - y: textInputOffset, + y: sectionY, animated: true, } ); return; @@ -72,7 +67,7 @@ export default function useScrollToTextInput( const availableScreenSpace = Math.abs( Math.floor( scrollViewMeasurements.current.height - - ( keyboardOffset + extraScrollHeight + caretHeight ) + ( keyboardOffset + extraScrollHeight + sectionHeight ) ) ); const maxOffset = Math.floor( @@ -80,12 +75,12 @@ export default function useScrollToTextInput( ); const isAtTheTop = - textInputOffset < scrollViewMeasurements.current.y + insets; + sectionY < scrollViewMeasurements.current.y + insets; - // Scroll down. - if ( textInputOffset > maxOffset && ! isAtTheTop ) { + // Scroll to the bottom of the section. + if ( sectionY > maxOffset && ! isAtTheTop ) { scrollViewRef.current.scrollTo( { - y: textInputOffset - availableScreenSpace, + y: sectionY - availableScreenSpace, animated: true, } ); } @@ -101,5 +96,5 @@ export default function useScrollToTextInput( ] ); - return [ scrollToTextInputOffset ]; + return [ scrollToSection ]; } diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/use-scroll.native.js b/packages/components/src/mobile/keyboard-aware-flat-list/use-scroll.native.js new file mode 100644 index 0000000000000..faba873a9903b --- /dev/null +++ b/packages/components/src/mobile/keyboard-aware-flat-list/use-scroll.native.js @@ -0,0 +1,100 @@ +/** + * External dependencies + */ + +import { useWindowDimensions } from 'react-native'; +import { + useAnimatedScrollHandler, + useSharedValue, +} from 'react-native-reanimated'; + +/** + * WordPress dependencies + */ +import { useCallback, useEffect, useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import useKeyboardOffset from './use-keyboard-offset'; +import useScrollToSection from './use-scroll-to-section'; +import useScrollToElement from './use-scroll-to-element'; + +export default function useScroll( { + scrollEnabled, + shouldPreventAutomaticScroll, + onScroll, + onSizeChange, + extraScrollHeight, +} ) { + const scrollViewRef = useRef(); + const scrollViewMeasurements = useRef(); + const scrollViewYOffset = useSharedValue( -1 ); + const lastScrollTo = useRef( { + clientId: null, + } ); + + const { height: windowHeight, width: windowWidth } = useWindowDimensions(); + const isLandscape = windowWidth >= windowHeight; + + const [ keyboardOffset ] = useKeyboardOffset( + scrollEnabled, + shouldPreventAutomaticScroll + ); + + const scrollHandler = useAnimatedScrollHandler( { + onScroll: ( event ) => { + const { contentOffset } = event; + scrollViewYOffset.value = contentOffset.y; + onScroll( event ); + }, + } ); + + // When the orientation changes, the ScrollView measurements + // need to be re-calculated. + useEffect( () => { + scrollViewMeasurements.current = null; + }, [ isLandscape ] ); + + const [ scrollToSection ] = useScrollToSection( + extraScrollHeight, + keyboardOffset, + scrollEnabled, + scrollViewMeasurements, + scrollViewRef, + scrollViewYOffset + ); + const [ scrollToElement ] = useScrollToElement( + scrollViewRef, + scrollToSection + ); + + const measureScrollView = useCallback( () => { + if ( scrollViewRef.current ) { + scrollViewRef.current.measureInWindow( ( _x, y, width, height ) => { + scrollViewMeasurements.current = { y, width, height }; + } ); + } + }, [ scrollViewRef ] ); + + const onContentSizeChange = useCallback( () => { + if ( onSizeChange ) { + onSizeChange(); + } + + // Sets the first values when the content size changes. + if ( ! scrollViewMeasurements.current ) { + measureScrollView(); + } + }, [ measureScrollView, onSizeChange ] ); + + return { + scrollViewRef, + scrollHandler, + keyboardOffset, + scrollToSection, + scrollToElement, + onContentSizeChange, + lastScrollTo, + }; +} diff --git a/packages/react-native-editor/CHANGELOG.md b/packages/react-native-editor/CHANGELOG.md index fc12b7df655cd..7df08c0e484b0 100644 --- a/packages/react-native-editor/CHANGELOG.md +++ b/packages/react-native-editor/CHANGELOG.md @@ -17,6 +17,7 @@ For each user feature we should also add a importance categorization label to i - [**] Fix crash when sharing unsupported media types on Android [#56791] - [**] Fix regressions with wrapper props and font size customization [#56985] - [***] Avoid keyboard dismiss when interacting with text blocks [#57070] +- [**] Auto-scroll upon block insertion [#57273] ## 1.109.3 - [**] Fix duplicate/unresponsive options in font size settings. [#56985]