import React, { Component, MutableRefObject, RefCallback } from 'react'; import { Animated, DeviceInfo, Dimensions, Easing, EmitterSubscription, findNodeHandle, Keyboard, KeyboardEvent, NativeSyntheticEvent, Platform, ScrollView, ScrollViewProps, StatusBar, StyleProp, StyleSheet, TargetedEvent, TextInput, ViewProps, ViewStyle, } from 'react-native'; import { measureInWindow, measureLayout, viewIsDescendantOf } from './uiManagerPromises'; // Workaround for missing DeviceInfo types declare module 'react-native' { const DeviceInfo: { isIPhoneX_deprecated: boolean; }; } type Props = typeof KeyboardAwareScrollView.defaultProps & { contentContainerStyleKeyboardShown?: StyleProp; scrollViewContentContainerStyle?: StyleProp; scrollViewRef?: MutableRefObject | RefCallback; onFocus?: (event: NativeSyntheticEvent) => void; } & ScrollViewProps; interface State { paddingBottom: Animated.Value; keyboardShown: boolean; scrollIndicatorBottomInset: number; } const EASING = Easing.bezier(0.4, 0, 0.2, 1); const ANIMATION_DURATION = Platform.select({ ios: 250, default: 125 }); const IPHONE_X_INSET = { top: 44, bottom: 34, }; /** * Keyboard aware scroll view. * Based on react-native KeyboardAvoidingView and react-native-keyboard-aware-scroll-view. */ export class KeyboardAwareScrollView extends Component { static defaultProps = { extraHeight: 24, }; private subscriptions: EmitterSubscription[] = []; private scrollToFocusedInputTimeout: number | null = null; private scrollView: ScrollView | null = null; private mounted = false; private scrollViewPosY = 0; private keyboardPosY = 0; constructor(props: Props) { super(props); this.state = { paddingBottom: new Animated.Value(0), keyboardShown: false, scrollIndicatorBottomInset: 0, }; } componentDidMount(): void { this.mounted = true; if (Platform.OS === 'ios') { this.subscriptions = [ Keyboard.addListener('keyboardWillShow', this.onKeyboardShow), Keyboard.addListener('keyboardWillHide', this.onKeyboardHide), ]; return; } this.subscriptions = [ Keyboard.addListener('keyboardDidShow', this.onKeyboardShow), Keyboard.addListener('keyboardDidHide', this.onKeyboardHide), ]; } componentWillUnmount(): void { this.mounted = false; if (this.scrollToFocusedInputTimeout) { clearTimeout(this.scrollToFocusedInputTimeout); this.scrollToFocusedInputTimeout = null; } this.subscriptions.forEach((subscription) => subscription.remove()); this.subscriptions = []; } private onRef = (scrollView: ScrollView): void => { this.scrollView = scrollView; const { scrollViewRef } = this.props; if (!scrollViewRef) { return; } if (typeof scrollViewRef === 'function') { scrollViewRef(scrollView); return; } scrollViewRef.current = scrollView; }; private onKeyboardShow = async (event: KeyboardEvent): Promise => { if (!this.scrollView) { return; } const handle = findNodeHandle(this.scrollView); if (!handle) { return; } // Get scroll view height and top position const { height, top } = await measureInWindow(handle); if (!this.mounted) { return; } // Save scroll view Y position this.scrollViewPosY = top; // Get keyboard position by Y axis at current view. this.keyboardPosY = event.endCoordinates.screenY - top; // Subtract status bar height for android. if (StatusBar.currentHeight) { this.keyboardPosY -= StatusBar.currentHeight; } // Calc padding bottom const paddingBottom = height - this.keyboardPosY; let scrollIndicatorBottomInset = paddingBottom; // Check if scrollview intersects iOS bottom inset if (DeviceInfo.isIPhoneX_deprecated) { const { height: screenHeight } = Dimensions.get('screen'); if (top + height > screenHeight - IPHONE_X_INSET.bottom) { // Subtract inset if needed scrollIndicatorBottomInset -= screenHeight - top - height + IPHONE_X_INSET.bottom; } } // Animate keyboard height change Animated.timing(this.state.paddingBottom, { duration: event.duration || ANIMATION_DURATION, toValue: paddingBottom, easing: EASING, useNativeDriver: false, }).start(); this.setState({ keyboardShown: true, scrollIndicatorBottomInset }); const currentlyFocusedField = TextInput.State.currentlyFocusedInput ? findNodeHandle(TextInput.State.currentlyFocusedInput()) : TextInput.State.currentlyFocusedField(); if (!currentlyFocusedField) { return; } void this.scrollToInput(currentlyFocusedField); }; private onKeyboardHide = (event: KeyboardEvent): void => { Animated.timing(this.state.paddingBottom, { duration: (event && event.duration) || ANIMATION_DURATION, toValue: 0, easing: EASING, useNativeDriver: false, }).start(); this.setState({ keyboardShown: false, scrollIndicatorBottomInset: 0 }); }; private onFocus = (e: NativeSyntheticEvent): void => { if (this.props.onFocus) { this.props.onFocus(e); } if (!this.state.keyboardShown) { return; } void this.scrollToInput(e.nativeEvent.target); }; scrollToInput = async (input: number): Promise => { if (!this.scrollView) { return; } const innerViewNode = this.scrollView.getInnerViewNode(); // Check if current focused TextInput is ancestor of ScrollView const isAncestor = await viewIsDescendantOf(input, innerViewNode); if (!isAncestor || !this.mounted) { return; } if (this.scrollToFocusedInputTimeout) { clearTimeout(this.scrollToFocusedInputTimeout); this.scrollToFocusedInputTimeout = null; } // eslint-disable-next-line @typescript-eslint/no-misused-promises this.scrollToFocusedInputTimeout = setTimeout(async () => { if (!this.mounted) { return; } try { const { extraHeight } = this.props; const handle = findNodeHandle(innerViewNode); if (!handle) { return; } const { top, height } = await measureLayout(input, handle); if (!this.mounted) { return; } const { top: innerViewPositionY } = await measureInWindow(handle); if (!this.mounted) { return; } const scrollDistance = this.scrollViewPosY - innerViewPositionY; let inset = 0; // Calc inset for iphoneX if (DeviceInfo.isIPhoneX_deprecated && this.scrollViewPosY < IPHONE_X_INSET.top) { inset = IPHONE_X_INSET.top - this.scrollViewPosY; } let scrollTo; if (top + height + extraHeight - scrollDistance > this.keyboardPosY) { // Input below the bottom scrollTo = top + extraHeight + height - this.keyboardPosY; } else if (top - scrollDistance - inset < extraHeight) { // Input above the top scrollTo = top - extraHeight - inset; } if (scrollTo === undefined || !this.scrollView) { // No need to scroll return; } this.scrollView.scrollTo({ animated: true, x: 0, y: Math.max(0, scrollTo), }); } catch { // nothing } }, ANIMATION_DURATION); }; render(): JSX.Element { const { contentContainerStyle, contentContainerStyleKeyboardShown, children, scrollViewContentContainerStyle, ...props } = this.props; const { keyboardShown } = this.state; const contentStyles = [ contentContainerStyle, keyboardShown && contentContainerStyleKeyboardShown, ]; const flatStyle: ViewStyle = StyleSheet.flatten(contentStyles); // Get bottom padding by yoga styles priority let paddingBottom: string | number = 0; if (flatStyle.paddingBottom !== undefined) { ({ paddingBottom } = flatStyle); } else if (flatStyle.paddingVertical !== undefined) { paddingBottom = flatStyle.paddingVertical; } else if (flatStyle.padding !== undefined) { paddingBottom = flatStyle.padding; } return ( {children} ); } } const styles = StyleSheet.create({ contentContainer: { flexGrow: 1, }, });