From 3934a0330b20bbd312d1496829aae1fecb1ef713 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Sun, 15 Oct 2023 21:44:50 +0200 Subject: [PATCH] feat: keep progress --- .../__tests__/focused-input.spec.tsx | 60 +++++++++++++++++++ .../KeyboardAwareScrollView.tsx | 2 +- TODO | 45 +++++++++++++- .../events/FocusedInputLayoutChangedEvent.kt | 24 ++++---- .../extensions/ThemedReactContext.kt | 9 +++ .../listeners/FocusedInputLayoutObserver.kt | 25 ++++---- .../listeners/KeyboardAnimationCallback.kt | 33 +++++----- .../input/use-reanimated-focused-input.md | 8 +-- example/__tests__/focused-input.spec.tsx | 60 +++++++++++++++++++ .../FocusedInputLayoutObserver.swift | 4 +- ios/views/KeyboardControllerView.mm | 12 ++-- jest/index.js | 14 +++++ .../KeyboardControllerViewNativeComponent.ts | 12 ++-- 13 files changed, 248 insertions(+), 60 deletions(-) create mode 100644 FabricExample/__tests__/focused-input.spec.tsx create mode 100644 example/__tests__/focused-input.spec.tsx diff --git a/FabricExample/__tests__/focused-input.spec.tsx b/FabricExample/__tests__/focused-input.spec.tsx new file mode 100644 index 0000000000..491d6c816f --- /dev/null +++ b/FabricExample/__tests__/focused-input.spec.tsx @@ -0,0 +1,60 @@ +import '@testing-library/jest-native/extend-expect'; +import React from 'react'; +import Reanimated, { useAnimatedStyle } from 'react-native-reanimated'; +import { render } from '@testing-library/react-native'; + +import { useReanimatedFocusedInput } from 'react-native-keyboard-controller'; + +function RectangleWithFocusedInputLayout() { + const input = useReanimatedFocusedInput(); + const style = useAnimatedStyle( + () => { + const layout = input.value?.layout; + + return { + top: layout?.y, + left: layout?.x, + height: layout?.height, + width: layout?.width, + }; + }, + [] + ); + + return ; +} + +describe('`useReanimatedFocusedInput` mocking', () => { + it('should have different styles depends on `useReanimatedFocusedInput`', () => { + const { getByTestId, update } = render(); + + expect(getByTestId('view')).toHaveStyle({ + top: 0, + left: 0, + width: 200, + height: 40, + }); + + (useReanimatedFocusedInput as jest.Mock).mockReturnValue({ + value: { + target: 2, + layout: { + x: 10, + y: 100, + width: 190, + height: 80, + absoluteX: 100, + absoluteY: 200, + }, + }, + }); + update(); + + expect(getByTestId('view')).toHaveStyle({ + top: 100, + left: 10, + width: 190, + height: 80, + }); + }); +}); diff --git a/FabricExample/src/screens/Examples/AwareScrollView/KeyboardAwareScrollView.tsx b/FabricExample/src/screens/Examples/AwareScrollView/KeyboardAwareScrollView.tsx index 8f6e742e38..84724e1d28 100644 --- a/FabricExample/src/screens/Examples/AwareScrollView/KeyboardAwareScrollView.tsx +++ b/FabricExample/src/screens/Examples/AwareScrollView/KeyboardAwareScrollView.tsx @@ -130,7 +130,7 @@ const KeyboardAwareScrollView: FC = ({ keyboardHeight.value = e.height; } - if (focusWasChanged && e.target !== -1) { + if (focusWasChanged && e.target !== -1 && !keyboardWillAppear) { console.log("focus was changed -> scrolling"); maybeScroll(e.height, true); } diff --git a/TODO b/TODO index d3498d3cc7..b6da043c75 100644 --- a/TODO +++ b/TODO @@ -8,13 +8,52 @@ - (?) when dispatching focusInput -> compare by height? because keyboardWillShow triggers when user types first letter <- we anyway plan to update layout when user types letters? - (x) contentContainerStyle -> paddingBottom should depend on keyboard size <- added additional view - (x) focus on 3, focus on 5 -> scroll looks strange <- persist scroll on tag changes as well +- (x) jest -> add new mocks + new unit tests +- (x) improve events precision (int -> float, pageY, width точно float или double) +- (x) console.log("focus was changed -> scrolling") (even when keyboard appears) <- `!keyboardWillAppear` condition added - scrollResponderScrollNativeHandleToKeyboard - think about renaming FocusedInputLayoutChangedObserver -> FocusedInputObserver - - send `null` when there is no input in focus -- jest -> add new mocks + new unit tests -- improve events precision (int -> float, pageY, width точно float или double) - const {update} = useReanimatedFocusedInput(); <- requires REA3 - crash on every file changes AwareScrollView/hot reload -- console.log("focus was changed -> scrolling") (even when keyboard appears) - focus on field -> type any symbol -> close keyboard => no back transition animation +- android - dispatch event when keyboard changes size +- enable hook under feature flag? + +``` +KeyboardTransitionEventData( + event = "topKeyboardMoveStart", + height = this.persistentKeyboardHeight, + progress = 1.0, + duration = 0, + target = viewTagFocused + ), + +data class KeyboardTransitionEventData( + val event: String, + val height: Double, + val progress: Double, + val duration: Int, + val target: Int, +) + +@Suppress("detekt:LongParameterList") +class KeyboardTransitionEvent( + surfaceId: Int, + viewId: Int, + private val data: KeyboardTransitionEventData, +) : Event(surfaceId, viewId) { + override fun getEventName() = data.event + + // All events for a given view can be coalesced? + override fun getCoalescingKey(): Short = 0 + + override fun getEventData(): WritableMap? = Arguments.createMap().apply { + putDouble("progress", data.progress) + putDouble("height", data.height) + putInt("duration", data.duration) + putInt("target", data.target) + } +} +``` diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/events/FocusedInputLayoutChangedEvent.kt b/android/src/main/java/com/reactnativekeyboardcontroller/events/FocusedInputLayoutChangedEvent.kt index b55824bc2e..a26d597d6c 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/events/FocusedInputLayoutChangedEvent.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/events/FocusedInputLayoutChangedEvent.kt @@ -7,12 +7,12 @@ import com.facebook.react.uimanager.events.Event class FocusedInputLayoutChangedEvent( surfaceId: Int, viewId: Int, - private val x: Int, - private val y: Int, - private val width: Int, - private val height: Int, - private val absoluteX: Int, - private val absoluteY: Int, + private val x: Double, + private val y: Double, + private val width: Double, + private val height: Double, + private val absoluteX: Double, + private val absoluteY: Double, private val target: Int, ) : Event(surfaceId, viewId) { override fun getEventName() = "topFocusedInputLayoutChanged" @@ -25,12 +25,12 @@ class FocusedInputLayoutChangedEvent( putMap( "layout", Arguments.createMap().apply { - putInt("x", x) - putInt("y", y) - putInt("width", width) - putInt("height", height) - putInt("absoluteX", absoluteX) - putInt("absoluteY", absoluteY) + putDouble("x", x) + putDouble("y", y) + putDouble("width", width) + putDouble("height", height) + putDouble("absoluteX", absoluteX) + putDouble("absoluteY", absoluteY) }, ) } diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/extensions/ThemedReactContext.kt b/android/src/main/java/com/reactnativekeyboardcontroller/extensions/ThemedReactContext.kt index 46dde68448..8c0b188827 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/extensions/ThemedReactContext.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/extensions/ThemedReactContext.kt @@ -2,6 +2,15 @@ package com.reactnativekeyboardcontroller.extensions import android.view.View import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.Event +import com.facebook.react.uimanager.events.EventDispatcher val ThemedReactContext.rootView: View? get() = this.currentActivity?.window?.decorView?.rootView + +fun ThemedReactContext?.dispatchEvent(viewId: Int, event: Event<*>) { + val eventDispatcher: EventDispatcher? = + UIManagerHelper.getEventDispatcherForReactTag(this, viewId) + eventDispatcher?.dispatchEvent(event) +} diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/listeners/FocusedInputLayoutObserver.kt b/android/src/main/java/com/reactnativekeyboardcontroller/listeners/FocusedInputLayoutObserver.kt index 4b25032410..5efc0839ab 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/listeners/FocusedInputLayoutObserver.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/listeners/FocusedInputLayoutObserver.kt @@ -3,11 +3,10 @@ package com.reactnativekeyboardcontroller.listeners import android.view.View.OnLayoutChangeListener import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.UIManagerHelper -import com.facebook.react.uimanager.events.Event -import com.facebook.react.uimanager.events.EventDispatcher import com.facebook.react.views.textinput.ReactEditText import com.facebook.react.views.view.ReactViewGroup import com.reactnativekeyboardcontroller.events.FocusedInputLayoutChangedEvent +import com.reactnativekeyboardcontroller.extensions.dispatchEvent import com.reactnativekeyboardcontroller.extensions.dp import com.reactnativekeyboardcontroller.extensions.screenLocation @@ -42,13 +41,19 @@ class FocusedInputLayoutObserver(val view: ReactViewGroup, private val context: val input = lastFocusedInput ?: return val (x, y) = input.screenLocation - this.sendEventToJS(FocusedInputLayoutChangedEvent(surfaceId, view.id, input.x.dp.toInt(), input.y.dp.toInt(), input.width.toFloat().dp.toInt(), input.height.toFloat().dp.toInt(), x.toFloat().dp.toInt(), y.toFloat().dp.toInt(), input.id)) - } - - // TODO: remove code duplication - private fun sendEventToJS(event: Event<*>) { - val eventDispatcher: EventDispatcher? = - UIManagerHelper.getEventDispatcherForReactTag(context, view.id) - eventDispatcher?.dispatchEvent(event) + context.dispatchEvent( + view.id, + FocusedInputLayoutChangedEvent( + surfaceId, + view.id, + input.x.dp, + input.y.dp, + input.width.toFloat().dp, + input.height.toFloat().dp, + x.toFloat().dp, + y.toFloat().dp, + input.id, + ), + ) } } diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt b/android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt index 2373760a6c..7359087a8c 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt @@ -15,12 +15,11 @@ import com.facebook.react.bridge.WritableMap import com.facebook.react.modules.core.DeviceEventManagerModule import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.UIManagerHelper -import com.facebook.react.uimanager.events.Event -import com.facebook.react.uimanager.events.EventDispatcher import com.facebook.react.views.textinput.ReactEditText import com.facebook.react.views.view.ReactViewGroup import com.reactnativekeyboardcontroller.InteractiveKeyboardProvider import com.reactnativekeyboardcontroller.events.KeyboardTransitionEvent +import com.reactnativekeyboardcontroller.extensions.dispatchEvent import com.reactnativekeyboardcontroller.extensions.dp import kotlin.math.abs @@ -57,7 +56,8 @@ class KeyboardAnimationCallback( // 2. event should be send only when keyboard is visible, since this event arrives earlier -> `tag` will be // 100% included in onStart/onMove/onEnd lifecycles, but triggering onStart/onEnd several time // can bring breaking changes - this.sendEventToJS( + context.dispatchEvent( + view.id, KeyboardTransitionEvent( surfaceId, view.id, @@ -68,7 +68,8 @@ class KeyboardAnimationCallback( viewTagFocused, ), ) - this.sendEventToJS( + context.dispatchEvent( + view.id, KeyboardTransitionEvent( surfaceId, view.id, @@ -117,7 +118,8 @@ class KeyboardAnimationCallback( val duration = DEFAULT_ANIMATION_TIME.toInt() this.emitEvent("KeyboardController::keyboardWillShow", getEventParams(keyboardHeight)) - this.sendEventToJS( + context.dispatchEvent( + view.id, KeyboardTransitionEvent( surfaceId, view.id, @@ -133,7 +135,8 @@ class KeyboardAnimationCallback( ValueAnimator.ofFloat(this.persistentKeyboardHeight.toFloat(), keyboardHeight.toFloat()) animation.addUpdateListener { animator -> val toValue = animator.animatedValue as Float - this.sendEventToJS( + context.dispatchEvent( + view.id, KeyboardTransitionEvent( surfaceId, view.id, @@ -147,7 +150,8 @@ class KeyboardAnimationCallback( } animation.doOnEnd { this.emitEvent("KeyboardController::keyboardDidShow", getEventParams(keyboardHeight)) - this.sendEventToJS( + context.dispatchEvent( + view.id, KeyboardTransitionEvent( surfaceId, view.id, @@ -188,7 +192,8 @@ class KeyboardAnimationCallback( ) Log.i(TAG, "HEIGHT:: $keyboardHeight TAG:: $viewTagFocused") - this.sendEventToJS( + context.dispatchEvent( + view.id, KeyboardTransitionEvent( surfaceId, view.id, @@ -235,7 +240,8 @@ class KeyboardAnimationCallback( ) val event = if (InteractiveKeyboardProvider.isInteractive) "topKeyboardMoveInteractive" else "topKeyboardMove" - this.sendEventToJS( + context.dispatchEvent( + view.id, KeyboardTransitionEvent( surfaceId, view.id, @@ -274,7 +280,8 @@ class KeyboardAnimationCallback( "KeyboardController::" + if (!isKeyboardVisible) "keyboardDidHide" else "keyboardDidShow", getEventParams(keyboardHeight), ) - this.sendEventToJS( + context.dispatchEvent( + view.id, KeyboardTransitionEvent( surfaceId, view.id, @@ -305,12 +312,6 @@ class KeyboardAnimationCallback( return (keyboardHeight - navigationBar).toFloat().dp.coerceAtLeast(0.0) } - private fun sendEventToJS(event: Event<*>) { - val eventDispatcher: EventDispatcher? = - UIManagerHelper.getEventDispatcherForReactTag(context, view.id) - eventDispatcher?.dispatchEvent(event) - } - private fun emitEvent(event: String, params: WritableMap) { context?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)?.emit(event, params) diff --git a/docs/docs/api/hooks/input/use-reanimated-focused-input.md b/docs/docs/api/hooks/input/use-reanimated-focused-input.md index f91c5f14c4..7cf952feee 100644 --- a/docs/docs/api/hooks/input/use-reanimated-focused-input.md +++ b/docs/docs/api/hooks/input/use-reanimated-focused-input.md @@ -18,16 +18,16 @@ The value from `useReanimatedFocusedInput` will be always updated before keyboar ## Event structure -Value from this hook is returning as `SharedValue`. The returned data has next structure: +Value from this hook is returned as `SharedValue`. The returned data has next structure: ```ts type KeyboardEventData = { target: number; // tag of the focused TextInput - layout: { + layout: { // layout of the focused TextInput x: number; // `x` coordinate inside the parent component y: number; // `y` coordinate inside the parent component - width: number; // width of the TextInput - height: number; // height of the TextInput + width: number; // `width` of the TextInput + height: number; // `height` of the TextInput absoluteX: number; // `x` coordinate on the screen absoluteY: number; // `y` coordinate on the screen }; diff --git a/example/__tests__/focused-input.spec.tsx b/example/__tests__/focused-input.spec.tsx new file mode 100644 index 0000000000..491d6c816f --- /dev/null +++ b/example/__tests__/focused-input.spec.tsx @@ -0,0 +1,60 @@ +import '@testing-library/jest-native/extend-expect'; +import React from 'react'; +import Reanimated, { useAnimatedStyle } from 'react-native-reanimated'; +import { render } from '@testing-library/react-native'; + +import { useReanimatedFocusedInput } from 'react-native-keyboard-controller'; + +function RectangleWithFocusedInputLayout() { + const input = useReanimatedFocusedInput(); + const style = useAnimatedStyle( + () => { + const layout = input.value?.layout; + + return { + top: layout?.y, + left: layout?.x, + height: layout?.height, + width: layout?.width, + }; + }, + [] + ); + + return ; +} + +describe('`useReanimatedFocusedInput` mocking', () => { + it('should have different styles depends on `useReanimatedFocusedInput`', () => { + const { getByTestId, update } = render(); + + expect(getByTestId('view')).toHaveStyle({ + top: 0, + left: 0, + width: 200, + height: 40, + }); + + (useReanimatedFocusedInput as jest.Mock).mockReturnValue({ + value: { + target: 2, + layout: { + x: 10, + y: 100, + width: 190, + height: 80, + absoluteX: 100, + absoluteY: 200, + }, + }, + }); + update(); + + expect(getByTestId('view')).toHaveStyle({ + top: 100, + left: 10, + width: 190, + height: 80, + }); + }); +}); diff --git a/ios/observers/FocusedInputLayoutObserver.swift b/ios/observers/FocusedInputLayoutObserver.swift index febdf744c9..db159c79f0 100644 --- a/ios/observers/FocusedInputLayoutObserver.swift +++ b/ios/observers/FocusedInputLayoutObserver.swift @@ -16,7 +16,7 @@ public class FocusedInputLayoutObserver: NSObject { // input tracking private var currentInput: UIView? private var hasKVObserver = false - private var lastEventDispatched: [AnyHashable : Any] = [:] + private var lastEventDispatched: [AnyHashable: Any] = [:] @objc public init( handler: @escaping (NSDictionary) -> Void @@ -72,7 +72,7 @@ public class FocusedInputLayoutObserver: NSObject { ], ] // TODO: compare by height? because keyboardWillShow triggers when user types first letter - if (NSDictionary(dictionary: data).isEqual(to: lastEventDispatched)) { + if NSDictionary(dictionary: data).isEqual(to: lastEventDispatched) { return } diff --git a/ios/views/KeyboardControllerView.mm b/ios/views/KeyboardControllerView.mm index a478a1f5be..1b767ab2d6 100644 --- a/ios/views/KeyboardControllerView.mm +++ b/ios/views/KeyboardControllerView.mm @@ -48,12 +48,12 @@ - (instancetype)initWithFrame:(CGRect)frame inputObserver = [[FocusedInputLayoutObserver alloc] initWithHandler:^(NSDictionary *event) { if (self->_eventEmitter) { int target = [event[@"target"] integerValue]; - int absoluteY = [event[@"layout"][@"absoluteY"] integerValue]; - int absoulteX = [event[@"layout"][@"absoluteX"] integerValue]; - int y = [event[@"layout"][@"y"] integerValue]; - int x = [event[@"layout"][@"x"] integerValue]; - int width = [event[@"layout"][@"width"] integerValue]; - int height = [event[@"layout"][@"height"] integerValue]; + double absoluteY = [event[@"layout"][@"absoluteY"] doubleValue]; + double absoulteX = [event[@"layout"][@"absoluteX"] doubleValue]; + double y = [event[@"layout"][@"y"] doubleValue]; + double x = [event[@"layout"][@"x"] doubleValue]; + double width = [event[@"layout"][@"width"] doubleValue]; + double height = [event[@"layout"][@"height"] doubleValue]; std::dynamic_pointer_cast( self->_eventEmitter) diff --git a/jest/index.js b/jest/index.js index edd699b2d7..b52994d083 100644 --- a/jest/index.js +++ b/jest/index.js @@ -10,6 +10,19 @@ const values = { height: { value: 0 }, }, }; +const focusedInput = { + value: { + target: 1, + layout: { + x: 0, + y: 0, + width: 200, + height: 40, + absoluteX: 0, + absoluteY: 100, + }, + }, +}; const mock = { // hooks @@ -18,6 +31,7 @@ const mock = { useResizeMode: jest.fn(), useGenericKeyboardHandler: jest.fn(), useKeyboardHandler: jest.fn(), + useReanimatedFocusedInput: jest.fn().mockReturnValue(focusedInput), // modules KeyboardController: { setInputMode: jest.fn(), diff --git a/src/specs/KeyboardControllerViewNativeComponent.ts b/src/specs/KeyboardControllerViewNativeComponent.ts index 1bce971ddc..71a45c3274 100644 --- a/src/specs/KeyboardControllerViewNativeComponent.ts +++ b/src/specs/KeyboardControllerViewNativeComponent.ts @@ -18,12 +18,12 @@ type KeyboardMoveEvent = Readonly<{ type FocusedInputLayoutChangedEvent = Readonly<{ target: Int32; layout: { - x: Int32; - y: Int32; - width: Int32; - height: Int32; - absoluteX: Int32; - absoluteY: Int32; + x: Double; + y: Double; + width: Double; + height: Double; + absoluteX: Double; + absoluteY: Double; }; }>;