diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/__fixtures__/testUtils.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/__fixtures__/testUtils.ts index 141e0929e3..f3e6c3f384 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/__fixtures__/testUtils.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/__fixtures__/testUtils.ts @@ -22,6 +22,14 @@ export const mockSize = { value: { width: 390, height: 2000 } }; export const KEYBOARD = 300; export const mockScrollTo = jest.fn(); +type Reaction = { + producer: () => unknown; + effect: (current: unknown, previous: unknown | null) => void; + previous: unknown; +}; + +const reactions: Reaction[] = []; + /** * Linear interpolate mock matching Reanimated's `interpolate` signature. * @@ -61,17 +69,38 @@ export function reset() { */ export function setupBeforeEach() { jest.resetModules(); + reactions.length = 0; jest.doMock("react-native-reanimated", () => ({ ...require("react-native-reanimated/mock"), scrollTo: mockScrollTo, interpolate: mockInterpolate, + useAnimatedReaction: ( + producer: () => unknown, + effect: (current: unknown, previous: unknown | null) => void, + ) => { + reactions.push({ + producer, + effect, + previous: producer(), + }); + }, })); reset(); mockScrollTo.mockClear(); } +/** Run registered Reanimated reactions after mutating mocked shared values. */ +export function flushAnimatedReactions() { + for (const reaction of reactions) { + const current = reaction.producer(); + + reaction.effect(current, reaction.previous); + reaction.previous = current; + } +} + type RenderOptions = Omit< Parameters[1], "freeze" | "offset" | "blankSpace" | "extraContentPadding" diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/freeze.android.spec.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/freeze.android.spec.ts index c2c051f9d6..c5bd36ab61 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/freeze.android.spec.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/freeze.android.spec.ts @@ -1,7 +1,9 @@ +import { sv } from "../../../../__fixtures__/sv"; import { type Handlers, KEYBOARD, createRender, + flushAnimatedReactions, mockLayout, mockOffset, mockScrollTo, @@ -77,4 +79,29 @@ describe("`useChatKeyboard` — Android freeze", () => { expect(result.current.padding.value).toBe(0); }); + + it("should apply the latest frozen keyboard padding when unfrozen", () => { + const freeze = sv(false); + const { result } = render({ + inverted: false, + keyboardLiftBehavior: "always", + freeze, + }); + + handlers.onStart({ height: KEYBOARD }); + handlers.onEnd({ height: KEYBOARD }); + expect(result.current.padding.value).toBe(KEYBOARD); + + freeze.value = true; + flushAnimatedReactions(); + handlers.onStart({ height: 0 }); + handlers.onMove({ height: 120 }); + handlers.onEnd({ height: 0 }); + expect(result.current.padding.value).toBe(KEYBOARD); + + freeze.value = false; + flushAnimatedReactions(); + + expect(result.current.padding.value).toBe(0); + }); }); diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/index.ios.spec.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/index.ios.spec.ts index eb0f8832d8..8f895dbf61 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/index.ios.spec.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/index.ios.spec.ts @@ -1,7 +1,9 @@ +import { sv } from "../../../../__fixtures__/sv"; import { type Handlers, KEYBOARD, createRender, + flushAnimatedReactions, mockLayout, mockOffset, mockScrollTo, @@ -103,6 +105,29 @@ describe("`useChatKeyboard` — iOS non-inverted + always", () => { expect(mockScrollTo).not.toHaveBeenCalled(); }); + + it("should apply the latest frozen keyboard padding when unfrozen", () => { + const freeze = sv(false); + const { result } = render({ + inverted: false, + keyboardLiftBehavior: "always", + freeze, + }); + + handlers.onStart({ height: KEYBOARD }); + expect(result.current.padding.value).toBe(KEYBOARD); + + freeze.value = true; + flushAnimatedReactions(); + handlers.onStart({ height: 0 }); + handlers.onEnd({ height: 0 }); + expect(result.current.padding.value).toBe(KEYBOARD); + + freeze.value = false; + flushAnimatedReactions(); + + expect(result.current.padding.value).toBe(0); + }); }); describe("`useChatKeyboard` — iOS inverted + always", () => { diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts index 560df54bae..3550392aa4 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts @@ -1,4 +1,4 @@ -import { useSharedValue } from "react-native-reanimated"; +import { useAnimatedReaction, useSharedValue } from "react-native-reanimated"; import { useKeyboardHandler } from "../../../hooks"; import useScrollState from "../../hooks/useScrollState"; @@ -64,15 +64,13 @@ function useChatKeyboard( onStart: (e) => { "worklet"; - if (freeze.value) { - return; - } - if (e.height > 0) { // eslint-disable-next-line react-compiler/react-compiler targetKeyboardHeight.value = e.height; } + currentHeight.value = e.height; + const effective = getEffectiveHeight( e.height, targetKeyboardHeight.value, @@ -109,6 +107,10 @@ function useChatKeyboard( effective + extraContentPadding.value, ); + if (freeze.value) { + return; + } + // persistent mode: when keyboard shrinks, clamp to valid range if ( keyboardLiftBehavior === "persistent" && @@ -229,9 +231,7 @@ function useChatKeyboard( onEnd: (e) => { "worklet"; - if (freeze.value) { - return; - } + currentHeight.value = e.height; const effective = getEffectiveHeight( e.height, @@ -239,12 +239,30 @@ function useChatKeyboard( offset, ); + if (freeze.value) { + return; + } + padding.value = effective; }, }, [inverted, keyboardLiftBehavior, offset, extraContentPadding], ); + useAnimatedReaction( + () => freeze.value, + (isFrozen, wasFrozen) => { + if (!isFrozen && wasFrozen === true) { + padding.value = getEffectiveHeight( + currentHeight.value, + targetKeyboardHeight.value, + offset, + ); + } + }, + [offset], + ); + return { padding, currentHeight, diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ts index 48178d9246..9050dba481 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ts @@ -1,4 +1,8 @@ -import { scrollTo, useSharedValue } from "react-native-reanimated"; +import { + scrollTo, + useAnimatedReaction, + useSharedValue, +} from "react-native-reanimated"; import { useKeyboardHandler } from "../../../hooks"; import useScrollState from "../../hooks/useScrollState"; @@ -82,13 +86,10 @@ function useChatKeyboard( onStart: (e) => { "worklet"; - if (freeze.value) { - return; - } - if (e.height > 0) { // eslint-disable-next-line react-compiler/react-compiler targetKeyboardHeight.value = e.height; + currentHeight.value = e.height; closing.value = false; } else { closing.value = true; @@ -128,6 +129,10 @@ function useChatKeyboard( minimumPaddingAbsorbed, ); + if (freeze.value) { + return; + } + if (inverted && e.duration === -1) { // Android inverted: skip post-interactive snap-back events // (duration === -1 means the keyboard is re-establishing its @@ -166,10 +171,6 @@ function useChatKeyboard( onMove: (e) => { "worklet"; - if (freeze.value) { - return; - } - currentHeight.value = e.height; if (inverted) { @@ -184,6 +185,10 @@ function useChatKeyboard( offset, ); + if (freeze.value) { + return; + } + const minimumPaddingAbsorbed = getMinimumPaddingAbsorbed( blankSpace.value, @@ -269,6 +274,10 @@ function useChatKeyboard( offset, ); + if (freeze.value) { + return; + } + const minimumPaddingAbsorbed = getMinimumPaddingAbsorbed( blankSpace.value, @@ -340,9 +349,7 @@ function useChatKeyboard( onEnd: (e) => { "worklet"; - if (freeze.value) { - return; - } + currentHeight.value = e.height; const effective = getEffectiveHeight( e.height, @@ -350,6 +357,10 @@ function useChatKeyboard( offset, ); + if (freeze.value) { + return; + } + padding.value = effective; // Record actual scroll displacement so close can be symmetric @@ -361,6 +372,20 @@ function useChatKeyboard( [inverted, keyboardLiftBehavior, offset], ); + useAnimatedReaction( + () => freeze.value, + (isFrozen, wasFrozen) => { + if (!isFrozen && wasFrozen === true) { + padding.value = getEffectiveHeight( + currentHeight.value, + targetKeyboardHeight.value, + offset, + ); + } + }, + [offset], + ); + return { padding, currentHeight,