diff --git a/packages/stack/src/types.tsx b/packages/stack/src/types.tsx index ad55079e..8e0bf4f8 100644 --- a/packages/stack/src/types.tsx +++ b/packages/stack/src/types.tsx @@ -292,18 +292,19 @@ export type StackNavigationOptions = StackHeaderOptions & * Defaults to `false` for the last screen for modals, otherwise `true`. */ detachPreviousScreen?: boolean; + /** + * If `false`, the keyboard will NOT automatically dismiss when navigating to a new screen from this screen. + * Defaults to `true`. + */ + keyboardHandlingEnabled?: boolean; }; export type StackNavigationConfig = { - /** - * If `false`, the keyboard will NOT automatically dismiss when navigating to a new screen. - * Defaults to `true`. - */ - keyboardHandlingEnabled?: boolean; /** * Whether inactive screens should be detached from the view hierarchy to save memory. - * Make sure to call `enableScreens` from `react-native-screens` to make it work. - * Defaults to `true` on Android, depends on the version of `react-native-screens` on iOS. + * This will have no effect if you disable `react-native-screens`. + * + * Defaults to `true`. */ detachInactiveScreens?: boolean; }; @@ -335,7 +336,7 @@ export type StackCardInterpolationProps = { progress: Animated.AnimatedInterpolation; }; /** - * Values for the current screen the screen after this one in the stack. + * Values for the screen after this one in the stack. * This can be `undefined` in case the screen animating is the last one. */ next?: { @@ -414,7 +415,7 @@ export type StackHeaderInterpolationProps = { progress: Animated.AnimatedInterpolation; }; /** - * Values for the current screen the screen after this one in the stack. + * Values for the screen after this one in the stack. * This can be `undefined` in case the screen animating is the last one. */ next?: { diff --git a/packages/stack/src/utils/useKeyboardManager.tsx b/packages/stack/src/utils/useKeyboardManager.tsx new file mode 100644 index 00000000..33b2d452 --- /dev/null +++ b/packages/stack/src/utils/useKeyboardManager.tsx @@ -0,0 +1,105 @@ +import * as React from 'react'; +import { TextInput, Keyboard, HostComponent } from 'react-native'; + +type InputRef = React.ElementRef> | undefined; + +export default function useKeyboardManager(isEnabled: () => boolean) { + // Numeric id of the previously focused text input + // When a gesture didn't change the tab, we can restore the focused input with this + const previouslyFocusedTextInputRef = React.useRef(undefined); + const startTimestampRef = React.useRef(0); + const keyboardTimeoutRef = React.useRef(); + + const clearKeyboardTimeout = React.useCallback(() => { + if (keyboardTimeoutRef.current !== undefined) { + clearTimeout(keyboardTimeoutRef.current); + keyboardTimeoutRef.current = undefined; + } + }, []); + + const onPageChangeStart = React.useCallback(() => { + if (!isEnabled()) { + return; + } + + clearKeyboardTimeout(); + + const input: InputRef = TextInput.State.currentlyFocusedInput(); + + // When a page change begins, blur the currently focused input + input?.blur(); + + // Store the id of this input so we can refocus it if change was cancelled + previouslyFocusedTextInputRef.current = input; + + // Store timestamp for touch start + startTimestampRef.current = Date.now(); + }, [clearKeyboardTimeout, isEnabled]); + + const onPageChangeConfirm = React.useCallback( + (force: boolean) => { + if (!isEnabled()) { + return; + } + + clearKeyboardTimeout(); + + if (force) { + // Always dismiss input, even if we don't have a ref to it + // We might not have the ref if onPageChangeStart was never called + // This can happen if page change was not from a gesture + Keyboard.dismiss(); + } else { + const input = previouslyFocusedTextInputRef.current; + + // Dismiss the keyboard only if an input was a focused before + // This makes sure we don't dismiss input on going back and focusing an input + input?.blur(); + } + + // Cleanup the ID on successful page change + previouslyFocusedTextInputRef.current = undefined; + }, + [clearKeyboardTimeout, isEnabled] + ); + + const onPageChangeCancel = React.useCallback(() => { + if (!isEnabled()) { + return; + } + + clearKeyboardTimeout(); + + // The page didn't change, we should restore the focus of text input + const input = previouslyFocusedTextInputRef.current; + + if (input) { + // If the interaction was super short we should make sure keyboard won't hide again. + + // Too fast input refocus will result only in keyboard flashing on screen and hiding right away. + // During first ~100ms keyboard will be dismissed no matter what, + // so we have to make sure it won't interrupt input refocus logic. + // That's why when the interaction is shorter than 100ms we add delay so it won't hide once again. + // Subtracting timestamps makes us sure the delay is executed only when needed. + if (Date.now() - startTimestampRef.current < 100) { + keyboardTimeoutRef.current = setTimeout(() => { + input?.focus(); + previouslyFocusedTextInputRef.current = undefined; + }, 100); + } else { + input?.focus(); + previouslyFocusedTextInputRef.current = undefined; + } + } + }, [clearKeyboardTimeout, isEnabled]); + + React.useEffect(() => { + return () => clearKeyboardTimeout(); + }, [clearKeyboardTimeout]); + + return { + onPageChangeStart, + onPageChangeConfirm, + onPageChangeCancel, + }; +} diff --git a/packages/stack/src/views/KeyboardManager.tsx b/packages/stack/src/views/KeyboardManager.tsx deleted file mode 100644 index dfb034f7..00000000 --- a/packages/stack/src/views/KeyboardManager.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import * as React from 'react'; -import { TextInput, Keyboard, HostComponent } from 'react-native'; - -type Props = { - enabled: boolean; - children: (props: { - onPageChangeStart: () => void; - onPageChangeConfirm: (force: boolean) => void; - onPageChangeCancel: () => void; - }) => React.ReactNode; -}; - -type InputRef = React.ElementRef> | undefined; - -export default class KeyboardManager extends React.Component { - componentWillUnmount() { - this.clearKeyboardTimeout(); - } - - // Numeric id of the previously focused text input - // When a gesture didn't change the tab, we can restore the focused input with this - private previouslyFocusedTextInput: InputRef = undefined; - private startTimestamp: number = 0; - private keyboardTimeout: any; - - private clearKeyboardTimeout = () => { - if (this.keyboardTimeout !== undefined) { - clearTimeout(this.keyboardTimeout); - this.keyboardTimeout = undefined; - } - }; - - private handlePageChangeStart = () => { - if (!this.props.enabled) { - return; - } - - this.clearKeyboardTimeout(); - - const input: InputRef = TextInput.State.currentlyFocusedInput(); - - // When a page change begins, blur the currently focused input - input?.blur(); - - // Store the id of this input so we can refocus it if change was cancelled - this.previouslyFocusedTextInput = input; - - // Store timestamp for touch start - this.startTimestamp = Date.now(); - }; - - private handlePageChangeConfirm = (force: boolean) => { - if (!this.props.enabled) { - return; - } - - this.clearKeyboardTimeout(); - - if (force) { - // Always dismiss input, even if we don't have a ref to it - // We might not have the ref if onPageChangeStart was never called - // This can happen if page change was not from a gesture - Keyboard.dismiss(); - } else { - const input = this.previouslyFocusedTextInput; - - // Dismiss the keyboard only if an input was a focused before - // This makes sure we don't dismiss input on going back and focusing an input - input?.blur(); - } - - // Cleanup the ID on successful page change - this.previouslyFocusedTextInput = undefined; - }; - - private handlePageChangeCancel = () => { - if (!this.props.enabled) { - return; - } - - this.clearKeyboardTimeout(); - - // The page didn't change, we should restore the focus of text input - const input = this.previouslyFocusedTextInput; - - if (input) { - // If the interaction was super short we should make sure keyboard won't hide again. - - // Too fast input refocus will result only in keyboard flashing on screen and hiding right away. - // During first ~100ms keyboard will be dismissed no matter what, - // so we have to make sure it won't interrupt input refocus logic. - // That's why when the interaction is shorter than 100ms we add delay so it won't hide once again. - // Subtracting timestamps makes us sure the delay is executed only when needed. - if (Date.now() - this.startTimestamp < 100) { - this.keyboardTimeout = setTimeout(() => { - input?.focus(); - this.previouslyFocusedTextInput = undefined; - }, 100); - } else { - input?.focus(); - this.previouslyFocusedTextInput = undefined; - } - } - }; - - render() { - return this.props.children({ - onPageChangeStart: this.handlePageChangeStart, - onPageChangeConfirm: this.handlePageChangeConfirm, - onPageChangeCancel: this.handlePageChangeCancel, - }); - } -} diff --git a/packages/stack/src/views/Stack/CardContainer.tsx b/packages/stack/src/views/Stack/CardContainer.tsx index cb876656..c455f3b6 100644 --- a/packages/stack/src/views/Stack/CardContainer.tsx +++ b/packages/stack/src/views/Stack/CardContainer.tsx @@ -11,6 +11,7 @@ import type { Props as HeaderContainerProps } from '../Header/HeaderContainer'; import Card from './Card'; import { forModalPresentationIOS } from '../../TransitionConfigs/CardStyleInterpolators'; import ModalPresentationContext from '../../utils/ModalPresentationContext'; +import useKeyboardManager from '../../utils/useKeyboardManager'; import type { Layout, Scene } from '../../types'; type Props = { @@ -37,9 +38,6 @@ type Props = { closing: boolean ) => void; onTransitionEnd?: (props: { route: Route }, closing: boolean) => void; - onPageChangeStart?: () => void; - onPageChangeConfirm?: (force: boolean) => void; - onPageChangeCancel?: () => void; onGestureStart?: (props: { route: Route }) => void; onGestureEnd?: (props: { route: Route }) => void; onGestureCancel?: (props: { route: Route }) => void; @@ -70,9 +68,6 @@ function CardContainer({ layout, onCloseRoute, onOpenRoute, - onPageChangeCancel, - onPageChangeConfirm, - onPageChangeStart, onGestureCancel, onGestureEnd, onGestureStart, @@ -88,6 +83,20 @@ function CardContainer({ }: Props) { const parentHeaderHeight = React.useContext(HeaderHeightContext); + const { + onPageChangeStart, + onPageChangeCancel, + onPageChangeConfirm, + } = useKeyboardManager( + React.useCallback(() => { + const { options, navigation } = scene.descriptor; + + return ( + navigation.isFocused() && options.keyboardHandlingEnabled !== false + ); + }, [scene.descriptor]) + ); + const handleOpen = () => { const { route } = scene.descriptor; diff --git a/packages/stack/src/views/Stack/CardStack.tsx b/packages/stack/src/views/Stack/CardStack.tsx index f06bc91e..c68c2fdd 100755 --- a/packages/stack/src/views/Stack/CardStack.tsx +++ b/packages/stack/src/views/Stack/CardStack.tsx @@ -63,9 +63,6 @@ type Props = { closing: boolean ) => void; onTransitionEnd: (props: { route: Route }, closing: boolean) => void; - onPageChangeStart?: () => void; - onPageChangeConfirm?: (force: boolean) => void; - onPageChangeCancel?: () => void; onGestureStart?: (props: { route: Route }) => void; onGestureEnd?: (props: { route: Route }) => void; onGestureCancel?: (props: { route: Route }) => void; @@ -430,9 +427,6 @@ export default class CardStack extends React.Component { isParentHeaderShown, onTransitionStart, onTransitionEnd, - onPageChangeStart, - onPageChangeConfirm, - onPageChangeCancel, onGestureStart, onGestureEnd, onGestureCancel, @@ -603,9 +597,6 @@ export default class CardStack extends React.Component { safeAreaInsetRight={safeAreaInsetRight} safeAreaInsetBottom={safeAreaInsetBottom} safeAreaInsetLeft={safeAreaInsetLeft} - onPageChangeStart={onPageChangeStart} - onPageChangeConfirm={onPageChangeConfirm} - onPageChangeCancel={onPageChangeCancel} onGestureStart={onGestureStart} onGestureCancel={onGestureCancel} onGestureEnd={onGestureEnd} diff --git a/packages/stack/src/views/Stack/StackView.tsx b/packages/stack/src/views/Stack/StackView.tsx index 6187998d..739bc4ef 100644 --- a/packages/stack/src/views/Stack/StackView.tsx +++ b/packages/stack/src/views/Stack/StackView.tsx @@ -17,7 +17,6 @@ import { import { GestureHandlerRootView } from '../GestureHandler'; import CardStack from './CardStack'; -import KeyboardManager from '../KeyboardManager'; import HeaderContainer, { Props as HeaderContainerProps, } from '../Header/HeaderContainer'; @@ -417,7 +416,6 @@ export default class StackView extends React.Component { render() { const { state, - keyboardHandlingEnabled, // eslint-disable-next-line @typescript-eslint/no-unused-vars descriptors: _, ...rest @@ -435,35 +433,30 @@ export default class StackView extends React.Component { {(insets) => ( - - {(props) => ( - - {(isParentHeaderShown) => ( - - )} - + + {(isParentHeaderShown) => ( + )} - + )}