Skip to content

Commit ba42552

Browse files
committed
Fix iOS extraContentPadding scroll desync by using atomic contentOffsetY
1 parent 7a7b6ca commit ba42552

3 files changed

Lines changed: 155 additions & 2 deletions

File tree

src/components/KeyboardChatScrollView/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ const KeyboardChatScrollView = forwardRef<
6969
scroll,
7070
layout,
7171
size,
72+
contentOffsetY,
7273
inverted,
7374
keyboardLiftBehavior,
7475
freeze,
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { sv } from "../../../../__fixtures__/sv";
2+
import {
3+
createRender,
4+
mockScrollTo,
5+
reactionEffect,
6+
} from "../__fixtures__/setup";
7+
8+
describe("useExtraContentPadding — contentOffsetY (iOS atomic path)", () => {
9+
it("should set contentOffsetY instead of scrollTo on grow (non-inverted)", () => {
10+
const render = createRender();
11+
const contentOffsetY = sv(100);
12+
13+
render({
14+
extraContentPadding: sv(20),
15+
keyboardPadding: sv(300),
16+
scroll: sv(100),
17+
layout: sv({ width: 390, height: 800 }),
18+
size: sv({ width: 390, height: 2000 }),
19+
contentOffsetY,
20+
inverted: false,
21+
keyboardLiftBehavior: "always",
22+
freeze: false,
23+
});
24+
25+
reactionEffect(20, 0);
26+
27+
expect(contentOffsetY.value).toBe(120);
28+
expect(mockScrollTo).not.toHaveBeenCalled();
29+
});
30+
31+
it("should set contentOffsetY instead of scrollTo on grow (inverted)", () => {
32+
const render = createRender();
33+
const contentOffsetY = sv(5);
34+
35+
render({
36+
extraContentPadding: sv(20),
37+
keyboardPadding: sv(300),
38+
scroll: sv(5),
39+
layout: sv({ width: 390, height: 800 }),
40+
size: sv({ width: 390, height: 2000 }),
41+
contentOffsetY,
42+
inverted: true,
43+
keyboardLiftBehavior: "always",
44+
freeze: false,
45+
});
46+
47+
reactionEffect(20, 0);
48+
49+
expect(contentOffsetY.value).toBe(-15);
50+
expect(mockScrollTo).not.toHaveBeenCalled();
51+
});
52+
53+
it("should set contentOffsetY instead of scrollTo on shrink (non-inverted)", () => {
54+
const render = createRender();
55+
const contentOffsetY = sv(1220);
56+
57+
render({
58+
extraContentPadding: sv(0),
59+
keyboardPadding: sv(300),
60+
scroll: sv(1220),
61+
layout: sv({ width: 390, height: 800 }),
62+
size: sv({ width: 390, height: 2000 }),
63+
contentOffsetY,
64+
inverted: false,
65+
keyboardLiftBehavior: "always",
66+
freeze: false,
67+
});
68+
69+
reactionEffect(0, 20);
70+
71+
expect(contentOffsetY.value).toBe(1200);
72+
expect(mockScrollTo).not.toHaveBeenCalled();
73+
});
74+
75+
it("should clamp contentOffsetY to maxScroll (non-inverted)", () => {
76+
const render = createRender();
77+
const contentOffsetY = sv(1490);
78+
79+
render({
80+
extraContentPadding: sv(50),
81+
keyboardPadding: sv(300),
82+
scroll: sv(1490),
83+
layout: sv({ width: 390, height: 800 }),
84+
size: sv({ width: 390, height: 2000 }),
85+
contentOffsetY,
86+
inverted: false,
87+
keyboardLiftBehavior: "always",
88+
freeze: false,
89+
});
90+
91+
// delta = 50, scroll + delta = 1540, maxScroll = 2000 - 800 + 300 + 50 = 1550
92+
reactionEffect(50, 0);
93+
94+
expect(contentOffsetY.value).toBe(1540);
95+
expect(mockScrollTo).not.toHaveBeenCalled();
96+
});
97+
98+
it("should clamp contentOffsetY to -totalPadding (inverted)", () => {
99+
const render = createRender();
100+
const contentOffsetY = sv(-280);
101+
102+
render({
103+
extraContentPadding: sv(50),
104+
keyboardPadding: sv(300),
105+
scroll: sv(-280),
106+
layout: sv({ width: 390, height: 800 }),
107+
size: sv({ width: 390, height: 2000 }),
108+
contentOffsetY,
109+
inverted: true,
110+
keyboardLiftBehavior: "always",
111+
freeze: false,
112+
});
113+
114+
// delta = 50, target = -280 - 50 = -330, clamp to -350
115+
reactionEffect(50, 0);
116+
117+
expect(contentOffsetY.value).toBe(-330);
118+
expect(mockScrollTo).not.toHaveBeenCalled();
119+
});
120+
121+
it("should fall back to scrollTo when contentOffsetY is undefined", () => {
122+
const render = createRender();
123+
124+
render({
125+
extraContentPadding: sv(20),
126+
keyboardPadding: sv(300),
127+
scroll: sv(100),
128+
layout: sv({ width: 390, height: 800 }),
129+
size: sv({ width: 390, height: 2000 }),
130+
contentOffsetY: undefined,
131+
inverted: false,
132+
keyboardLiftBehavior: "always",
133+
freeze: false,
134+
});
135+
136+
reactionEffect(20, 0);
137+
138+
expect(mockScrollTo).toHaveBeenCalledWith(expect.anything(), 0, 120, false);
139+
});
140+
});

src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ type UseExtraContentPaddingOptions = {
1919
layout: SharedValue<{ width: number; height: number }>;
2020
/** Total content dimensions. */
2121
size: SharedValue<{ width: number; height: number }>;
22+
/** IOS only — when provided, sets contentOffset atomically with contentInset. */
23+
contentOffsetY?: SharedValue<number>;
2224
inverted: boolean;
2325
keyboardLiftBehavior: KeyboardLiftBehavior;
2426
freeze: boolean;
@@ -47,6 +49,7 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
4749
scroll,
4850
layout,
4951
size,
52+
contentOffsetY,
5053
inverted,
5154
keyboardLiftBehavior,
5255
freeze,
@@ -104,15 +107,24 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
104107
if (inverted) {
105108
const target = Math.max(scroll.value - effectiveDelta, -currentTotal);
106109

107-
scrollTo(scrollViewRef, 0, target, false);
110+
if (contentOffsetY) {
111+
// eslint-disable-next-line react-compiler/react-compiler
112+
contentOffsetY.value = target;
113+
} else {
114+
scrollTo(scrollViewRef, 0, target, false);
115+
}
108116
} else {
109117
const maxScroll = Math.max(
110118
size.value.height - layout.value.height + currentTotal,
111119
0,
112120
);
113121
const target = Math.min(scroll.value + effectiveDelta, maxScroll);
114122

115-
scrollTo(scrollViewRef, 0, target, false);
123+
if (contentOffsetY) {
124+
contentOffsetY.value = target;
125+
} else {
126+
scrollTo(scrollViewRef, 0, target, false);
127+
}
116128
}
117129
},
118130
[inverted, keyboardLiftBehavior, freeze],

0 commit comments

Comments
 (0)