Skip to content

Commit

Permalink
fix: modal integration (#466)
Browse files Browse the repository at this point in the history
## 📜 Description

Fixed a problem of keyboard movement not being detected in Modal window
on Android.

## 💡 Motivation and Context

The most challenging in this PR is getting an access to `dialog`.
Initially I thought to create an additional view
(`ModalKeyboardProvider`) and then through this view get an access to
the `dialog`, but after some experiments I realized, that we'll get an
access to `DialogRootViewGroup` and this class is not holding a
reference to `dialog`, so this idea failed.

Another approach was to listen to all events in `eventDispatcher` and
when we detect `onShow` (`topShow`) event, then using `uiManager` we can
resolve the view and get `ReactModalHostView`. This approach work, but
it has one downside - we listen to all events. It doesn't hit the
performance, but this approach looks like a workaround (it has too many
transitive dependencies).

A PR facebook/react-native#45534 introduces a
new API for detection show/hide modals. I hope it'll be merged at some
point of time and we can start to use new API. In a meantime I'll use
the current approach and will incapsulate all work in
`ModalAttachedWatcher` - in this case we have our own interface for
dealing with modals, and we can change internals without affecting other
files 😎

Closes
#369

Potentially helps to fix
#387

## 📢 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 -->

### E2E

- added new `modal` test;

### Android

- added `ModalAttachedWatcher` class;
- `KeyboardAnimationCallback` now accepts config to reduce amount of
passed params (`KeyboardAnimationCallbackConfig`);
- pass `eventPropagationView` to `KeyboardAnimationCallback` and
`FocusedInputObserver`;
- return `WindowInsetsCompat.CONSUMED` from `onApplyWindowInsets`
instead of `insets`;
- add `syncKeyboardPosition` to `KeyboardAnimationCallback`;
- dispatch the same events through cycle instead of code duplication.

## 🤔 How Has This Been Tested?

Tested manually on:
- Xiaomi Redmi Note 5 Pro (Android 9);
- Pixel 7 Pro (Android 14);
- Pixel 3A (Android 13, emulator);
- Pixel 2 (AOSP, Android 9, e2e tests);
- Pixel 2 (Android 11, emulator, e2e tests).

## 📸 Screenshots (if appropriate):

|Before|After|
|-------|-----|
|<img width="358" alt="Screenshot 2024-07-24 at 16 43 20"
src="https://github.com/user-attachments/assets/bb3f5a2a-799a-4e46-af4f-4616d163ebbe">|<img
width="360" alt="Screenshot 2024-07-24 at 16 41 39"
src="https://github.com/user-attachments/assets/c87fffce-b8ef-4bf3-a4f3-0985e5a030ff">|


## 📝 Checklist

- [x] CI successfully passed
- [x] I added new mocks and corresponding unit-tests if library API was
changed
  • Loading branch information
kirillzyusko authored Jul 25, 2024
1 parent a2abcd2 commit 4a796eb
Show file tree
Hide file tree
Showing 13 changed files with 219 additions and 73 deletions.
2 changes: 1 addition & 1 deletion FabricExample/src/screens/Examples/Main/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,6 @@ export const examples: Example[] = [
title: "Modal",
testID: "modal",
info: ScreenNames.MODAL,
icons: "🔴 🌎",
icons: "🌎",
},
];
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.reactnativekeyboardcontroller.listeners

import android.text.TextWatcher
import android.view.View
import android.view.View.OnLayoutChangeListener
import android.view.ViewTreeObserver.OnGlobalFocusChangeListener
import com.facebook.react.bridge.Arguments
Expand Down Expand Up @@ -35,7 +36,11 @@ val noFocusedInputEvent = FocusedInputLayoutChangedEventData(
parentScrollViewTarget = -1,
)

class FocusedInputObserver(val view: ReactViewGroup, private val context: ThemedReactContext?) {
class FocusedInputObserver(
val view: View,
private val eventPropagationView: ReactViewGroup,
private val context: ThemedReactContext?,
) {
// constructor variables
private val surfaceId = UIManagerHelper.getSurfaceId(view)

Expand All @@ -53,10 +58,10 @@ class FocusedInputObserver(val view: ReactViewGroup, private val context: Themed
private val textListener: (String) -> Unit = { text ->
syncUpLayout()
context.dispatchEvent(
view.id,
eventPropagationView.id,
FocusedInputTextChangedEvent(
surfaceId,
view.id,
eventPropagationView.id,
text = text,
),
)
Expand All @@ -73,10 +78,10 @@ class FocusedInputObserver(val view: ReactViewGroup, private val context: Themed

syncUpLayout()
context.dispatchEvent(
view.id,
eventPropagationView.id,
FocusedInputSelectionChangedEvent(
surfaceId,
view.id,
eventPropagationView.id,
event = FocusedInputSelectionChangedEventData(
target = input.id,
start = start,
Expand All @@ -90,7 +95,7 @@ class FocusedInputObserver(val view: ReactViewGroup, private val context: Themed
)
}
private val focusListener = OnGlobalFocusChangeListener { oldFocus, newFocus ->
// unfocused or focused was changed
// unfocused or focus was changed
if (newFocus == null || oldFocus != null) {
lastFocusedInput?.removeOnLayoutChangeListener(layoutListener)
lastFocusedInput?.removeTextChangedListener(textWatcher)
Expand Down Expand Up @@ -152,10 +157,10 @@ class FocusedInputObserver(val view: ReactViewGroup, private val context: Themed
if (event != lastEventDispatched) {
lastEventDispatched = event
context.dispatchEvent(
view.id,
eventPropagationView.id,
FocusedInputLayoutChangedEvent(
surfaceId,
view.id,
eventPropagationView.id,
event = event,
),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,20 @@ import kotlin.math.abs
private val TAG = KeyboardAnimationCallback::class.qualifiedName
private val isResizeHandledInCallbackMethods = Build.VERSION.SDK_INT < Build.VERSION_CODES.R

class KeyboardAnimationCallback(
val view: ReactViewGroup,
data class KeyboardAnimationCallbackConfig(
val persistentInsetTypes: Int,
val deferredInsetTypes: Int,
dispatchMode: Int = DISPATCH_MODE_STOP,
val context: ThemedReactContext?,
val dispatchMode: Int = WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP,
val hasTranslucentNavigationBar: Boolean = false,
) : WindowInsetsAnimationCompat.Callback(dispatchMode), OnApplyWindowInsetsListener {
private val surfaceId = UIManagerHelper.getSurfaceId(view)
)

class KeyboardAnimationCallback(
val eventPropagationView: ReactViewGroup,
val view: View,
val context: ThemedReactContext?,
private val config: KeyboardAnimationCallbackConfig,
) : WindowInsetsAnimationCompat.Callback(config.dispatchMode), OnApplyWindowInsetsListener {
private val surfaceId = UIManagerHelper.getSurfaceId(eventPropagationView)

// state variables
private var persistentKeyboardHeight = 0.0
Expand All @@ -58,10 +63,10 @@ class KeyboardAnimationCallback(
// 100% included in onStart/onMove/onEnd life cycles, but triggering onStart/onEnd several time
// can bring breaking changes
context.dispatchEvent(
view.id,
eventPropagationView.id,
KeyboardTransitionEvent(
surfaceId,
view.id,
eventPropagationView.id,
"topKeyboardMoveStart",
this.persistentKeyboardHeight,
1.0,
Expand All @@ -70,10 +75,10 @@ class KeyboardAnimationCallback(
),
)
context.dispatchEvent(
view.id,
eventPropagationView.id,
KeyboardTransitionEvent(
surfaceId,
view.id,
eventPropagationView.id,
"topKeyboardMoveEnd",
this.persistentKeyboardHeight,
1.0,
Expand All @@ -89,12 +94,12 @@ class KeyboardAnimationCallback(
private var layoutObserver: FocusedInputObserver? = null

init {
require(persistentInsetTypes and deferredInsetTypes == 0) {
require(config.persistentInsetTypes and config.deferredInsetTypes == 0) {
"persistentInsetTypes and deferredInsetTypes can not contain any of " +
" same WindowInsetsCompat.Type values"
}

layoutObserver = FocusedInputObserver(view = view, context = context)
layoutObserver = FocusedInputObserver(view = view, eventPropagationView = eventPropagationView, context = context)
view.viewTreeObserver.addOnGlobalFocusChangeListener(focusListener)
}

Expand Down Expand Up @@ -137,7 +142,7 @@ class KeyboardAnimationCallback(
this.onKeyboardResized(keyboardHeight)
}

return insets
return WindowInsetsCompat.CONSUMED
}

@Suppress("detekt:ReturnCount")
Expand Down Expand Up @@ -179,10 +184,10 @@ class KeyboardAnimationCallback(

Log.i(TAG, "HEIGHT:: $keyboardHeight TAG:: $viewTagFocused")
context.dispatchEvent(
view.id,
eventPropagationView.id,
KeyboardTransitionEvent(
surfaceId,
view.id,
eventPropagationView.id,
"topKeyboardMoveStart",
keyboardHeight,
if (!isKeyboardVisible) 0.0 else 1.0,
Expand All @@ -204,13 +209,13 @@ class KeyboardAnimationCallback(
runningAnimations.find { it.isKeyboardAnimation && !animationsToSkip.contains(it) } ?: return insets

// First we get the insets which are potentially deferred
val typesInset = insets.getInsets(deferredInsetTypes)
val typesInset = insets.getInsets(config.deferredInsetTypes)
// Then we get the persistent inset types which are applied as padding during layout
var otherInset = insets.getInsets(persistentInsetTypes)
var otherInset = insets.getInsets(config.persistentInsetTypes)

// Now that we subtract the two insets, to calculate the difference. We also coerce
// the insets to be >= 0, to make sure we don't use negative insets.
if (hasTranslucentNavigationBar) {
if (config.hasTranslucentNavigationBar) {
otherInset = Insets.NONE
}
val diff = Insets.subtract(typesInset, otherInset).let {
Expand All @@ -233,10 +238,10 @@ class KeyboardAnimationCallback(

val event = if (InteractiveKeyboardProvider.isInteractive) "topKeyboardMoveInteractive" else "topKeyboardMove"
context.dispatchEvent(
view.id,
eventPropagationView.id,
KeyboardTransitionEvent(
surfaceId,
view.id,
eventPropagationView.id,
event,
height,
progress,
Expand Down Expand Up @@ -284,10 +289,10 @@ class KeyboardAnimationCallback(
getEventParams(keyboardHeight),
)
context.dispatchEvent(
view.id,
eventPropagationView.id,
KeyboardTransitionEvent(
surfaceId,
view.id,
eventPropagationView.id,
"topKeyboardMoveEnd",
keyboardHeight,
if (!isKeyboardVisible) 0.0 else 1.0,
Expand All @@ -300,6 +305,35 @@ class KeyboardAnimationCallback(
duration = 0
}

fun syncKeyboardPosition() {
val keyboardHeight = getCurrentKeyboardHeight()
// update internal state
isKeyboardVisible = isKeyboardVisible()
prevKeyboardHeight = keyboardHeight
isTransitioning = false
duration = 0

context.emitEvent(
"KeyboardController::" + if (!isKeyboardVisible) "keyboardDidHide" else "keyboardDidShow",
getEventParams(keyboardHeight),
)
// dispatch `onMove` to update RN animated value and `onEnd` to indicate that transition finished
listOf("topKeyboardMove", "topKeyboardMoveEnd").forEach { eventName ->
context.dispatchEvent(
eventPropagationView.id,
KeyboardTransitionEvent(
surfaceId,
eventPropagationView.id,
eventName,
keyboardHeight,
if (!isKeyboardVisible) 0.0 else 1.0,
duration,
viewTagFocused,
),
)
}
}

fun destroy() {
view.viewTreeObserver.removeOnGlobalFocusChangeListener(focusListener)
layoutObserver?.destroy()
Expand All @@ -312,42 +346,20 @@ class KeyboardAnimationCallback(
duration = 0

context.emitEvent("KeyboardController::keyboardWillShow", getEventParams(keyboardHeight))
context.dispatchEvent(
view.id,
KeyboardTransitionEvent(
surfaceId,
view.id,
"topKeyboardMoveStart",
keyboardHeight,
1.0,
0,
viewTagFocused,
),
)
context.dispatchEvent(
view.id,
KeyboardTransitionEvent(
surfaceId,
view.id,
"topKeyboardMove",
keyboardHeight,
1.0,
0,
viewTagFocused,
),
)
context.dispatchEvent(
view.id,
KeyboardTransitionEvent(
surfaceId,
view.id,
"topKeyboardMoveEnd",
keyboardHeight,
1.0,
0,
viewTagFocused,
),
)
listOf("topKeyboardMoveStart", "topKeyboardMove", "topKeyboardMoveEnd").forEach { eventName ->
context.dispatchEvent(
eventPropagationView.id,
KeyboardTransitionEvent(
surfaceId,
eventPropagationView.id,
eventName,
keyboardHeight,
1.0,
0,
viewTagFocused,
),
)
}
context.emitEvent("KeyboardController::keyboardDidShow", getEventParams(keyboardHeight))

this.persistentKeyboardHeight = keyboardHeight
Expand All @@ -363,7 +375,7 @@ class KeyboardAnimationCallback(
val insets = ViewCompat.getRootWindowInsets(view)
val keyboardHeight = insets?.getInsets(WindowInsetsCompat.Type.ime())?.bottom ?: 0
val navigationBar =
if (hasTranslucentNavigationBar) {
if (config.hasTranslucentNavigationBar) {
0
} else {
insets?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.reactnativekeyboardcontroller.modal

import android.util.Log
import android.view.WindowManager
import androidx.core.view.ViewCompat
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.common.UIManagerType
import com.facebook.react.uimanager.events.Event
import com.facebook.react.uimanager.events.EventDispatcherListener
import com.facebook.react.views.modal.ReactModalHostView
import com.facebook.react.views.view.ReactViewGroup
import com.reactnativekeyboardcontroller.BuildConfig
import com.reactnativekeyboardcontroller.listeners.KeyboardAnimationCallback
import com.reactnativekeyboardcontroller.listeners.KeyboardAnimationCallbackConfig

private val TAG = ModalAttachedWatcher::class.qualifiedName

class ModalAttachedWatcher(
private val view: ReactViewGroup,
private val reactContext: ThemedReactContext,
private val config: () -> KeyboardAnimationCallbackConfig,
) : EventDispatcherListener {
private val archType = if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) UIManagerType.FABRIC else UIManagerType.DEFAULT
private val uiManager = UIManagerHelper.getUIManager(reactContext.reactApplicationContext, archType)
private val eventDispatcher = UIManagerHelper.getEventDispatcher(reactContext.reactApplicationContext, archType)

override fun onEventDispatch(event: Event<out Event<*>>?) {
if (event?.eventName != MODAL_SHOW_EVENT) {
return
}

val modal = try {
uiManager?.resolveView(event.viewTag) as? ReactModalHostView
} catch (ignore: Exception) {
Log.w(TAG, "Can not resolve view for Modal#${event.viewTag}", ignore)
null
}

if (modal == null) {
return
}

val dialog = modal.dialog
val window = dialog?.window
val rootView = window?.decorView?.rootView

if (rootView != null) {
val callback = KeyboardAnimationCallback(
view = rootView,
eventPropagationView = view,
context = reactContext,
config = config(),
)

ViewCompat.setWindowInsetsAnimationCallback(rootView, callback)
ViewCompat.setOnApplyWindowInsetsListener(rootView, callback)

dialog.setOnDismissListener {
callback.syncKeyboardPosition()
callback.destroy()
}

// imitating edge-to-edge mode behavior
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
}
}

fun enable() {
eventDispatcher?.addListener(this)
}

fun disable() {
eventDispatcher?.removeListener(this)
}

companion object {
private const val MODAL_SHOW_EVENT = "topShow"
}
}
Loading

0 comments on commit 4a796eb

Please sign in to comment.