diff --git a/src/libs/UpdateMultilineInputRange/index.ios.js b/src/libs/UpdateMultilineInputRange/index.ios.js new file mode 100644 index 000000000000..85ed529a33bc --- /dev/null +++ b/src/libs/UpdateMultilineInputRange/index.ios.js @@ -0,0 +1,23 @@ +/** + * Place the cursor at the end of the value (if there is a value in the input). + * + * When a multiline input contains a text value that goes beyond the scroll height, the cursor will be placed + * at the end of the text value, and automatically scroll the input field to this position after the field gains + * focus. This provides a better user experience in cases where the text in the field has to be edited. The auto- + * scroll behaviour works on all platforms except iOS native. + * See https://github.com/Expensify/App/issues/20836 for more details. + * + * @param {Object} input the input element + */ +export default function updateMultilineInputRange(input) { + if (!input) { + return; + } + + /* + * Adding this iOS specific patch because of the scroll issue in native iOS + * Issue: does not scroll multiline input when text exceeds the maximum number of lines + * For more details: https://github.com/Expensify/App/pull/27702#issuecomment-1728651132 + */ + input.focus(); +} diff --git a/src/libs/focusAndUpdateMultilineInputRange.js b/src/libs/UpdateMultilineInputRange/index.js similarity index 80% rename from src/libs/focusAndUpdateMultilineInputRange.js rename to src/libs/UpdateMultilineInputRange/index.js index b5e438899d3d..179d30dc611d 100644 --- a/src/libs/focusAndUpdateMultilineInputRange.js +++ b/src/libs/UpdateMultilineInputRange/index.js @@ -1,5 +1,5 @@ /** - * Focus a multiline text input and place the cursor at the end of the value (if there is a value in the input). + * Place the cursor at the end of the value (if there is a value in the input). * * When a multiline input contains a text value that goes beyond the scroll height, the cursor will be placed * at the end of the text value, and automatically scroll the input field to this position after the field gains @@ -9,12 +9,11 @@ * * @param {Object} input the input element */ -export default function focusAndUpdateMultilineInputRange(input) { +export default function updateMultilineInputRange(input) { if (!input) { return; } - input.focus(); if (input.value && input.setSelectionRange) { const length = input.value.length; input.setSelectionRange(length, length); diff --git a/src/pages/EditRequestDescriptionPage.js b/src/pages/EditRequestDescriptionPage.js index 0b4858b0bacb..0c0fcad7f60b 100644 --- a/src/pages/EditRequestDescriptionPage.js +++ b/src/pages/EditRequestDescriptionPage.js @@ -1,6 +1,7 @@ -import React, {useRef} from 'react'; +import React, {useRef, useCallback} from 'react'; import {View} from 'react-native'; import PropTypes from 'prop-types'; +import {useFocusEffect} from '@react-navigation/native'; import TextInput from '../components/TextInput'; import ScreenWrapper from '../components/ScreenWrapper'; import HeaderWithBackButton from '../components/HeaderWithBackButton'; @@ -10,6 +11,7 @@ import styles from '../styles/styles'; import CONST from '../CONST'; import useLocalize from '../hooks/useLocalize'; import * as Browser from '../libs/Browser'; +import updateMultilineInputRange from '../libs/UpdateMultilineInputRange'; const propTypes = { /** Transaction default description value */ @@ -22,11 +24,28 @@ const propTypes = { function EditRequestDescriptionPage({defaultDescription, onSubmit}) { const {translate} = useLocalize(); const descriptionInputRef = useRef(null); + const focusTimeoutRef = useRef(null); + + useFocusEffect( + useCallback(() => { + focusTimeoutRef.current = setTimeout(() => { + if (descriptionInputRef.current) { + descriptionInputRef.current.focus(); + } + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; + }, CONST.ANIMATED_TRANSITION); + }, []), + ); + return ( descriptionInputRef.current && descriptionInputRef.current.focus()} testID={EditRequestDescriptionPage.displayName} > @@ -46,7 +65,13 @@ function EditRequestDescriptionPage({defaultDescription, onSubmit}) { label={translate('moneyRequestConfirmationList.whatsItFor')} accessibilityLabel={translate('moneyRequestConfirmationList.whatsItFor')} accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} - ref={(e) => (descriptionInputRef.current = e)} + ref={(el) => { + if (!el) { + return; + } + descriptionInputRef.current = el; + updateMultilineInputRange(descriptionInputRef.current); + }} autoGrowHeight containerStyles={[styles.autoGrowHeightMultilineInput]} textAlignVertical="top" diff --git a/src/pages/PrivateNotes/PrivateNotesEditPage.js b/src/pages/PrivateNotes/PrivateNotesEditPage.js index 5f12d8087a93..9d837c1c6be6 100644 --- a/src/pages/PrivateNotes/PrivateNotesEditPage.js +++ b/src/pages/PrivateNotes/PrivateNotesEditPage.js @@ -1,7 +1,8 @@ -import React, {useState, useRef} from 'react'; +import React, {useState, useRef, useCallback} from 'react'; import PropTypes from 'prop-types'; import {View, Keyboard} from 'react-native'; import {withOnyx} from 'react-native-onyx'; +import {useFocusEffect} from '@react-navigation/native'; import lodashGet from 'lodash/get'; import Str from 'expensify-common/lib/str'; import ExpensiMark from 'expensify-common/lib/ExpensiMark'; @@ -23,7 +24,7 @@ import personalDetailsPropType from '../personalDetailsPropType'; import * as Report from '../../libs/actions/Report'; import useLocalize from '../../hooks/useLocalize'; import OfflineWithFeedback from '../../components/OfflineWithFeedback'; -import focusAndUpdateMultilineInputRange from '../../libs/focusAndUpdateMultilineInputRange'; +import updateMultilineInputRange from '../../libs/UpdateMultilineInputRange'; import ROUTES from '../../ROUTES'; const propTypes = { @@ -66,6 +67,23 @@ function PrivateNotesEditPage({route, personalDetailsList, session, report}) { // To focus on the input field when the page loads const privateNotesInput = useRef(null); + const focusTimeoutRef = useRef(null); + + useFocusEffect( + useCallback(() => { + focusTimeoutRef.current = setTimeout(() => { + if (privateNotesInput.current) { + privateNotesInput.current.focus(); + } + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; + }, CONST.ANIMATED_TRANSITION); + }, []), + ); const savePrivateNote = () => { const editedNote = parser.replace(privateNote); @@ -79,7 +97,6 @@ function PrivateNotesEditPage({route, personalDetailsList, session, report}) { return ( focusAndUpdateMultilineInputRange(privateNotesInput.current)} testID={PrivateNotesEditPage.displayName} > setPrivateNote(text)} - ref={(el) => (privateNotesInput.current = el)} + ref={(el) => { + if (!el) { + return; + } + privateNotesInput.current = el; + updateMultilineInputRange(privateNotesInput.current); + }} /> diff --git a/src/pages/ReportWelcomeMessagePage.js b/src/pages/ReportWelcomeMessagePage.js index a9c09d870574..7e9ed440d1e8 100644 --- a/src/pages/ReportWelcomeMessagePage.js +++ b/src/pages/ReportWelcomeMessagePage.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import {View} from 'react-native'; import ExpensiMark from 'expensify-common/lib/ExpensiMark'; +import {useFocusEffect} from '@react-navigation/native'; import compose from '../libs/compose'; import withLocalize, {withLocalizePropTypes} from '../components/withLocalize'; import ScreenWrapper from '../components/ScreenWrapper'; @@ -19,7 +20,7 @@ import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundVi import Form from '../components/Form'; import * as PolicyUtils from '../libs/PolicyUtils'; import {policyPropTypes, policyDefaultProps} from './workspace/withPolicy'; -import focusAndUpdateMultilineInputRange from '../libs/focusAndUpdateMultilineInputRange'; +import updateMultilineInputRange from '../libs/UpdateMultilineInputRange'; const propTypes = { ...withLocalizePropTypes, @@ -45,6 +46,7 @@ function ReportWelcomeMessagePage(props) { const parser = new ExpensiMark(); const [welcomeMessage, setWelcomeMessage] = useState(parser.htmlToMarkdown(props.report.welcomeMessage)); const welcomeMessageInputRef = useRef(null); + const focusTimeoutRef = useRef(null); const handleWelcomeMessageChange = useCallback((value) => { setWelcomeMessage(value); @@ -54,56 +56,58 @@ function ReportWelcomeMessagePage(props) { Report.updateWelcomeMessage(props.report.reportID, props.report.welcomeMessage, welcomeMessage.trim()); }, [props.report.reportID, props.report.welcomeMessage, welcomeMessage]); - return ( - { - if (!welcomeMessageInputRef.current) { - return; + useFocusEffect( + useCallback(() => { + focusTimeoutRef.current = setTimeout(() => { + if (welcomeMessageInputRef.current) { + welcomeMessageInputRef.current.focus(); } - focusAndUpdateMultilineInputRange(welcomeMessageInputRef.current); - }} - testID={ReportWelcomeMessagePage.displayName} - > - {({didScreenTransitionEnd}) => ( - - -
- {props.translate('welcomeMessagePage.explainerText')} - - { - // Before updating the DOM, React sets the affected ref.current values to null. After updating the DOM, React immediately sets them to the corresponding DOM nodes - // to avoid focus multiple time, we should early return if el is null. - if (!el) { - return; - } - if (!welcomeMessageInputRef.current && didScreenTransitionEnd) { - focusAndUpdateMultilineInputRange(el); - } - welcomeMessageInputRef.current = el; - }} - value={welcomeMessage} - onChangeText={handleWelcomeMessageChange} - autoCapitalize="none" - textAlignVertical="top" - containerStyles={[styles.autoGrowHeightMultilineInput]} - /> - -
-
- )} + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; + }, CONST.ANIMATED_TRANSITION); + }, []), + ); + + return ( + + + +
+ {props.translate('welcomeMessagePage.explainerText')} + + { + if (!el) { + return; + } + welcomeMessageInputRef.current = el; + updateMultilineInputRange(welcomeMessageInputRef.current); + }} + value={welcomeMessage} + onChangeText={handleWelcomeMessageChange} + autoCapitalize="none" + textAlignVertical="top" + containerStyles={[styles.autoGrowHeightMultilineInput]} + /> + +
+
); } diff --git a/src/pages/iou/MoneyRequestDescriptionPage.js b/src/pages/iou/MoneyRequestDescriptionPage.js index ab206218e103..61f688f5d4bd 100644 --- a/src/pages/iou/MoneyRequestDescriptionPage.js +++ b/src/pages/iou/MoneyRequestDescriptionPage.js @@ -1,6 +1,7 @@ -import React, {useEffect, useRef} from 'react'; +import React, {useEffect, useRef, useCallback} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; +import {useFocusEffect} from '@react-navigation/native'; import PropTypes from 'prop-types'; import _ from 'underscore'; import lodashGet from 'lodash/get'; @@ -17,7 +18,7 @@ import * as IOU from '../../libs/actions/IOU'; import * as MoneyRequestUtils from '../../libs/MoneyRequestUtils'; import CONST from '../../CONST'; import useLocalize from '../../hooks/useLocalize'; -import focusAndUpdateMultilineInputRange from '../../libs/focusAndUpdateMultilineInputRange'; +import updateMultilineInputRange from '../../libs/UpdateMultilineInputRange'; import * as Browser from '../../libs/Browser'; const propTypes = { @@ -54,10 +55,27 @@ const defaultProps = { function MoneyRequestDescriptionPage({iou, route, selectedTab}) { const {translate} = useLocalize(); const inputRef = useRef(null); + const focusTimeoutRef = useRef(null); const iouType = lodashGet(route, 'params.iouType', ''); const reportID = lodashGet(route, 'params.reportID', ''); const isDistanceRequest = MoneyRequestUtils.isDistanceRequest(iouType, selectedTab); + useFocusEffect( + useCallback(() => { + focusTimeoutRef.current = setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; + }, CONST.ANIMATED_TRANSITION); + }, []), + ); + useEffect(() => { const moneyRequestId = `${iouType}${reportID}`; const shouldReset = iou.id !== moneyRequestId; @@ -89,36 +107,43 @@ function MoneyRequestDescriptionPage({iou, route, selectedTab}) { focusAndUpdateMultilineInputRange(inputRef.current)} testID={MoneyRequestDescriptionPage.displayName} > - navigateBack()} - /> -
updateComment(value)} - submitButtonText={translate('common.save')} - enabledWhenOffline - > - - (inputRef.current = el)} - autoGrowHeight - containerStyles={[styles.autoGrowHeightMultilineInput]} - textAlignVertical="top" - submitOnEnter={!Browser.isMobile()} - /> - -
+ <> + navigateBack()} + /> +
updateComment(value)} + submitButtonText={translate('common.save')} + enabledWhenOffline + > + + { + if (!el) { + return; + } + inputRef.current = el; + updateMultilineInputRange(inputRef.current); + }} + autoGrowHeight + containerStyles={[styles.autoGrowHeightMultilineInput]} + textAlignVertical="top" + submitOnEnter={!Browser.isMobile()} + /> + +
+
); } diff --git a/src/pages/tasks/NewTaskDescriptionPage.js b/src/pages/tasks/NewTaskDescriptionPage.js index ac38f3aa19a2..44fd4346538d 100644 --- a/src/pages/tasks/NewTaskDescriptionPage.js +++ b/src/pages/tasks/NewTaskDescriptionPage.js @@ -1,6 +1,7 @@ -import React, {useRef} from 'react'; +import React, {useRef, useCallback} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; +import {useFocusEffect} from '@react-navigation/native'; import PropTypes from 'prop-types'; import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; import compose from '../../libs/compose'; @@ -14,7 +15,7 @@ import TextInput from '../../components/TextInput'; import Permissions from '../../libs/Permissions'; import ROUTES from '../../ROUTES'; import * as Task from '../../libs/actions/Task'; -import focusAndUpdateMultilineInputRange from '../../libs/focusAndUpdateMultilineInputRange'; +import updateMultilineInputRange from '../../libs/UpdateMultilineInputRange'; import CONST from '../../CONST'; import * as Browser from '../../libs/Browser'; @@ -40,9 +41,26 @@ const defaultProps = { function NewTaskDescriptionPage(props) { const inputRef = useRef(null); - + const focusTimeoutRef = useRef(null); // On submit, we want to call the assignTask function and wait to validate // the response + + useFocusEffect( + useCallback(() => { + focusTimeoutRef.current = setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; + }, CONST.ANIMATED_TRANSITION); + }, []), + ); + const onSubmit = (values) => { Task.setDescriptionValue(values.taskDescription); Navigation.goBack(ROUTES.NEW_TASK); @@ -55,37 +73,44 @@ function NewTaskDescriptionPage(props) { return ( focusAndUpdateMultilineInputRange(inputRef.current)} shouldEnableMaxHeight testID={NewTaskDescriptionPage.displayName} > - Task.dismissModalAndClearOutTaskInfo()} - onBackButtonPress={() => Navigation.goBack(ROUTES.NEW_TASK)} - /> -
onSubmit(values)} - enabledWhenOffline - > - - (inputRef.current = el)} - autoGrowHeight - submitOnEnter={!Browser.isMobile()} - containerStyles={[styles.autoGrowHeightMultilineInput]} - textAlignVertical="top" - /> - -
+ <> + Task.dismissModalAndClearOutTaskInfo()} + onBackButtonPress={() => Navigation.goBack(ROUTES.NEW_TASK)} + /> +
onSubmit(values)} + enabledWhenOffline + > + + { + if (!el) { + return; + } + inputRef.current = el; + updateMultilineInputRange(inputRef.current); + }} + autoGrowHeight + submitOnEnter={!Browser.isMobile()} + containerStyles={[styles.autoGrowHeightMultilineInput]} + textAlignVertical="top" + /> + +
+
); } diff --git a/src/pages/tasks/TaskDescriptionPage.js b/src/pages/tasks/TaskDescriptionPage.js index d5e826a45032..61d33f781892 100644 --- a/src/pages/tasks/TaskDescriptionPage.js +++ b/src/pages/tasks/TaskDescriptionPage.js @@ -1,6 +1,7 @@ import React, {useCallback, useRef} from 'react'; import PropTypes from 'prop-types'; import {View} from 'react-native'; +import {useFocusEffect} from '@react-navigation/native'; import {withOnyx} from 'react-native-onyx'; import ScreenWrapper from '../../components/ScreenWrapper'; import HeaderWithBackButton from '../../components/HeaderWithBackButton'; @@ -14,7 +15,7 @@ import compose from '../../libs/compose'; import * as Task from '../../libs/actions/Task'; import * as ReportUtils from '../../libs/ReportUtils'; import CONST from '../../CONST'; -import focusAndUpdateMultilineInputRange from '../../libs/focusAndUpdateMultilineInputRange'; +import updateMultilineInputRange from '../../libs/UpdateMultilineInputRange'; import * as Browser from '../../libs/Browser'; import Navigation from '../../libs/Navigation/Navigation'; import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView'; @@ -57,56 +58,67 @@ function TaskDescriptionPage(props) { }); } const inputRef = useRef(null); + const focusTimeoutRef = useRef(null); const isOpen = ReportUtils.isOpenTaskReport(props.report); const canModifyTask = Task.canModifyTask(props.report, props.currentUserPersonalDetails.accountID); const isTaskNonEditable = ReportUtils.isTaskReport(props.report) && (!canModifyTask || !isOpen); + useFocusEffect( + useCallback(() => { + focusTimeoutRef.current = setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; + }, CONST.ANIMATED_TRANSITION); + }, []), + ); + return ( focusAndUpdateMultilineInputRange(inputRef.current)} shouldEnableMaxHeight testID={TaskDescriptionPage.displayName} > - {({didScreenTransitionEnd}) => ( - - -
- - { - // if we wrap the page with FullPageNotFoundView we need to explicitly handle focusing on text input - if (!el) { - return; - } - if (!inputRef.current && didScreenTransitionEnd) { - focusAndUpdateMultilineInputRange(el); - } - inputRef.current = el; - }} - autoGrowHeight - submitOnEnter={!Browser.isMobile()} - containerStyles={[styles.autoGrowHeightMultilineInput]} - textAlignVertical="top" - /> - -
-
- )} + + +
+ + { + if (!el) { + return; + } + inputRef.current = el; + updateMultilineInputRange(inputRef.current); + }} + autoGrowHeight + submitOnEnter={!Browser.isMobile()} + containerStyles={[styles.autoGrowHeightMultilineInput]} + textAlignVertical="top" + /> + +
+
); }