Skip to content

Commit c436fbb

Browse files
authored
fix: bug with KeyboardChatScrollView + inverted + maintainVisibleScrollPosition + FlashList (#1437)
## 📜 Description Fixed a problem with non-working `maintainVisibleScrollPosition` when using `inverted` prop of `KeyboardChatScrollView`. ## 💡 Motivation and Context The issue stems from the fact that we use additional `View` in `KeyboardChatScrollView`: ```tsx <ScrollViewComponent ref={ref} animatedProps={animatedProps} {...rest}> {inverted ? ( // The only thing it can break is `StickyHeader`, but it's already broken in FlatList and other lists // don't support this functionality, so we can add additional view here // The correct fix would be to add a new prop in ScrollView that allows // to customize children extraction logic and skip custom view <View collapsable={false} nativeID="container"> {children} </View> ) : ( children )} </ScrollViewComponent> ``` It totally breaks `maintainVisibleScrollPosition` behavior. The algorithm is similar on both iOS/Android, but we'll use Android code snippets for demonstration. First of all it iterates over **direct children** of `contentView`: ```kt for (i in config.minIndexForVisible until contentView.childCount) { val child = contentView.getChildAt(i) val position = child.y + child.height if (position > currentScroll || i == contentView.childCount - 1) { firstVisibleViewRef = WeakReference(child) prevFirstVisibleFrame = frame break } } ``` So when we draw additional `View` - the algorithm becomes completely broken. Further the code does something like this: ```kt val deltaY = newFrame.top - prevFirstVisibleFrame.top if (deltaY != 0) { scrollView.scrollToPreservingMomentum(scrollView.scrollX, scrollY + deltaY) } ``` But this code will never work properly, because on first step we will not be able to compute proper `prevFirstVisibleFrame` (since we have only single `View` and this `View` is always visible). First idea that I had in my mind was adding `TransientView` that would substitute `childCount`/`getChildAt` and forward them down to inner children. However it significantly increases the complexity of the app. And I decided to dig deeper into "why exactly I added this code?". The reason was very simple. My virtualized list example was flickering during keyboard animation (pay attention to "So far it looks cool!" message in the bottom of the screen): https://github.com/user-attachments/assets/e66d119d-3818-4b10-b921-4d84af3d414c If we check debugger we can see that this item has been recycled: <img width="1156" height="159" alt="Screenshot 2026-04-17 at 10 57 35" src="https://github.com/user-attachments/assets/696150da-2f44-48ba-b93b-fa8ce02ac6de" /> > [!IMPORTANT] > This issue seems to be reproducible only with `FlashList` (not `FlatList`). If I remember correctly `FlatList` tries to mount all items if we don't provide a function for layout prediction (just do it in batch updates). `FlashList` actually recycles items (and because of that it has better memory footprint). So the issue is reproducible only in `FlashList` since it recycles items. > [!WARNING] > I also managed to reproduce it on new arch only. Old arch works well even without `drawDistance` specification 🤷‍♂️ I think this problem is caused by the fact that we change `translateY` of the content and perform scroll. In this case visible items are not the same that `FlashList` thinks are visible (because `FlashList` doesn't know anything about `translateY` that was modified natively). So the fix is to remove additional view and simply increase `windowSize` in example app (I also mentioned this side effect in documentation). Additionally I think that this fake `View` may completely break recycling of `FlashList` because we render a single big `View` which technically is always visible 🤷‍♂️ <hr /> So in general: rendering additional `View` may break many things (maintainVisibleScrollPosition, recycling) so it's not a correct solution. Current solution - remove that fake view AND increase rendering buffer for virtualized lists. Closes #1423 ## 📢 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 --> ### Docs - mention that `inverted` prop may require enhanced `drawDistance`; ### JS - removed additional `View` when rendering `inverted` list; ### Android - use `contentView` directly (no first child of `contentView` anymore) for modifying `translateY` posiiton; ## 🤔 How Has This Been Tested? Tested manually on Pixel 7 Pro (API 36). ## 📸 Screenshots (if appropriate): ### Flickering |Before|After| |-------|----| |<video src="https://github.com/user-attachments/assets/e66d119d-3818-4b10-b921-4d84af3d414c">|<video src="https://github.com/user-attachments/assets/16b5439b-4fb6-4c61-9921-d9704f28cf6e">| ### Auto-scrolling |Before|After| |-------|----| |<video src="https://github.com/user-attachments/assets/1390ab14-7359-4d21-b391-25a59f51bb0a">|<video src="https://github.com/user-attachments/assets/4d2c0884-ff07-4bd8-a1da-aaa505c4ec2c">| ## 📝 Checklist - [x] CI successfully passed - [x] I added new mocks and corresponding unit-tests if library API was changed
1 parent 15111a4 commit c436fbb

10 files changed

Lines changed: 42 additions & 23 deletions

File tree

FabricExample/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"@react-navigation/native": "7.1.17",
2323
"@react-navigation/native-stack": "7.3.26",
2424
"@react-navigation/stack": "7.3.6",
25-
"@shopify/flash-list": "^2.2.0",
25+
"@shopify/flash-list": "^2.3.1",
2626
"lottie-react-native": "^7.3.4",
2727
"react": "19.1.0",
2828
"react-dom": "19.1.0",

FabricExample/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,9 @@ const VirtualizedListScrollView = forwardRef<
6969
const { inverted, freeze, mode, keyboardLiftBehavior } =
7070
useChatConfigStore();
7171

72-
// on new arch only FlatList supports `inverted` prop
73-
const isInvertedSupported = inverted && mode === "flat" ? inverted : false;
72+
// only FlatList and FlashList supports `inverted` prop
73+
const isInvertedSupported =
74+
inverted && (mode === "flat" || mode === "flash") ? inverted : false;
7475
const onLayout = useCallback(
7576
(e: LayoutChangeEvent) => {
7677
setLayoutPass((l) => l + 1);

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import React, {
88
useState,
99
} from "react";
1010
import {
11+
Dimensions,
1112
FlatList,
1213
Image,
1314
StyleSheet,
@@ -34,6 +35,7 @@ import styles, {
3435
MARGIN,
3536
TEXT_INPUT_HEIGHT,
3637
contentContainerStyle,
38+
invertedContentContainerStyle,
3739
} from "./styles";
3840
import VirtualizedListScrollView, {
3941
type VirtualizedListScrollViewRef,
@@ -117,8 +119,18 @@ function KeyboardChatScrollViewPlayground() {
117119
)}
118120
{mode === "flash" && (
119121
<FlashList
120-
contentContainerStyle={contentContainerStyle}
121-
data={messages}
122+
contentContainerStyle={
123+
inverted ? invertedContentContainerStyle : contentContainerStyle
124+
}
125+
data={inverted ? reversedMessages : messages}
126+
// use slightly bigger distance to avoid flashing for inverted case
127+
// internally `KeyboardChatScrollView` re-positions content and in case
128+
// of inverted it shifts it by `translateY={keyboardSize}` and adjust scroll position
129+
// so real content movement can be `keyboardSize x2`, and `FlashList` may recycle wrong items
130+
// using bigger distance we assure that we will not see flickering of messages
131+
// during keyboard animation
132+
drawDistance={Dimensions.get("screen").height}
133+
inverted={inverted}
122134
keyExtractor={(item) => item.text}
123135
maintainVisibleContentPosition={{
124136
startRenderingFromBottom: inverted,

FabricExample/yarn.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1909,10 +1909,10 @@
19091909
"@react-navigation/elements" "^2.4.6"
19101910
color "^4.2.3"
19111911

1912-
"@shopify/flash-list@^2.2.0":
1913-
version "2.2.0"
1914-
resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-2.2.0.tgz#bafd714576182681cba25fb13195dee19f5ca521"
1915-
integrity sha512-mL61IofcfBNRZ/qazIf+pghGULkcZUQ7EZNldH1JBbIjtDb25ADSiQrt62ZTnRz0H5+bPFEZUmN9+WChHzX8pw==
1912+
"@shopify/flash-list@^2.3.1":
1913+
version "2.3.1"
1914+
resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-2.3.1.tgz#d4f90b1471a741a97c07d9aadbfaf200e92c86f7"
1915+
integrity sha512-7oktg2NQR7KAODjFoDaWe8/OBzyYbdTE3zQTrUBMxjIbxHTHN7UXRX1hX3DHk8KvtkgQdRfZOV8Gjj2l4fGrXw==
19161916

19171917
"@sideway/address@^4.1.5":
19181918
version "4.1.5"

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ class ClippingScrollViewDecoratorView(
4848
// virtualizer calculations correct (it reads layout positions,
4949
// not translationY).
5050
val contentView = scrollView.getChildAt(0) as? ViewGroup ?: return
51-
(contentView.getChildAt(0) as? ViewGroup)?.translationY = newTopInsetPx.toFloat()
51+
contentView.translationY = newTopInsetPx.toFloat()
5252

5353
scrollView.setPadding(
5454
scrollView.paddingLeft,

docs/docs/api/components/keyboard-chat-scroll-view.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ Accepts either a plain `boolean` or a [Reanimated `SharedValue<boolean>`](https:
4040

4141
Set to `true` if your list uses the `inverted` prop (the standard pattern for chat-style lists where the newest messages appear at the bottom).
4242

43+
:::warning Virtualized list caution
44+
If you're using `inverted={true}` with virtualized lists (e.g. `FlashList`), make sure to increase `drawDistance` (or any similar prop that controls how much content is rendered outside the visible area). This can help [prevent](https://github.com/kirillzyusko/react-native-keyboard-controller/pull/1437) content flashing during keyboard animations.
45+
:::
46+
4347
### `keyboardLiftBehavior`
4448

4549
Controls how the chat content responds when the keyboard appears. Defaults to `"always"`.

docs/versioned_docs/version-1.21.0/api/components/keyboard-chat-scroll-view.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ Accepts either a plain `boolean` or a [Reanimated `SharedValue<boolean>`](https:
4040

4141
Set to `true` if your list uses the `inverted` prop (the standard pattern for chat-style lists where the newest messages appear at the bottom).
4242

43+
:::warning Virtualized list caution
44+
If you're using `inverted={true}` with virtualized lists (e.g. `FlashList`), make sure to increase `drawDistance` (or any similar prop that controls how much content is rendered outside the visible area). This can help [prevent](https://github.com/kirillzyusko/react-native-keyboard-controller/pull/1437) content flashing during keyboard animations.
45+
:::
46+
4347
### `keyboardLiftBehavior`
4448

4549
Controls how the chat content responds when the keyboard appears. Defaults to `"always"`.

example/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ const VirtualizedListScrollView = forwardRef<
6969
const { inverted, freeze, mode, keyboardLiftBehavior } =
7070
useChatConfigStore();
7171

72-
// on old arch only FlatList and FlashList supports `inverted` prop
72+
// only FlatList and FlashList supports `inverted` prop
7373
const isInvertedSupported =
7474
inverted && (mode === "flat" || mode === "flash") ? inverted : false;
7575
const onLayout = useCallback(

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import React, {
88
useState,
99
} from "react";
1010
import {
11+
Dimensions,
1112
FlatList,
1213
Image,
1314
StyleSheet,
@@ -127,6 +128,13 @@ function KeyboardChatScrollViewPlayground() {
127128
inverted ? invertedContentContainerStyle : contentContainerStyle
128129
}
129130
data={inverted ? reversedMessages : messages}
131+
// use slightly bigger distance to avoid flashing for inverted case
132+
// internally `KeyboardChatScrollView` re-positions content and in case
133+
// of inverted it shifts it by `translateY={keyboardSize}` and adjust scroll position
134+
// so real content movement can be `keyboardSize x2`, and `FlashList` may recycle wrong items
135+
// using bigger distance we assure that we will not see flickering of messages
136+
// during keyboard animation
137+
drawDistance={Dimensions.get("screen").height}
130138
inverted={inverted}
131139
keyExtractor={(item) => item.text}
132140
renderItem={({ item }) => <Message {...item} />}

src/components/ScrollViewWithBottomPadding/index.tsx

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { forwardRef } from "react";
2-
import { Platform, View } from "react-native";
2+
import { Platform } from "react-native";
33
import Reanimated, {
44
useAnimatedProps,
55
useSharedValue,
@@ -125,17 +125,7 @@ const ScrollViewWithBottomPadding = forwardRef<
125125
style={styles.container}
126126
>
127127
<ScrollViewComponent ref={ref} animatedProps={animatedProps} {...rest}>
128-
{inverted ? (
129-
// The only thing it can break is `StickyHeader`, but it's already broken in FlatList and other lists
130-
// don't support this functionality, so we can add additional view here
131-
// The correct fix would be to add a new prop in ScrollView that allows
132-
// to customize children extraction logic and skip custom view
133-
<View collapsable={false} nativeID="container">
134-
{children}
135-
</View>
136-
) : (
137-
children
138-
)}
128+
{children}
139129
</ScrollViewComponent>
140130
</ReanimatedClippingScrollView>
141131
);

0 commit comments

Comments
 (0)