Skip to content

Commit

Permalink
feat(#1): avoid soft input view, set avoid offset method
Browse files Browse the repository at this point in the history
AvoidSoftInputView introduces possibility to apply translation/bottom padding
to custom content (e.g. form rendered in modal)

setAvoidOffset introduces possibility of increasing/decreasing soft input
translation/padding applied to root view/scroll view

BREAKING CHANGE: now all set* functions are available under AvoidSoftInput object
```js
// import * as AvoidSoftinput from 'react-native-avoid-softinput';
import { AvoidSoftInput } from 'react-native-avoid-softinput';
```
  • Loading branch information
mateusz1913 committed Jul 8, 2021
1 parent 974f756 commit 371186b
Show file tree
Hide file tree
Showing 34 changed files with 1,590 additions and 453 deletions.
@@ -0,0 +1,222 @@
package com.reactnativeavoidsoftinput

import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.view.View
import android.view.ViewTreeObserver
import android.view.WindowManager
import android.widget.ScrollView
import com.facebook.react.bridge.*
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.facebook.react.uimanager.PixelUtil

class AvoidSoftInputModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext), LifecycleEventListener, AvoidSoftInputProvider.SoftInputListener {
private var mIsInitialized = false
private var mIsEnabled = false
private var mAvoidSoftInputProvider: AvoidSoftInputProvider? = null
private var mCurrentFocusedView: View? = null
private var mPreviousFocusedView: View? = null
private var mScrollViewParent: ScrollView? = null
private var mIsRootViewSlideUp = false
private var mDefaultSoftInputMode: Int = reactContext.currentActivity?.window?.attributes?.softInputMode ?: WindowManager.LayoutParams.SOFT_INPUT_STATE_UNSPECIFIED
private var mCurrentBottomPadding: Int = 0
private var mBottomOffset: Float = 0F
private var mAvoidOffset: Float = 0F

private val mOnGlobalFocusChangeListener = ViewTreeObserver.OnGlobalFocusChangeListener { oldView, newView ->
mCurrentFocusedView = newView
mPreviousFocusedView = oldView
}

override fun getName(): String = NAME

override fun initialize() {
super.initialize()
reactContext.addLifecycleEventListener(this)
}

private fun initializeHandlers() {
if (!mIsInitialized) {
mAvoidSoftInputProvider = AvoidSoftInputProvider(reactContext).initializeProvider()
mAvoidSoftInputProvider?.setSoftInputListener(this)
reactContext.currentActivity?.window?.decorView?.rootView?.viewTreeObserver?.addOnGlobalFocusChangeListener(mOnGlobalFocusChangeListener)
mIsInitialized = true
}
}

private fun cleanupHandlers() {
if (mIsInitialized) {
mAvoidSoftInputProvider?.dismiss()
mAvoidSoftInputProvider?.setSoftInputListener(null)
mAvoidSoftInputProvider = null
reactContext.currentActivity?.window?.decorView?.rootView?.viewTreeObserver?.removeOnGlobalFocusChangeListener(mOnGlobalFocusChangeListener)
mIsInitialized = false
}
}

@ReactMethod
fun setEnabled(isEnabled: Boolean) {
mIsEnabled = isEnabled
}

@ReactMethod
fun setAvoidOffset(avoidOffset: Float) {
mAvoidOffset = PixelUtil.toPixelFromDIP(avoidOffset)
}

@ReactMethod
fun setAdjustNothing() {
setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
}

@ReactMethod
fun setAdjustPan() {
setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN)
}

@ReactMethod
fun setAdjustResize() {
setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
}

@ReactMethod
fun setAdjustUnspecified() {
setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_UNSPECIFIED)
}

@ReactMethod
fun setDefaultAppSoftInputMode() {
setSoftInputMode(mDefaultSoftInputMode)
}

private fun setSoftInputMode(mode: Int) {
val activity = reactContext.currentActivity ?: return

UiThreadUtil.runOnUiThread {
activity.window.setSoftInputMode(mode)
}
}

override fun onSoftInputShown(from: Int, to: Int) {
sendEvent(SOFT_INPUT_SHOWN, Arguments.createMap().apply {
putInt(SOFT_INPUT_HEIGHT_KEY, to)
})

val activity = reactContext.currentActivity ?: return
val rootView = activity.window.decorView.rootView
val currentFocusedView = mCurrentFocusedView ?: mPreviousFocusedView

if (!mIsEnabled || currentFocusedView == null || checkIfNestedInAvoidSoftInputView(currentFocusedView, rootView)) {
return
}

val keyboardOffset = computeKeyboardOffset(to, currentFocusedView, rootView, rootView) ?: return
mScrollViewParent = getScrollViewParent(currentFocusedView, rootView)

mBottomOffset = keyboardOffset + mAvoidOffset
val scrollViewParent = mScrollViewParent
mCurrentBottomPadding = scrollViewParent?.paddingBottom ?: 0

UiThreadUtil.runOnUiThread {
ValueAnimator.ofFloat(0F, mBottomOffset).apply {
duration = INCREASE_PADDING_DURATION_IN_MS
addListener(object: AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
super.onAnimationEnd(animation)
if (scrollViewParent != null) {
val positionInScrollView = getPositionYRelativeToScrollViewParent(currentFocusedView, rootView)
scrollViewParent.smoothScrollTo(0, positionInScrollView)
}
}
})
addUpdateListener {
if (scrollViewParent != null) {
scrollViewParent.setPadding(
scrollViewParent.paddingLeft,
scrollViewParent.paddingTop,
scrollViewParent.paddingRight,
mCurrentBottomPadding + (it.animatedValue as Float).toInt()
)
} else {
rootView.translationY = -(it.animatedValue as Float)
}
}
start()
}
}
mIsRootViewSlideUp = true
}

override fun onSoftInputHidden(from: Int, to: Int) {
sendEvent(SOFT_INPUT_HIDDEN, Arguments.createMap().apply {
putInt(SOFT_INPUT_HEIGHT_KEY, 0)
})

val activity = reactContext.currentActivity ?: return
val rootView = activity.window.decorView.rootView
val currentFocusedView = mCurrentFocusedView ?: mPreviousFocusedView

if (!mIsRootViewSlideUp || !mIsEnabled || currentFocusedView == null || checkIfNestedInAvoidSoftInputView(currentFocusedView, rootView)) {
return
}

mIsRootViewSlideUp = false
val scrollViewParent = mScrollViewParent

UiThreadUtil.runOnUiThread {
ValueAnimator.ofFloat(mBottomOffset, 0F).apply {
duration = DECREASE_PADDING_DURATION_IN_MS
addListener(object: AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
super.onAnimationEnd(animation)
if (scrollViewParent != null) {
val positionInScrollView = getPositionYRelativeToScrollViewParent(currentFocusedView, rootView)
scrollViewParent.smoothScrollTo(0, positionInScrollView)
}
mScrollViewParent = null
mCurrentBottomPadding = 0
}
})
addUpdateListener {
if (scrollViewParent != null) {
scrollViewParent.setPadding(
scrollViewParent.paddingLeft,
scrollViewParent.paddingTop,
scrollViewParent.paddingRight,
mCurrentBottomPadding + (it.animatedValue as Float).toInt()
)
} else {
rootView.translationY = -(it.animatedValue as Float)
}
}
start()
}
}
}

private fun sendEvent(eventName: String, params: WritableMap?) {
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit(eventName, params)
}

companion object {
const val NAME = "AvoidSoftInput"
const val INCREASE_PADDING_DURATION_IN_MS: Long = 660
const val DECREASE_PADDING_DURATION_IN_MS: Long = 220
const val SOFT_INPUT_HEIGHT_KEY = "softInputHeight"
const val SOFT_INPUT_SHOWN = "softInputShown"
const val SOFT_INPUT_HIDDEN = "softInputHidden"
}

override fun onHostResume() {
initializeHandlers()
}

override fun onHostPause() {}

override fun onHostDestroy() {
cleanupHandlers()
}
}
Expand Up @@ -6,12 +6,12 @@ import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager


class AvoidSoftinputPackage : ReactPackage {
class AvoidSoftInputPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(AvoidSoftinputModule(reactContext))
return listOf(AvoidSoftInputModule(reactContext))
}

override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
return listOf(AvoidSoftInputViewManager())
}
}
Expand Up @@ -5,23 +5,34 @@ import android.graphics.Rect
import android.graphics.drawable.ColorDrawable
import android.view.*
import android.widget.PopupWindow
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContext
import com.facebook.react.uimanager.DisplayMetricsHolder
import com.facebook.react.uimanager.PixelUtil
import java.util.Timer
import kotlin.concurrent.schedule

class AvoidSoftinputProvider(): PopupWindow(), ViewTreeObserver.OnGlobalLayoutListener {
class AvoidSoftInputProvider(): PopupWindow(), ViewTreeObserver.OnGlobalLayoutListener {
private lateinit var mRootView: View
private var mParent: View? = null
private var mRect = Rect()
private var mSoftInputHeight = 0
private val mMinSoftInputHeightToDetect = PixelUtil.toPixelFromDIP(60f).toInt()
private var mListener: SoftInputListener? = null
private lateinit var mReactContext: ReactApplicationContext
private lateinit var mReactContext: ReactContext
private lateinit var mShowTimer: Timer
private lateinit var mHideTimer: Timer

constructor(reactContext: ReactApplicationContext) : this() {
constructor(reactContext: ReactContext) : this(reactContext, null)

constructor(reactContext: ReactContext, rootView: ViewGroup?): this() {
mReactContext = reactContext
mRootView = View(mReactContext.currentActivity)
mParent = rootView
contentView = mRootView

mShowTimer = Timer("showTimer", true)
mHideTimer = Timer("hideTimer", true)

mRootView.viewTreeObserver.addOnGlobalLayoutListener(this)
setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))

Expand All @@ -32,9 +43,14 @@ class AvoidSoftinputProvider(): PopupWindow(), ViewTreeObserver.OnGlobalLayoutLi
inputMethodMode = INPUT_METHOD_NEEDED
}

fun initializeProvider(): AvoidSoftinputProvider {
fun initializeProvider(): AvoidSoftInputProvider {
val activity = mReactContext.currentActivity ?: return this
if (!isShowing) {
if (mParent != null) {
mParent?.post {
showAtLocation(mParent, Gravity.NO_GRAVITY, 0, 0)
}
}
val decorView = activity.window.decorView

decorView.post {
Expand All @@ -51,7 +67,7 @@ class AvoidSoftinputProvider(): PopupWindow(), ViewTreeObserver.OnGlobalLayoutLi

override fun onGlobalLayout() {
mRootView.getWindowVisibleDisplayFrame(mRect)
val heightDiff = DisplayMetricsHolder.getScreenDisplayMetrics().heightPixels - mRect.bottom - getNavigationBarHeight()
val heightDiff = DisplayMetricsHolder.getScreenDisplayMetrics().heightPixels - mRect.bottom - getNavigationBarHeight(mReactContext)
val isSoftInputPotentiallyShown = mSoftInputHeight != heightDiff && heightDiff > mMinSoftInputHeightToDetect

if (!isSoftInputPotentiallyShown) {
Expand All @@ -70,22 +86,22 @@ class AvoidSoftinputProvider(): PopupWindow(), ViewTreeObserver.OnGlobalLayoutLi
onSoftInputShown(previousSoftInputHeight, mSoftInputHeight)
}

private fun getNavigationBarHeight(): Int {
var navigationBarHeight = 0
val resourceId: Int = mReactContext.resources.getIdentifier("navigation_bar_height", "dimen", "android")
if (resourceId > 0) {
navigationBarHeight = mReactContext.resources.getDimensionPixelSize(resourceId)
}

return navigationBarHeight
}

private fun onSoftInputShown(from: Int, to: Int) {
mListener?.onSoftInputShown(from, to)
mShowTimer.cancel()
mShowTimer = Timer("showTimer", true)
mShowTimer.schedule(100) {
mListener?.onSoftInputShown(from, to)
mShowTimer.cancel()
}
}

private fun onSoftInputHidden(from: Int, to: Int) {
mListener?.onSoftInputHidden(from, to)
mHideTimer.cancel()
mHideTimer = Timer("hideTimer", true)
mHideTimer.schedule(100) {
mListener?.onSoftInputHidden(from, to)
mHideTimer.cancel()
}
}

interface SoftInputListener {
Expand Down

0 comments on commit 371186b

Please sign in to comment.