diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/views/ClippingScrollViewDecoratorView.kt b/android/src/main/java/com/reactnativekeyboardcontroller/views/ClippingScrollViewDecoratorView.kt index 051a1e1da9..dcfaa37e4c 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/views/ClippingScrollViewDecoratorView.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/views/ClippingScrollViewDecoratorView.kt @@ -24,10 +24,10 @@ class ClippingScrollViewDecoratorView( } private fun decorateScrollView() { - val scrollView = getChildAt(0) as? ScrollView + val scrollView = getChildAt(0) as? ScrollView ?: return - scrollView?.clipToPadding = false - scrollView?.setPadding( + scrollView.clipToPadding = false + scrollView.setPadding( scrollView.paddingLeft, scrollView.paddingTop, scrollView.paddingRight, diff --git a/e2e/kit/assets/android/e2e_emulator_28/ToolbarKeyboardClosed.png b/e2e/kit/assets/android/e2e_emulator_28/ToolbarKeyboardClosed.png index 5584e158a7..8f786caf58 100644 Binary files a/e2e/kit/assets/android/e2e_emulator_28/ToolbarKeyboardClosed.png and b/e2e/kit/assets/android/e2e_emulator_28/ToolbarKeyboardClosed.png differ diff --git a/e2e/kit/assets/android/e2e_emulator_31/ToolbarKeyboardClosed.png b/e2e/kit/assets/android/e2e_emulator_31/ToolbarKeyboardClosed.png index f5bdc13ffe..692a5d6cc1 100644 Binary files a/e2e/kit/assets/android/e2e_emulator_31/ToolbarKeyboardClosed.png and b/e2e/kit/assets/android/e2e_emulator_31/ToolbarKeyboardClosed.png differ diff --git a/e2e/kit/assets/ios/iPhone 13 Pro/ToolbarKeyboardClosed.png b/e2e/kit/assets/ios/iPhone 13 Pro/ToolbarKeyboardClosed.png index 76f230f45c..2d99b5252e 100644 Binary files a/e2e/kit/assets/ios/iPhone 13 Pro/ToolbarKeyboardClosed.png and b/e2e/kit/assets/ios/iPhone 13 Pro/ToolbarKeyboardClosed.png differ diff --git a/e2e/kit/assets/ios/iPhone 14 Pro/ToolbarKeyboardClosed.png b/e2e/kit/assets/ios/iPhone 14 Pro/ToolbarKeyboardClosed.png index d0d5a2e7d1..c11bf9b729 100644 Binary files a/e2e/kit/assets/ios/iPhone 14 Pro/ToolbarKeyboardClosed.png and b/e2e/kit/assets/ios/iPhone 14 Pro/ToolbarKeyboardClosed.png differ diff --git a/e2e/kit/assets/ios/iPhone 15 Pro/ToolbarKeyboardClosed.png b/e2e/kit/assets/ios/iPhone 15 Pro/ToolbarKeyboardClosed.png index b1da4c71f2..c7cbd27dc5 100644 Binary files a/e2e/kit/assets/ios/iPhone 15 Pro/ToolbarKeyboardClosed.png and b/e2e/kit/assets/ios/iPhone 15 Pro/ToolbarKeyboardClosed.png differ diff --git a/e2e/kit/assets/ios/iPhone 16 Pro/ToolbarKeyboardClosed.png b/e2e/kit/assets/ios/iPhone 16 Pro/ToolbarKeyboardClosed.png index 9e0ae2e52e..562f8a18ce 100644 Binary files a/e2e/kit/assets/ios/iPhone 16 Pro/ToolbarKeyboardClosed.png and b/e2e/kit/assets/ios/iPhone 16 Pro/ToolbarKeyboardClosed.png differ diff --git a/e2e/kit/assets/ios/iPhone 16e/ToolbarKeyboardClosed.png b/e2e/kit/assets/ios/iPhone 16e/ToolbarKeyboardClosed.png index f14cf6f01a..b30649ac88 100644 Binary files a/e2e/kit/assets/ios/iPhone 16e/ToolbarKeyboardClosed.png and b/e2e/kit/assets/ios/iPhone 16e/ToolbarKeyboardClosed.png differ diff --git a/e2e/kit/assets/ios/iPhone 17 Pro/ToolbarKeyboardClosed.png b/e2e/kit/assets/ios/iPhone 17 Pro/ToolbarKeyboardClosed.png index f93d51662b..9007616dcc 100644 Binary files a/e2e/kit/assets/ios/iPhone 17 Pro/ToolbarKeyboardClosed.png and b/e2e/kit/assets/ios/iPhone 17 Pro/ToolbarKeyboardClosed.png differ diff --git a/src/components/KeyboardAwareScrollView/index.tsx b/src/components/KeyboardAwareScrollView/index.tsx index 4b216662a0..a6352bc167 100644 --- a/src/components/KeyboardAwareScrollView/index.tsx +++ b/src/components/KeyboardAwareScrollView/index.tsx @@ -12,8 +12,7 @@ import Reanimated, { scrollTo, useAnimatedReaction, useAnimatedRef, - useAnimatedStyle, - useScrollViewOffset, + useDerivedValue, useSharedValue, } from "react-native-reanimated"; @@ -24,10 +23,13 @@ import { } from "../../hooks"; import { findNodeHandle } from "../../utils/findNodeHandle"; import useCombinedRef from "../hooks/useCombinedRef"; +import useScrollState from "../hooks/useScrollState"; +import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding"; import { useSmoothKeyboardHandler } from "./useSmoothKeyboardHandler"; import { debounce, scrollDistanceWithRespectToSnapPoints } from "./utils"; +import type { AnimatedScrollViewComponent } from "../ScrollViewWithBottomPadding"; import type { LayoutChangeEvent, ScrollView, @@ -49,7 +51,7 @@ export type KeyboardAwareScrollViewProps = { /** Adjusting the bottom spacing of KeyboardAwareScrollView. Default is `0`. */ extraKeyboardSpace?: number; /** Custom component for `ScrollView`. Default is `ScrollView`. */ - ScrollViewComponent?: React.ComponentType; + ScrollViewComponent?: AnimatedScrollViewComponent; } & ScrollViewProps; export type KeyboardAwareScrollViewRef = { assureFocusedInputVisible: () => void; @@ -131,7 +133,11 @@ const KeyboardAwareScrollView = forwardRef< const onRef = useCombinedRef(scrollViewAnimatedRef, scrollViewRef); const scrollViewTarget = useSharedValue(null); const scrollPosition = useSharedValue(0); - const position = useScrollViewOffset(scrollViewAnimatedRef); + const { + offset: position, + layout: scrollViewLayout, + size: scrollViewContentSize, + } = useScrollState(scrollViewAnimatedRef); const currentKeyboardFrameHeight = useSharedValue(0); const keyboardHeight = useSharedValue(0); const keyboardWillAppear = useSharedValue(false); @@ -142,6 +148,7 @@ const KeyboardAwareScrollView = forwardRef< const layout = useSharedValue(null); const lastSelection = useSharedValue(null); + const ghostViewSpace = useSharedValue(-1); const { height } = useWindowDimensions(); @@ -213,6 +220,30 @@ const KeyboardAwareScrollView = forwardRef< }, [bottomOffset, enabled, height, snapToOffsets], ); + const removeGhostPadding = useCallback((e: number) => { + "worklet"; + + // new `ScrollViewWithBottomPadding` behavior: if we hide keyboard and we are in the end of `ScrollView` + // then we always need to scroll back, because we apply a padding that doesn't change layout, so we will + // not have auto scroll back in this case + if (!keyboardWillAppear.value && ghostViewSpace.value > 0) { + scrollTo( + scrollViewAnimatedRef, + 0, + scrollPosition.value - + interpolate( + e, + [initialKeyboardSize.value, keyboardHeight.value], + [ghostViewSpace.value, 0], + ), + false, + ); + + return true; + } + + return false; + }, []); const performScrollWithPositionRestoration = useCallback( (newPosition: number) => { "worklet"; @@ -372,12 +403,25 @@ const KeyboardAwareScrollView = forwardRef< // will pick up correct values position.value += maybeScroll(e.height, true); } + + ghostViewSpace.value = + position.value + + scrollViewLayout.value.height - + scrollViewContentSize.value.height; + + if (ghostViewSpace.value > 0) { + scrollPosition.value = position.value; + } }, onMove: (e) => { "worklet"; syncKeyboardFrame(e); + if (removeGhostPadding(e.height)) { + return; + } + // if the user has set disableScrollOnKeyboardHide, only auto-scroll when the keyboard opens if (!disableScrollOnKeyboardHide || keyboardWillAppear.value) { maybeScroll(e.height); @@ -386,13 +430,20 @@ const KeyboardAwareScrollView = forwardRef< onEnd: (e) => { "worklet"; + removeGhostPadding(e.height); + keyboardHeight.value = e.height; scrollPosition.value = position.value; syncKeyboardFrame(e); }, }, - [maybeScroll, disableScrollOnKeyboardHide, syncKeyboardFrame], + [ + maybeScroll, + removeGhostPadding, + disableScrollOnKeyboardHide, + syncKeyboardFrame, + ], ); const synchronize = useCallback(async () => { @@ -443,32 +494,28 @@ const KeyboardAwareScrollView = forwardRef< [], ); - const view = useAnimatedStyle( - () => - enabled - ? { - // animations become choppy when scrolling to the end of the `ScrollView` (when the last input is focused) - // this happens because the layout recalculates on every frame. To avoid this we slightly increase padding - // by `+1`. In this way we assure, that `scrollTo` will never scroll to the end, because it uses interpolation - // from 0 to `keyboardHeight`, and here our padding is `keyboardHeight + 1`. It allows us not to re-run layout - // re-calculation on every animation frame and it helps to achieve smooth animation. - // see: https://github.com/kirillzyusko/react-native-keyboard-controller/pull/342 - paddingBottom: currentKeyboardFrameHeight.value + 1, - } - : {}, + // animations become choppy when scrolling to the end of the `ScrollView` (when the last input is focused) + // this happens because the layout recalculates on every frame. To avoid this we slightly increase padding + // by `+1`. In this way we assure, that `scrollTo` will never scroll to the end, because it uses interpolation + // from 0 to `keyboardHeight`, and here our padding is `keyboardHeight + 1`. It allows us not to re-run layout + // re-calculation on every animation frame and it helps to achieve smooth animation. + // see: https://github.com/kirillzyusko/react-native-keyboard-controller/pull/342 + const padding = useDerivedValue( + () => (enabled ? currentKeyboardFrameHeight.value + 1 : 0), [enabled], ); return ( - {children} - {enabled && } - + ); }, ); diff --git a/src/components/hooks/useScrollState.ts b/src/components/hooks/useScrollState.ts new file mode 100644 index 0000000000..663df7ab45 --- /dev/null +++ b/src/components/hooks/useScrollState.ts @@ -0,0 +1,59 @@ +import { useEffect } from "react"; +import { useEvent, useSharedValue } from "react-native-reanimated"; + +import { useEventHandlerRegistration } from "../../internal"; + +import type { AnimatedRef } from "react-native-reanimated"; +import type Reanimated from "react-native-reanimated"; + +const NATIVE_SCROLL_EVENT_NAMES = [ + "onScroll", + "onScrollBeginDrag", + "onScrollEndDrag", + "onMomentumScrollBegin", + "onMomentumScrollEnd", +]; + +type ScrollEvent = { + contentOffset: { + x: number; + y: number; + }; + layoutMeasurement: { + width: number; + height: number; + }; + contentSize: { + width: number; + height: number; + }; +}; + +const useScrollState = (ref: AnimatedRef) => { + const offset = useSharedValue(0); + const layout = useSharedValue({ width: 0, height: 0 }); + const size = useSharedValue({ width: 0, height: 0 }); + + const register = useEventHandlerRegistration(ref); + + const eventHandler = useEvent((event: ScrollEvent) => { + "worklet"; + + // eslint-disable-next-line react-compiler/react-compiler + offset.value = event.contentOffset.y; + layout.value = event.layoutMeasurement; + size.value = event.contentSize; + }, NATIVE_SCROLL_EVENT_NAMES); + + useEffect(() => { + const cleanup = register(eventHandler); + + return () => { + cleanup(); + }; + }, []); + + return { offset, layout, size }; +}; + +export default useScrollState;