Skip to content

Commit 423dbef

Browse files
authored
feat: use contentInset for KeyboardAwareScrollView (#797)
## 📜 Description Use `ScrollViewWithBottomPadding` component for `KeyboardAwareScrollView` to optimize performance and fix other issues that were caused by the fact of usage additional view inside `ScrollView`. ## 💡 Motivation and Context In this PR I'm moving away from the idea of having a fake view in the end of the `ScrollView` and instead start to use `ScrollViewWithBottomPadding` component to achieve a desired visual effect. The additional view causes many issues such as: - unintended layout shift if `flex: 1` style is used; - broken auto-grow for multiline input; - unexpected styling issues if you use `gap`/`justifyContent: "space-between"` and other properties. In this PR I'm switching to the component that has been added in #1294 With its new power I can achieve cross-platform behavior and: - without layout modification add a scrollable padding; - get the same behavior on both iOS/Android; - don't use any hidden children that can break something. This PR has been opened for a long time, but finally can be merged because I got working version on Android. Closes #794 #645 #929 #168 Potentially: #748 software-mansion/react-native-reanimated#5567 #719 Unlocks one item from #883 ## 📢 Changelog <!-- High level overview of important changes --> <!-- For example: fixed status bar manipulation; added new types declarations; --> <!-- If your changes don't affect one of platform/language below - then remove this platform/language --> ### JS - added `useScrollState` hook; - use `ScrollViewWithBottomPadding` component in `KeyboardAwareScrollView`; - added `removeGhostPadding` in `KeyboardAwareScrollView` and use it there. ### E2E - update assets for `KeyboardToolbarClosed` test-case. ## 🤔 How Has This Been Tested? Tested manually on: - iPhone 17 Pro (iOS 26.2, simulator); - Pixel 7 Pro (API 36, real device); - e2e_emulator_28 (API 28, emulator);; - all e2e devices; ## 📸 Screenshots (if appropriate): |iOS|Android| |---|--------| |<video src="https://github.com/user-attachments/assets/adb98eb9-6ac0-436f-a5cc-3c795722401e">|<video src="https://github.com/user-attachments/assets/07c4b5db-da79-4167-9d3e-c064817bf6f3">| ## 📝 Checklist - [x] CI successfully passed - [x] I added new mocks and corresponding unit-tests if library API was changed
1 parent 7bf7ba1 commit 423dbef

11 files changed

Lines changed: 130 additions & 24 deletions

File tree

android/src/main/java/com/reactnativekeyboardcontroller/views/ClippingScrollViewDecoratorView.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ class ClippingScrollViewDecoratorView(
2424
}
2525

2626
private fun decorateScrollView() {
27-
val scrollView = getChildAt(0) as? ScrollView
27+
val scrollView = getChildAt(0) as? ScrollView ?: return
2828

29-
scrollView?.clipToPadding = false
30-
scrollView?.setPadding(
29+
scrollView.clipToPadding = false
30+
scrollView.setPadding(
3131
scrollView.paddingLeft,
3232
scrollView.paddingTop,
3333
scrollView.paddingRight,
-3 Bytes
Loading
1 Byte
Loading
2 Bytes
Loading
979 Bytes
Loading
-1 Bytes
Loading
3 Bytes
Loading
5 Bytes
Loading
-6.89 KB
Loading

src/components/KeyboardAwareScrollView/index.tsx

Lines changed: 68 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@ import Reanimated, {
1212
scrollTo,
1313
useAnimatedReaction,
1414
useAnimatedRef,
15-
useAnimatedStyle,
16-
useScrollViewOffset,
15+
useDerivedValue,
1716
useSharedValue,
1817
} from "react-native-reanimated";
1918

@@ -24,10 +23,13 @@ import {
2423
} from "../../hooks";
2524
import { findNodeHandle } from "../../utils/findNodeHandle";
2625
import useCombinedRef from "../hooks/useCombinedRef";
26+
import useScrollState from "../hooks/useScrollState";
27+
import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding";
2728

2829
import { useSmoothKeyboardHandler } from "./useSmoothKeyboardHandler";
2930
import { debounce, scrollDistanceWithRespectToSnapPoints } from "./utils";
3031

32+
import type { AnimatedScrollViewComponent } from "../ScrollViewWithBottomPadding";
3133
import type {
3234
LayoutChangeEvent,
3335
ScrollView,
@@ -49,7 +51,7 @@ export type KeyboardAwareScrollViewProps = {
4951
/** Adjusting the bottom spacing of KeyboardAwareScrollView. Default is `0`. */
5052
extraKeyboardSpace?: number;
5153
/** Custom component for `ScrollView`. Default is `ScrollView`. */
52-
ScrollViewComponent?: React.ComponentType<ScrollViewProps>;
54+
ScrollViewComponent?: AnimatedScrollViewComponent;
5355
} & ScrollViewProps;
5456
export type KeyboardAwareScrollViewRef = {
5557
assureFocusedInputVisible: () => void;
@@ -131,7 +133,11 @@ const KeyboardAwareScrollView = forwardRef<
131133
const onRef = useCombinedRef(scrollViewAnimatedRef, scrollViewRef);
132134
const scrollViewTarget = useSharedValue<number | null>(null);
133135
const scrollPosition = useSharedValue(0);
134-
const position = useScrollViewOffset(scrollViewAnimatedRef);
136+
const {
137+
offset: position,
138+
layout: scrollViewLayout,
139+
size: scrollViewContentSize,
140+
} = useScrollState(scrollViewAnimatedRef);
135141
const currentKeyboardFrameHeight = useSharedValue(0);
136142
const keyboardHeight = useSharedValue(0);
137143
const keyboardWillAppear = useSharedValue(false);
@@ -142,6 +148,7 @@ const KeyboardAwareScrollView = forwardRef<
142148
const layout = useSharedValue<FocusedInputLayoutChangedEvent | null>(null);
143149
const lastSelection =
144150
useSharedValue<FocusedInputSelectionChangedEvent | null>(null);
151+
const ghostViewSpace = useSharedValue(-1);
145152

146153
const { height } = useWindowDimensions();
147154

@@ -213,6 +220,30 @@ const KeyboardAwareScrollView = forwardRef<
213220
},
214221
[bottomOffset, enabled, height, snapToOffsets],
215222
);
223+
const removeGhostPadding = useCallback((e: number) => {
224+
"worklet";
225+
226+
// new `ScrollViewWithBottomPadding` behavior: if we hide keyboard and we are in the end of `ScrollView`
227+
// then we always need to scroll back, because we apply a padding that doesn't change layout, so we will
228+
// not have auto scroll back in this case
229+
if (!keyboardWillAppear.value && ghostViewSpace.value > 0) {
230+
scrollTo(
231+
scrollViewAnimatedRef,
232+
0,
233+
scrollPosition.value -
234+
interpolate(
235+
e,
236+
[initialKeyboardSize.value, keyboardHeight.value],
237+
[ghostViewSpace.value, 0],
238+
),
239+
false,
240+
);
241+
242+
return true;
243+
}
244+
245+
return false;
246+
}, []);
216247
const performScrollWithPositionRestoration = useCallback(
217248
(newPosition: number) => {
218249
"worklet";
@@ -372,12 +403,25 @@ const KeyboardAwareScrollView = forwardRef<
372403
// will pick up correct values
373404
position.value += maybeScroll(e.height, true);
374405
}
406+
407+
ghostViewSpace.value =
408+
position.value +
409+
scrollViewLayout.value.height -
410+
scrollViewContentSize.value.height;
411+
412+
if (ghostViewSpace.value > 0) {
413+
scrollPosition.value = position.value;
414+
}
375415
},
376416
onMove: (e) => {
377417
"worklet";
378418

379419
syncKeyboardFrame(e);
380420

421+
if (removeGhostPadding(e.height)) {
422+
return;
423+
}
424+
381425
// if the user has set disableScrollOnKeyboardHide, only auto-scroll when the keyboard opens
382426
if (!disableScrollOnKeyboardHide || keyboardWillAppear.value) {
383427
maybeScroll(e.height);
@@ -386,13 +430,20 @@ const KeyboardAwareScrollView = forwardRef<
386430
onEnd: (e) => {
387431
"worklet";
388432

433+
removeGhostPadding(e.height);
434+
389435
keyboardHeight.value = e.height;
390436
scrollPosition.value = position.value;
391437

392438
syncKeyboardFrame(e);
393439
},
394440
},
395-
[maybeScroll, disableScrollOnKeyboardHide, syncKeyboardFrame],
441+
[
442+
maybeScroll,
443+
removeGhostPadding,
444+
disableScrollOnKeyboardHide,
445+
syncKeyboardFrame,
446+
],
396447
);
397448

398449
const synchronize = useCallback(async () => {
@@ -443,32 +494,28 @@ const KeyboardAwareScrollView = forwardRef<
443494
[],
444495
);
445496

446-
const view = useAnimatedStyle(
447-
() =>
448-
enabled
449-
? {
450-
// animations become choppy when scrolling to the end of the `ScrollView` (when the last input is focused)
451-
// this happens because the layout recalculates on every frame. To avoid this we slightly increase padding
452-
// by `+1`. In this way we assure, that `scrollTo` will never scroll to the end, because it uses interpolation
453-
// from 0 to `keyboardHeight`, and here our padding is `keyboardHeight + 1`. It allows us not to re-run layout
454-
// re-calculation on every animation frame and it helps to achieve smooth animation.
455-
// see: https://github.com/kirillzyusko/react-native-keyboard-controller/pull/342
456-
paddingBottom: currentKeyboardFrameHeight.value + 1,
457-
}
458-
: {},
497+
// animations become choppy when scrolling to the end of the `ScrollView` (when the last input is focused)
498+
// this happens because the layout recalculates on every frame. To avoid this we slightly increase padding
499+
// by `+1`. In this way we assure, that `scrollTo` will never scroll to the end, because it uses interpolation
500+
// from 0 to `keyboardHeight`, and here our padding is `keyboardHeight + 1`. It allows us not to re-run layout
501+
// re-calculation on every animation frame and it helps to achieve smooth animation.
502+
// see: https://github.com/kirillzyusko/react-native-keyboard-controller/pull/342
503+
const padding = useDerivedValue(
504+
() => (enabled ? currentKeyboardFrameHeight.value + 1 : 0),
459505
[enabled],
460506
);
461507

462508
return (
463-
<ScrollViewComponent
509+
<ScrollViewWithBottomPadding
464510
ref={onRef}
465511
{...rest}
512+
bottomPadding={padding}
466513
scrollEventThrottle={16}
514+
ScrollViewComponent={ScrollViewComponent}
467515
onLayout={onScrollViewLayout}
468516
>
469517
{children}
470-
{enabled && <Reanimated.View style={view} />}
471-
</ScrollViewComponent>
518+
</ScrollViewWithBottomPadding>
472519
);
473520
},
474521
);

0 commit comments

Comments
 (0)