UNPKG

react-native-keyboard-avoiding-scroll-view

Version:

React Native ScrollView extension that prevents inputs from being covered by the keyboard

266 lines (265 loc) 12.5 kB
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Dimensions, findNodeHandle, Keyboard, LayoutAnimation, Platform, SafeAreaView, StyleSheet, TextInput as NativeTextInput, View, } from 'react-native'; import { genericMemo } from './utils/react'; import { measureInWindow } from './utils/measureInWindow'; import { hijackTextInputEvents } from './utils/hijackTextInputEvents'; const { height: SCREEN_HEIGHT } = Dimensions.get('window'); const KEYBOARD_PADDING = 48; export const KeyboardAvoidingContainer = genericMemo(({ stickyFooter, containerStyle, ScrollViewComponent, scrollViewRef, scrollViewProps, stickyFooterRef, stickyFooterProps, }) => { return (<SafeAreaView style={[styles.container, containerStyle]}> <ScrollViewComponent ref={scrollViewRef} {...scrollViewProps}/> {stickyFooter && (<View ref={stickyFooterRef} {...stickyFooterProps}> {stickyFooter} </View>)} </SafeAreaView>); }); export function useKeyboardAvoidingContainerProps({ stickyFooter, containerStyle, onScroll, contentContainerStyle: contentContainerStyleProp, style: styleProp, ...passthroughScrollViewProps }) { const scrollViewRef = useRef(null); const stickyFooterRef = useRef(null); const scrollPositionRef = useRef(0); const scrollViewOffsetRef = useRef(0); const keyboardLayoutRef = useRef(null); const stickyFooterLayoutRef = useRef(null); const focusedTextInputLayoutRef = useRef(null); const layoutAnimationConfiguredRef = useRef(false); const [scrollViewOffset, setScrollViewOffset] = useState(0); const [scrollViewContentBottomInset, setScrollViewContentBottomInset,] = useState(0); const [scrollViewBottomInset, setScrollViewBottomInset] = useState(0); const [stickyFooterOffset, setStickyFooterOffset] = useState(0); useEffect(() => { requestAnimationFrame(() => { if (scrollViewRef.current) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const scrollResponder = scrollViewRef.current.getScrollResponder(); scrollResponder.scrollTo({ y: scrollPositionRef.current + (scrollViewOffset - scrollViewOffsetRef.current), animated: true, }); scrollViewOffsetRef.current = scrollViewOffset; } }); }, [scrollViewOffset]); const handleScroll = useCallback((event) => { scrollPositionRef.current = event.nativeEvent.contentOffset.y; if (onScroll) { onScroll(event); } }, [onScroll]); const handleStickyFooterLayout = useCallback((event) => { setScrollViewBottomInset(event.nativeEvent.layout.height); }, []); const updateOffsets = useCallback(({ keyboardEvent } = {}) => { const keyboardAbsoluteTop = keyboardLayoutRef.current ? keyboardLayoutRef.current.screenY : SCREEN_HEIGHT; const keyboardAbsoluteTopWithPadding = keyboardAbsoluteTop - KEYBOARD_PADDING; const keyboardHeight = keyboardLayoutRef.current ? keyboardLayoutRef.current.height : 0; const focusedTextInputAbsoluteBottom = focusedTextInputLayoutRef.current ? focusedTextInputLayoutRef.current.screenY + focusedTextInputLayoutRef.current.height : keyboardAbsoluteTopWithPadding; const stickyFooterAbsoluteBottom = stickyFooterLayoutRef.current ? stickyFooterLayoutRef.current.screenY + stickyFooterLayoutRef.current.height : keyboardAbsoluteTopWithPadding; const stickyFooterHeight = stickyFooterLayoutRef.current ? stickyFooterLayoutRef.current.height : 0; const newScrollViewOffset = Math.max(0, focusedTextInputAbsoluteBottom - keyboardAbsoluteTopWithPadding + stickyFooterHeight); const newScrollViewBottomInset = KEYBOARD_PADDING + stickyFooterHeight + keyboardHeight; const newStickyFooterOffset = Math.max(0, stickyFooterAbsoluteBottom - keyboardAbsoluteTop); if (!layoutAnimationConfiguredRef.current && (newScrollViewBottomInset !== scrollViewContentBottomInset || newStickyFooterOffset !== stickyFooterOffset)) { LayoutAnimation.configureNext(keyboardEvent && keyboardEvent.duration > 10 ? { duration: keyboardEvent.duration, update: { duration: keyboardEvent.duration, type: (keyboardEvent.easing != null && LayoutAnimation.Types[keyboardEvent.easing]) || 'keyboard', }, } : LayoutAnimation.Presets.easeInEaseOut); requestAnimationFrame(() => { setTimeout(() => { layoutAnimationConfiguredRef.current = false; }, keyboardEvent && keyboardEvent.duration > 10 ? keyboardEvent.duration : LayoutAnimation.Presets.easeInEaseOut.duration); }); layoutAnimationConfiguredRef.current = true; } setScrollViewOffset(newScrollViewOffset); setScrollViewContentBottomInset(newScrollViewBottomInset); setStickyFooterOffset(newStickyFooterOffset); }, [scrollViewContentBottomInset, stickyFooterOffset]); useEffect(() => { const keyboardWillShowSub = Keyboard.addListener('keyboardWillShow', // Right before the keyboard is shown, we know that a text input is being // focused on. // Therefore, we calculate the layout of the text input and the layout // of the sticky footer and update offsets accordingly. async (event) => { // Prevent race conditions if (keyboardLayoutRef.current) return; const { endCoordinates: newKeyboardLayout } = event; const newFocusedTextInputNodeHandle = NativeTextInput.State.currentlyFocusedField(); const newStickyFooterNodeHandle = findNodeHandle(stickyFooterRef.current); const [newFocusedTextInputLayout, newStickyFooterLayout,] = await Promise.all([ newFocusedTextInputNodeHandle ? measureInWindow(newFocusedTextInputNodeHandle) : Promise.resolve(null), newStickyFooterNodeHandle ? measureInWindow(newStickyFooterNodeHandle) : Promise.resolve(null), ]); keyboardLayoutRef.current = newKeyboardLayout; focusedTextInputLayoutRef.current = newFocusedTextInputLayout; stickyFooterLayoutRef.current = newStickyFooterLayout; updateOffsets({ keyboardEvent: event }); }); const keyboardWillChangeFrameSub = Keyboard.addListener('keyboardWillChangeFrame', event => { // Avoid overlap with `keyboardWillShow` if (!keyboardLayoutRef.current) return; const { endCoordinates: newKeyboardLayout } = event; // Avoid overlap with `keyboardWillHide` if (newKeyboardLayout.height === keyboardLayoutRef.current.height || newKeyboardLayout.height === 0) { return; } keyboardLayoutRef.current = newKeyboardLayout; }); const keyboardWillHideSub = Keyboard.addListener('keyboardWillHide', // Right before the keyboard is hidden, we know that a text input is being // blurred. // Therefore, we reset the layouts and update the offsets accordingly. event => { keyboardLayoutRef.current = null; focusedTextInputLayoutRef.current = null; stickyFooterLayoutRef.current = null; updateOffsets({ keyboardEvent: event }); }); return () => { keyboardWillShowSub.remove(); keyboardWillChangeFrameSub.remove(); keyboardWillHideSub.remove(); }; }, [updateOffsets]); useEffect(() => { const textInputEvents = hijackTextInputEvents(); // We watch for the switch between two text inputs and update offsets // accordingly. // A switch between two text inputs happens when a keyboard is shown // and another text input is currently being focused on. const sub = textInputEvents.addListener('textInputDidFocus', newFocusedTextInputNodeHandle => { requestAnimationFrame(async () => { if (!keyboardLayoutRef.current || !focusedTextInputLayoutRef.current) { return; } const newFocusedTextInputLayout = newFocusedTextInputNodeHandle ? await measureInWindow(newFocusedTextInputNodeHandle) : null; focusedTextInputLayoutRef.current = newFocusedTextInputLayout ? { ...newFocusedTextInputLayout, screenY: newFocusedTextInputLayout.screenY + scrollViewOffsetRef.current, } : newFocusedTextInputLayout; updateOffsets(); }); }); return () => { sub.remove(); }; }, [scrollViewOffset, updateOffsets]); const scrollViewContentContainerStyle = useMemo(() => { const flatContentContainerStyleProp = StyleSheet.flatten(contentContainerStyleProp) || {}; let scrollViewContentBottomInsetProp = 0; if ('paddingBottom' in flatContentContainerStyleProp) { if (typeof flatContentContainerStyleProp.paddingBottom === 'number') { scrollViewContentBottomInsetProp = flatContentContainerStyleProp.paddingBottom; } } else if ('padding' in flatContentContainerStyleProp) { if (typeof flatContentContainerStyleProp.padding === 'number') { scrollViewContentBottomInsetProp = flatContentContainerStyleProp.padding; } } return { paddingBottom: scrollViewContentBottomInset + scrollViewContentBottomInsetProp, }; }, [contentContainerStyleProp, scrollViewContentBottomInset]); const scrollViewStyle = useMemo(() => { const flatStyleProp = StyleSheet.flatten(styleProp) || {}; let scrollViewBottomInsetProp = 0; if ('marginBottom' in flatStyleProp) { if (typeof flatStyleProp.marginBottom === 'number') { scrollViewBottomInsetProp = flatStyleProp.marginBottom; } } else if ('margin' in flatStyleProp) { if (typeof flatStyleProp.margin === 'number') { scrollViewBottomInsetProp = flatStyleProp.margin; } } return { marginBottom: scrollViewBottomInset + scrollViewBottomInsetProp, }; }, [scrollViewBottomInset, styleProp]); const scrollViewProps = useMemo(() => // eslint-disable-next-line @typescript-eslint/no-object-literal-type-assertion ({ keyboardDismissMode: Platform.OS === 'ios' ? 'interactive' : 'on-drag', keyboardShouldPersistTaps: 'handled', ...passthroughScrollViewProps, onScroll: handleScroll, contentContainerStyle: [ contentContainerStyleProp, scrollViewContentContainerStyle, ], style: [styleProp, scrollViewStyle], }), [ contentContainerStyleProp, handleScroll, passthroughScrollViewProps, scrollViewContentContainerStyle, scrollViewStyle, styleProp, ]); const stickyFooterProps = useMemo(() => ({ onLayout: handleStickyFooterLayout, style: [styles.stickyFooter, { bottom: stickyFooterOffset }], }), [handleStickyFooterLayout, stickyFooterOffset]); return { stickyFooter, containerStyle, scrollViewProps, scrollViewRef, stickyFooterProps, stickyFooterRef, }; } const styles = StyleSheet.create({ container: { flex: 1, }, stickyFooter: { position: 'absolute', bottom: 0, left: 0, right: 0, }, });