1diff --git a/ios/observers/movement/observer/KeyboardMovementObserver+Lifecycle.swift b/ios/observers/movement/observer/KeyboardMovementObserver+Lifecycle.swift
2index cdbd63359be9c04fbb0cb30b3cbd1e399bb72d9a..41a72bcc5ebde85237e6aa6bd8240712fdebc0cd 100644
3--- a/ios/observers/movement/observer/KeyboardMovementObserver+Lifecycle.swift
4+++ b/ios/observers/movement/observer/KeyboardMovementObserver+Lifecycle.swift
5@@ -25,6 +25,12 @@ public extension KeyboardMovementObserver {
6 name: UIResponder.keyboardWillShowNotification,
7 object: nil
8 )
9+ NotificationCenter.default.addObserver(
10+ self,
11+ selector: #selector(keyboardWillChangeFrame),
12+ name: UIResponder.keyboardWillChangeFrameNotification,
13+ object: nil
14+ )
15 }
17 @objc func unmount() {
18diff --git a/ios/observers/movement/observer/KeyboardMovementObserver+Listeners.swift b/ios/observers/movement/observer/KeyboardMovementObserver+Listeners.swift
19index 545a5b20f6caac289afdfd11cd7596d57de14122..fbdc9840113ee8caaac685d6f14d7d9f3c725c75 100644
20--- a/ios/observers/movement/observer/KeyboardMovementObserver+Listeners.swift
21+++ b/ios/observers/movement/observer/KeyboardMovementObserver+Listeners.swift
22@@ -8,6 +8,12 @@
23 extension KeyboardMovementObserver {
24 @objc func keyboardWillAppear(_ notification: Notification) {
25 guard !UIResponder.isKeyboardPreloading else { return }
26+ floatingHideTask?.cancel()
27+
28+ if isFloatingKeyboard(notification) {
29+ hideIfKeyboardShown(notification)
30+ return
31+ }
33 let (duration, frame) = notification.keyboardMetaData()
34 if let keyboardFrame = frame {
35@@ -36,8 +42,40 @@ extension KeyboardMovementObserver {
36 }
37 }
39+ @objc func keyboardWillChangeFrame(_ notification: Notification) {
40+ guard !UIResponder.isKeyboardPreloading, isFloatingKeyboard(notification) else { return }
41+ // runs after a willShow/willHide posted with this notification, which cancel it, so hide events fire once
42+ floatingHideTask?.cancel()
43+ let task = DispatchWorkItem { [weak self] in
44+ self?.hideIfKeyboardShown(notification)
45+ }
46+ floatingHideTask = task
47+ DispatchQueue.main.async(execute: task)
48+ }
49+
50+ // iPadOS floating and minimized keyboards are narrower than the screen or end above the app's bottom, so
51+ // they cover no app content; UIKit switches to them without willHide, so they must count as hidden.
52+ private func isFloatingKeyboard(_ notification: Notification) -> Bool {
53+ guard let frame = notification.keyboardMetaData().1?.cgRectValue, !frame.isEmpty else { return false }
54+ let screen = (notification.object as? UIScreen ?? UIScreen.main).bounds
55+ // in Slide Over a docked keyboard ends at the app window's bottom, above the screen's
56+ let windowBottom = UIApplication.shared.activeWindow.map {
57+ $0.convert($0.bounds, to: $0.screen.coordinateSpace).maxY
58+ } ?? screen.height
59+ let dockedBottom = min(screen.height, windowBottom)
60+ return frame.width.rounded() < screen.width.rounded() || frame.maxY.rounded() < dockedBottom.rounded()
61+ }
62+
63+ private func hideIfKeyboardShown(_ notification: Notification) {
64+ let targetHeight = self.notification?.keyboardMetaData().1?.cgRectValue.height ?? 0
65+ if targetHeight > 0 {
66+ keyboardWillDisappear(notification)
67+ }
68+ }
69+
70 @objc func keyboardWillDisappear(_ notification: Notification) {
71 guard !UIResponder.isKeyboardPreloading else { return }
72+ floatingHideTask?.cancel()
73 let (duration, _) = notification.keyboardMetaData()
74 tag = UIResponder.current.reactViewTag
75 self.notification = notification
76diff --git a/ios/observers/movement/observer/KeyboardMovementObserver.swift b/ios/observers/movement/observer/KeyboardMovementObserver.swift
77index ae97d611a7f727ebbb0e17eabbb3294e1cc76a2f..2ea1d2ddd3fac6ad418d97c700ab885fe645951f 100644
78--- a/ios/observers/movement/observer/KeyboardMovementObserver.swift
79+++ b/ios/observers/movement/observer/KeyboardMovementObserver.swift
80@@ -37,6 +37,7 @@ public class KeyboardMovementObserver: NSObject {
81 // manual did events
82 var notification: Notification?
83 var keyboardDidTask: DispatchWorkItem?
84+ var floatingHideTask: DispatchWorkItem?
86 @objc public init(
87 handler: @escaping (NSString, NSNumber, NSNumber, NSNumber, NSNumber) -> Void,
88diff --git a/lib/commonjs/components/KeyboardChatScrollView/index.js b/lib/commonjs/components/KeyboardChatScrollView/index.js
89index 0b78ff38d8f0a2360632df3d9988c0c32bd56b99..9600d6c8ac857a3f56753f49b5a52e3eb830eaba 100644
90--- a/lib/commonjs/components/KeyboardChatScrollView/index.js
91+++ b/lib/commonjs/components/KeyboardChatScrollView/index.js
92@@ -28,9 +28,12 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({
93 offset = 0,
94 extraContentPadding = ZERO_CONTENT_PADDING,
95 blankSpace = ZERO_BLANK_SPACE,
96+ adjustedInsetCompensation = 0,
97+ adjustedStartInsetCompensation = 0,
98 applyWorkaroundForContentInsetHitTestBug = false,
99 onLayout: onLayoutProp,
100 onContentSizeChange: onContentSizeChangeProp,
101+ onContentInsetChange,
102 onEndVisible,
103 ...rest
104 }, ref) => {
105@@ -54,6 +57,8 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({
106 offset,
107 blankSpace,
108 extraContentPadding,
109+ adjustedInsetCompensation,
110+ adjustedStartInsetCompensation,
111 initialContentOffsetY: (_rest$contentOffset = rest.contentOffset) === null || _rest$contentOffset === void 0 ? void 0 : _rest$contentOffset.y
112 });
113 (0, _useExtraContentPadding.useExtraContentPadding)({
114@@ -61,6 +66,8 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({
115 extraContentPadding,
116 keyboardPadding: padding,
117 blankSpace,
118+ adjustedInsetCompensation,
119+ adjustedStartInsetCompensation,
120 scroll,
121 layout,
122 size,
123@@ -72,7 +79,8 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({
124 (0, _useFrozenPadding.useFrozenPadding)({
125 freeze: freezeSV,
126 offset,
127- padding
128+ padding,
129+ currentHeight
130 });
131 (0, _useEndVisible.useEndVisible)({
132 scroll,
133@@ -91,10 +99,26 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({
134 // a bug for you, please open an issue.
135 const totalPadding = (0, _reanimated.useDerivedValue)(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value)));
137+ // iOS applies the destination contentInset and contentOffset together at
138+ // keyboard-animation start. Keep that native target behavior, but report
139+ // the presentation height so virtualized-list layout follows the keyboard.
140+ const reportedPadding = (0, _reanimated.useDerivedValue)(() => _reactNative.Platform.OS === "ios" ? Math.min(layout.value.height, Math.max(blankSpace.value, currentHeight.value + extraContentPadding.value)) : totalPadding.value);
141+
142+ // Mirror the effective bottom padding (keyboard + composer + blank floor)
143+ // to the consumer - a virtualized list needs it in its own scroll math or
144+ // its end/maintain targets point at the under-the-keyboard resting offset.
145+ (0, _reanimated.useAnimatedReaction)(() => reportedPadding.value, (current, previous) => {
146+ if (onContentInsetChange && current !== previous) {
147+ (0, _reactNativeReanimated.runOnJS)(onContentInsetChange)({
148+ bottom: current
149+ });
150+ }
151+ }, [onContentInsetChange]);
152+
153 // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace).
154 // Apps that render into the unsafe area can supply a negative
155 // scrollIndicatorInsets adjustment at the application layer.
156- const indicatorPadding = (0, _reanimated.useDerivedValue)(() => padding.value + extraContentPadding.value);
157+ const indicatorPadding = (0, _reanimated.useDerivedValue)(() => padding.value);
158 const onLayout = (0, _react.useCallback)(e => {
159 onLayoutInternal(e);
160 onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e);
161diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js
162index 4d33b3fe426070cf312fc493ce902b2c24d853ea..6a787d3054ba09e90c54b615ab302df51a2888f7 100644
163--- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js
164+++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js
165@@ -206,7 +206,7 @@ const clampedScrollTarget = (offsetBeforeScroll, keyboardHeight, contentHeight,
166 * ```
167 */
168 exports.clampedScrollTarget = clampedScrollTarget;
169-const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll) => {
170+const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll, startInsetCompensation = 0) => {
171 "worklet";
173 const paddingForMax = totalPaddingForMaxScroll !== undefined ? totalPaddingForMaxScroll : keyboardHeight;
174@@ -214,8 +214,9 @@ const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight,
175 const maxScroll = Math.max(contentHeight - layoutHeight, 0);
176 return Math.max(Math.min(relativeScroll - keyboardHeight, maxScroll), -paddingForMax);
177 }
178- const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, 0);
179- return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll);
180+ const minScroll = -startInsetCompensation;
181+ const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, minScroll);
182+ return Math.min(Math.max(keyboardHeight + relativeScroll, minScroll), maxScroll);
183 };
184 exports.computeIOSContentOffset = computeIOSContentOffset;
185 //# sourceMappingURL=helpers.js.map
186\ No newline at end of file
187diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
188index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..480b60f7b55d7a4945bf163e5378311638c437fc 100644
189--- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
190+++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
191@@ -32,7 +32,9 @@ function useChatKeyboard(scrollViewRef, options) {
192 freeze,
193 offset,
194 blankSpace,
195- extraContentPadding
196+ extraContentPadding,
197+ adjustedInsetCompensation,
198+ adjustedStartInsetCompensation
199 } = options;
200 const padding = (0, _reactNativeReanimated.useSharedValue)(0);
201 const currentHeight = (0, _reactNativeReanimated.useSharedValue)(0);
202@@ -67,6 +69,9 @@ function useChatKeyboard(scrollViewRef, options) {
203 prevAbsorption.value = padding.value;
204 }
205 const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset);
206+ if (e.duration === 0) {
207+ currentHeight.value = effective;
208+ }
209 const atEnd = (0, _helpers.isScrollAtEnd)(scroll.value, layout.value.height, size.value.height, inverted);
211 // Scale minimum padding absorption by how much of it is visible.
212@@ -75,7 +80,7 @@ function useChatKeyboard(scrollViewRef, options) {
213 const visiblePadding = visibleFraction * blankSpace.value;
214 const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value);
215 const scrollEffective = (0, _helpers.getScrollEffective)(effective, minimumPaddingAbsorbed);
216- const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value);
217+ const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value) + adjustedInsetCompensation;
219 // `onInteractive` owns the native scroll offset. When its final
220 // keyboardWillHide event arrives, consume the marker so this
221@@ -95,8 +100,8 @@ function useChatKeyboard(scrollViewRef, options) {
222 const maxScroll = Math.max(size.value.height - layout.value.height, 0);
223 contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll));
224 } else {
225- const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0);
226- contentOffsetY.value = Math.max(0, Math.min(scroll.value, maxScroll));
227+ const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, -adjustedStartInsetCompensation);
228+ contentOffsetY.value = Math.max(-adjustedStartInsetCompensation, Math.min(scroll.value, maxScroll));
229 }
230 return;
231 }
232@@ -110,8 +115,8 @@ function useChatKeyboard(scrollViewRef, options) {
233 const maxScroll = Math.max(size.value.height - layout.value.height, 0);
234 contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll));
235 } else {
236- const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0);
237- contentOffsetY.value = Math.max(0, Math.min(scroll.value, maxScroll));
238+ const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, -adjustedStartInsetCompensation);
239+ contentOffsetY.value = Math.max(-adjustedStartInsetCompensation, Math.min(scroll.value, maxScroll));
240 }
241 return;
242 }
243@@ -137,17 +142,25 @@ function useChatKeyboard(scrollViewRef, options) {
244 contentOffsetY.value = scroll.value;
245 return;
246 }
247- contentOffsetY.value = (0, _helpers.computeIOSContentOffset)(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding);
248+ contentOffsetY.value = (0, _helpers.computeIOSContentOffset)(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding, adjustedStartInsetCompensation);
249 },
250- onInteractive: () => {
251+ onMove: e => {
252 "worklet";
254- isInteractiveDismissal.value = true;
255+ if (freeze.value) {
256+ return;
257+ }
258+ currentHeight.value = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset);
259 },
260- onMove: () => {
261+ onInteractive: e => {
262 "worklet";
264- // iOS doesn't need per-frame updates (contentOffset handles it)
265+ isInteractiveDismissal.value = true;
266+
267+ if (freeze.value) {
268+ return;
269+ }
270+ currentHeight.value = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset);
271 },
272 onEnd: e => {
273 "worklet";
274@@ -156,9 +169,10 @@ function useChatKeyboard(scrollViewRef, options) {
275 return;
276 }
277 const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset);
278+ currentHeight.value = effective;
279 padding.value = effective;
280 }
281- }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]);
282+ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, adjustedStartInsetCompensation]);
283 return {
284 padding,
285 currentHeight,
286diff --git a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js
287index 1c2912e70a1a0c4a75ae8ec4cfbc4d7fa7bc6b83..eaf845042700207e4251b2fa66e5797b606f1fbb 100644
288--- a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js
289+++ b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js
290@@ -33,6 +33,8 @@ function useExtraContentPadding(options) {
291 extraContentPadding,
292 keyboardPadding,
293 blankSpace,
294+ adjustedInsetCompensation,
295+ adjustedStartInsetCompensation,
296 scroll,
297 layout,
298 size,
299@@ -74,8 +76,8 @@ function useExtraContentPadding(options) {
300 }
302 // Compute effective delta considering blankSpace floor
303- const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous);
304- const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current);
305+ const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous) + adjustedInsetCompensation;
306+ const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current) + adjustedInsetCompensation;
307 // Native scroll offsets are pixel-rounded. Feeding their fractions back
308 // into each animation step accumulates drift depending on frame timing.
309 // Round absolute totals before subtracting so the total movement stays
310@@ -120,14 +122,15 @@ function useExtraContentPadding(options) {
311 if (inverted) {
312 target = Math.max(base - effectiveDelta, -currentTotal);
313 } else {
314- const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, 0);
315+ const minScroll = -adjustedStartInsetCompensation;
316+ const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, minScroll);
318- // Clamp at 0 as well: when the content is shorter than the viewport a
319- // shrinking padding makes `effectiveDelta` negative while `base` is
320- // already 0, so the target would go below the top of the content.
321- target = Math.max(Math.min(base + effectiveDelta, maxScroll), 0);
322+ // Clamp at the top as well: when the content is shorter than the
323+ // viewport a shrinking padding makes `effectiveDelta` negative while
324+ // `base` is already at the top.
325+ target = Math.max(minScroll, Math.min(base + effectiveDelta, maxScroll));
326 }
327 scrollToTarget(target);
328- }, [inverted, keyboardLiftBehavior]);
329+ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation, adjustedStartInsetCompensation]);
330 }
331 //# sourceMappingURL=index.js.map
332\ No newline at end of file
333diff --git a/lib/commonjs/components/KeyboardChatScrollView/useFrozenPadding/index.js b/lib/commonjs/components/KeyboardChatScrollView/useFrozenPadding/index.js
334index 444fef5eea3988b60cb62a6b4b42084ef0510fd9..3e684bf71d5e58659a8367733a7c2ee47cc05455 100644
335--- a/lib/commonjs/components/KeyboardChatScrollView/useFrozenPadding/index.js
336+++ b/lib/commonjs/components/KeyboardChatScrollView/useFrozenPadding/index.js
337@@ -30,7 +30,8 @@ var _helpers = require("../useChatKeyboard/helpers");
338 function useFrozenPadding({
339 freeze,
340 offset,
341- padding
342+ padding,
343+ currentHeight
344 }) {
345 const lastHeight = (0, _reactNativeReanimated.useSharedValue)(0);
346 const targetKeyboardHeight = (0, _reactNativeReanimated.useSharedValue)(0);
347@@ -56,7 +57,9 @@ function useFrozenPadding({
348 }, []);
349 (0, _reanimated.useAnimatedReaction)(() => freeze.value, (isFrozen, wasFrozen) => {
350 if (!isFrozen && wasFrozen === true) {
351- padding.value = (0, _helpers.getEffectiveHeight)(lastHeight.value, targetKeyboardHeight.value, offset);
352+ const effective = (0, _helpers.getEffectiveHeight)(lastHeight.value, targetKeyboardHeight.value, offset);
353+ padding.value = effective;
354+ currentHeight.value = effective;
355 }
356 }, [offset]);
357 }
358diff --git a/lib/commonjs/hooks/useKeyboardState/index.js b/lib/commonjs/hooks/useKeyboardState/index.js
359index a4b287006a47011a58c8a16f19b31a479087f44e..4084025d644c8555b269f9d496266e2cb3234024 100644
360--- a/lib/commonjs/hooks/useKeyboardState/index.js
361+++ b/lib/commonjs/hooks/useKeyboardState/index.js
362@@ -7,7 +7,7 @@ exports.useKeyboardState = useKeyboardState;
363 var _react = require("react");
364 var _bindings = require("../../bindings");
365 var _module = require("../../module");
366-const EVENTS = ["keyboardWillShow", "keyboardDidHide"];
367+const EVENTS = ["keyboardWillShow", "keyboardDidShow", "keyboardDidHide"];
368 const getLatestState = () => ({
369 ..._module.KeyboardController.state(),
370 isVisible: _module.KeyboardController.isVisible()
371diff --git a/lib/commonjs/module.js b/lib/commonjs/module.js
372index f27c5a095129cf60831088a73ce94d44f3ba52cd..3c2f7569510d0a56fd937b6e91a6adc0d5ff6f59 100644
373--- a/lib/commonjs/module.js
374+++ b/lib/commonjs/module.js
375@@ -22,6 +22,12 @@ _bindings.KeyboardEvents.addListener("keyboardWillShow", e => {
376 isClosed = false;
377 lastState = e;
378 });
379+// Android re-syncs an IME inset that changes without an animation with keyboardDidShow
380+// alone. Drop with kirillzyusko/react-native-keyboard-controller#1698.
381+_bindings.KeyboardEvents.addListener("keyboardDidShow", e => {
382+ isClosed = false;
383+ lastState = e;
384+});
385 const dismiss = async options => {
386 const keepFocus = (options === null || options === void 0 ? void 0 : options.keepFocus) ?? false;
387 const animated = (options === null || options === void 0 ? void 0 : options.animated) ?? true;
388diff --git a/lib/module/components/KeyboardChatScrollView/index.js b/lib/module/components/KeyboardChatScrollView/index.js
389index e0c83918d9569e2ff541e45d31460121c73ec225..724c62e2892f75f6bb53844b84c1ade1bf87c7ec 100644
390--- a/lib/module/components/KeyboardChatScrollView/index.js
391+++ b/lib/module/components/KeyboardChatScrollView/index.js
392@@ -1,9 +1,9 @@
393 function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
394 import React, { forwardRef, useCallback, useMemo } from "react";
395-import { StyleSheet } from "react-native";
396-import { makeMutable, useAnimatedRef } from "react-native-reanimated";
397+import { Platform, StyleSheet } from "react-native";
398+import { makeMutable, runOnJS, useAnimatedRef } from "react-native-reanimated";
399 import Reanimated from "react-native-reanimated";
400-import { useAnimatedStyle, useDerivedValue } from "../../reanimated";
401+import { useAnimatedReaction, useAnimatedStyle, useDerivedValue } from "../../reanimated";
402 import useCombinedRef from "../hooks/useCombinedRef";
403 import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding";
404 import { useChatKeyboard } from "./useChatKeyboard";
405@@ -21,9 +21,12 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({
406 offset = 0,
407 extraContentPadding = ZERO_CONTENT_PADDING,
408 blankSpace = ZERO_BLANK_SPACE,
409+ adjustedInsetCompensation = 0,
410+ adjustedStartInsetCompensation = 0,
411 applyWorkaroundForContentInsetHitTestBug = false,
412 onLayout: onLayoutProp,
413 onContentSizeChange: onContentSizeChangeProp,
414+ onContentInsetChange,
415 onEndVisible,
416 ...rest
417 }, ref) => {
418@@ -47,6 +50,8 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({
419 offset,
420 blankSpace,
421 extraContentPadding,
422+ adjustedInsetCompensation,
423+ adjustedStartInsetCompensation,
424 initialContentOffsetY: (_rest$contentOffset = rest.contentOffset) === null || _rest$contentOffset === void 0 ? void 0 : _rest$contentOffset.y
425 });
426 useExtraContentPadding({
427@@ -54,6 +59,8 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({
428 extraContentPadding,
429 keyboardPadding: padding,
430 blankSpace,
431+ adjustedInsetCompensation,
432+ adjustedStartInsetCompensation,
433 scroll,
434 layout,
435 size,
436@@ -65,7 +72,8 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({
437 useFrozenPadding({
438 freeze: freezeSV,
439 offset,
440- padding
441+ padding,
442+ currentHeight
443 });
444 useEndVisible({
445 scroll,
446@@ -84,10 +92,26 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({
447 // a bug for you, please open an issue.
448 const totalPadding = useDerivedValue(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value)));
450+ // iOS applies the destination contentInset and contentOffset together at
451+ // keyboard-animation start. Keep that native target behavior, but report
452+ // the presentation height so virtualized-list layout follows the keyboard.
453+ const reportedPadding = useDerivedValue(() => Platform.OS === "ios" ? Math.min(layout.value.height, Math.max(blankSpace.value, currentHeight.value + extraContentPadding.value)) : totalPadding.value);
454+
455+ // Mirror the effective bottom padding (keyboard + composer + blank floor)
456+ // to the consumer - a virtualized list needs it in its own scroll math or
457+ // its end/maintain targets point at the under-the-keyboard resting offset.
458+ useAnimatedReaction(() => reportedPadding.value, (current, previous) => {
459+ if (onContentInsetChange && current !== previous) {
460+ runOnJS(onContentInsetChange)({
461+ bottom: current
462+ });
463+ }
464+ }, [onContentInsetChange]);
465+
466 // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace).
467 // Apps that render into the unsafe area can supply a negative
468 // scrollIndicatorInsets adjustment at the application layer.
469- const indicatorPadding = useDerivedValue(() => padding.value + extraContentPadding.value);
470+ const indicatorPadding = useDerivedValue(() => padding.value);
471 const onLayout = useCallback(e => {
472 onLayoutInternal(e);
473 onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e);
474diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js
475index 295e2219b560519b8d033968b5105d1da3e77315..2f7b87e6c68956f757db94a8197c1c61d1fafe28 100644
476--- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js
477+++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js
478@@ -193,7 +193,7 @@ export const clampedScrollTarget = (offsetBeforeScroll, keyboardHeight, contentH
479 * computeIOSContentOffset(100, 300, 1000, 800, false); // 400
480 * ```
481 */
482-export const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll) => {
483+export const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll, startInsetCompensation = 0) => {
484 "worklet";
486 const paddingForMax = totalPaddingForMaxScroll !== undefined ? totalPaddingForMaxScroll : keyboardHeight;
487@@ -201,7 +201,8 @@ export const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentH
488 const maxScroll = Math.max(contentHeight - layoutHeight, 0);
489 return Math.max(Math.min(relativeScroll - keyboardHeight, maxScroll), -paddingForMax);
490 }
491- const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, 0);
492- return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll);
493+ const minScroll = -startInsetCompensation;
494+ const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, minScroll);
495+ return Math.min(Math.max(keyboardHeight + relativeScroll, minScroll), maxScroll);
496 };
497 //# sourceMappingURL=helpers.js.map
498\ No newline at end of file
499diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
500index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..753893680fd20f207491f0c2dbca49da185e4373 100644
501--- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
502+++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
503@@ -25,7 +25,9 @@ function useChatKeyboard(scrollViewRef, options) {
504 freeze,
505 offset,
506 blankSpace,
507- extraContentPadding
508+ extraContentPadding,
509+ adjustedInsetCompensation,
510+ adjustedStartInsetCompensation
511 } = options;
512 const padding = useSharedValue(0);
513 const currentHeight = useSharedValue(0);
514@@ -60,6 +62,9 @@ function useChatKeyboard(scrollViewRef, options) {
515 prevAbsorption.value = padding.value;
516 }
517 const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset);
518+ if (e.duration === 0) {
519+ currentHeight.value = effective;
520+ }
521 const atEnd = isScrollAtEnd(scroll.value, layout.value.height, size.value.height, inverted);
523 // Scale minimum padding absorption by how much of it is visible.
524@@ -68,7 +73,7 @@ function useChatKeyboard(scrollViewRef, options) {
525 const visiblePadding = visibleFraction * blankSpace.value;
526 const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value);
527 const scrollEffective = getScrollEffective(effective, minimumPaddingAbsorbed);
528- const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value);
529+ const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value) + adjustedInsetCompensation;
531 // `onInteractive` owns the native scroll offset. When its final
532 // keyboardWillHide event arrives, consume the marker so this
533@@ -88,8 +93,8 @@ function useChatKeyboard(scrollViewRef, options) {
534 const maxScroll = Math.max(size.value.height - layout.value.height, 0);
535 contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll));
536 } else {
537- const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0);
538- contentOffsetY.value = Math.max(0, Math.min(scroll.value, maxScroll));
539+ const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, -adjustedStartInsetCompensation);
540+ contentOffsetY.value = Math.max(-adjustedStartInsetCompensation, Math.min(scroll.value, maxScroll));
541 }
542 return;
543 }
544@@ -103,8 +108,8 @@ function useChatKeyboard(scrollViewRef, options) {
545 const maxScroll = Math.max(size.value.height - layout.value.height, 0);
546 contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll));
547 } else {
548- const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0);
549- contentOffsetY.value = Math.max(0, Math.min(scroll.value, maxScroll));
550+ const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, -adjustedStartInsetCompensation);
551+ contentOffsetY.value = Math.max(-adjustedStartInsetCompensation, Math.min(scroll.value, maxScroll));
552 }
553 return;
554 }
555@@ -130,17 +135,25 @@ function useChatKeyboard(scrollViewRef, options) {
556 contentOffsetY.value = scroll.value;
557 return;
558 }
559- contentOffsetY.value = computeIOSContentOffset(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding);
560+ contentOffsetY.value = computeIOSContentOffset(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding, adjustedStartInsetCompensation);
561 },
562- onInteractive: () => {
563+ onMove: e => {
564 "worklet";
566- isInteractiveDismissal.value = true;
567+ if (freeze.value) {
568+ return;
569+ }
570+ currentHeight.value = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset);
571 },
572- onMove: () => {
573+ onInteractive: e => {
574 "worklet";
576- // iOS doesn't need per-frame updates (contentOffset handles it)
577+ isInteractiveDismissal.value = true;
578+
579+ if (freeze.value) {
580+ return;
581+ }
582+ currentHeight.value = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset);
583 },
584 onEnd: e => {
585 "worklet";
586@@ -149,9 +162,10 @@ function useChatKeyboard(scrollViewRef, options) {
587 return;
588 }
589 const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset);
590+ currentHeight.value = effective;
591 padding.value = effective;
592 }
593- }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]);
594+ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, adjustedStartInsetCompensation]);
595 return {
596 padding,
597 currentHeight,
598diff --git a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js
599index 458bced308670ec6bb30f8f5ef037ab841f4372b..9b7f1a5e9fa38928a07e7a462919105b76fe3155 100644
600--- a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js
601+++ b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js
602@@ -27,6 +27,8 @@ function useExtraContentPadding(options) {
603 extraContentPadding,
604 keyboardPadding,
605 blankSpace,
606+ adjustedInsetCompensation,
607+ adjustedStartInsetCompensation,
608 scroll,
609 layout,
610 size,
611@@ -68,8 +70,8 @@ function useExtraContentPadding(options) {
612 }
614 // Compute effective delta considering blankSpace floor
615- const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous);
616- const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current);
617+ const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous) + adjustedInsetCompensation;
618+ const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current) + adjustedInsetCompensation;
619 // Native scroll offsets are pixel-rounded. Feeding their fractions back
620 // into each animation step accumulates drift depending on frame timing.
621 // Round absolute totals before subtracting so the total movement stays
622@@ -114,15 +116,16 @@ function useExtraContentPadding(options) {
623 if (inverted) {
624 target = Math.max(base - effectiveDelta, -currentTotal);
625 } else {
626- const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, 0);
627+ const minScroll = -adjustedStartInsetCompensation;
628+ const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, minScroll);
630- // Clamp at 0 as well: when the content is shorter than the viewport a
631- // shrinking padding makes `effectiveDelta` negative while `base` is
632- // already 0, so the target would go below the top of the content.
633- target = Math.max(Math.min(base + effectiveDelta, maxScroll), 0);
634+ // Clamp at the top as well: when the content is shorter than the
635+ // viewport a shrinking padding makes `effectiveDelta` negative while
636+ // `base` is already at the top.
637+ target = Math.max(minScroll, Math.min(base + effectiveDelta, maxScroll));
638 }
639 scrollToTarget(target);
640- }, [inverted, keyboardLiftBehavior]);
641+ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation, adjustedStartInsetCompensation]);
642 }
643 export { useExtraContentPadding };
644 //# sourceMappingURL=index.js.map
645\ No newline at end of file
646diff --git a/lib/module/components/KeyboardChatScrollView/useFrozenPadding/index.js b/lib/module/components/KeyboardChatScrollView/useFrozenPadding/index.js
647index eddfd75913fef9111178771b4d5b4ba49864d217..d03dbd1e1fcbecb1866fb02547961a754eaf06e7 100644
648--- a/lib/module/components/KeyboardChatScrollView/useFrozenPadding/index.js
649+++ b/lib/module/components/KeyboardChatScrollView/useFrozenPadding/index.js
650@@ -24,7 +24,8 @@ import { getEffectiveHeight } from "../useChatKeyboard/helpers";
651 function useFrozenPadding({
652 freeze,
653 offset,
654- padding
655+ padding,
656+ currentHeight
657 }) {
658 const lastHeight = useSharedValue(0);
659 const targetKeyboardHeight = useSharedValue(0);
660@@ -50,7 +51,9 @@ function useFrozenPadding({
661 }, []);
662 useAnimatedReaction(() => freeze.value, (isFrozen, wasFrozen) => {
663 if (!isFrozen && wasFrozen === true) {
664- padding.value = getEffectiveHeight(lastHeight.value, targetKeyboardHeight.value, offset);
665+ const effective = getEffectiveHeight(lastHeight.value, targetKeyboardHeight.value, offset);
666+ padding.value = effective;
667+ currentHeight.value = effective;
668 }
669 }, [offset]);
670 }
671diff --git a/lib/module/hooks/useKeyboardState/index.js b/lib/module/hooks/useKeyboardState/index.js
672index 8da7c09fbb398253420d7bfcd9a85abdb981065b..31b1ff4370d8328a0569e559e3bbfa4b201f5a9d 100644
673--- a/lib/module/hooks/useKeyboardState/index.js
674+++ b/lib/module/hooks/useKeyboardState/index.js
675@@ -1,7 +1,7 @@
676 import { useEffect, useState } from "react";
677 import { KeyboardEvents } from "../../bindings";
678 import { KeyboardController } from "../../module";
679-const EVENTS = ["keyboardWillShow", "keyboardDidHide"];
680+const EVENTS = ["keyboardWillShow", "keyboardDidShow", "keyboardDidHide"];
681 const getLatestState = () => ({
682 ...KeyboardController.state(),
683 isVisible: KeyboardController.isVisible()
684diff --git a/lib/module/module.js b/lib/module/module.js
685index 01aad2effee498a8daec55e1d4644a5fbc723765..1140b706265d6c4295581c96b82b27bbf9e6d020 100644
686--- a/lib/module/module.js
687+++ b/lib/module/module.js
688@@ -16,6 +16,12 @@ KeyboardEvents.addListener("keyboardWillShow", e => {
689 isClosed = false;
690 lastState = e;
691 });
692+// Android re-syncs an IME inset that changes without an animation with keyboardDidShow
693+// alone. Drop with kirillzyusko/react-native-keyboard-controller#1698.
694+KeyboardEvents.addListener("keyboardDidShow", e => {
695+ isClosed = false;
696+ lastState = e;
697+});
698 const dismiss = async options => {
699 const keepFocus = (options === null || options === void 0 ? void 0 : options.keepFocus) ?? false;
700 const animated = (options === null || options === void 0 ? void 0 : options.animated) ?? true;
701diff --git a/lib/typescript/components/KeyboardChatScrollView/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/types.d.ts
702index eba1514c2d94ae42c024da3882475567a8e8260b..6597d80df11f973ceb2f52c30ee0ece8bd812402 100644
703--- a/lib/typescript/components/KeyboardChatScrollView/types.d.ts
704+++ b/lib/typescript/components/KeyboardChatScrollView/types.d.ts
705@@ -88,6 +88,10 @@ export type KeyboardChatScrollViewProps = {
706 * Default is `undefined` (equivalent to `0` — no minimum floor).
707 */
708 blankSpace?: SharedValue<number>;
709+ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */
710+ adjustedInsetCompensation?: number;
711+ /** Leading inset UIKit adds outside raw contentInset. Offset math only. */
712+ adjustedStartInsetCompensation?: number;
713 /**
714 * Fires whenever the effective content inset changes — the static `contentInset`
715 * prop combined with the dynamic keyboard-driven padding.
716diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts
717index 2b51cc4737b4a05b5396da31e67e54177f43c498..3acec1fbddee6227915590a6c8d449757cb4d7b2 100644
718--- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts
719+++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts
720@@ -129,4 +129,4 @@ export declare const clampedScrollTarget: (offsetBeforeScroll: number, keyboardH
721 * computeIOSContentOffset(100, 300, 1000, 800, false); // 400
722 * ```
723 */
724-export declare const computeIOSContentOffset: (relativeScroll: number, keyboardHeight: number, contentHeight: number, layoutHeight: number, inverted: boolean, totalPaddingForMaxScroll?: number) => number;
725+export declare const computeIOSContentOffset: (relativeScroll: number, keyboardHeight: number, contentHeight: number, layoutHeight: number, inverted: boolean, totalPaddingForMaxScroll?: number, startInsetCompensation?: number) => number;
726diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts
727index 8d358bc7bb2c6e0a26e4c04810e371a7693edd84..911f3d90ce56de0f64a39b027d4fa147ba93d0cd 100644
728--- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts
729+++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts
730@@ -11,6 +11,10 @@ type UseChatKeyboardOptions = {
731 blankSpace: SharedValue<number>;
732 /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */
733 extraContentPadding: SharedValue<number>;
734+ /** Safe-area extra beyond raw contentInset. Offset math only. */
735+ adjustedInsetCompensation: number;
736+ /** Leading inset UIKit adds beyond the raw contentInset. Offset math only. */
737+ adjustedStartInsetCompensation: number;
738 };
739 type UseChatKeyboardReturn = {
740 /** Extra scrollable space (= keyboard height). Used as contentInset on iOS, contentInsetBottom/contentInsetTop on Android. */
741diff --git a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts
742index ec73f70544a062fbecfc1d8be92d839d3e0bea6f..45b387446587fd3e611fa02a01534bb5d9fa6bbd 100644
743--- a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts
744+++ b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts
745@@ -8,6 +8,10 @@ type UseExtraContentPaddingOptions = {
746 keyboardPadding: SharedValue<number>;
747 /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */
748 blankSpace: SharedValue<number>;
749+ /** Safe-area extra beyond raw contentInset. Offset math only. */
750+ adjustedInsetCompensation: number;
751+ /** Leading inset UIKit adds beyond raw contentInset. Offset math only. */
752+ adjustedStartInsetCompensation: number;
753 /** Current vertical scroll offset. */
754 scroll: SharedValue<number>;
755 /** Visible viewport dimensions. */
756diff --git a/lib/typescript/components/KeyboardChatScrollView/useFrozenPadding/index.d.ts b/lib/typescript/components/KeyboardChatScrollView/useFrozenPadding/index.d.ts
757index 5c92b3850e21039eb7f11eb2944e2478c0ebab88..01d310135f5328f687087db09d9efb6a498e53d1 100644
758--- a/lib/typescript/components/KeyboardChatScrollView/useFrozenPadding/index.d.ts
759+++ b/lib/typescript/components/KeyboardChatScrollView/useFrozenPadding/index.d.ts
760@@ -6,6 +6,7 @@ type UseFrozenPaddingOptions = {
761 offset: number;
762 /** Keyboard-driven padding managed by `useChatKeyboard`. */
763 padding: SharedValue<number>;
764+ currentHeight: SharedValue<number>;
765 };
766 /**
767 * Hook that reconciles keyboard padding after a frozen interval.
768@@ -26,5 +27,5 @@ type UseFrozenPaddingOptions = {
769 * useFrozenPadding({ freeze, offset, padding });
770 * ```
771 */
772-declare function useFrozenPadding({ freeze, offset, padding, }: UseFrozenPaddingOptions): void;
773+declare function useFrozenPadding({ freeze, offset, padding, currentHeight, }: UseFrozenPaddingOptions): void;
774 export { useFrozenPadding };
775diff --git a/src/components/KeyboardChatScrollView/index.tsx b/src/components/KeyboardChatScrollView/index.tsx
776index 935bc71dabad6cd5c82bab77f64b5b6e1e5dc9b6..5306506e791c64e6c64abc91499c8974ac318301 100644
777--- a/src/components/KeyboardChatScrollView/index.tsx
778+++ b/src/components/KeyboardChatScrollView/index.tsx
779@@ -1,9 +1,13 @@
780 import React, { forwardRef, useCallback, useMemo } from "react";
781-import { StyleSheet } from "react-native";
782-import { makeMutable, useAnimatedRef } from "react-native-reanimated";
783+import { Platform, StyleSheet } from "react-native";
784+import { makeMutable, runOnJS, useAnimatedRef } from "react-native-reanimated";
785 import Reanimated from "react-native-reanimated";
787-import { useAnimatedStyle, useDerivedValue } from "../../reanimated";
788+import {
789+ useAnimatedReaction,
790+ useAnimatedStyle,
791+ useDerivedValue,
792+} from "../../reanimated";
793 import useCombinedRef from "../hooks/useCombinedRef";
794 import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding";
796@@ -35,9 +39,12 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent<
797 offset = 0,
798 extraContentPadding = ZERO_CONTENT_PADDING,
799 blankSpace = ZERO_BLANK_SPACE,
800+ adjustedInsetCompensation = 0,
801+ adjustedStartInsetCompensation = 0,
802 applyWorkaroundForContentInsetHitTestBug = false,
803 onLayout: onLayoutProp,
804 onContentSizeChange: onContentSizeChangeProp,
805+ onContentInsetChange,
806 onEndVisible,
807 ...rest
808 },
809@@ -64,6 +71,8 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent<
810 offset,
811 blankSpace,
812 extraContentPadding,
813+ adjustedInsetCompensation,
814+ adjustedStartInsetCompensation,
815 initialContentOffsetY: rest.contentOffset?.y,
816 });
818@@ -72,6 +81,8 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent<
819 extraContentPadding,
820 keyboardPadding: padding,
821 blankSpace,
822+ adjustedInsetCompensation,
823+ adjustedStartInsetCompensation,
824 scroll,
825 layout,
826 size,
827@@ -85,6 +96,7 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent<
828 freeze: freezeSV,
829 offset,
830 padding,
831+ currentHeight,
832 });
834 useEndVisible({
835@@ -109,13 +121,40 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent<
836 ),
837 );
839- // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace).
840- // Apps that render into the unsafe area can supply a negative
841- // scrollIndicatorInsets adjustment at the application layer.
842- const indicatorPadding = useDerivedValue(
843- () => padding.value + extraContentPadding.value,
844+ // iOS applies the destination contentInset and contentOffset together at
845+ // keyboard-animation start. Keep that native target behavior, but report
846+ // the presentation height so virtualized-list layout follows the keyboard.
847+ const reportedPadding = useDerivedValue(() =>
848+ Platform.OS === "ios"
849+ ? Math.min(
850+ layout.value.height,
851+ Math.max(
852+ blankSpace.value,
853+ currentHeight.value + extraContentPadding.value,
854+ ),
855+ )
856+ : totalPadding.value,
857 );
859+ // Mirror the effective bottom padding (keyboard + composer + blank floor)
860+ // to the consumer — a virtualized list needs it in its own scroll math or
861+ // its end/maintain targets point at the under-the-keyboard resting offset.
862+ useAnimatedReaction(
863+ () => reportedPadding.value,
864+ (current, previous) => {
865+ if (onContentInsetChange && current !== previous) {
866+ runOnJS(onContentInsetChange)({ bottom: current });
867+ }
868+ },
869+ [onContentInsetChange],
870+ );
871+
872+ // Scroll indicator inset = keyboard only (excludes extraContentPadding and
873+ // blankSpace): with a floating composer the indicator track should run the
874+ // full height of the scroll view, behind the composer, like iOS Messages.
875+ // The keyboard still lifts it so it never disappears under the keyboard.
876+ const indicatorPadding = useDerivedValue(() => padding.value);
877+
878 const onLayout = useCallback(
879 (e: LayoutChangeEvent) => {
880 onLayoutInternal(e);
881diff --git a/src/components/KeyboardChatScrollView/types.ts b/src/components/KeyboardChatScrollView/types.ts
882index b7c98c8ede80f690c8c9ad60263efe3ebd5b73df..e1012a80d59ff172396ff17550cb8402d5ba5673 100644
883--- a/src/components/KeyboardChatScrollView/types.ts
884+++ b/src/components/KeyboardChatScrollView/types.ts
885@@ -93,6 +93,17 @@ export type KeyboardChatScrollViewProps = {
886 * Default is `undefined` (equivalent to `0` — no minimum floor).
887 */
888 blankSpace?: SharedValue<number>;
889+ /**
890+ * Extra bottom inset UIKit adds on top of the raw `contentInset` this
891+ * component writes (e.g. the home-indicator safe area under
892+ * `contentInsetAdjustmentBehavior="automatic"`). Used ONLY in scroll-offset
893+ * math (max scroll / end pinning) — never written into `contentInset`.
894+ *
895+ * Default is `0`.
896+ */
897+ adjustedInsetCompensation?: number;
898+ /** Leading inset UIKit adds outside raw contentInset. Offset math only. */
899+ adjustedStartInsetCompensation?: number;
900 /**
901 * Fires whenever the effective content inset changes — the static `contentInset`
902 * prop combined with the dynamic keyboard-driven padding.
903diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts
904index 37eacd10857cd70c51b3468c4b466e4959d0a415..3d77ab27c614f44b64da8270823942cb670b097b 100644
905--- a/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts
906+++ b/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts
907@@ -245,6 +245,7 @@ export const computeIOSContentOffset = (
908 layoutHeight: number,
909 inverted: boolean,
910 totalPaddingForMaxScroll?: number,
911+ startInsetCompensation: number = 0,
912 ): number => {
913 "worklet";
915@@ -262,7 +263,14 @@ export const computeIOSContentOffset = (
916 );
917 }
919- const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, 0);
920+ const minScroll = -startInsetCompensation;
921+ const maxScroll = Math.max(
922+ contentHeight - layoutHeight + paddingForMax,
923+ minScroll,
924+ );
926- return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll);
927+ return Math.min(
928+ Math.max(keyboardHeight + relativeScroll, minScroll),
929+ maxScroll,
930+ );
931 };
932diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts
933index f67fc0d1706bc7ab9169da533514a961799a5551..a599edb77c094574c57adcb217d8404e3cb63d7d 100644
934--- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts
935+++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts
936@@ -43,6 +43,8 @@ function useChatKeyboard(
937 offset,
938 blankSpace,
939 extraContentPadding,
940+ adjustedInsetCompensation,
941+ adjustedStartInsetCompensation,
942 } = options;
944 const padding = useSharedValue(0);
945@@ -90,6 +92,10 @@ function useChatKeyboard(
946 offset,
947 );
949+ if (e.duration === 0) {
950+ currentHeight.value = effective;
951+ }
952+
953 const atEnd = isScrollAtEnd(
954 scroll.value,
955 layout.value.height,
956@@ -115,10 +121,12 @@ function useChatKeyboard(
957 effective,
958 minimumPaddingAbsorbed,
959 );
960- const actualTotalPadding = Math.max(
961- blankSpace.value,
962- effective + extraContentPadding.value,
963- );
964+ // UIKit adds adjustedInsetCompensation (safe area) on top of the raw
965+ // inset; include it here so end/max-scroll targets match the real
966+ // resting offsets.
967+ const actualTotalPadding =
968+ Math.max(blankSpace.value, effective + extraContentPadding.value) +
969+ adjustedInsetCompensation;
971 // `onInteractive` owns the native scroll offset. When its final
972 // keyboardWillHide event arrives, consume the marker so this
973@@ -152,11 +160,11 @@ function useChatKeyboard(
974 } else {
975 const maxScroll = Math.max(
976 size.value.height - layout.value.height + actualTotalPadding,
977- 0,
978+ -adjustedStartInsetCompensation,
979 );
981 contentOffsetY.value = Math.max(
982- 0,
983+ -adjustedStartInsetCompensation,
984 Math.min(scroll.value, maxScroll),
985 );
986 }
987@@ -187,11 +195,11 @@ function useChatKeyboard(
988 } else {
989 const maxScroll = Math.max(
990 size.value.height - layout.value.height + actualTotalPadding,
991- 0,
992+ -adjustedStartInsetCompensation,
993 );
995 contentOffsetY.value = Math.max(
996- 0,
997+ -adjustedStartInsetCompensation,
998 Math.min(scroll.value, maxScroll),
999 );
1000 }
1001@@ -241,17 +249,36 @@ function useChatKeyboard(
1002 layout.value.height,
1003 inverted,
1004 actualTotalPadding,
1005+ adjustedStartInsetCompensation,
1006 );
1007 },
1008- onInteractive: () => {
1009+ onMove: (e) => {
1010 "worklet";
1012- isInteractiveDismissal.value = true;
1013+ if (freeze.value) {
1014+ return;
1015+ }
1016+
1017+ currentHeight.value = getEffectiveHeight(
1018+ e.height,
1019+ targetKeyboardHeight.value,
1020+ offset,
1021+ );
1022 },
1023- onMove: () => {
1024+ onInteractive: (e) => {
1025 "worklet";
1027- // iOS doesn't need per-frame updates (contentOffset handles it)
1028+ isInteractiveDismissal.value = true;
1029+
1030+ if (freeze.value) {
1031+ return;
1032+ }
1033+
1034+ currentHeight.value = getEffectiveHeight(
1035+ e.height,
1036+ targetKeyboardHeight.value,
1037+ offset,
1038+ );
1039 },
1040 onEnd: (e) => {
1041 "worklet";
1042@@ -266,10 +293,18 @@ function useChatKeyboard(
1043 offset,
1044 );
1046+ currentHeight.value = effective;
1047 padding.value = effective;
1048 },
1049 },
1050- [inverted, keyboardLiftBehavior, offset, extraContentPadding],
1051+ [
1052+ inverted,
1053+ keyboardLiftBehavior,
1054+ offset,
1055+ extraContentPadding,
1056+ adjustedInsetCompensation,
1057+ adjustedStartInsetCompensation,
1058+ ],
1059 );
1061 return {
1062diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts
1063index f89b7c81f4ac0e85e98b790c75309b7203be1fb2..02e0e196f2f6c3222317429716e50da487ac45e3 100644
1064--- a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts
1065+++ b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts
1066@@ -13,6 +13,10 @@ type UseChatKeyboardOptions = {
1067 blankSpace: SharedValue<number>;
1068 /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */
1069 extraContentPadding: SharedValue<number>;
1070+ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */
1071+ adjustedInsetCompensation: number;
1072+ /** Leading inset UIKit adds beyond the raw contentInset. Offset math only. */
1073+ adjustedStartInsetCompensation: number;
1074 };
1076 type UseChatKeyboardReturn = {
1077diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
1078index 78fe7cf88b110c955a36d8df2ee5b90c088d4228..98719c1a320c474fc45a846d8ea8b16a16c4ab9d 100644
1079--- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
1080+++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
1081@@ -18,6 +18,10 @@ type UseExtraContentPaddingOptions = {
1082 keyboardPadding: SharedValue<number>;
1083 /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */
1084 blankSpace: SharedValue<number>;
1085+ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */
1086+ adjustedInsetCompensation: number;
1087+ /** Leading inset UIKit adds beyond raw contentInset. Offset math only. */
1088+ adjustedStartInsetCompensation: number;
1089 /** Current vertical scroll offset. */
1090 scroll: SharedValue<number>;
1091 /** Visible viewport dimensions. */
1092@@ -53,6 +57,8 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
1093 extraContentPadding,
1094 keyboardPadding,
1095 blankSpace,
1096+ adjustedInsetCompensation,
1097+ adjustedStartInsetCompensation,
1098 scroll,
1099 layout,
1100 size,
1101@@ -104,14 +110,12 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
1102 }
1104 // Compute effective delta considering blankSpace floor
1105- const previousTotal = Math.max(
1106- blankSpace.value,
1107- keyboardPadding.value + previous,
1108- );
1109- const currentTotal = Math.max(
1110- blankSpace.value,
1111- keyboardPadding.value + current,
1112- );
1113+ const previousTotal =
1114+ Math.max(blankSpace.value, keyboardPadding.value + previous) +
1115+ adjustedInsetCompensation;
1116+ const currentTotal =
1117+ Math.max(blankSpace.value, keyboardPadding.value + current) +
1118+ adjustedInsetCompensation;
1119 // Native scroll offsets are pixel-rounded. Feeding their fractions back
1120 // into each animation step accumulates drift depending on frame timing.
1121 // Round absolute totals before subtracting so the total movement stays
1122@@ -175,20 +179,26 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
1123 if (inverted) {
1124 target = Math.max(base - effectiveDelta, -currentTotal);
1125 } else {
1126+ const minScroll = -adjustedStartInsetCompensation;
1127 const maxScroll = Math.max(
1128 size.value.height - layout.value.height + currentTotal,
1129- 0,
1130+ minScroll,
1131 );
1133- // Clamp at 0 as well: when the content is shorter than the viewport a
1134- // shrinking padding makes `effectiveDelta` negative while `base` is
1135- // already 0, so the target would go below the top of the content.
1136- target = Math.max(Math.min(base + effectiveDelta, maxScroll), 0);
1137+ // Clamp at the top as well: when the content is shorter than the
1138+ // viewport a shrinking padding makes `effectiveDelta` negative while
1139+ // `base` is already at the top.
1140+ target = Math.max(minScroll, Math.min(base + effectiveDelta, maxScroll));
1141 }
1143 scrollToTarget(target);
1144 },
1145- [inverted, keyboardLiftBehavior],
1146+ [
1147+ inverted,
1148+ keyboardLiftBehavior,
1149+ adjustedInsetCompensation,
1150+ adjustedStartInsetCompensation,
1151+ ],
1152 );
1153 }
1155diff --git a/src/components/KeyboardChatScrollView/useFrozenPadding/index.ts b/src/components/KeyboardChatScrollView/useFrozenPadding/index.ts
1156index 00b01bf2e8c8e948c15e1b89318bb82fef2c5f3f..26c4560230921074aa8ce31fad4615e5188e26c1 100644
1157--- a/src/components/KeyboardChatScrollView/useFrozenPadding/index.ts
1158+++ b/src/components/KeyboardChatScrollView/useFrozenPadding/index.ts
1159@@ -13,6 +13,8 @@ type UseFrozenPaddingOptions = {
1160 offset: number;
1161 /** Keyboard-driven padding managed by `useChatKeyboard`. */
1162 padding: SharedValue<number>;
1163+ /** Presentation keyboard height managed by `useChatKeyboard`. */
1164+ currentHeight: SharedValue<number>;
1165 };
1167 /**
1168@@ -38,6 +40,7 @@ function useFrozenPadding({
1169 freeze,
1170 offset,
1171 padding,
1172+ currentHeight,
1173 }: UseFrozenPaddingOptions): void {
1174 const lastHeight = useSharedValue(0);
1175 const targetKeyboardHeight = useSharedValue(0);
1176@@ -70,11 +73,14 @@ function useFrozenPadding({
1177 () => freeze.value,
1178 (isFrozen, wasFrozen) => {
1179 if (!isFrozen && wasFrozen === true) {
1180- padding.value = getEffectiveHeight(
1181+ const effective = getEffectiveHeight(
1182 lastHeight.value,
1183 targetKeyboardHeight.value,
1184 offset,
1185 );
1186+
1187+ padding.value = effective;
1188+ currentHeight.value = effective;
1189 }
1190 },
1191 [offset],
1192diff --git a/src/hooks/useKeyboardState/index.ts b/src/hooks/useKeyboardState/index.ts
1193index e5fe38dd8e7b8370bfb575296259933094857ca5..d0b98f60a6d7ad7f72ebd70b22e1ce6b7fb56874 100644
1194--- a/src/hooks/useKeyboardState/index.ts
1195+++ b/src/hooks/useKeyboardState/index.ts
1196@@ -5,7 +5,7 @@ import { KeyboardController } from "../../module";
1198 import type { IKeyboardState } from "../../types";
1200-const EVENTS = ["keyboardWillShow", "keyboardDidHide"] as const;
1201+const EVENTS = ["keyboardWillShow", "keyboardDidShow", "keyboardDidHide"] as const;
1203 const getLatestState = () => ({
1204 ...KeyboardController.state(),
1205diff --git a/src/module.ts b/src/module.ts
1206index f74941914c9ecebbd0a6c2ba55c5436378de79d4..b7223cff162d18be11f8de248a7bb8f32e694658 100644
1207--- a/src/module.ts
1208+++ b/src/module.ts
1209@@ -26,6 +26,13 @@ KeyboardEvents.addListener("keyboardWillShow", (e) => {
1210 lastState = e;
1211 });
1213+// Android re-syncs an IME inset that changes without an animation with keyboardDidShow
1214+// alone. Drop with kirillzyusko/react-native-keyboard-controller#1698.
1215+KeyboardEvents.addListener("keyboardDidShow", (e) => {
1216+ isClosed = false;
1217+ lastState = e;
1218+});
1219+
1220 const dismiss = async (options?: Partial<DismissOptions>): Promise<void> => {
1221 const keepFocus = options?.keepFocus ?? false;
1222 const animated = options?.animated ?? true;