Skip to content

Commit 5aff491

Browse files
committed
Only absorb blank space when fully visible on keyboard open
Fix Android keyboard close/open asymmetry and apply binary blank absorption Two changes to the Android useChatKeyboard hook: 1. Apply the same binary blank absorption from the iOS fix: only absorb blank space when fully visible (visibleFraction >= 1), otherwise shift content by the full keyboard amount. Previously Android used linear scaling which caused partial occlusion when blank was partially visible. 2. Fix close animation overshooting on non-inverted lists. The close path was computing the theoretical scroll displacement (keyboard height minus blank absorption) to undo the open shift. But when the open shift was clamped by maxScroll, the theoretical value exceeded the actual shift, causing close to move content down more than open moved it up. Record the actual scroll displacement at the end of the open animation (scroll.value - offsetBeforeScroll) and use that for the close calculation. This makes open/close symmetric regardless of clamping.
1 parent 8b94048 commit 5aff491

3 files changed

Lines changed: 33 additions & 24 deletions

File tree

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

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,10 @@ describe("blankSize — iOS inverted + always", () => {
133133
});
134134

135135
describe("blankSize — iOS persistent behavior", () => {
136-
it("full absorption on close: uses actualTotalPadding for snap", () => {
136+
it("partial visibility on close: uses actualTotalPadding for snap", () => {
137137
// Scroll near end so blank is partially visible
138138
// (pastContentEnd = 1000+800-1500 = 300, fraction = 300/500 = 0.6)
139-
// blankAbsorbed = 500*0.6 = 300, scrollEff = max(0, 300-300) = 0 → full absorption
139+
// Partial visibility → no absorption → content shifts normally
140140
mockSize.value = { width: 390, height: 1500 };
141141
mockOffset.value = 1000;
142142
const { result } = render({
@@ -145,9 +145,11 @@ describe("blankSize — iOS persistent behavior", () => {
145145
blankSize: sv(500),
146146
});
147147

148-
// Open keyboard — fully absorbed, position preserved
148+
// Open keyboard — partial visibility, no absorption, content shifts
149+
// computeIOSContentOffset(1000, 300, 1500, 800, false, 500)
150+
// maxScroll = max(1500-800+500, 0) = 1200 → min(1300, 1200) = 1200
149151
handlers.onStart({ height: KEYBOARD });
150-
expect(result.current.contentOffsetY!.value).toBe(1000);
152+
expect(result.current.contentOffsetY!.value).toBe(1200);
151153

152154
// Close keyboard — persistent + at end → snap using actualTotalPadding
153155
handlers.onStart({ height: 0 });
@@ -189,10 +191,10 @@ describe("blankSize — iOS never behavior", () => {
189191
});
190192

191193
describe("blankSize — iOS whenAtEnd behavior", () => {
192-
it("full absorption prevents shift even when at end", () => {
194+
it("partial visibility shifts content normally when at end", () => {
193195
// Scroll near end so blank is partially visible
194196
// (pastContentEnd = 1000+800-1500 = 300, fraction = 300/500 = 0.6)
195-
// blankAbsorbed = 500*0.6 = 300, scrollEff = max(0, 300-300) = 0 → full absorption
197+
// Partial visibility → no absorption → content shifts normally
196198
mockSize.value = { width: 390, height: 1500 };
197199
mockOffset.value = 1000;
198200
const { result } = render({
@@ -204,7 +206,9 @@ describe("blankSize — iOS whenAtEnd behavior", () => {
204206
handlers.onStart({ height: KEYBOARD });
205207

206208
expect(result.current.padding.value).toBe(KEYBOARD);
207-
// blankAbsorbed=300, scrollEff=0 → contentOffsetY = scroll.value
208-
expect(result.current.contentOffsetY!.value).toBe(1000);
209+
// blankAbsorbed=0 (partial visibility), scrollEff=300
210+
// computeIOSContentOffset(1000, 300, 1500, 800, false, 500)
211+
// maxScroll = 1200 → min(1300, 1200) = 1200
212+
expect(result.current.contentOffsetY!.value).toBe(1200);
209213
});
210214
});

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,9 @@ function useChatKeyboard(
9696
inverted,
9797
);
9898
const blankAbsorbed =
99-
getBlankAbsorbed(blankSize.value, extraContentPadding.value) *
100-
visibleFraction;
99+
visibleFraction >= 1
100+
? getBlankAbsorbed(blankSize.value, extraContentPadding.value)
101+
: 0;
101102
const scrollEff = getScrollEffective(effective, blankAbsorbed);
102103
const actualTotalPadding = Math.max(
103104
blankSize.value,

src/components/KeyboardChatScrollView/useChatKeyboard/index.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ function useChatKeyboard(
4949
const targetKeyboardHeight = useSharedValue(0);
5050
const closing = useSharedValue(false);
5151
const blankFractionOnOpen = useSharedValue(0);
52+
const actualOpenShift = useSharedValue(0);
5253
const {
5354
layout,
5455
size,
@@ -116,8 +117,9 @@ function useChatKeyboard(
116117
inverted,
117118
);
118119
const blankAbsorbed =
119-
getBlankAbsorbed(blankSize.value, extraContentPadding.value) *
120-
visibleFraction;
120+
visibleFraction >= 1
121+
? getBlankAbsorbed(blankSize.value, extraContentPadding.value)
122+
: 0;
121123
const scrollEff = getScrollEffective(effective, blankAbsorbed);
122124

123125
if (inverted && e.duration === -1) {
@@ -127,7 +129,7 @@ function useChatKeyboard(
127129
return;
128130
} else if (e.height > 0) {
129131
// Android: keyboard opening — set padding + capture scroll position
130-
blankFractionOnOpen.value = visibleFraction;
132+
blankFractionOnOpen.value = visibleFraction >= 1 ? 1 : 0;
131133
padding.value = effective;
132134
offsetBeforeScroll.value = scroll.value;
133135

@@ -148,17 +150,9 @@ function useChatKeyboard(
148150
} else {
149151
// Preserve "whenAtEnd" sentinel: if open didn't shift, close shouldn't either
150152
if (offsetBeforeScroll.value !== -1) {
151-
// Non-inverted: undo only the actual scroll displacement
152-
// (accounting for blank absorption at open time)
153-
const prevBlankAbsorbed =
154-
getBlankAbsorbed(blankSize.value, extraContentPadding.value) *
155-
blankFractionOnOpen.value;
156-
const prevScrollEff = getScrollEffective(
157-
padding.value,
158-
prevBlankAbsorbed,
159-
);
160-
161-
offsetBeforeScroll.value = scroll.value - prevScrollEff;
153+
// Use the actual displacement recorded at end of open animation
154+
// (not the theoretical value) so close is symmetric with open
155+
offsetBeforeScroll.value = scroll.value - actualOpenShift.value;
162156
}
163157
}
164158
}
@@ -319,6 +313,11 @@ function useChatKeyboard(
319313
);
320314

321315
scrollTo(scrollViewRef, 0, target, false);
316+
317+
// Track actual (clamped) displacement during open for symmetric close
318+
if (!closing.value) {
319+
actualOpenShift.value = target - offsetBeforeScroll.value;
320+
}
322321
}
323322
},
324323
onEnd: (e) => {
@@ -335,6 +334,11 @@ function useChatKeyboard(
335334
);
336335

337336
padding.value = effective;
337+
338+
// Record actual scroll displacement so close can be symmetric
339+
if (effective > 0 && offsetBeforeScroll.value !== -1) {
340+
actualOpenShift.value = scroll.value - offsetBeforeScroll.value;
341+
}
338342
},
339343
},
340344
[inverted, keyboardLiftBehavior, freeze, offset],

0 commit comments

Comments
 (0)