Skip to content

Commit 1c3da31

Browse files
committed
fix: inverted + FlatList + Android + interactive dismissal
1 parent 33241b7 commit 1c3da31

9 files changed

Lines changed: 257 additions & 0 deletions

File tree

src/components/KeyboardChatScrollView/useChatKeyboard/__fixtures__/testUtils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export type KeyboardEvent = { height: number };
88
export type Handlers = {
99
onStart: (e: KeyboardEvent) => void;
1010
onMove: (e: KeyboardEvent) => void;
11+
onInteractive: (e: KeyboardEvent) => void;
1112
onEnd: (e: KeyboardEvent) => void;
1213
};
1314

src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/freeze.android.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
let handlers: Handlers = {
1515
onStart: jest.fn(),
1616
onMove: jest.fn(),
17+
onInteractive: jest.fn(),
1718
onEnd: jest.fn(),
1819
};
1920

src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/index.android.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
let handlers: Handlers = {
1515
onStart: jest.fn(),
1616
onMove: jest.fn(),
17+
onInteractive: jest.fn(),
1718
onEnd: jest.fn(),
1819
};
1920

src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/index.ios.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
let handlers: Handlers = {
1313
onStart: jest.fn(),
1414
onMove: jest.fn(),
15+
onInteractive: jest.fn(),
1516
onEnd: jest.fn(),
1617
};
1718

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { Platform } from "react-native";
2+
3+
import {
4+
type Handlers,
5+
KEYBOARD,
6+
mockOffset,
7+
mockScrollTo,
8+
render,
9+
setupBeforeEach,
10+
} from "../__fixtures__/testUtils";
11+
12+
let handlers: Handlers = {
13+
onStart: jest.fn(),
14+
onMove: jest.fn(),
15+
onInteractive: jest.fn(),
16+
onEnd: jest.fn(),
17+
};
18+
19+
jest.mock("../../../../hooks", () => ({
20+
useKeyboardHandler: jest.fn((h: Handlers) => {
21+
handlers = h;
22+
}),
23+
useResizeMode: jest.fn(),
24+
}));
25+
26+
jest.mock("../../../hooks/useScrollState", () => ({
27+
__esModule: true,
28+
default: jest.fn(() => ({
29+
offset: mockOffset,
30+
layout: { value: { width: 390, height: 800 } },
31+
size: { value: { width: 390, height: 2000 } },
32+
})),
33+
}));
34+
35+
beforeEach(() => {
36+
setupBeforeEach();
37+
Object.defineProperty(Platform, "OS", { value: "android" });
38+
});
39+
40+
afterAll(() => {
41+
Object.defineProperty(Platform, "OS", { value: "ios" });
42+
});
43+
44+
describe("`useChatKeyboard` — Android interactive dismissal (inverted)", () => {
45+
it("should lock scroll and update containerTranslateY on first interactive", () => {
46+
mockOffset.value = 50;
47+
const { result } = render({
48+
inverted: true,
49+
keyboardLiftBehavior: "always",
50+
});
51+
52+
// keyboard opens
53+
handlers.onStart({ height: KEYBOARD });
54+
handlers.onMove({ height: KEYBOARD });
55+
mockScrollTo.mockClear();
56+
57+
// interactive dismiss begins
58+
handlers.onStart({ height: 0 });
59+
handlers.onInteractive({ height: 200 });
60+
61+
expect(mockScrollTo).toHaveBeenCalledWith(expect.anything(), 0, 50, false);
62+
expect(result.current.containerTranslateY.value).toBe(-200);
63+
});
64+
65+
it("should maintain scroll lock across multiple interactive frames", () => {
66+
mockOffset.value = 50;
67+
render({ inverted: true, keyboardLiftBehavior: "always" });
68+
69+
handlers.onStart({ height: KEYBOARD });
70+
handlers.onMove({ height: KEYBOARD });
71+
mockScrollTo.mockClear();
72+
73+
handlers.onStart({ height: 0 });
74+
handlers.onInteractive({ height: 250 });
75+
76+
// simulate scroll view drifting due to touch
77+
mockOffset.value = 80;
78+
handlers.onInteractive({ height: 200 });
79+
80+
// should still lock at original position (50), not the drifted one
81+
expect(mockScrollTo).toHaveBeenLastCalledWith(
82+
expect.anything(),
83+
0,
84+
50,
85+
false,
86+
);
87+
});
88+
89+
it("should unlock scroll when keyboard stays at full height", () => {
90+
mockOffset.value = 50;
91+
render({ inverted: true, keyboardLiftBehavior: "always" });
92+
93+
handlers.onStart({ height: KEYBOARD });
94+
handlers.onMove({ height: KEYBOARD });
95+
mockScrollTo.mockClear();
96+
97+
// interactive dismiss begins and then reverses back to full height
98+
handlers.onStart({ height: 0 });
99+
handlers.onInteractive({ height: 250 });
100+
handlers.onInteractive({ height: KEYBOARD });
101+
mockScrollTo.mockClear();
102+
103+
// keyboard stays at full height for consecutive frames → unlock
104+
handlers.onInteractive({ height: KEYBOARD });
105+
expect(mockScrollTo).not.toHaveBeenCalled();
106+
});
107+
108+
it("should re-lock when keyboard starts closing from full height", () => {
109+
mockOffset.value = 50;
110+
render({ inverted: true, keyboardLiftBehavior: "always" });
111+
112+
handlers.onStart({ height: KEYBOARD });
113+
handlers.onMove({ height: KEYBOARD });
114+
115+
// interactive: dismiss → return to full → unlock → dismiss again
116+
handlers.onStart({ height: 0 });
117+
handlers.onInteractive({ height: 250 });
118+
handlers.onInteractive({ height: KEYBOARD });
119+
handlers.onInteractive({ height: KEYBOARD }); // unlock
120+
121+
// user scrolled while unlocked
122+
mockOffset.value = 120;
123+
mockScrollTo.mockClear();
124+
125+
// keyboard starts closing again → re-lock at current position (120)
126+
handlers.onInteractive({ height: 280 });
127+
expect(mockScrollTo).toHaveBeenCalledWith(expect.anything(), 0, 120, false);
128+
});
129+
130+
it("should unlock scroll when keyboard stays at zero", () => {
131+
mockOffset.value = 50;
132+
render({ inverted: true, keyboardLiftBehavior: "always" });
133+
134+
handlers.onStart({ height: KEYBOARD });
135+
handlers.onMove({ height: KEYBOARD });
136+
mockScrollTo.mockClear();
137+
138+
// interactive dismiss to zero
139+
handlers.onStart({ height: 0 });
140+
handlers.onInteractive({ height: 100 });
141+
handlers.onInteractive({ height: 0 });
142+
mockScrollTo.mockClear();
143+
144+
// keyboard stays at zero → unlock
145+
handlers.onInteractive({ height: 0 });
146+
expect(mockScrollTo).not.toHaveBeenCalled();
147+
});
148+
149+
it("should re-lock when keyboard starts revealing from zero", () => {
150+
mockOffset.value = 50;
151+
render({ inverted: true, keyboardLiftBehavior: "always" });
152+
153+
handlers.onStart({ height: KEYBOARD });
154+
handlers.onMove({ height: KEYBOARD });
155+
156+
// interactive dismiss to zero, unlock, then reveal
157+
handlers.onStart({ height: 0 });
158+
handlers.onInteractive({ height: 100 });
159+
handlers.onInteractive({ height: 0 });
160+
handlers.onInteractive({ height: 0 }); // unlock
161+
162+
mockOffset.value = 90;
163+
mockScrollTo.mockClear();
164+
165+
// keyboard starts revealing → re-lock at current position (90)
166+
handlers.onInteractive({ height: 20 });
167+
expect(mockScrollTo).toHaveBeenCalledWith(expect.anything(), 0, 90, false);
168+
});
169+
170+
it("should reset lock state in onEnd", () => {
171+
mockOffset.value = 50;
172+
render({ inverted: true, keyboardLiftBehavior: "always" });
173+
174+
handlers.onStart({ height: KEYBOARD });
175+
handlers.onMove({ height: KEYBOARD });
176+
177+
// interactive session
178+
handlers.onStart({ height: 0 });
179+
handlers.onInteractive({ height: 200 });
180+
handlers.onEnd({ height: 0 });
181+
182+
// new interactive session should re-save at new position
183+
mockOffset.value = 200;
184+
mockScrollTo.mockClear();
185+
186+
handlers.onStart({ height: KEYBOARD });
187+
handlers.onStart({ height: 0 });
188+
handlers.onInteractive({ height: 250 });
189+
190+
expect(mockScrollTo).toHaveBeenCalledWith(expect.anything(), 0, 200, false);
191+
});
192+
193+
it("freeze: should not respond to interactive events", () => {
194+
const { result } = render({
195+
inverted: true,
196+
keyboardLiftBehavior: "always",
197+
freeze: true,
198+
});
199+
200+
handlers.onInteractive({ height: 200 });
201+
202+
expect(mockScrollTo).not.toHaveBeenCalled();
203+
expect(result.current.containerTranslateY.value).toBe(0);
204+
});
205+
});

src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/offset.android.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
let handlers: Handlers = {
1515
onStart: jest.fn(),
1616
onMove: jest.fn(),
17+
onInteractive: jest.fn(),
1718
onEnd: jest.fn(),
1819
};
1920

src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/offset.ios.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
let handlers: Handlers = {
1313
onStart: jest.fn(),
1414
onMove: jest.fn(),
15+
onInteractive: jest.fn(),
1516
onEnd: jest.fn(),
1617
};
1718

src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/persistent.ios.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
let handlers: Handlers = {
1111
onStart: jest.fn(),
1212
onMove: jest.fn(),
13+
onInteractive: jest.fn(),
1314
onEnd: jest.fn(),
1415
};
1516

src/components/KeyboardChatScrollView/useChatKeyboard/index.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ function useChatKeyboard(
6161
const containerTranslateY = useSharedValue(0);
6262
const offsetBeforeScroll = useSharedValue(0);
6363
const targetKeyboardHeight = useSharedValue(0);
64+
const lockedScrollPosition = useSharedValue(-1);
65+
const prevInteractiveHeight = useSharedValue(-1);
6466

6567
const { layout, size, offset: scroll } = useScrollState(scrollViewRef);
6668

@@ -209,6 +211,47 @@ function useChatKeyboard(
209211
scrollTo(scrollViewRef, 0, target, false);
210212
}
211213
},
214+
onInteractive: (e) => {
215+
"worklet";
216+
217+
if (freeze || OS === "ios") {
218+
return;
219+
}
220+
221+
const effective = getEffectiveHeight(e.height);
222+
223+
if (inverted) {
224+
const maxEffective = getEffectiveHeight(targetKeyboardHeight.value);
225+
const prevEffective = prevInteractiveHeight.value;
226+
const isFirstInteractive = prevEffective === -1;
227+
228+
prevInteractiveHeight.value = effective;
229+
230+
if (isFirstInteractive) {
231+
// first interactive event of a gesture session — lock scroll
232+
lockedScrollPosition.value = scroll.value;
233+
} else if (
234+
(prevEffective === maxEffective && effective === maxEffective) ||
235+
(prevEffective === 0 && effective === 0)
236+
) {
237+
// keyboard stayed at rest position for consecutive frames — unlock
238+
// so the user can scroll freely while keyboard is fully visible/hidden
239+
lockedScrollPosition.value = -1;
240+
} else if (
241+
(prevEffective === 0 && effective > 0) ||
242+
(prevEffective === maxEffective && effective < maxEffective)
243+
) {
244+
// keyboard left a rest position — re-lock at current scroll
245+
lockedScrollPosition.value = scroll.value;
246+
}
247+
248+
if (lockedScrollPosition.value !== -1) {
249+
scrollTo(scrollViewRef, 0, lockedScrollPosition.value, false);
250+
}
251+
252+
containerTranslateY.value = -effective;
253+
}
254+
},
212255
onEnd: (e) => {
213256
"worklet";
214257

@@ -219,6 +262,8 @@ function useChatKeyboard(
219262
const effective = getEffectiveHeight(e.height);
220263

221264
padding.value = effective;
265+
lockedScrollPosition.value = -1;
266+
prevInteractiveHeight.value = -1;
222267

223268
if (
224269
OS !== "ios" &&

0 commit comments

Comments
 (0)