Skip to content

Commit 14ededa

Browse files
authored
feat: sync layout from JS (#1213)
## 📜 Description Added an ability to have a bi-directional communication and read/update layout from JS on demand. ## 💡 Motivation and Context This PR adds a command that we can dispatch from JS in order to get an actual layout of focused input. The task sounds pretty simple, but I anyway had some problems, and would like to describe solutions from them here. First of all the querying of layout is an async operation and we need to wait for completion before running a worklet code (so that we can be sure, that event has been actually dispatched and shared-value has up-to-date value). But default mechanism for view commands not suppose to return any kind of data back to the JS thread. Alternative option is to define "module" method and return the data/completion status from there. However in new architecture it is highly not recommended to use such approach, because this approach relies on RN internals that can be changed/removed from the future. So I decided to go with recommended approach. To overcome asynchronous stuff I decided to set one-time listener and resolve a promise when I get specific `layoutDidSynchronize` event - this is not a perfect solution but does a job for now and doesn't use deprecated stuff, so I'm pretty happy. On a high level overview - we pass `update` function through the `context`, because `context` has an access to `KeyboardControllerView` and from there we dispatch/wait. Also I decided to expose `assureFocusedInputVisible` method for `KeyboardAwareScrollView`. In the past I had requirements to scroll a little bit when validation error appears, and back to the time I just hard-coded it like `ref.current.scrollTo(50)`, but of course this fix didn't handle all the cases. Now it's just enough to call `ref.current.assureFocusedInputVisible()` and enjoy pixel perfect automatic calculations 😎 And in the end I decided to cover new functionality with e2e tests so that we will not break them accidentally in the future 🤞 Closes #1208 ## 📢 Changelog <!-- High level overview of important changes --> <!-- For example: fixed status bar manipulation; added new types declarations; --> <!-- If your changes don't affect one of platform/language below - then remove this platform/language --> ### Docs - added info about new `update` method; - added info about `assureFocusedInputVisible` method of ref; ### E2E - cover new functionality described in #1208 - cover use cases when we set a focus on input that doesn't belong to `KeyboardAwareScrollView`; ### JS - added `synchronizeFocusedInputLayout` command for `KeyboardControllerView`; - added `KeyboardController::layoutDidSynchronize` event; - expose `assureFocusedInputVisible` method for `KeyboardAwareScrollView`; ### iOS - implement `synchronizeFocusedInputLayout` command for paper/fabric; ### Android - implement `synchronizeFocusedInputLayout` command for paper/fabric; ## 🤔 How Has This Been Tested? Tested manually on iPhone 16 Pro (iOS 26.0)/Pixel 6 Pro (API 31, 35) both paper/fabric. ## 📸 Screenshots (if appropriate): |Android|iOS| |-------|-----| |<video src="https://github.com/user-attachments/assets/42a48042-e244-4a2d-8017-606d5fabfb7e">|<video src="https://github.com/user-attachments/assets/d72ee356-3fd6-4019-b5c6-9e09ef60608d">| ## 📝 Checklist - [x] CI successfully passed - [x] I added new mocks and corresponding unit-tests if library API was changed
1 parent 41858c7 commit 14ededa

31 files changed

+247
-30
lines changed

android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardControllerViewManager.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.reactnativekeyboardcontroller
22

3+
import com.facebook.react.bridge.ReadableArray
34
import com.facebook.react.uimanager.ThemedReactContext
45
import com.facebook.react.uimanager.ViewManagerDelegate
56
import com.facebook.react.uimanager.annotations.ReactProp
@@ -56,6 +57,23 @@ class KeyboardControllerViewManager :
5657
) = manager.setEnabled(view as EdgeToEdgeReactViewGroup, value)
5758
// endregion
5859

60+
// region Commands
61+
override fun receiveCommand(
62+
root: ReactViewGroup,
63+
commandId: String,
64+
args: ReadableArray?,
65+
) {
66+
when (commandId) {
67+
"synchronizeFocusedInputLayout" -> synchronizeFocusedInputLayout(root)
68+
else -> super.receiveCommand(root, commandId, args)
69+
}
70+
}
71+
72+
override fun synchronizeFocusedInputLayout(view: ReactViewGroup) {
73+
manager.synchronizeFocusedInputLayout(view as EdgeToEdgeReactViewGroup)
74+
}
75+
// endregion
76+
5977
// region Getters
6078
override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> =
6179
manager.getExportedCustomDirectEventTypeConstants()

android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ class KeyboardAnimationCallback(
109109
}
110110
}
111111
}
112-
private var layoutObserver: FocusedInputObserver? = null
112+
internal var layoutObserver: FocusedInputObserver? = null
113113

114114
init {
115115
require(config.persistentInsetTypes and config.deferredInsetTypes == 0) {

android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardControllerViewManagerImpl.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package com.reactnativekeyboardcontroller.managers
22

3+
import com.facebook.react.bridge.Arguments
34
import com.facebook.react.common.MapBuilder
45
import com.facebook.react.uimanager.ThemedReactContext
56
import com.reactnativekeyboardcontroller.events.FocusedInputLayoutChangedEvent
67
import com.reactnativekeyboardcontroller.events.FocusedInputSelectionChangedEvent
78
import com.reactnativekeyboardcontroller.events.FocusedInputTextChangedEvent
89
import com.reactnativekeyboardcontroller.events.KeyboardTransitionEvent
10+
import com.reactnativekeyboardcontroller.extensions.emitEvent
911
import com.reactnativekeyboardcontroller.listeners.WindowDimensionListener
1012
import com.reactnativekeyboardcontroller.views.EdgeToEdgeReactViewGroup
1113

@@ -25,6 +27,11 @@ class KeyboardControllerViewManagerImpl {
2527
listener = null
2628
}
2729

30+
fun synchronizeFocusedInputLayout(view: EdgeToEdgeReactViewGroup) {
31+
view.callback?.layoutObserver?.syncUpLayout()
32+
view.reactContext.emitEvent("KeyboardController::layoutDidSynchronize", Arguments.createMap())
33+
}
34+
2835
fun setEnabled(
2936
view: EdgeToEdgeReactViewGroup,
3037
enabled: Boolean,

android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ object EdgeToEdgeViewRegistry {
3838
@Suppress("detekt:TooManyFunctions")
3939
@SuppressLint("ViewConstructor")
4040
class EdgeToEdgeReactViewGroup(
41-
private val reactContext: ThemedReactContext,
41+
val reactContext: ThemedReactContext,
4242
) : ReactViewGroup(reactContext) {
4343
// props
4444
private var isStatusBarTranslucent = false
@@ -58,7 +58,7 @@ class EdgeToEdgeReactViewGroup(
5858
// internal class members
5959
private var eventView: ReactViewGroup? = null
6060
private var wasMounted = false
61-
private var callback: KeyboardAnimationCallback? = null
61+
internal var callback: KeyboardAnimationCallback? = null
6262
private val config =
6363
KeyboardAnimationCallbackConfig(
6464
persistentInsetTypes = WindowInsetsCompat.Type.systemBars(),

android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardControllerViewManager.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.reactnativekeyboardcontroller
22

3+
import com.facebook.react.bridge.ReadableArray
34
import com.facebook.react.uimanager.ThemedReactContext
45
import com.facebook.react.uimanager.annotations.ReactProp
56
import com.facebook.react.views.view.ReactViewGroup
@@ -58,6 +59,21 @@ class KeyboardControllerViewManager : ReactViewManager() {
5859
}
5960
// endregion
6061

62+
// region Commands
63+
override fun receiveCommand(
64+
root: ReactViewGroup,
65+
commandId: String,
66+
args: ReadableArray?,
67+
) {
68+
when (commandId) {
69+
"synchronizeFocusedInputLayout" -> {
70+
manager.synchronizeFocusedInputLayout(root as EdgeToEdgeReactViewGroup)
71+
}
72+
else -> super.receiveCommand(root, commandId, args)
73+
}
74+
}
75+
//endregion
76+
6177
// region Getters
6278
override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> =
6379
manager.getExportedCustomDirectEventTypeConstants()

docs/docs/api/components/keyboard-aware-scroll-view.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,14 @@ export function Example() {
244244
}
245245
```
246246

247+
## Methods
248+
249+
### `assureFocusedInputVisible`
250+
251+
A method that assures that focused input is visible and not obscured by keyboard or other elements.
252+
253+
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).
254+
247255
## Example
248256

249257
```tsx

docs/docs/api/hooks/input/use-reanimated-focused-input.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Hook will update its value in next cases:
2626
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.
2727
:::
2828

29-
## Event structure
29+
## `input`
3030

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

@@ -47,10 +47,14 @@ type FocusedInputLayoutChangedEvent = {
4747
};
4848
```
4949

50+
### `update`
51+
52+
To update the focused input, use `update` function. Thus you can query the position on demand from JS thread.
53+
5054
## Example
5155

5256
```tsx
53-
const { input } = useReanimatedFocusedInput();
57+
const { input, update } = useReanimatedFocusedInput();
5458
```
5559

5660
Also have a look on [example](https://github.com/kirillzyusko/react-native-keyboard-controller/tree/main/example) app for more comprehensive usage.

e2e/kit/016-aware-scroll-view-with-sticky-view.e2e.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import { expectBitmapsToBeEqual } from "./asserts";
22
import {
33
scrollDownUntilElementIsVisible,
4+
tap,
45
waitAndTap,
56
waitForExpect,
67
} from "./helpers";
78

89
const BLINKING_CURSOR = 0.35;
910

11+
const closeKeyboard = async () => {
12+
// tap outside to close a keyboard
13+
await tap("aware_scroll_sticky_view_scroll_container", { x: 0, y: 100 });
14+
};
15+
1016
describe("AwareScrollView with StickyView test cases", () => {
1117
it("should push input above keyboard on focus", async () => {
1218
await waitAndTap("aware_scroll_view_sticky_footer");
@@ -32,4 +38,35 @@ describe("AwareScrollView with StickyView test cases", () => {
3238
);
3339
});
3440
});
41+
42+
it("should react on `bottomOffset` change even if input is not visible", async () => {
43+
await scrollDownUntilElementIsVisible(
44+
"aware_scroll_sticky_view_scroll_container",
45+
"TextInput#9",
46+
{ x: 0, y: 0.2, checkScrollViewVisibility: false },
47+
);
48+
await waitAndTap("toggle_height");
49+
await waitForExpect(async () => {
50+
await expectBitmapsToBeEqual(
51+
"AwareScrollViewWithStickyViewFirstInputFocused",
52+
BLINKING_CURSOR,
53+
);
54+
});
55+
});
56+
57+
it("shouldn't scroll a scroll view when focusing input inside sticky view", async () => {
58+
await closeKeyboard();
59+
await element(by.id("aware_scroll_sticky_view_scroll_container")).swipe(
60+
"down",
61+
"fast",
62+
1,
63+
);
64+
await waitAndTap("Amount");
65+
await waitForExpect(async () => {
66+
await expectBitmapsToBeEqual(
67+
"AwareScrollViewWithStickyViewStickyInputFocused",
68+
BLINKING_CURSOR,
69+
);
70+
});
71+
});
3572
});
108 KB
Loading
126 KB
Loading

0 commit comments

Comments
 (0)