Skip to content

Commit 9b387ce

Browse files
t0maborokkafar
andauthored
fix(Android, FormSheet): Add logic for dismissing keyboard when the BottomSheet is presented (#3617)
## Description Add logic to handle keyboard visibility and focus restoration on sheet enter/exit > [!IMPORTANT] > It doesn't work well in some edge cases, especially when we have a Screen with a TextInput and a FormSheet with another TextInput. When using an HW keyboard in such a case, if we submit the Input inside the FormSheet (losing focus) and then want to reopen it, the focus search might select the TextInput on the Screen, as we do not block focus on the Screen behind the FormSheet. > Creating ticket for that: software-mansion/react-native-screens-labs#929 Implementation details: - I'm checking the IME inset on the DecorView, there's 1 soft keyboard for the whole application - it's easier to detect whether it was open on the previous Screen - I'm restoring the focus, only if the keyboard was visible on the previous screen - that might be bad for applications that are designed for keyboard/D-Pad navigation - I don't want the Screen to be the subject of focus - there is no good reason for that imo, therefore, I've added FOCUS_AFTER_DESCENDANTS to focus on the 1st focusable descendant inside the FormSheet - I'm using onCreate/Destroy callbacks, instead of onStart/Stop callbacks, because of the timing for autoFocus inside the FormSheet Closes: software-mansion/react-native-screens-labs#928 ## Changes - added lifecycle callbacks for sheet creation/destruction that will show/hide keyboard from Screen and save the latest focus ## Before & after - visual documentation | Before | After | | --- | --- | | <video src="https://github.com/user-attachments/assets/bd07bc9d-ee8e-4c92-bea8-75117446bb96" /> | <video src="https://github.com/user-attachments/assets/5e8cd8e6-6da9-47d0-b138-48332087cc59" /> | ## Test plan Test3617 ## Checklist - [x] Included code example that can be used to test this change. - [ ] Updated / created local changelog entries in relevant test files. - [x] For visual changes, included screenshots / GIFs / recordings documenting the change. - [ ] For API changes, updated relevant public types. - [ ] Ensured that CI passes --------- Co-authored-by: Kacper Kafara <kacperkafara@gmail.com>
1 parent 2711117 commit 9b387ce

4 files changed

Lines changed: 214 additions & 3 deletions

File tree

android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetDelegate.kt

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import android.animation.ValueAnimator
77
import android.content.Context
88
import android.os.Build
99
import android.view.View
10+
import android.view.ViewGroup
1011
import android.view.WindowManager
1112
import android.view.inputmethod.InputMethodManager
1213
import androidx.coordinatorlayout.widget.CoordinatorLayout
@@ -27,6 +28,7 @@ import com.swmansion.rnscreens.ScreenStackFragment
2728
import com.swmansion.rnscreens.events.ScreenAnimationDelegate
2829
import com.swmansion.rnscreens.events.ScreenEventEmitter
2930
import com.swmansion.rnscreens.transition.ExternalBoundaryValuesEvaluator
31+
import com.swmansion.rnscreens.utils.isSoftKeyboardVisibleOrNull
3032

3133
class SheetDelegate(
3234
val screen: Screen,
@@ -63,6 +65,11 @@ class SheetDelegate(
6365
checkNotNull(screen.reactContext.currentActivity) { "[RNScreens] Attempt to access activity on detached context" }
6466
.window.decorView
6567

68+
private var viewToRestoreFocus: View? = null
69+
70+
private val inputMethodManager: InputMethodManager?
71+
get() = screen.reactContext.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
72+
6673
init {
6774
assert(screen.fragment is ScreenStackFragment) { "[RNScreens] Sheets are supported only in native stack" }
6875
screen.fragment!!.lifecycle.addObserver(this)
@@ -77,13 +84,19 @@ class SheetDelegate(
7784
event: Lifecycle.Event,
7885
) {
7986
when (event) {
87+
Lifecycle.Event.ON_CREATE -> handleHostFragmentOnCreate()
8088
Lifecycle.Event.ON_START -> handleHostFragmentOnStart()
8189
Lifecycle.Event.ON_RESUME -> handleHostFragmentOnResume()
8290
Lifecycle.Event.ON_PAUSE -> handleHostFragmentOnPause()
91+
Lifecycle.Event.ON_DESTROY -> handleHostFragmentOnDestroy()
8392
else -> Unit
8493
}
8594
}
8695

96+
private fun handleHostFragmentOnCreate() {
97+
preserveBackgroundFocus()
98+
}
99+
87100
private fun handleHostFragmentOnStart() {
88101
InsetsObserverProxy.registerOnView(requireDecorView())
89102
}
@@ -96,6 +109,10 @@ class SheetDelegate(
96109
InsetsObserverProxy.removeOnApplyWindowInsetsListener(this)
97110
}
98111

112+
private fun handleHostFragmentOnDestroy() {
113+
restoreBackgroundFocus()
114+
}
115+
99116
private fun onSheetStateChanged(newState: Int) {
100117
val isStable = SheetUtils.isStateStable(newState)
101118

@@ -114,6 +131,32 @@ class SheetDelegate(
114131
}
115132
}
116133

134+
private fun preserveBackgroundFocus() {
135+
val activity = screen.reactContext.currentActivity ?: return
136+
137+
activity.currentFocus?.let { focusedView ->
138+
activity.window?.decorView?.let { decorView ->
139+
if (isSoftKeyboardVisibleOrNull(decorView) == true) {
140+
viewToRestoreFocus = focusedView
141+
}
142+
}
143+
144+
// Note: There's no good reason that Screen should be direct target for focus, we're rather
145+
// prefer its children to gain it.
146+
screen.descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS
147+
screen.requestFocus()
148+
inputMethodManager?.hideSoftInputFromWindow(focusedView.windowToken, 0)
149+
}
150+
}
151+
152+
private fun restoreBackgroundFocus() {
153+
viewToRestoreFocus?.let { view ->
154+
view.requestFocus()
155+
inputMethodManager?.showSoftInput(view, 0)
156+
}
157+
viewToRestoreFocus = null
158+
}
159+
117160
internal fun updateBottomSheetMetrics(behavior: BottomSheetBehavior<Screen>) {
118161
val containerHeight = tryResolveMaxFormSheetHeight()
119162
check(containerHeight != null) {
@@ -555,9 +598,7 @@ class SheetDelegate(
555598
// I want to be polite here and request focus before dismissing the keyboard,
556599
// however even if it fails I want to try to hide the keyboard. This sometimes works...
557600
bottomSheet.requestFocus()
558-
val imm =
559-
screen.reactContext.getSystemService(InputMethodManager::class.java)
560-
imm.hideSoftInputFromWindow(bottomSheet.windowToken, 0)
601+
inputMethodManager?.hideSoftInputFromWindow(bottomSheet.windowToken, 0)
561602
}
562603
}
563604
}

android/src/main/java/com/swmansion/rnscreens/utils/DecorViewInsetsUtils.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,12 @@ private fun getTopInset(insetsCompat: WindowInsetsCompat): Int =
2121
.getInsets(
2222
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
2323
).top
24+
25+
internal fun isSoftKeyboardVisibleOrNull(decorView: View): Boolean? {
26+
val insetsCompat = ViewCompat.getRootWindowInsets(decorView) ?: return null
27+
28+
return insetsCompat
29+
.isVisible(
30+
WindowInsetsCompat.Type.ime(),
31+
)
32+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import React, { useState } from 'react';
2+
import { Button, View, Text, StyleSheet, TextInput } from 'react-native';
3+
import { NavigationContainer } from '@react-navigation/native';
4+
import {
5+
createNativeStackNavigator,
6+
NativeStackScreenProps,
7+
} from '@react-navigation/native-stack';
8+
import Colors from '../../shared/styling/Colors';
9+
10+
type StackParamList = {
11+
Home: undefined;
12+
FormSheet: undefined;
13+
FormSheetWithAutoFocus: undefined;
14+
FormSheetWoFocusable: undefined;
15+
};
16+
17+
const Stack = createNativeStackNavigator<StackParamList>();
18+
19+
const HomeScreen = ({ navigation }: NativeStackScreenProps<StackParamList>) => {
20+
const [isFocused, setIsFocused] = useState(false);
21+
22+
return (
23+
<View style={styles.screen}>
24+
<Text style={styles.title}>Home Screen</Text>
25+
<Text style={styles.text}>Is focused? {isFocused ? 'YES' : 'NO'}</Text>
26+
<TextInput
27+
style={[
28+
styles.input,
29+
isFocused
30+
? { borderColor: Colors.GreenDark100 }
31+
: { borderColor: Colors.RedDark100 },
32+
]}
33+
placeholder="Enter text (no sheet)"
34+
onFocus={() => setIsFocused(true)}
35+
onBlur={() => setIsFocused(false)}
36+
/>
37+
<Button
38+
title="Open Form Sheet"
39+
onPress={() => navigation.navigate('FormSheet')}
40+
/>
41+
<Button
42+
title="Open Form Sheet (w/ autoFocus)"
43+
onPress={() => navigation.navigate('FormSheetWithAutoFocus')}
44+
/>
45+
<Button
46+
title="Open Form Sheet (w/o focusable)"
47+
onPress={() => navigation.navigate('FormSheetWoFocusable')}
48+
/>
49+
</View>
50+
);
51+
};
52+
53+
const FormSheetScreen = () => {
54+
return (
55+
<View style={styles.formSheetContainer}>
56+
<View style={styles.rectangle} />
57+
<TextInput style={styles.input} placeholder="Enter text in FormSheet" />
58+
</View>
59+
);
60+
};
61+
62+
const FormSheetScreenWithAutoFocus = () => {
63+
return (
64+
<View style={styles.formSheetContainer}>
65+
<View style={styles.rectangle} />
66+
<TextInput
67+
style={styles.input}
68+
autoFocus
69+
placeholder="Enter text in FormSheet"
70+
/>
71+
</View>
72+
);
73+
};
74+
75+
function FormSheetWoFocusable() {
76+
return (
77+
<View style={styles.formSheetContainer} focusable={false}>
78+
<View style={styles.rectangle} focusable={false} />
79+
</View>
80+
);
81+
}
82+
83+
export default function App() {
84+
return (
85+
<NavigationContainer>
86+
<Stack.Navigator>
87+
<Stack.Screen name="Home" component={HomeScreen} />
88+
<Stack.Screen
89+
name="FormSheet"
90+
component={FormSheetScreen}
91+
options={{
92+
presentation: 'formSheet',
93+
sheetAllowedDetents: 'fitToContents',
94+
headerShown: false,
95+
contentStyle: {
96+
backgroundColor: Colors.YellowLight40,
97+
},
98+
}}
99+
/>
100+
<Stack.Screen
101+
name="FormSheetWithAutoFocus"
102+
component={FormSheetScreenWithAutoFocus}
103+
options={{
104+
presentation: 'formSheet',
105+
sheetAllowedDetents: 'fitToContents',
106+
headerShown: false,
107+
contentStyle: {
108+
backgroundColor: Colors.YellowLight40,
109+
},
110+
}}
111+
/>
112+
<Stack.Screen
113+
name="FormSheetWoFocusable"
114+
component={FormSheetWoFocusable}
115+
options={{
116+
presentation: 'formSheet',
117+
sheetAllowedDetents: 'fitToContents',
118+
headerShown: false,
119+
contentStyle: {
120+
backgroundColor: Colors.YellowLight40,
121+
},
122+
}}
123+
/>
124+
</Stack.Navigator>
125+
</NavigationContainer>
126+
);
127+
}
128+
129+
const styles = StyleSheet.create({
130+
screen: {
131+
flex: 1,
132+
alignItems: 'center',
133+
padding: 20,
134+
gap: 16,
135+
},
136+
title: {
137+
fontSize: 24,
138+
},
139+
formSheetContainer: {
140+
alignItems: 'center',
141+
justifyContent: 'center',
142+
padding: 20,
143+
gap: 20,
144+
},
145+
text: {
146+
fontSize: 16,
147+
},
148+
rectangle: {
149+
width: '100%',
150+
backgroundColor: Colors.NavyLight80,
151+
height: 200,
152+
alignItems: 'center',
153+
justifyContent: 'center',
154+
},
155+
input: {
156+
width: '80%',
157+
height: 50,
158+
borderWidth: 2,
159+
},
160+
});

apps/src/tests/issue-tests/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ export { default as Test3564 } from './Test3564';
180180
export { default as Test3566 } from './Test3566';
181181
export { default as Test3576 } from './Test3576';
182182
export { default as Test3596 } from './Test3596';
183+
export { default as Test3617 } from './Test3617';
183184
export { default as TestScreenAnimation } from './TestScreenAnimation';
184185
// The following test was meant to demo the "go back" gesture using Reanimated
185186
// but the associated PR in react-navigation is currently put on hold

0 commit comments

Comments
 (0)