From 350fb747838e85bae41ee3059099c5a5f71292c6 Mon Sep 17 00:00:00 2001 From: ahmdshrif Date: Mon, 8 Jun 2026 11:42:27 +0300 Subject: [PATCH] Convert `KeyboardAvoidingView` to function component Follows the same migration as ImageBackground (#56355), RefreshControl (#56354) and Modal (#56348). As with ImageBackground, a ref on now forwards to the underlying View (exposing measure, setNativeProps, ...) instead of the class instance; KeyboardAvoidingViewInstance is re-typed to the View ref accordingly. --- .../Keyboard/KeyboardAvoidingView.js | 431 +++++++++--------- packages/react-native/ReactNativeApi.d.ts | 32 +- 2 files changed, 233 insertions(+), 230 deletions(-) diff --git a/packages/react-native/Libraries/Components/Keyboard/KeyboardAvoidingView.js b/packages/react-native/Libraries/Components/Keyboard/KeyboardAvoidingView.js index 87ac6de0665a..a14ddce7b92f 100644 --- a/packages/react-native/Libraries/Components/Keyboard/KeyboardAvoidingView.js +++ b/packages/react-native/Libraries/Components/Keyboard/KeyboardAvoidingView.js @@ -19,12 +19,11 @@ import type {KeyboardEvent, KeyboardMetrics} from './Keyboard'; import LayoutAnimation from '../../LayoutAnimation/LayoutAnimation'; import StyleSheet from '../../StyleSheet/StyleSheet'; import Platform from '../../Utilities/Platform'; -import {type EventSubscription} from '../../vendor/emitter/EventEmitter'; import AccessibilityInfo from '../AccessibilityInfo/AccessibilityInfo'; import View from '../View/View'; import Keyboard from './Keyboard'; import * as React from 'react'; -import {createRef} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; /** @build-types emit-as-interface Uniwind compatibility */ export type KeyboardAvoidingViewProps = Readonly<{ @@ -53,122 +52,119 @@ export type KeyboardAvoidingViewProps = Readonly<{ keyboardVerticalOffset?: number, }>; -type KeyboardAvoidingViewState = { - bottom: number, -}; - /** * View that moves out of the way when the keyboard appears by automatically * adjusting its height, position, or bottom padding. */ -class KeyboardAvoidingView extends React.Component< - KeyboardAvoidingViewProps, - KeyboardAvoidingViewState, -> { - _frame: ?ViewLayout = null; - _keyboardEvent: ?KeyboardEvent = null; - _subscriptions: Array = []; - viewRef: {current: React.ElementRef | null, ...}; - _initialFrameHeight: number = 0; - _bottom: number = 0; - - constructor(props: KeyboardAvoidingViewProps) { - super(props); - this.state = {bottom: 0}; - this.viewRef = createRef(); - } - - async _relativeKeyboardHeight( - keyboardFrame: KeyboardMetrics, - ): Promise { - const frame = this._frame; - if (!frame || !keyboardFrame) { - return 0; - } - - // On iOS when Prefer Cross-Fade Transitions is enabled, the keyboard position - // & height is reported differently (0 instead of Y position value matching height of frame) - if ( - Platform.OS === 'ios' && - keyboardFrame.screenY === 0 && - (await AccessibilityInfo.prefersCrossFadeTransitions()) - ) { - return 0; - } - - const keyboardY = - keyboardFrame.screenY - (this.props.keyboardVerticalOffset ?? 0); - - if (this.props.behavior === 'height') { - return Math.max( - this.state.bottom + frame.y + frame.height - keyboardY, - 0, - ); - } - - // Calculate the displacement needed for the view such that it - // no longer overlaps with the keyboard - return Math.max(frame.y + frame.height - keyboardY, 0); - } - - _onKeyboardChange = (event: ?KeyboardEvent) => { - this._keyboardEvent = event; - // $FlowFixMe[unused-promise] - this._updateBottomIfNecessary(); +const KeyboardAvoidingView: component( + ref?: React.RefSetter>, + ...props: KeyboardAvoidingViewProps +) = ({ + ref, + behavior, + children, + contentContainerStyle, + enabled = true, + keyboardVerticalOffset = 0, + style, + onLayout, + ...props +}: { + ref?: React.RefSetter>, + ...KeyboardAvoidingViewProps, +}): React.Node => { + const [bottom, setBottom] = useState(0); + + const frame = useRef(null); + const keyboardEvent = useRef(null); + const initialFrameHeight = useRef(0); + const bottomValue = useRef(0); + + // Mirror the latest props/state so the (stable) keyboard subscription + // callbacks can read them like the original class component read + // `this.props` / `this.state`, without resubscribing on every render. + const latest = useRef<{ + behavior: ?('height' | 'position' | 'padding'), + enabled: boolean, + keyboardVerticalOffset: number, + bottom: number, + onLayout: ?(event: ViewLayoutEvent) => mixed, + }>({ + behavior, + enabled: enabled ?? true, + keyboardVerticalOffset, + bottom, + onLayout, + }); + latest.current = { + behavior, + enabled: enabled ?? true, + keyboardVerticalOffset, + bottom, + onLayout, }; - _onKeyboardHide = (event: ?KeyboardEvent) => { - this._keyboardEvent = null; - // $FlowFixMe[unused-promise] - this._updateBottomIfNecessary(); - }; - - _onLayout = async (event: ViewLayoutEvent) => { - event.persist(); - - const oldFrame = this._frame; - this._frame = event.nativeEvent.layout; - if (!this._initialFrameHeight) { - // save the initial frame height, before the keyboard is visible - this._initialFrameHeight = this._frame.height; - } + const relativeKeyboardHeight = useCallback( + async (keyboardFrame: KeyboardMetrics): Promise => { + const currentFrame = frame.current; + if (!currentFrame || !keyboardFrame) { + return 0; + } + + // On iOS when Prefer Cross-Fade Transitions is enabled, the keyboard position + // & height is reported differently (0 instead of Y position value matching height of frame) + if ( + Platform.OS === 'ios' && + keyboardFrame.screenY === 0 && + (await AccessibilityInfo.prefersCrossFadeTransitions()) + ) { + return 0; + } + + const keyboardY = + keyboardFrame.screenY - latest.current.keyboardVerticalOffset; + + if (latest.current.behavior === 'height') { + return Math.max( + latest.current.bottom + + currentFrame.y + + currentFrame.height - + keyboardY, + 0, + ); + } - // update bottom height for the first time or when the height is changed - if (!oldFrame || oldFrame.height !== this._frame.height) { - await this._updateBottomIfNecessary(); - } - - if (this.props.onLayout) { - this.props.onLayout(event); - } - }; + // Calculate the displacement needed for the view such that it + // no longer overlaps with the keyboard + return Math.max(currentFrame.y + currentFrame.height - keyboardY, 0); + }, + [], + ); // Avoid unnecessary renders if the KeyboardAvoidingView is disabled. - _setBottom = (value: number) => { - const enabled = this.props.enabled ?? true; - this._bottom = value; - if (enabled) { - this.setState({bottom: value}); + const setBottomValue = useCallback((value: number) => { + bottomValue.current = value; + if (latest.current.enabled) { + setBottom(value); } - }; + }, []); - _updateBottomIfNecessary = async () => { - if (this._keyboardEvent == null) { - this._setBottom(0); + const updateBottomIfNecessary = useCallback(async () => { + if (keyboardEvent.current == null) { + setBottomValue(0); return; } - const {duration, easing, endCoordinates} = this._keyboardEvent; - const height = await this._relativeKeyboardHeight(endCoordinates); + const {duration, easing, endCoordinates} = keyboardEvent.current; + const height = await relativeKeyboardHeight(endCoordinates); - if (this._bottom === height) { + if (bottomValue.current === height) { return; } - this._setBottom(height); + setBottomValue(height); - const enabled = this.props.enabled ?? true; - if (enabled && duration && easing) { + if (latest.current.enabled && duration && easing) { LayoutAnimation.configureNext({ // We have to pass the duration equal to minimal accepted duration defined here: RCTLayoutAnimation.m duration: duration > 10 ? duration : 10, @@ -178,126 +174,145 @@ class KeyboardAvoidingView extends React.Component< }, }); } - }; - - componentDidUpdate( - _: KeyboardAvoidingViewProps, - prevState: KeyboardAvoidingViewState, - ): void { - const enabled = this.props.enabled ?? true; - if (enabled && this._bottom !== prevState.bottom) { - this.setState({bottom: this._bottom}); - } - } - - componentDidMount(): void { + }, [relativeKeyboardHeight, setBottomValue]); + + const onKeyboardChange = useCallback( + (event: ?KeyboardEvent) => { + keyboardEvent.current = event; + // $FlowFixMe[unused-promise] + updateBottomIfNecessary(); + }, + [updateBottomIfNecessary], + ); + + const onKeyboardHide = useCallback( + (_event: ?KeyboardEvent) => { + keyboardEvent.current = null; + // $FlowFixMe[unused-promise] + updateBottomIfNecessary(); + }, + [updateBottomIfNecessary], + ); + + const onLayoutHandler = useCallback( + async (event: ViewLayoutEvent) => { + event.persist(); + + const oldFrame = frame.current; + const newFrame = event.nativeEvent.layout; + frame.current = newFrame; + if (!initialFrameHeight.current) { + // save the initial frame height, before the keyboard is visible + initialFrameHeight.current = newFrame.height; + } + + // update bottom height for the first time or when the height is changed + if (!oldFrame || oldFrame.height !== newFrame.height) { + await updateBottomIfNecessary(); + } + + latest.current.onLayout?.(event); + }, + [updateBottomIfNecessary], + ); + + // componentDidMount + componentWillUnmount + useEffect(() => { if (!Keyboard.isVisible()) { - this._keyboardEvent = null; - this._setBottom(0); + keyboardEvent.current = null; + setBottomValue(0); } - if (Platform.OS === 'ios') { - this._subscriptions = [ - // When undocked, split or floating, iOS will emit - // UIKeyboardWillHideNotification notification. - // UIKeyboardWillChangeFrameNotification will be emitted before - // UIKeyboardWillHideNotification, so we need to listen to - // keyboardWillHide and keyboardWillShow instead of - // keyboardWillChangeFrame. - Keyboard.addListener('keyboardWillHide', this._onKeyboardHide), - Keyboard.addListener('keyboardWillShow', this._onKeyboardChange), - ]; - } else { - this._subscriptions = [ - Keyboard.addListener('keyboardDidHide', this._onKeyboardHide), - Keyboard.addListener('keyboardDidShow', this._onKeyboardChange), - ]; + const subscriptions = + Platform.OS === 'ios' + ? [ + // When undocked, split or floating, iOS will emit + // UIKeyboardWillHideNotification notification. + // UIKeyboardWillChangeFrameNotification will be emitted before + // UIKeyboardWillHideNotification, so we need to listen to + // keyboardWillHide and keyboardWillShow instead of + // keyboardWillChangeFrame. + Keyboard.addListener('keyboardWillHide', onKeyboardHide), + Keyboard.addListener('keyboardWillShow', onKeyboardChange), + ] + : [ + Keyboard.addListener('keyboardDidHide', onKeyboardHide), + Keyboard.addListener('keyboardDidShow', onKeyboardChange), + ]; + + return () => { + subscriptions.forEach(subscription => { + subscription.remove(); + }); + }; + }, [onKeyboardChange, onKeyboardHide, setBottomValue]); + + // componentDidUpdate: when enabled, keep the rendered bottom in sync with the + // latest computed value (e.g. after `enabled` flips from false to true). + useEffect(() => { + if ((enabled ?? true) && bottomValue.current !== bottom) { + setBottom(bottomValue.current); } - } - - componentWillUnmount(): void { - this._subscriptions.forEach(subscription => { - subscription.remove(); - }); - } + }, [enabled, bottom]); + + const bottomHeight = enabled === true ? bottom : 0; + switch (behavior) { + case 'height': + let heightStyle; + if (frame.current != null && bottom > 0) { + // Note that we only apply a height change when there is keyboard present, + // i.e. bottom is greater than 0. If we remove that condition, + // frame.current.height will never go back to its original value. + // When height changes, we need to disable flex. + heightStyle = { + height: initialFrameHeight.current - bottomHeight, + flex: 0, + }; + } + return ( + + {children} + + ); - render(): React.Node { - const { - behavior, - children, - contentContainerStyle, - enabled = true, - // eslint-disable-next-line no-unused-vars - keyboardVerticalOffset = 0, - style, - onLayout, - ...props - } = this.props; - const bottomHeight = enabled === true ? this.state.bottom : 0; - switch (behavior) { - case 'height': - let heightStyle; - if (this._frame != null && this.state.bottom > 0) { - // Note that we only apply a height change when there is keyboard present, - // i.e. this.state.bottom is greater than 0. If we remove that condition, - // this.frame.height will never go back to its original value. - // When height changes, we need to disable flex. - heightStyle = { - height: this._initialFrameHeight - bottomHeight, - flex: 0, - }; - } - return ( + case 'position': + return ( + + style={StyleSheet.compose(contentContainerStyle, { + bottom: bottomHeight, + })}> {children} - ); - - case 'position': - return ( - - - {children} - - - ); + + ); - case 'padding': - return ( - - {children} - - ); + case 'padding': + return ( + + {children} + + ); - default: - return ( - - {children} - - ); - } + default: + return ( + + {children} + + ); } -} +}; + +KeyboardAvoidingView.displayName = 'KeyboardAvoidingView'; -export type KeyboardAvoidingViewInstance = KeyboardAvoidingView; +export type KeyboardAvoidingViewInstance = React.ElementRef; export default KeyboardAvoidingView; diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 48505ca1b824..78223b1c3323 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<3051fd66740b04f529e5a5164231398c>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -246,6 +246,12 @@ declare const InputAccessoryView: typeof InputAccessoryView_default declare const InputAccessoryView_default: React.ComponentType declare const Keyboard: typeof Keyboard_default declare const Keyboard_default: KeyboardImpl +declare const KeyboardAvoidingView: typeof KeyboardAvoidingView_default +declare const KeyboardAvoidingView_default: ( + props: KeyboardAvoidingViewProps & { + ref?: React.Ref> + }, +) => React.ReactNode declare const LayoutAnimation: typeof LayoutAnimation_default declare const LayoutAnimation_default: { configureNext: typeof configureNext @@ -2776,32 +2782,14 @@ declare interface IPerformanceLogger { declare function isEnabled(): boolean declare type Item = any declare type Keyboard = typeof Keyboard -declare class KeyboardAvoidingView extends React.Component< - KeyboardAvoidingViewProps, - KeyboardAvoidingViewState -> { - viewRef: { - current: null | React.ComponentRef - } - componentDidMount(): void - componentDidUpdate( - _: KeyboardAvoidingViewProps, - prevState: KeyboardAvoidingViewState, - ): void - componentWillUnmount(): void - constructor(props: KeyboardAvoidingViewProps) - render(): React.ReactNode -} -declare type KeyboardAvoidingViewInstance = KeyboardAvoidingView +declare type KeyboardAvoidingView = typeof KeyboardAvoidingView +declare type KeyboardAvoidingViewInstance = React.ComponentRef declare interface KeyboardAvoidingViewProps extends Readonly { readonly behavior?: "height" | "padding" | "position" readonly contentContainerStyle?: ViewStyleProp readonly enabled?: boolean readonly keyboardVerticalOffset?: number } -declare type KeyboardAvoidingViewState = { - bottom: number -} declare type KeyboardEvent = AndroidKeyboardEvent | IOSKeyboardEvent declare type KeyboardEventDefinitions = { keyboardDidChangeFrame: [KeyboardEvent] @@ -5948,7 +5936,7 @@ export { KeyEvent, // 20fa4267 KeyUpEvent, // 57f832c5 Keyboard, // 49414c97 - KeyboardAvoidingView, // f612e39b + KeyboardAvoidingView, // 5b1e7a8a KeyboardAvoidingViewInstance, // 084c1430 KeyboardAvoidingViewProps, // 8f05bdcc KeyboardEvent, // c3f895d4