Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a0f1b51
feat: sync layout from JS
kirillzyusko Nov 20, 2025
dafa7e3
fix: race condition
kirillzyusko Nov 20, 2025
78e7036
chore: temp commit
kirillzyusko Nov 21, 2025
c14bf7a
fix: TS errors (let's not add a ref temporarily, because it's causing…
kirillzyusko Nov 21, 2025
1a5727a
fix: ktlint
kirillzyusko Nov 21, 2025
20e5e91
chore: migrate to commands since it's more stable
kirillzyusko Nov 23, 2025
2abddcd
fix: very stupid bug
kirillzyusko Nov 23, 2025
3cad7d8
chore: further cleanup
kirillzyusko Nov 23, 2025
3f43976
feat: shared android implementations
kirillzyusko Nov 23, 2025
fa0ca9a
fix: tsc
kirillzyusko Nov 23, 2025
d6df8e0
fix: use spec according to guidelines
kirillzyusko Nov 23, 2025
4f7bcad
feat: implement iOS (fabric)
kirillzyusko Nov 23, 2025
fdcffe5
feat: implement Android (paper)
kirillzyusko Nov 24, 2025
340f112
feat: implement iOS (paper)
kirillzyusko Nov 24, 2025
8ff32f6
fix: ktlint
kirillzyusko Nov 24, 2025
560bca3
fix: e2e tests (Android works, haven't tested iOS yet)
kirillzyusko Nov 24, 2025
2b0f76e
chore: changes after self review
kirillzyusko Nov 24, 2025
9fda9eb
fix: commands now can reach its view on Fabric
kirillzyusko Nov 24, 2025
b90818a
fix: e2e test freeze on iOS
kirillzyusko Nov 24, 2025
f7bb59c
e2e: all assets generated
kirillzyusko Nov 25, 2025
1a126ef
fix: actually expose `assureFocusedInputVisible` method
kirillzyusko Nov 25, 2025
334e386
fix: actually set up one time listener
kirillzyusko Nov 25, 2025
8a21c3d
fix: call `super.receiveCommand` only if we don't know how to handle …
kirillzyusko Nov 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.reactnativekeyboardcontroller

import com.facebook.react.bridge.ReadableArray
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewManagerDelegate
import com.facebook.react.uimanager.annotations.ReactProp
Expand Down Expand Up @@ -56,6 +57,23 @@ class KeyboardControllerViewManager :
) = manager.setEnabled(view as EdgeToEdgeReactViewGroup, value)
// endregion

// region Commands
override fun receiveCommand(
root: ReactViewGroup,
commandId: String,
args: ReadableArray?,
) {
when (commandId) {
"synchronizeFocusedInputLayout" -> synchronizeFocusedInputLayout(root)
else -> super.receiveCommand(root, commandId, args)
}
}
Comment thread
kirillzyusko marked this conversation as resolved.

override fun synchronizeFocusedInputLayout(view: ReactViewGroup) {
manager.synchronizeFocusedInputLayout(view as EdgeToEdgeReactViewGroup)
}
// endregion

// region Getters
override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> =
manager.getExportedCustomDirectEventTypeConstants()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ class KeyboardAnimationCallback(
}
}
}
private var layoutObserver: FocusedInputObserver? = null
internal var layoutObserver: FocusedInputObserver? = null

init {
require(config.persistentInsetTypes and config.deferredInsetTypes == 0) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package com.reactnativekeyboardcontroller.managers

import com.facebook.react.bridge.Arguments
import com.facebook.react.common.MapBuilder
import com.facebook.react.uimanager.ThemedReactContext
import com.reactnativekeyboardcontroller.events.FocusedInputLayoutChangedEvent
import com.reactnativekeyboardcontroller.events.FocusedInputSelectionChangedEvent
import com.reactnativekeyboardcontroller.events.FocusedInputTextChangedEvent
import com.reactnativekeyboardcontroller.events.KeyboardTransitionEvent
import com.reactnativekeyboardcontroller.extensions.emitEvent
import com.reactnativekeyboardcontroller.listeners.WindowDimensionListener
import com.reactnativekeyboardcontroller.views.EdgeToEdgeReactViewGroup

Expand All @@ -25,6 +27,11 @@ class KeyboardControllerViewManagerImpl {
listener = null
}

fun synchronizeFocusedInputLayout(view: EdgeToEdgeReactViewGroup) {
view.callback?.layoutObserver?.syncUpLayout()
view.reactContext.emitEvent("KeyboardController::layoutDidSynchronize", Arguments.createMap())
}

fun setEnabled(
view: EdgeToEdgeReactViewGroup,
enabled: Boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ object EdgeToEdgeViewRegistry {
@Suppress("detekt:TooManyFunctions")
@SuppressLint("ViewConstructor")
class EdgeToEdgeReactViewGroup(
private val reactContext: ThemedReactContext,
val reactContext: ThemedReactContext,
) : ReactViewGroup(reactContext) {
// props
private var isStatusBarTranslucent = false
Expand All @@ -58,7 +58,7 @@ class EdgeToEdgeReactViewGroup(
// internal class members
private var eventView: ReactViewGroup? = null
private var wasMounted = false
private var callback: KeyboardAnimationCallback? = null
internal var callback: KeyboardAnimationCallback? = null
private val config =
KeyboardAnimationCallbackConfig(
persistentInsetTypes = WindowInsetsCompat.Type.systemBars(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.reactnativekeyboardcontroller

import com.facebook.react.bridge.ReadableArray
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.annotations.ReactProp
import com.facebook.react.views.view.ReactViewGroup
Expand Down Expand Up @@ -58,6 +59,21 @@ class KeyboardControllerViewManager : ReactViewManager() {
}
// endregion

// region Commands
override fun receiveCommand(
root: ReactViewGroup,
commandId: String,
args: ReadableArray?,
) {
when (commandId) {
"synchronizeFocusedInputLayout" -> {
manager.synchronizeFocusedInputLayout(root as EdgeToEdgeReactViewGroup)
}
else -> super.receiveCommand(root, commandId, args)
}
}
//endregion

// region Getters
override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> =
manager.getExportedCustomDirectEventTypeConstants()
Expand Down
8 changes: 8 additions & 0 deletions docs/docs/api/components/keyboard-aware-scroll-view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,14 @@ export function Example() {
}
```

## Methods

### `assureFocusedInputVisible`

A method that assures that focused input is visible and not obscured by keyboard or other elements.

You may want to call it, when layout inside `ScrollView` changes (for example validation message appears or disappears and it shifts position of focused input).

## Example

```tsx
Expand Down
8 changes: 6 additions & 2 deletions docs/docs/api/hooks/input/use-reanimated-focused-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Hook will update its value in next cases:
The value from `useReanimatedFocusedInput` will be always updated before keyboard events, so you can safely read values in `onStart` handler and be sure they are up-to-date.
:::

## Event structure
## `input`

The `input` property from this hook is returned as `SharedValue`. The returned data has next structure:

Expand All @@ -47,10 +47,14 @@ type FocusedInputLayoutChangedEvent = {
};
```

### `update`

To update the focused input, use `update` function. Thus you can query the position on demand from JS thread.

## Example

```tsx
const { input } = useReanimatedFocusedInput();
const { input, update } = useReanimatedFocusedInput();
```

Also have a look on [example](https://github.com/kirillzyusko/react-native-keyboard-controller/tree/main/example) app for more comprehensive usage.
37 changes: 37 additions & 0 deletions e2e/kit/016-aware-scroll-view-with-sticky-view.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { expectBitmapsToBeEqual } from "./asserts";
import {
scrollDownUntilElementIsVisible,
tap,
waitAndTap,
waitForExpect,
} from "./helpers";

const BLINKING_CURSOR = 0.35;

const closeKeyboard = async () => {
// tap outside to close a keyboard
await tap("aware_scroll_sticky_view_scroll_container", { x: 0, y: 100 });
};

describe("AwareScrollView with StickyView test cases", () => {
it("should push input above keyboard on focus", async () => {
await waitAndTap("aware_scroll_view_sticky_footer");
Expand All @@ -32,4 +38,35 @@ describe("AwareScrollView with StickyView test cases", () => {
);
});
});

it("should react on `bottomOffset` change even if input is not visible", async () => {
await scrollDownUntilElementIsVisible(
"aware_scroll_sticky_view_scroll_container",
"TextInput#9",
{ x: 0, y: 0.2, checkScrollViewVisibility: false },
);
await waitAndTap("toggle_height");
await waitForExpect(async () => {
await expectBitmapsToBeEqual(
"AwareScrollViewWithStickyViewFirstInputFocused",
BLINKING_CURSOR,
);
});
});

it("shouldn't scroll a scroll view when focusing input inside sticky view", async () => {
await closeKeyboard();
await element(by.id("aware_scroll_sticky_view_scroll_container")).swipe(
"down",
"fast",
1,
);
await waitAndTap("Amount");
await waitForExpect(async () => {
await expectBitmapsToBeEqual(
"AwareScrollViewWithStickyViewStickyInputFocused",
BLINKING_CURSOR,
);
});
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 13 additions & 2 deletions e2e/kit/helpers/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,23 @@ export const switchToEmojiKeyboard = async () => {
export const scrollDownUntilElementIsVisible = async (
scrollViewId: string,
elementId: string,
{
x,
y,
checkScrollViewVisibility,
}: { x: number; y: number; checkScrollViewVisibility: boolean } = {
x: NaN,
y: 0.5,
checkScrollViewVisibility: true,
},
): Promise<void> => {
await waitForElementById(scrollViewId, TIMEOUT_FOR_LONG_OPERATIONS);
if (checkScrollViewVisibility) {
await waitForElementById(scrollViewId, TIMEOUT_FOR_LONG_OPERATIONS);
}
await waitFor(element(by.id(elementId)))
.toBeVisible()
.whileElement(by.id(scrollViewId))
.scroll(100, "down", NaN, 0.5);
.scroll(100, "down", x, y);
};

export const scrollUpUntilElementIsBarelyVisible = async (
Expand Down
1 change: 1 addition & 0 deletions ios/KeyboardControllerModule.mm
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ - (void)sendEvent:(NSString *)name body:(id)body
@"KeyboardController::keyboardDidHide",
// focused input
@"KeyboardController::focusDidSet",
@"KeyboardController::layoutDidSynchronize",
// window dimensions
@"KeyboardController::windowDidResize",
];
Expand Down
2 changes: 1 addition & 1 deletion ios/observers/FocusedInputObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ public class FocusedInputObserver: NSObject {
dispatchEventToJS(data: noFocusedInputEvent)
}

@objc func syncUpLayout() {
@objc public func syncUpLayout() {
let responder = currentResponder as UIResponder?
let focusedInput = currentInput
let globalFrame = focusedInput?.globalFrame
Expand Down
11 changes: 11 additions & 0 deletions ios/views/KeyboardControllerView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ @implementation KeyboardControllerView {
CGSize _lastScreenSize;
}

- (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args
{
RCTKeyboardControllerViewHandleCommand(self, commandName, args);
}

- (void)synchronizeFocusedInputLayout
{
[inputObserver syncUpLayout];
[KeyboardController.shared sendEvent:@"KeyboardController::layoutDidSynchronize" body:nil];
}

+ (ComponentDescriptorProvider)componentDescriptorProvider
{
return concreteComponentDescriptorProvider<KeyboardControllerViewComponentDescriptor>();
Expand Down
3 changes: 3 additions & 0 deletions ios/views/KeyboardControllerViewManager.mm
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@ @interface RCT_EXTERN_MODULE (KeyboardControllerViewManager, RCTViewManager)
RCT_EXPORT_VIEW_PROPERTY(onFocusedInputTextChanged, RCTDirectEventBlock);
RCT_EXPORT_VIEW_PROPERTY(onFocusedInputSelectionChanged, RCTDirectEventBlock);

// commands
RCT_EXTERN_METHOD(synchronizeFocusedInputLayout : (nonnull NSNumber *)reactTag);

@end
14 changes: 13 additions & 1 deletion ios/views/KeyboardControllerViewManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,24 @@ class KeyboardControllerViewManager: RCTViewManager {
override func view() -> (KeyboardControllerView) {
return KeyboardControllerView(frame: CGRect.zero, bridge: bridge)
}

@objc(synchronizeFocusedInputLayout:)
func synchronizeFocusedInputLayout(_ reactTag: NSNumber) {
bridge.uiManager.addUIBlock { _, viewRegistry in
guard let view = viewRegistry?[reactTag] as? KeyboardControllerView else {
return
}

view.inputObserver?.syncUpLayout()
KeyboardController.shared()?.sendEvent("KeyboardController::layoutDidSynchronize", body: nil)
}
}
}

class KeyboardControllerView: UIView {
// internal variables
private var keyboardObserver: KeyboardMovementObserver?
private var inputObserver: FocusedInputObserver?
var inputObserver: FocusedInputObserver?
private var eventDispatcher: RCTEventDispatcherProtocol
private var bridge: RCTBridge
// internal state
Expand Down
38 changes: 32 additions & 6 deletions src/animated.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
/* eslint react/jsx-sort-props: off */
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Animated, Platform, StyleSheet } from "react-native";
import {
controlEdgeToEdgeValues,
isEdgeToEdge,
} from "react-native-is-edge-to-edge";
import Reanimated, { useSharedValue } from "react-native-reanimated";

import { KeyboardControllerView } from "./bindings";
import {
FocusedInputEvents,
KeyboardControllerView,
KeyboardControllerViewCommands,
} from "./bindings";
import { KeyboardContext } from "./context";
import { useAnimatedValue, useEventHandlerRegistration } from "./internal";
import { KeyboardController } from "./module";
Expand Down Expand Up @@ -117,7 +127,7 @@ export const KeyboardProvider = (props: KeyboardProviderProps) => {
preload = true,
} = props;
// ref
const viewTagRef = useRef<React.Component<KeyboardControllerProps>>(null);
const viewRef = useRef<React.Component<KeyboardControllerProps>>(null);
// state
const [enabled, setEnabled] = useState(initiallyEnabled);
// animated values
Expand All @@ -127,15 +137,31 @@ export const KeyboardProvider = (props: KeyboardProviderProps) => {
const progressSV = useSharedValue(0);
const heightSV = useSharedValue(0);
const layout = useSharedValue<FocusedInputLayoutChangedEvent | null>(null);
const setKeyboardHandlers = useEventHandlerRegistration(viewTagRef);
const setInputHandlers = useEventHandlerRegistration(viewTagRef);
const setKeyboardHandlers = useEventHandlerRegistration(viewRef);
const setInputHandlers = useEventHandlerRegistration(viewRef);
const update = useCallback(async () => {
KeyboardControllerViewCommands.synchronizeFocusedInputLayout(
viewRef.current,
);

await new Promise((resolve) => {
const subscription = FocusedInputEvents.addListener(
"layoutDidSynchronize",
() => {
subscription.remove();
resolve(null);
},
);
});
}, []);
// memo
const context = useMemo<KeyboardAnimationContext>(
() => ({
enabled,
animated: { progress: progress, height: Animated.multiply(height, -1) },
reanimated: { progress: progressSV, height: heightSV },
layout,
update,
setKeyboardHandlers,
setInputHandlers,
setEnabled,
Expand Down Expand Up @@ -232,7 +258,7 @@ export const KeyboardProvider = (props: KeyboardProviderProps) => {
return (
<KeyboardContext.Provider value={context}>
<KeyboardControllerViewAnimated
ref={viewTagRef}
ref={viewRef}
enabled={enabled}
navigationBarTranslucent={IS_EDGE_TO_EDGE || navigationBarTranslucent}
statusBarTranslucent={IS_EDGE_TO_EDGE || statusBarTranslucent}
Expand Down
5 changes: 4 additions & 1 deletion src/bindings.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const LINKING_ERROR =
"- You rebuilt the app after installing the package\n" +
"- You are not using Expo Go\n";

const KeyboardControllerViewNativeComponentModule = require("./specs/KeyboardControllerViewNativeComponent");
const RCTKeyboardController =
require("./specs/NativeKeyboardController").default;

Expand Down Expand Up @@ -55,7 +56,9 @@ export const WindowDimensionsEvents: WindowDimensionsEventsModule = {
eventEmitter.addListener(KEYBOARD_CONTROLLER_NAMESPACE + name, cb),
};
export const KeyboardControllerView: React.FC<KeyboardControllerProps> =
require("./specs/KeyboardControllerViewNativeComponent").default;
KeyboardControllerViewNativeComponentModule.default;
export const KeyboardControllerViewCommands =
KeyboardControllerViewNativeComponentModule.Commands;
export const KeyboardGestureArea: React.FC<KeyboardGestureAreaProps> =
(Platform.OS === "android" && Platform.Version >= 30) || Platform.OS === "ios"
? require("./specs/KeyboardGestureAreaNativeComponent").default
Expand Down
Loading
Loading