import React, { Component, FC } from 'react'; import { ScrollViewProps, useWindowDimensions } from 'react-native'; import Reanimated, { AnimatedProps, interpolate, measure, MeasuredDimensions, scrollTo, useAnimatedRef, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, useWorkletCallback } from 'react-native-reanimated'; import { AnimatedRef } from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes'; import { useSmoothKeyboardHandler } from './useSmoothKeyboardHandler'; /** * Everything begins from `onStart` handler. This handler is called every time, * when keyboard changes its size or when focused `TextInput` was changed. In * this handler we are calculating/memoizing values which later will be used * during layout movement. For that we calculate: * - layout of focused field (`layout`) - to understand whether there will be overlap * - initial keyboard size (`initialKeyboardSize`) - used in scroll interpolation * - future keyboard height (`keyboardHeight`) - used in scroll interpolation * - current scroll position (`scrollPosition`) - used to scroll from this point * * Once we've calculated all necessary variables - we can actually start to use them. * It happens in `onMove` handler - this function simply calls `maybeScroll` with * current keyboard frame height. This functions makes the smooth transition. * * When the transition has finished we go to `onEnd` handler. In this handler * we verify, that the current field is not overlapped within a keyboard frame. * For full `onStart`/`onMove`/`onEnd` flow it may look like a redundant thing, * however there could be some cases, when `onMove` is not called: * - on iOS when TextInput was changed - keyboard transition is instant * - on Android when TextInput was changed and keyboard size wasn't changed * So `onEnd` handler handle the case, when `onMove` wasn't triggered. * * ====================================================================================================================+ * -----------------------------------------------------Flow chart-----------------------------------------------------+ * ====================================================================================================================+ * * +============================+ +============================+ +==================================+ * + User Press on TextInput + => + Keyboard starts showing + => + As keyboard moves frame by frame + => * + + + (run `onStart`) + + `onMove` is getting called + * +============================+ +============================+ +==================================+ * * * +============================+ +============================+ +=====================================+ * + Keyboard is shown and we + => + User moved focus to + => + Only `onStart`/`onEnd` maybe called + * + call `onEnd` handler + + another `TextInput` + + (without involving `onMove`) + * +============================+ +============================+ +=====================================+ * */ export const AppKeyboardAwareScrollView: FC< AnimatedProps & { bottomOffset?: number } > = ({ children, bottomOffset = 0, ...rest }) => { const scrollViewAnimatedRef = useAnimatedRef(); const scrollPosition = useSharedValue(0); const position = useSharedValue(0); const layout = useSharedValue(null); const fakeViewHeight = useSharedValue(0); const keyboardHeight = useSharedValue(0); const tag = useSharedValue(-1); const initialKeyboardSize = useSharedValue(0); const scrollBeforeKeyboardMovement = useSharedValue(0); const { height } = useWindowDimensions(); const onScroll = useAnimatedScrollHandler( { onScroll: e => { position.value = e.contentOffset.y; } }, [] ); const measureByTag = useWorkletCallback((viewTag: number) => { return measure((() => viewTag) as unknown as AnimatedRef>); }, []); /** * Function that will scroll a ScrollView as keyboard gets moving */ const maybeScroll = useWorkletCallback( (e: number, animated = false) => { fakeViewHeight.value = e; const visibleRect = height - keyboardHeight.value; const point = (layout.value?.pageY ?? 0) + (layout.value?.height ?? 0); if (visibleRect - point <= bottomOffset) { const interpolatedScrollTo = interpolate( e, [initialKeyboardSize.value, keyboardHeight.value], [0, keyboardHeight.value - (height - point) + bottomOffset] ); const targetScrollY = Math.max(interpolatedScrollTo, 0) + scrollPosition.value; scrollTo(scrollViewAnimatedRef, 0, targetScrollY, animated as boolean); } }, [bottomOffset] ); useSmoothKeyboardHandler( { onStart: e => { 'worklet'; const keyboardWillChangeSize = keyboardHeight.value !== e.height && e.height > 0; const keyboardWillAppear = e.height > 0 && keyboardHeight.value === 0; const keyboardWillHide = e.height === 0; if (keyboardWillChangeSize) { initialKeyboardSize.value = keyboardHeight.value; } if (keyboardWillHide) { // on back transition need to interpolate as [0, keyboardHeight] initialKeyboardSize.value = 0; scrollPosition.value = scrollBeforeKeyboardMovement.value; } if (keyboardWillAppear || keyboardWillChangeSize) { // persist scroll value scrollPosition.value = position.value; // just persist height - later will be used in interpolation keyboardHeight.value = e.height; } // focus was changed if (tag.value !== e.target || keyboardWillChangeSize) { tag.value = e.target; if (tag.value !== -1) { // save position of focused text input when keyboard starts to move layout.value = measureByTag(e.target); // save current scroll position - when keyboard will hide we'll reuse // this value to achieve smooth hide effect scrollBeforeKeyboardMovement.value = position.value; } } }, onMove: e => { 'worklet'; maybeScroll(e.height); }, onEnd: e => { 'worklet'; keyboardHeight.value = e.height; scrollPosition.value = position.value; if (e.target !== -1 && e.height !== 0) { const prevLayout = layout.value; // just be sure, that view is no overlapped (i.e. focus changed) layout.value = measureByTag(e.target); maybeScroll(e.height, true); // do layout substitution back to assure there will be correct // back transition when keyboard hides layout.value = prevLayout; } } }, [height] ); const view = useAnimatedStyle( () => ({ height: fakeViewHeight.value }), [] ); return ( <> {children} ); };