Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Binary file modified e2e/kit/assets/android/e2e_emulator_28/ToolbarKeyboardClosed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified e2e/kit/assets/ios/iPhone 13 Pro/ToolbarKeyboardClosed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified e2e/kit/assets/ios/iPhone 14 Pro/ToolbarKeyboardClosed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified e2e/kit/assets/ios/iPhone 15 Pro/ToolbarKeyboardClosed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified e2e/kit/assets/ios/iPhone 16 Pro/ToolbarKeyboardClosed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified e2e/kit/assets/ios/iPhone 16e/ToolbarKeyboardClosed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified e2e/kit/assets/ios/iPhone 17 Pro/ToolbarKeyboardClosed.png

@kirillzyusko kirillzyusko Feb 1, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caught a regression on iOS 26 + Xcode 16 👍

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
89 changes: 68 additions & 21 deletions src/components/KeyboardAwareScrollView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ import Reanimated, {
scrollTo,
useAnimatedReaction,
useAnimatedRef,
useAnimatedStyle,
useScrollViewOffset,
useDerivedValue,
useSharedValue,
} from "react-native-reanimated";

Expand All @@ -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,
Expand All @@ -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<ScrollViewProps>;
ScrollViewComponent?: AnimatedScrollViewComponent;
} & ScrollViewProps;
export type KeyboardAwareScrollViewRef = {
assureFocusedInputVisible: () => void;
Expand Down Expand Up @@ -131,7 +133,11 @@ const KeyboardAwareScrollView = forwardRef<
const onRef = useCombinedRef(scrollViewAnimatedRef, scrollViewRef);
const scrollViewTarget = useSharedValue<number | null>(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);
Expand All @@ -142,6 +148,7 @@ const KeyboardAwareScrollView = forwardRef<
const layout = useSharedValue<FocusedInputLayoutChangedEvent | null>(null);
const lastSelection =
useSharedValue<FocusedInputSelectionChangedEvent | null>(null);
const ghostViewSpace = useSharedValue(-1);

const { height } = useWindowDimensions();

Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 (
<ScrollViewComponent
<ScrollViewWithBottomPadding
ref={onRef}
{...rest}
bottomPadding={padding}
scrollEventThrottle={16}
ScrollViewComponent={ScrollViewComponent}
onLayout={onScrollViewLayout}
>
{children}
{enabled && <Reanimated.View style={view} />}
</ScrollViewComponent>
</ScrollViewWithBottomPadding>
);
},
);
Expand Down
59 changes: 59 additions & 0 deletions src/components/hooks/useScrollState.ts
Original file line number Diff line number Diff line change
@@ -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<Reanimated.ScrollView>) => {
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;
Loading