Skip to content

Commit fa6d67d

Browse files
authored
feat: new behavior="translate-with-padding" for KeyboardAvoidingView (#830)
## 📜 Description Added new `behavior="translate-with-padding"` for `KeyboardAvoidingView`. ## 💡 Motivation and Context The new mode ideally fits for chat-like applications and has very good performance. The implementation was mostly inspired by [WindowInsetsAnimation](https://github.com/android/user-interface-samples/tree/main/WindowInsetsAnimation) sample app. However when I tried to replace `translateY` to `0` and apply `paddingBottom` which is equal to previous `translateY` I had flashes on Android (mostly because of nature of Android - transform properties (e.g., `translateY`) only trigger a **repaint** (no _reflow_) and are applied **immediately**. Changes to **layout** properties like **padding** force the Android rendering system to recalculate layout (**reflow**), which can take an extra frame to resolve). So instead of switching those properties I decided to keep `translateY` and add `paddingTop` (in the end of animation to resize the container). Since we change `paddingTop` only in the beginning or in the end of animation the transition is unbelievable smooth, because we trigger layout re-calculation only once (so in terms of complexity new approach is `O(1)` vs `O(n)`). Closes #719 (comment) software-mansion/react-native-reanimated#6854 #650 (comment) ## 🔢 Things to do - write e2e tests for chat FlatList example screen (to cover new functionality with e2e tests); - update documentation page with detailed explanation of modes and when to use each of them. ## 📢 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 new `"translate-with-padding"` behavior for `KeyboardAvoidingView` - added `useTranslateAnimation` hook; - integrate `useTranslateAnimation` with `"translate-with-padding"`; - update example app to more performant implementation; ### Docs - mention new behavior; ## 🤔 How Has This Been Tested? Tested both paper and fabric on: - iPhone 15 Pro (iOS 17.5); - Medium phone (API 35). ## 📸 Screenshots (if appropriate): ### Paper |Android|iOS| |--------|---| |<video src="https://github.com/user-attachments/assets/f8d35138-77ae-432e-a95b-97104a01b4fe">|<video src="https://github.com/user-attachments/assets/93db0cdb-4ef4-4fb0-8cd4-9ada0312ff7d">| ### Fabric |Android|iOS| |--------|---| |<video src="https://github.com/user-attachments/assets/e2398edc-d6c0-41e8-8fdc-eb95e7741f74">|<video src="https://github.com/user-attachments/assets/379567d3-5d7a-482c-81af-ab359356a11c">| ## 📝 Checklist - [x] CI successfully passed - [x] I added new mocks and corresponding unit-tests if library API was changed
1 parent 482212e commit fa6d67d

File tree

10 files changed

+113
-86
lines changed

10 files changed

+113
-86
lines changed

FabricExample/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"@react-native-community/blur": "^4.4.1",
1515
"@react-native-masked-view/masked-view": "^0.3.2",
1616
"@react-navigation/bottom-tabs": "^6.6.1",
17+
"@react-navigation/elements": "^2.2.5",
1718
"@react-navigation/native": "^6.1.18",
1819
"@react-navigation/native-stack": "^6.11.0",
1920
"@react-navigation/stack": "^6.4.1",

FabricExample/src/screens/Examples/ReanimatedChatFlatList/index.tsx

+10-40
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
1+
import { useHeaderHeight } from "@react-navigation/elements";
12
import React from "react";
2-
import { FlatList, TextInput, View } from "react-native";
3-
import { useKeyboardHandler } from "react-native-keyboard-controller";
4-
import Animated, {
5-
useAnimatedStyle,
6-
useSharedValue,
7-
} from "react-native-reanimated";
3+
import { FlatList, TextInput } from "react-native";
4+
import { KeyboardAvoidingView } from "react-native-keyboard-controller";
85

96
import Message from "../../../components/Message";
107
import { history } from "../../../components/Message/data";
@@ -20,41 +17,15 @@ const RenderItem: ListRenderItem<MessageProps> = ({ item, index }) => {
2017
return <Message key={index} {...item} />;
2118
};
2219

23-
const useGradualAnimation = () => {
24-
const height = useSharedValue(0);
25-
26-
useKeyboardHandler(
27-
{
28-
onMove: (e) => {
29-
"worklet";
30-
31-
// eslint-disable-next-line react-compiler/react-compiler
32-
height.value = e.height;
33-
},
34-
onEnd: (e) => {
35-
"worklet";
36-
37-
height.value = e.height;
38-
},
39-
},
40-
[],
41-
);
42-
43-
return { height };
44-
};
45-
4620
function ReanimatedChatFlatList() {
47-
const { height } = useGradualAnimation();
48-
49-
const fakeView = useAnimatedStyle(
50-
() => ({
51-
height: Math.abs(height.value),
52-
}),
53-
[],
54-
);
21+
const headerHeight = useHeaderHeight();
5522

5623
return (
57-
<View style={styles.container}>
24+
<KeyboardAvoidingView
25+
behavior="translate-with-padding"
26+
keyboardVerticalOffset={headerHeight}
27+
style={styles.container}
28+
>
5829
<FlatList
5930
inverted
6031
contentContainerStyle={styles.contentContainer}
@@ -63,8 +34,7 @@ function ReanimatedChatFlatList() {
6334
renderItem={RenderItem}
6435
/>
6536
<TextInput style={styles.textInput} />
66-
<Animated.View style={fakeView} />
67-
</View>
37+
</KeyboardAvoidingView>
6838
);
6939
}
7040

FabricExample/yarn.lock

+7
Original file line numberDiff line numberDiff line change
@@ -2367,6 +2367,13 @@
23672367
resolved "https://registry.yarnpkg.com/@react-navigation/elements/-/elements-1.3.31.tgz#28dd802a0787bb03fc0e5be296daf1804dbebbcf"
23682368
integrity sha512-bUzP4Awlljx5RKEExw8WYtif8EuQni2glDaieYROKTnaxsu9kEIA515sXQgUDZU4Ob12VoL7+z70uO3qrlfXcQ==
23692369

2370+
"@react-navigation/elements@^2.2.5":
2371+
version "2.2.5"
2372+
resolved "https://registry.yarnpkg.com/@react-navigation/elements/-/elements-2.2.5.tgz#0e2ca76e2003e96b417a3d7c2829bf1afd69193f"
2373+
integrity sha512-sDhE+W14P7MNWLMxXg1MEVXwkLUpMZJGflE6nQNzLmolJQIHgcia0Mrm8uRa3bQovhxYu1UzEojLZ+caoZt7Fg==
2374+
dependencies:
2375+
color "^4.2.3"
2376+
23702377
"@react-navigation/native-stack@^6.11.0":
23712378
version "6.11.0"
23722379
resolved "https://registry.yarnpkg.com/@react-navigation/native-stack/-/native-stack-6.11.0.tgz#a33f92cbd55dfe28fb0ba67df99aaa95240eb87c"

docs/docs/api/components/keyboard-avoiding-view.mdx

+1
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ Specify how to react to the presence of the keyboard. Could be one value of:
117117
- `position`
118118
- `padding`
119119
- `height`
120+
- `translate-with-padding`
120121

121122
### `contentContainerStyle`
122123

docs/versioned_docs/version-1.16.0/api/components/keyboard-avoiding-view.mdx

+1
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ Specify how to react to the presence of the keyboard. Could be one value of:
117117
- `position`
118118
- `padding`
119119
- `height`
120+
- `translate-with-padding`
120121

121122
### `contentContainerStyle`
122123

example/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@react-native-community/blur": "^4.4.1",
1616
"@react-native-masked-view/masked-view": "^0.3.2",
1717
"@react-navigation/bottom-tabs": "^6.6.1",
18+
"@react-navigation/elements": "^2.2.5",
1819
"@react-navigation/native": "^6.1.18",
1920
"@react-navigation/native-stack": "^6.11.0",
2021
"@react-navigation/stack": "^6.4.1",

example/src/screens/Examples/ReanimatedChatFlatList/index.tsx

+10-40
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
1+
import { useHeaderHeight } from "@react-navigation/elements";
12
import React from "react";
2-
import { FlatList, TextInput, View } from "react-native";
3-
import { useKeyboardHandler } from "react-native-keyboard-controller";
4-
import Animated, {
5-
useAnimatedStyle,
6-
useSharedValue,
7-
} from "react-native-reanimated";
3+
import { FlatList, TextInput } from "react-native";
4+
import { KeyboardAvoidingView } from "react-native-keyboard-controller";
85

96
import Message from "../../../components/Message";
107
import { history } from "../../../components/Message/data";
@@ -20,41 +17,15 @@ const RenderItem: ListRenderItem<MessageProps> = ({ item, index }) => {
2017
return <Message key={index} {...item} />;
2118
};
2219

23-
const useGradualAnimation = () => {
24-
const height = useSharedValue(0);
25-
26-
useKeyboardHandler(
27-
{
28-
onMove: (e) => {
29-
"worklet";
30-
31-
// eslint-disable-next-line react-compiler/react-compiler
32-
height.value = e.height;
33-
},
34-
onEnd: (e) => {
35-
"worklet";
36-
37-
height.value = e.height;
38-
},
39-
},
40-
[],
41-
);
42-
43-
return { height };
44-
};
45-
4620
function ReanimatedChatFlatList() {
47-
const { height } = useGradualAnimation();
48-
49-
const fakeView = useAnimatedStyle(
50-
() => ({
51-
height: Math.abs(height.value),
52-
}),
53-
[],
54-
);
21+
const headerHeight = useHeaderHeight();
5522

5623
return (
57-
<View style={styles.container}>
24+
<KeyboardAvoidingView
25+
behavior="translate-with-padding"
26+
keyboardVerticalOffset={headerHeight}
27+
style={styles.container}
28+
>
5829
<FlatList
5930
inverted
6031
contentContainerStyle={styles.contentContainer}
@@ -63,8 +34,7 @@ function ReanimatedChatFlatList() {
6334
renderItem={RenderItem}
6435
/>
6536
<TextInput style={styles.textInput} />
66-
<Animated.View style={fakeView} />
67-
</View>
37+
</KeyboardAvoidingView>
6838
);
6939
}
7040

example/yarn.lock

+7
Original file line numberDiff line numberDiff line change
@@ -2367,6 +2367,13 @@
23672367
resolved "https://registry.yarnpkg.com/@react-navigation/elements/-/elements-1.3.31.tgz#28dd802a0787bb03fc0e5be296daf1804dbebbcf"
23682368
integrity sha512-bUzP4Awlljx5RKEExw8WYtif8EuQni2glDaieYROKTnaxsu9kEIA515sXQgUDZU4Ob12VoL7+z70uO3qrlfXcQ==
23692369

2370+
"@react-navigation/elements@^2.2.5":
2371+
version "2.2.5"
2372+
resolved "https://registry.yarnpkg.com/@react-navigation/elements/-/elements-2.2.5.tgz#0e2ca76e2003e96b417a3d7c2829bf1afd69193f"
2373+
integrity sha512-sDhE+W14P7MNWLMxXg1MEVXwkLUpMZJGflE6nQNzLmolJQIHgcia0Mrm8uRa3bQovhxYu1UzEojLZ+caoZt7Fg==
2374+
dependencies:
2375+
color "^4.2.3"
2376+
23702377
"@react-navigation/native-stack@^6.11.0":
23712378
version "6.11.0"
23722379
resolved "https://registry.yarnpkg.com/@react-navigation/native-stack/-/native-stack-6.11.0.tgz#a33f92cbd55dfe28fb0ba67df99aaa95240eb87c"

src/components/KeyboardAvoidingView/hooks.ts

+54
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { useState } from "react";
2+
import { Platform } from "react-native";
23
import { useSharedValue } from "react-native-reanimated";
34

45
import { useKeyboardContext } from "../../context";
56
import { useKeyboardHandler } from "../../hooks";
67

8+
const OS = Platform.OS;
9+
710
export const useKeyboardAnimation = () => {
811
const { reanimated } = useKeyboardContext();
912

@@ -53,3 +56,54 @@ export const useKeyboardAnimation = () => {
5356

5457
return { height, progress, heightWhenOpened, isClosed };
5558
};
59+
export const useTranslateAnimation = () => {
60+
const { reanimated } = useKeyboardContext();
61+
62+
// calculate it only once on mount, to avoid `SharedValue` reads during a render
63+
const [initialProgress] = useState(() => reanimated.progress.value);
64+
65+
const padding = useSharedValue(initialProgress);
66+
const translate = useSharedValue(0);
67+
68+
useKeyboardHandler(
69+
{
70+
onStart: (e) => {
71+
"worklet";
72+
73+
if (e.height === 0) {
74+
// eslint-disable-next-line react-compiler/react-compiler
75+
padding.value = 0;
76+
}
77+
if (OS === "ios") {
78+
translate.value = e.progress;
79+
}
80+
},
81+
onMove: (e) => {
82+
"worklet";
83+
84+
if (OS === "android") {
85+
translate.value = e.progress;
86+
}
87+
},
88+
onInteractive: (e) => {
89+
"worklet";
90+
91+
padding.value = 0;
92+
93+
translate.value = e.progress;
94+
},
95+
onEnd: (e) => {
96+
"worklet";
97+
98+
padding.value = e.progress;
99+
100+
if (OS === "android") {
101+
translate.value = e.progress;
102+
}
103+
},
104+
},
105+
[],
106+
);
107+
108+
return { translate, padding };
109+
};

src/components/KeyboardAvoidingView/index.tsx

+21-6
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import Reanimated, {
1010

1111
import { useWindowDimensions } from "../../hooks";
1212

13-
import { useKeyboardAnimation } from "./hooks";
13+
import { useKeyboardAnimation, useTranslateAnimation } from "./hooks";
1414

1515
import type { LayoutRectangle, ViewProps } from "react-native";
1616

@@ -45,7 +45,7 @@ export type KeyboardAvoidingViewProps = KeyboardAvoidingViewBaseProps &
4545
/**
4646
* Specify how to react to the presence of the keyboard.
4747
*/
48-
behavior?: "height" | "padding";
48+
behavior?: "height" | "padding" | "translate-with-padding";
4949

5050
/**
5151
* `contentContainerStyle` is not allowed for these behaviors.
@@ -85,6 +85,7 @@ const KeyboardAvoidingView = forwardRef<
8585
const initialFrame = useSharedValue<LayoutRectangle | null>(null);
8686
const frame = useDerivedValue(() => initialFrame.value || defaultLayout);
8787

88+
const { translate, padding } = useTranslateAnimation();
8889
const keyboard = useKeyboardAnimation();
8990
const { height: screenHeight } = useWindowDimensions();
9091

@@ -96,6 +97,14 @@ const KeyboardAvoidingView = forwardRef<
9697

9798
return Math.max(frame.value.y + frame.value.height - keyboardY, 0);
9899
}, [screenHeight, keyboardVerticalOffset]);
100+
const interpolateToRelativeKeyboardHeight = useCallback(
101+
(value: number) => {
102+
"worklet";
103+
104+
return interpolate(value, [0, 1], [0, relativeKeyboardHeight()]);
105+
},
106+
[relativeKeyboardHeight],
107+
);
99108

100109
const onLayoutWorklet = useCallback((layout: LayoutRectangle) => {
101110
"worklet";
@@ -114,11 +123,11 @@ const KeyboardAvoidingView = forwardRef<
114123
);
115124

116125
const animatedStyle = useAnimatedStyle(() => {
117-
const bottom = interpolate(
126+
const bottom = interpolateToRelativeKeyboardHeight(
118127
keyboard.progress.value,
119-
[0, 1],
120-
[0, relativeKeyboardHeight()],
121128
);
129+
const translateY = interpolateToRelativeKeyboardHeight(translate.value);
130+
const paddingBottom = interpolateToRelativeKeyboardHeight(padding.value);
122131
const bottomHeight = enabled ? bottom : 0;
123132

124133
switch (behavior) {
@@ -138,10 +147,16 @@ const KeyboardAvoidingView = forwardRef<
138147
case "padding":
139148
return { paddingBottom: bottomHeight };
140149

150+
case "translate-with-padding":
151+
return {
152+
paddingTop: paddingBottom,
153+
transform: [{ translateY: -translateY }],
154+
};
155+
141156
default:
142157
return {};
143158
}
144-
}, [behavior, enabled, relativeKeyboardHeight]);
159+
}, [behavior, enabled, interpolateToRelativeKeyboardHeight]);
145160
const isPositionBehavior = behavior === "position";
146161
const containerStyle = isPositionBehavior ? contentContainerStyle : style;
147162
const combinedStyles = useMemo(

0 commit comments

Comments
 (0)