Skip to content

Commit f8ceb6c

Browse files
authored
fix: improve keyboard handling for sheet presentation (#379)
* fix(android): improve keyboard handling during sheet presentation - Dismiss keyboard gracefully before presenting a new sheet over a parent sheet - Save and restore focused view when child sheet dismisses - Use WindowInsetsAnimationCompat callback for proper keyboard dismiss timing - Dismiss keyboard when RN Screens modal is presented over a sheet - Add getTopmostSheet helper to TrueSheetStackManager - Refactor KeyboardUtils to consolidate dismiss methods * fix(ios): only adjust footer for keyboard when input is within sheet * fix(android): add fallback for keyboard dismiss callback on API < 30
1 parent b13b6fd commit f8ceb6c

9 files changed

Lines changed: 163 additions & 8 deletions

File tree

android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import com.facebook.react.views.view.ReactViewGroup
1818
import com.lodev09.truesheet.core.GrabberOptions
1919
import com.lodev09.truesheet.core.TrueSheetStackManager
2020
import com.lodev09.truesheet.events.*
21+
import com.lodev09.truesheet.utils.KeyboardUtils
2122

2223
/**
2324
* Main TrueSheet host view that manages the sheet and dispatches events to JavaScript.
@@ -276,6 +277,16 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
276277
@UiThread
277278
fun present(detentIndex: Int, animated: Boolean = true, promiseCallback: () -> Unit) {
278279
if (!viewController.isPresented) {
280+
// Only dismiss keyboard if the focused view is within a parent sheet (iOS-like behavior)
281+
val parentSheet = TrueSheetStackManager.getTopmostSheet()
282+
if (KeyboardUtils.isKeyboardVisible(reactContext) && parentSheet?.viewController?.isFocusedViewWithinSheet() == true) {
283+
parentSheet.viewController.saveFocusedView()
284+
KeyboardUtils.dismiss(this) {
285+
post { present(detentIndex, animated, promiseCallback) }
286+
}
287+
return
288+
}
289+
279290
// Attach coordinator to the root container
280291
rootContainerView = findRootContainerView()
281292
viewController.coordinatorLayout?.let { rootContainerView?.addView(it) }

android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
140140

141141
// Keyboard State
142142
private var detentIndexBeforeKeyboard: Int = -1
143+
private var focusedViewBeforeBlur: View? = null
143144

144145
// Promises
145146
var presentPromise: (() -> Unit)? = null
@@ -248,7 +249,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
248249
private val isKeyboardTransitioning: Boolean
249250
get() = keyboardObserver?.isTransitioning ?: false
250251

251-
private fun isFocusedViewWithinSheet(): Boolean {
252+
fun isFocusedViewWithinSheet(): Boolean {
252253
val sheet = sheetView ?: return false
253254
return keyboardObserver?.isFocusedViewWithinSheet(sheet) ?: false
254255
}
@@ -331,6 +332,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
331332
isPresentAnimating = false
332333
lastEmittedPositionPx = -1
333334
detentIndexBeforeKeyboard = -1
335+
focusedViewBeforeBlur = null
334336
shouldAnimatePresent = true
335337
}
336338

@@ -513,7 +515,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
513515
reactContext = reactContext,
514516
onModalPresented = {
515517
if (isPresented && isSheetVisible && isTopmostSheet) {
516-
hideForModal()
518+
dismissKeyboard()
519+
post { hideForModal() }
517520
}
518521
},
519522
onModalWillDismiss = {
@@ -710,6 +713,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
710713
}
711714

712715
private fun finishDismiss() {
716+
parentSheetView?.viewController?.restoreFocusedView()
713717
emitDidDismissEvents()
714718
cleanupSheet()
715719
}
@@ -885,6 +889,22 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
885889
return true
886890
}
887891

892+
fun saveFocusedView() {
893+
focusedViewBeforeBlur = reactContext.currentActivity?.currentFocus
894+
}
895+
896+
fun restoreFocusedView() {
897+
val viewToFocus = focusedViewBeforeBlur ?: return
898+
focusedViewBeforeBlur = null
899+
900+
if (!viewToFocus.isAttachedToWindow) return
901+
if (viewToFocus.requestFocus()) {
902+
viewToFocus.postDelayed({
903+
KeyboardUtils.show(viewToFocus)
904+
}, 100)
905+
}
906+
}
907+
888908
fun setupKeyboardObserver() {
889909
val coordinator = coordinatorLayout ?: run {
890910
RNLog.e(reactContext, "TrueSheet: coordinatorLayout is null in setupKeyboardObserver")

android/src/main/java/com/lodev09/truesheet/core/TrueSheetStackManager.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,14 @@ object TrueSheetStackManager {
133133
return presentedSheetStack.lastOrNull { it.rootContainerView == rootContainer } == sheetView
134134
}
135135
}
136+
137+
/**
138+
* Returns the topmost presented sheet, or null if none.
139+
*/
140+
@JvmStatic
141+
fun getTopmostSheet(): TrueSheetView? {
142+
synchronized(presentedSheetStack) {
143+
return presentedSheetStack.lastOrNull { it.viewController.isPresented && it.viewController.isSheetVisible }
144+
}
145+
}
136146
}

android/src/main/java/com/lodev09/truesheet/utils/KeyboardUtils.kt

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,79 @@
11
package com.lodev09.truesheet.utils
22

33
import android.content.Context
4+
import android.os.Build
45
import android.view.View
56
import android.view.inputmethod.InputMethodManager
67
import androidx.core.view.ViewCompat
8+
import androidx.core.view.WindowInsetsAnimationCompat
79
import androidx.core.view.WindowInsetsCompat
810
import com.facebook.react.uimanager.ThemedReactContext
911

1012
object KeyboardUtils {
1113

14+
/**
15+
* Checks if the soft keyboard is currently visible.
16+
*/
17+
fun isKeyboardVisible(reactContext: ThemedReactContext): Boolean {
18+
val rootView = reactContext.currentActivity?.window?.decorView?.rootView ?: return false
19+
return isKeyboardVisible(rootView)
20+
}
21+
22+
private fun isKeyboardVisible(view: View): Boolean {
23+
val insets = ViewCompat.getRootWindowInsets(view) ?: return false
24+
return insets.isVisible(WindowInsetsCompat.Type.ime())
25+
}
26+
1227
/**
1328
* Dismisses the soft keyboard if currently shown.
1429
*/
1530
fun dismiss(reactContext: ThemedReactContext) {
16-
val imm = reactContext.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
17-
reactContext.currentActivity?.currentFocus?.let { focusedView ->
18-
imm?.hideSoftInputFromWindow(focusedView.windowToken, 0)
31+
val rootView = reactContext.currentActivity?.window?.decorView?.rootView ?: return
32+
dismiss(rootView, null)
33+
}
34+
35+
/**
36+
* Dismisses the soft keyboard with an optional callback when the animation completes.
37+
*/
38+
fun dismiss(view: View, onComplete: (() -> Unit)?) {
39+
val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
40+
val focusedView = view.rootView.findFocus()
41+
42+
if (focusedView == null || !isKeyboardVisible(view)) {
43+
onComplete?.invoke()
44+
return
1945
}
46+
47+
if (onComplete != null) {
48+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
49+
ViewCompat.setWindowInsetsAnimationCallback(
50+
view,
51+
object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
52+
override fun onProgress(
53+
insets: WindowInsetsCompat,
54+
runningAnimations: List<WindowInsetsAnimationCompat>
55+
): WindowInsetsCompat = insets
56+
57+
override fun onEnd(animation: WindowInsetsAnimationCompat) {
58+
ViewCompat.setWindowInsetsAnimationCallback(view, null)
59+
onComplete()
60+
}
61+
}
62+
)
63+
} else {
64+
view.postDelayed({ onComplete() }, 120)
65+
}
66+
}
67+
68+
imm?.hideSoftInputFromWindow(focusedView.windowToken, 0)
69+
}
70+
71+
/**
72+
* Shows the soft keyboard for the given view.
73+
*/
74+
fun show(view: View) {
75+
val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
76+
imm?.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT)
2077
}
2178

2279
/**

example/bare/ios/Podfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2646,7 +2646,7 @@ PODS:
26462646
- ReactCommon/turbomodule/core
26472647
- SocketRocket
26482648
- Yoga
2649-
- RNTrueSheet (3.6.9):
2649+
- RNTrueSheet (3.7.0-beta.3):
26502650
- boost
26512651
- DoubleConversion
26522652
- fast_float
@@ -3095,7 +3095,7 @@ SPEC CHECKSUMS:
30953095
RNGestureHandler: e1cf8ef3f11045536eed6bd4f132b003ef5f9a5f
30963096
RNReanimated: f1868b36f4b2b52a0ed00062cfda69506f75eaee
30973097
RNScreens: d821082c6dd1cb397cc0c98b026eeafaa68be479
3098-
RNTrueSheet: 66d29463562c7ba9d9679f5d7af46b8ca8ec5f46
3098+
RNTrueSheet: 192e3d4a0e32be2f16a06d1f15e939b6e045af2d
30993099
RNWorklets: d9c050940f140af5d8b611d937eab1cbfce5e9a5
31003100
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
31013101
Yoga: 689c8e04277f3ad631e60fe2a08e41d411daf8eb

example/shared/src/screens/ModalScreen.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export const ModalScreen = ({ onNavigateToTest, onDismiss }: ModalScreenProps) =
4242
<Spacer />
4343
<Button text="Navigate Test" onPress={onNavigateToTest} />
4444

45-
<PromptSheet initialDetentIndex={0} ref={promptSheet} dimmed={false} />
45+
<PromptSheet ref={promptSheet} dimmed={false} />
4646
<FlatListSheet ref={flatlistSheet} />
4747

4848
<Modal

ios/TrueSheetFooterView.mm

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
#import <react/renderer/components/TrueSheetSpec/RCTComponentViewHelpers.h>
1616
#import "TrueSheetViewController.h"
1717
#import "utils/LayoutUtil.h"
18+
#import "utils/UIView+FirstResponder.h"
1819

1920
using namespace facebook::react;
2021

@@ -139,6 +140,16 @@ - (TrueSheetViewController *)findSheetViewController {
139140
return nil;
140141
}
141142

143+
- (BOOL)isFirstResponderWithinSheet {
144+
TrueSheetViewController *sheetController = [self findSheetViewController];
145+
if (!sheetController) {
146+
return NO;
147+
}
148+
149+
UIView *firstResponder = [sheetController.view findFirstResponder];
150+
return firstResponder != nil;
151+
}
152+
142153
- (void)keyboardWillChangeFrame:(NSNotification *)notification {
143154
if (!_bottomConstraint) {
144155
return;
@@ -150,6 +161,11 @@ - (void)keyboardWillChangeFrame:(NSNotification *)notification {
150161
return;
151162
}
152163

164+
// Only respond if the focused view is within this sheet
165+
if (![self isFirstResponderWithinSheet]) {
166+
return;
167+
}
168+
153169
NSDictionary *userInfo = notification.userInfo;
154170
CGRect keyboardFrame = [userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
155171
NSTimeInterval duration = [userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];

ios/utils/UIView+FirstResponder.h

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//
2+
// Created by Jovanni Lo (@lodev09)
3+
// Copyright (c) 2024-present. All rights reserved.
4+
//
5+
// This source code is licensed under the MIT license found in the
6+
// LICENSE file in the root directory of this source tree.
7+
//
8+
9+
#import <UIKit/UIKit.h>
10+
11+
@interface UIView (FirstResponder)
12+
13+
- (UIView *)findFirstResponder;
14+
15+
@end

ios/utils/UIView+FirstResponder.mm

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//
2+
// Created by Jovanni Lo (@lodev09)
3+
// Copyright (c) 2024-present. All rights reserved.
4+
//
5+
// This source code is licensed under the MIT license found in the
6+
// LICENSE file in the root directory of this source tree.
7+
//
8+
9+
#import "UIView+FirstResponder.h"
10+
11+
@implementation UIView (FirstResponder)
12+
13+
- (UIView *)findFirstResponder {
14+
if (self.isFirstResponder) {
15+
return self;
16+
}
17+
for (UIView *subview in self.subviews) {
18+
UIView *firstResponder = [subview findFirstResponder];
19+
if (firstResponder) {
20+
return firstResponder;
21+
}
22+
}
23+
return nil;
24+
}
25+
26+
@end

0 commit comments

Comments
 (0)