Skip to content

Commit

Permalink
[RNKC-054] - detect keyboard size changes (#58)
Browse files Browse the repository at this point in the history
* [RNKC-054] - detect keyboard size changes

* [RNKC-054] - don't consume insets

* [RNKC-054] - set callback for applying additional paddings after setting WindowInsetsAnimationCallback (otherwise insets may be ignored and main view can go under navigation bar)

* [RNKC-054] - fixed event example and subscribed to correct views

* [RNKC-054] - update fabric example

* [RNKC-054] - update documentation

* [RNKC-054] - update class name

* [RNKC-054] - use own view rather than decorView
  • Loading branch information
kirillzyusko authored Aug 23, 2022
1 parent 6b051f9 commit a105cf9
Show file tree
Hide file tree
Showing 4 changed files with 124 additions and 104 deletions.
8 changes: 4 additions & 4 deletions FabricExample/src/screens/Examples/Events/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ function EventsListener() {
text2: `📲 Height: ${e.height}`,
});
});
const shown = KeyboardEvents.addListener('keyboardDidShow', () => {
const shown = KeyboardEvents.addListener('keyboardDidShow', (e) => {
Toast.show({
type: 'success',
text1: '⌨️ Keyboard is shown',
text2: '👋',
text2: `👋 Height: ${e.height}`,
});
});
const hide = KeyboardEvents.addListener('keyboardWillHide', (e) => {
Expand All @@ -35,11 +35,11 @@ function EventsListener() {
text2: `📲 Height: ${e.height}`,
});
});
const hid = KeyboardEvents.addListener('keyboardDidHide', () => {
const hid = KeyboardEvents.addListener('keyboardDidHide', (e) => {
Toast.show({
type: 'error',
text1: '⌨️ Keyboard is hidden',
text2: '🤐',
text2: `🤐 Height: ${e.height}`,
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,10 @@
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.reactnativekeyboardcontroller

import android.content.Context
import android.util.Log
import android.view.View
import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsAnimationCompat
import androidx.core.view.WindowInsetsCompat
Expand All @@ -27,9 +13,9 @@ import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.WritableMap
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.facebook.react.uimanager.UIManagerModule
import com.facebook.react.uimanager.events.Event
import com.facebook.react.views.view.ReactViewGroup
import com.reactnativekeyboardcontroller.events.KeyboardTransitionEvent
import java.util.*

fun toDp(px: Float, context: Context?): Int {
if (context == null) {
Expand All @@ -39,32 +25,17 @@ fun toDp(px: Float, context: Context?): Int {
return (px / context.resources.displayMetrics.density).toInt()
}

/**
* A [WindowInsetsAnimationCompat.Callback] which will translate/move the given view during any
* inset animations of the given inset type.
*
* This class works in tandem with [RootViewDeferringInsetsCallback] to support the deferring of
* certain [WindowInsetsCompat.Type] values during a [WindowInsetsAnimationCompat], provided in
* [deferredInsetTypes]. The values passed into this constructor should match those which
* the [RootViewDeferringInsetsCallback] is created with.
*
* @param view the view to translate from it's start to end state
* @param persistentInsetTypes the bitmask of any inset types which were handled as part of the
* layout
* @param deferredInsetTypes the bitmask of insets types which should be deferred until after
* any [WindowInsetsAnimationCompat]s have ended
* @param dispatchMode The dispatch mode for this callback.
* See [WindowInsetsAnimationCompat.Callback.getDispatchMode].
*/
class TranslateDeferringInsetsAnimationCallback(
class KeyboardAnimationCallback(
val view: ReactViewGroup,
val persistentInsetTypes: Int,
val deferredInsetTypes: Int,
dispatchMode: Int = DISPATCH_MODE_STOP,
val context: ReactApplicationContext?
) : WindowInsetsAnimationCompat.Callback(dispatchMode) {
private val TAG = TranslateDeferringInsetsAnimationCallback::class.qualifiedName
val context: ReactApplicationContext?,
val onApplyWindowInsetsListener: OnApplyWindowInsetsListener
) : WindowInsetsAnimationCompat.Callback(dispatchMode), OnApplyWindowInsetsListener {
private val TAG = KeyboardAnimationCallback::class.qualifiedName
private var persistentKeyboardHeight = 0
private var isKeyboardVisible = false

init {
require(persistentInsetTypes and deferredInsetTypes == 0) {
Expand All @@ -73,54 +44,69 @@ class TranslateDeferringInsetsAnimationCallback(
}
}

/**
* This method is called everytime when keyboard appears or hides (*)
* and the call happens before `onStart` (in `onStart` we update `this.isKeyboardVisible` field)
*
* *in fact it's getting called much more times, but here for the simplicity we are talking only about keyboard
*/
override fun onApplyWindowInsets(v: View?, insets: WindowInsetsCompat): WindowInsetsCompat {
// when keyboard appears values will be (false && true)
// when keyboard disappears values will be (true && false)
// having such check allows us not to dispatch unnecessary incorrect events
// the condition will be executed only when keyboard is opened and changes its size
// (for example it happens when user changes keyboard type from 'text' to 'emoji' input
if (isKeyboardVisible && isKeyboardVisible()) {
val keyboardHeight = getCurrentKeyboardHeight()
/**
* By default it's up to OS whether to animate keyboard changes or not.
* For example my Xiaomi Redmi Note 5 Pro (Android 9) applies layout animation
* whereas Pixel 3 (Android 12) is not applying layout animation and view changes
* its position instantly. We stick to the default behavior and rely on it.
* Though if we decide to animate always (any animation looks better than instant transition)
* we can use the code below:
*
* <pre>
* val animation = ValueAnimator.ofInt(-this.persistentKeyboardHeight, -keyboardHeight)
*
* animation.addUpdateListener { animator ->
* val toValue = animator.animatedValue as Int
* this.sendEventToJS(KeyboardTransitionEvent(view.id, toValue, 1.0))
* }
* animation.setDuration(250).startDelay = 0
* animation.start()
* </pre>
*
* But for now let's rely on OS preferences.
*/
this.sendEventToJS(KeyboardTransitionEvent(view.id, -keyboardHeight, 1.0))
this.emitEvent("KeyboardController::keyboardDidShow", getEventParams(keyboardHeight))

this.persistentKeyboardHeight = keyboardHeight
}

return onApplyWindowInsetsListener.onApplyWindowInsets(v, insets)
}

override fun onStart(
animation: WindowInsetsAnimationCompat,
bounds: WindowInsetsAnimationCompat.BoundsCompat
): WindowInsetsAnimationCompat.BoundsCompat {
isKeyboardVisible = isKeyboardVisible()
val keyboardHeight = getCurrentKeyboardHeight()
val isKeyboardVisible = isKeyboardVisible()

if (isKeyboardVisible) {
// do not update it on hide, since back progress will be invalid
this.persistentKeyboardHeight = keyboardHeight
}

val params: WritableMap = Arguments.createMap()
params.putInt("height", keyboardHeight)
context?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)?.emit("KeyboardController::" + if (!isKeyboardVisible) "keyboardWillHide" else "keyboardWillShow", params)
this.emitEvent("KeyboardController::" + if (!isKeyboardVisible) "keyboardWillHide" else "keyboardWillShow", getEventParams(keyboardHeight))

Log.i(TAG, "KeyboardController::" + if (!isKeyboardVisible) "keyboardWillHide" else "keyboardWillShow")
Log.i(TAG, "HEIGHT:: $keyboardHeight")

return super.onStart(animation, bounds)
}

override fun onEnd(animation: WindowInsetsAnimationCompat) {
super.onEnd(animation)

val isKeyboardVisible = isKeyboardVisible()

val params: WritableMap = Arguments.createMap()
context?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)?.emit("KeyboardController::" + if (!isKeyboardVisible) "keyboardDidHide" else "keyboardDidShow", params)

Log.i(TAG, "KeyboardController::" + if (!isKeyboardVisible) "keyboardDidHide" else "keyboardDidShow")
}

private fun isKeyboardVisible(): Boolean {
val insets = ViewCompat.getRootWindowInsets(view)

return insets?.isVisible(WindowInsetsCompat.Type.ime()) ?: false
}

private fun getCurrentKeyboardHeight(): Int {
val insets = ViewCompat.getRootWindowInsets(view)
val keyboardHeight = insets?.getInsets(WindowInsetsCompat.Type.ime())?.bottom ?: 0
val navigationBar = insets?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0

// on hide it will be negative value, so we are using max function
return Math.max(toDp((keyboardHeight - navigationBar).toFloat(), context), 0)
}

override fun onProgress(
insets: WindowInsetsCompat,
runningAnimations: List<WindowInsetsAnimationCompat>
Expand Down Expand Up @@ -148,11 +134,50 @@ class TranslateDeferringInsetsAnimationCallback(
}
Log.i(TAG, "DiffY: $diffY $height")

this.sendEventToJS(KeyboardTransitionEvent(view.id, height, progress))

return insets
}

override fun onEnd(animation: WindowInsetsAnimationCompat) {
super.onEnd(animation)

this.persistentKeyboardHeight = getCurrentKeyboardHeight()
this.emitEvent("KeyboardController::" + if (!isKeyboardVisible) "keyboardDidHide" else "keyboardDidShow", getEventParams(this.persistentKeyboardHeight))
}

private fun isKeyboardVisible(): Boolean {
val insets = ViewCompat.getRootWindowInsets(view)

return insets?.isVisible(WindowInsetsCompat.Type.ime()) ?: false
}

private fun getCurrentKeyboardHeight(): Int {
val insets = ViewCompat.getRootWindowInsets(view)
val keyboardHeight = insets?.getInsets(WindowInsetsCompat.Type.ime())?.bottom ?: 0
val navigationBar = insets?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0

// on hide it will be negative value, so we are using max function
return Math.max(toDp((keyboardHeight - navigationBar).toFloat(), context), 0)
}

private fun sendEventToJS(event: Event<*>) {
context
?.getNativeModule(UIManagerModule::class.java)
?.eventDispatcher
?.dispatchEvent(KeyboardTransitionEvent(view.id, height, progress))
?.dispatchEvent(event)
}

return insets
private fun emitEvent(event: String, params: WritableMap) {
context?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)?.emit(event, params)

Log.i(TAG, event)
}

private fun getEventParams(height: Int): WritableMap {
val params: WritableMap = Arguments.createMap()
params.putInt("height", height)

return params
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,34 +29,29 @@ class KeyboardControllerViewManager(reactContext: ReactApplicationContext) : Rea
return view
}

val window = activity.window
val decorView = window.decorView

ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
val content =
mReactContext.currentActivity?.window?.decorView?.rootView?.findViewById<FitWindowsLinearLayout>(
R.id.action_bar_root
val callback = KeyboardAnimationCallback(
view = view,
persistentInsetTypes = WindowInsetsCompat.Type.systemBars(),
deferredInsetTypes = WindowInsetsCompat.Type.ime(),
// We explicitly allow dispatch to continue down to binding.messageHolder's
// child views, so that step 2.5 below receives the call
dispatchMode = WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE,
context = mReactContext,
onApplyWindowInsetsListener = { v, insets ->
val content =
mReactContext.currentActivity?.window?.decorView?.rootView?.findViewById<FitWindowsLinearLayout>(
R.id.action_bar_root
)
content?.setPadding(
0, if (this.isStatusBarTranslucent) 0 else insets?.getInsets(WindowInsetsCompat.Type.systemBars())?.top ?: 0, 0,
insets?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0
)
content?.setPadding(
0, if (this.isStatusBarTranslucent) 0 else insets?.getInsets(WindowInsetsCompat.Type.systemBars())?.top ?: 0, 0,
insets?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0
)

insets
}

ViewCompat.setWindowInsetsAnimationCallback(
decorView,
TranslateDeferringInsetsAnimationCallback(
view = view,
persistentInsetTypes = WindowInsetsCompat.Type.systemBars(),
deferredInsetTypes = WindowInsetsCompat.Type.ime(),
// We explicitly allow dispatch to continue down to binding.messageHolder's
// child views, so that step 2.5 below receives the call
dispatchMode = WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE,
context = mReactContext
)
insets
}
)
ViewCompat.setWindowInsetsAnimationCallback(view, callback)
ViewCompat.setOnApplyWindowInsetsListener(view, callback)

return view
}
Expand Down
8 changes: 4 additions & 4 deletions example/src/screens/Examples/Events/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ function EventsListener() {
text2: `📲 Height: ${e.height}`,
});
});
const shown = KeyboardEvents.addListener('keyboardDidShow', () => {
const shown = KeyboardEvents.addListener('keyboardDidShow', (e) => {
Toast.show({
type: 'success',
text1: '⌨️ Keyboard is shown',
text2: '👋',
text2: `👋 Height: ${e.height}`,
});
});
const hide = KeyboardEvents.addListener('keyboardWillHide', (e) => {
Expand All @@ -35,11 +35,11 @@ function EventsListener() {
text2: `📲 Height: ${e.height}`,
});
});
const hid = KeyboardEvents.addListener('keyboardDidHide', () => {
const hid = KeyboardEvents.addListener('keyboardDidHide', (e) => {
Toast.show({
type: 'error',
text1: '⌨️ Keyboard is hidden',
text2: '🤐',
text2: `🤐 Height: ${e.height}`,
});
});

Expand Down

0 comments on commit a105cf9

Please sign in to comment.