Skip to content

Commit 9cc50f5

Browse files
authored
feat: emit focus/blur events during present/dismiss (#278)
* feat: emit focus/blur events during present/dismiss * docs: update focus/blur events documentation * feat(android): emit focus/blur events during present/dismiss - Add willFocus/didFocus events with willPresent/didPresent - Add willBlur/didBlur events with willDismiss/didDismiss - Use postDelayed with animation duration for did* events after animation - Handle both programmatic dismiss (STATE_HIDDEN) and user-initiated cancel - Use isDismissing flag to prevent duplicate events * refactor(android): extract dismiss event helpers to reduce redundancy - Add emitWillDismissEvents() helper for willBlur/willDismiss + parent focus - Add emitDidDismissEvents() helper for didBlur/didDismiss + parent focus + promise - Update dismiss(), STATE_HIDDEN, and setOnCancelListener to use helpers * fix(android): emit position change on user-initiated dismiss onSlide isn't triggered for user-initiated dismiss (back button, tap outside), so manually emit off-screen position after dismiss animation completes. * test: add focus/blur event callback tests * fix: add animated param to mock present/dismiss methods
1 parent d8359ab commit 9cc50f5

7 files changed

Lines changed: 219 additions & 49 deletions

File tree

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,15 +157,15 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
157157
override fun viewControllerWillPresent(index: Int, position: Float, detent: Float) {
158158
val surfaceId = UIManagerHelper.getSurfaceId(this)
159159
eventDispatcher?.dispatchEvent(WillPresentEvent(surfaceId, id, index, position, detent))
160+
161+
// Enable touch event dispatching to React Native
162+
viewController.eventDispatcher = eventDispatcher
163+
containerView?.footerView?.eventDispatcher = eventDispatcher
160164
}
161165

162166
override fun viewControllerDidPresent(index: Int, position: Float, detent: Float) {
163167
val surfaceId = UIManagerHelper.getSurfaceId(this)
164168
eventDispatcher?.dispatchEvent(DidPresentEvent(surfaceId, id, index, position, detent))
165-
166-
// Enable touch event dispatching to React Native
167-
viewController.eventDispatcher = eventDispatcher
168-
containerView?.footerView?.eventDispatcher = eventDispatcher
169169
}
170170

171171
override fun viewControllerWillDismiss() {

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

Lines changed: 77 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
6666
private const val GRABBER_TAG = "TrueSheetGrabber"
6767
private const val DEFAULT_MAX_WIDTH = 640 // dp
6868
private const val DEFAULT_CORNER_RADIUS = 16 // dp
69+
70+
// Animation durations from res/anim/true_sheet_slide_in.xml and true_sheet_slide_out.xml
71+
private const val PRESENT_ANIMATION_DURATION = 200L
72+
private const val DISMISS_ANIMATION_DURATION = 150L
6973
}
7074

7175
// ====================================================================
@@ -115,6 +119,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
115119
private val resolvedDetentPositions = mutableListOf<Int>()
116120

117121
private var isDragging = false
122+
private var isDismissing = false
118123
private var isReconfiguring = false
119124
private var windowAnimation: Int = 0
120125
private var lastEmittedPositionPx: Int = -1
@@ -272,6 +277,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
272277

273278
dialog = null
274279
isDragging = false
280+
isDismissing = false
275281
isPresented = false
276282
isDialogVisible = false
277283
lastEmittedPositionPx = -1
@@ -286,43 +292,51 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
286292
setupGrabber()
287293

288294
sheetContainer?.post {
289-
val detentInfo = getDetentInfoForIndex(currentDetentIndex)
290-
val detent = getDetentValueForIndex(detentInfo.index)
291295
val positionPx = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
292296

293-
delegate?.viewControllerDidPresent(detentInfo.index, detentInfo.position, detent)
294-
295297
// Store resolved position for initial detent
296-
storeResolvedPosition(detentInfo.index)
298+
storeResolvedPosition(currentDetentIndex)
297299
emitChangePositionDelegate(positionPx, realtime = false)
298300

301+
positionFooter()
302+
}
303+
304+
// Emit didPresent/didFocus after present animation completes
305+
sheetContainer?.postDelayed({
306+
val detentInfo = getDetentInfoForIndex(currentDetentIndex)
307+
val detent = getDetentValueForIndex(detentInfo.index)
308+
309+
delegate?.viewControllerDidPresent(detentInfo.index, detentInfo.position, detent)
310+
299311
// Notify parent sheet that it has lost focus (after this sheet appeared)
300312
parentSheetView?.viewControllerDidBlur()
301313

314+
// Emit didFocus with didPresent
315+
delegate?.viewControllerDidFocus()
316+
302317
presentPromise?.invoke()
303318
presentPromise = null
304-
305-
positionFooter()
306-
}
319+
}, PRESENT_ANIMATION_DURATION)
307320
}
308321

309322
dialog.setOnCancelListener {
310-
delegate?.viewControllerWillDismiss()
323+
// Skip if already handled by STATE_HIDDEN (programmatic dismiss)
324+
if (isDismissing) return@setOnCancelListener
311325

312-
// Notify parent sheet that it is about to regain focus
313-
parentSheetView?.viewControllerWillFocus()
314-
}
326+
// User-initiated dismiss (back button, tap outside)
327+
isDismissing = true
328+
emitWillDismissEvents()
315329

316-
dialog.setOnDismissListener {
317-
val hadParent = parentSheetView != null
330+
// Emit off-screen position since onSlide isn't triggered for user-initiated dismiss
331+
emitChangePositionDelegate(screenHeight, realtime = false)
318332

319-
// Notify parent sheet that it has regained focus
320-
parentSheetView?.viewControllerDidFocus()
321-
parentSheetView = null
333+
// Emit didBlur/didDismiss after dismiss animation completes
334+
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
335+
emitDidDismissEvents()
336+
}, DISMISS_ANIMATION_DURATION)
337+
}
322338

323-
dismissPromise?.invoke()
324-
dismissPromise = null
325-
delegate?.viewControllerDidDismiss(hadParent)
339+
dialog.setOnDismissListener {
326340
cleanupDialog()
327341
}
328342
}
@@ -348,7 +362,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
348362

349363
override fun onStateChanged(sheetView: View, newState: Int) {
350364
if (newState == BottomSheetBehavior.STATE_HIDDEN) {
351-
dismiss()
365+
// Mark as dismissing so setOnCancelListener skips emitting events
366+
isDismissing = true
367+
emitDidDismissEvents()
368+
dialog.dismiss()
352369
return
353370
}
354371

@@ -433,6 +450,34 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
433450
rnScreensObserver = null
434451
}
435452

453+
// ====================================================================
454+
// MARK: - Dismiss Event Helpers
455+
// ====================================================================
456+
457+
/**
458+
* Emits willBlur and willDismiss events, and notifies parent sheet of upcoming focus change.
459+
*/
460+
private fun emitWillDismissEvents() {
461+
delegate?.viewControllerWillBlur()
462+
delegate?.viewControllerWillDismiss()
463+
parentSheetView?.viewControllerWillFocus()
464+
}
465+
466+
/**
467+
* Emits didBlur and didDismiss events, notifies parent sheet, and invokes dismiss promise.
468+
*/
469+
private fun emitDidDismissEvents() {
470+
val hadParent = parentSheetView != null
471+
parentSheetView?.viewControllerDidFocus()
472+
parentSheetView = null
473+
474+
delegate?.viewControllerDidBlur()
475+
delegate?.viewControllerDidDismiss(hadParent)
476+
477+
dismissPromise?.invoke()
478+
dismissPromise = null
479+
}
480+
436481
// ====================================================================
437482
// MARK: - Dialog Visibility (for stacking)
438483
// ====================================================================
@@ -518,6 +563,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
518563

519564
delegate?.viewControllerWillPresent(detentInfo.index, detentInfo.position, detent)
520565

566+
// Emit willFocus with willPresent
567+
delegate?.viewControllerWillFocus()
568+
521569
if (!animated) {
522570
dialog.window?.setWindowAnimations(0)
523571
}
@@ -527,15 +575,20 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
527575
}
528576

529577
fun dismiss(animated: Boolean = true) {
578+
emitWillDismissEvents()
579+
530580
this.post {
531581
// Emit off-screen position (detent = 0 since sheet is fully hidden)
532582
emitChangePositionDelegate(screenHeight, realtime = false)
533583
}
534584

535-
dialog?.apply {
536-
if (!animated) window?.setWindowAnimations(0)
537-
post { dismiss() }
585+
if (!animated) {
586+
dialog?.window?.setWindowAnimations(0)
538587
}
588+
589+
// Temporarily enable hideable to allow STATE_HIDDEN transition
590+
behavior?.isHideable = true
591+
behavior?.state = BottomSheetBehavior.STATE_HIDDEN
539592
}
540593

541594
// ====================================================================

docs/docs/reference/02-events.mdx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,19 +108,31 @@ Use this event when you need smooth, continuous position tracking for animations
108108

109109
## `onWillFocus`
110110

111-
This is called when the sheet is about to regain focus after a sheet or `react-native-screens` modal presented on top of it begins dismissing.
111+
This is called when the sheet is about to gain focus. This happens:
112+
- When the sheet is being presented (along with `onWillPresent`)
113+
- When a sheet or `react-native-screens` modal presented on top of it begins dismissing
112114

113115
## `onDidFocus`
114116

115-
This is called when the sheet has regained focus after a sheet or `react-native-screens` modal presented on top of it is dismissed. Useful for refreshing data or re-enabling interactions when returning to a sheet.
117+
This is called when the sheet has gained focus. This happens:
118+
- When the sheet has been presented (along with `onDidPresent`)
119+
- When a sheet or `react-native-screens` modal presented on top of it is dismissed
120+
121+
Useful for refreshing data or re-enabling interactions.
116122

117123
## `onWillBlur`
118124

119-
This is called when the sheet is about to lose focus because another sheet or `react-native-screens` modal is about to be presented on top of it.
125+
This is called when the sheet is about to lose focus. This happens:
126+
- When the sheet is being dismissed (along with `onWillDismiss`)
127+
- When another sheet or `react-native-screens` modal is about to be presented on top of it
120128

121129
## `onDidBlur`
122130

123-
This is called when the sheet has lost focus because another sheet or `react-native-screens` modal is presented on top of it. Useful for pausing updates or disabling interactions while the sheet is not the topmost.
131+
This is called when the sheet has lost focus. This happens:
132+
- When the sheet has been dismissed (along with `onDidDismiss`)
133+
- When another sheet or `react-native-screens` modal is presented on top of it
134+
135+
Useful for pausing updates or disabling interactions.
124136

125137
## `onBackPress`
126138

example/src/screens/MapScreen.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ export const MapScreen = () => {
176176
{showExtraContent && <DemoContent text="Extra content that changes height" />}
177177
<Button text="Expand" onPress={() => sheetRef.current?.resize(2)} />
178178
<Button text="Collapse" onPress={() => sheetRef.current?.resize(0)} />
179+
<Button text="Dismiss" onPress={() => sheetRef.current?.dismiss()} />
179180
<BasicSheet ref={basicSheet} />
180181
<PromptSheet ref={promptSheet} />
181182
<ScrollViewSheet ref={scrollViewSheet} />

ios/TrueSheetViewController.mm

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -159,15 +159,20 @@ - (void)viewWillAppear:(BOOL)animated {
159159
}
160160
}
161161

162-
if ([self.delegate respondsToSelector:@selector(viewControllerWillPresentAtIndex:position:detent:)]) {
163-
dispatch_async(dispatch_get_main_queue(), ^{
162+
dispatch_async(dispatch_get_main_queue(), ^{
163+
if ([self.delegate respondsToSelector:@selector(viewControllerWillPresentAtIndex:position:detent:)]) {
164164
NSInteger index = self.currentDetentIndex;
165165
CGFloat position = self.currentPosition;
166166
CGFloat detent = [self detentValueForIndex:index];
167167

168168
[self.delegate viewControllerWillPresentAtIndex:index position:position detent:detent];
169-
});
170-
}
169+
}
170+
171+
// Emit willFocus with willPresent
172+
if ([self.delegate respondsToSelector:@selector(viewControllerWillFocus)]) {
173+
[self.delegate viewControllerWillFocus];
174+
}
175+
});
171176
}
172177

173178
[self setupTransitionTracker];
@@ -184,13 +189,19 @@ - (void)viewDidAppear:(BOOL)animated {
184189
}
185190
}
186191

187-
if ([self.delegate respondsToSelector:@selector(viewControllerDidPresentAtIndex:position:detent:)]) {
188-
dispatch_async(dispatch_get_main_queue(), ^{
192+
dispatch_async(dispatch_get_main_queue(), ^{
193+
if ([self.delegate respondsToSelector:@selector(viewControllerDidPresentAtIndex:position:detent:)]) {
189194
NSInteger index = [self currentDetentIndex];
190195
CGFloat detent = [self detentValueForIndex:index];
191196
[self.delegate viewControllerDidPresentAtIndex:index position:self.currentPosition detent:detent];
192-
});
193-
}
197+
}
198+
199+
// Emit didFocus with didPresent
200+
if ([self.delegate respondsToSelector:@selector(viewControllerDidFocus)]) {
201+
[self.delegate viewControllerDidFocus];
202+
}
203+
});
204+
194205
[self setupGestureRecognizer];
195206
_isPresented = YES;
196207
}
@@ -204,9 +215,16 @@ - (void)viewWillDisappear:(BOOL)animated {
204215
[super viewWillDisappear:animated];
205216

206217
if (self.isDismissing) {
207-
if ([self.delegate respondsToSelector:@selector(viewControllerWillDismiss)]) {
208-
[self.delegate viewControllerWillDismiss];
209-
}
218+
dispatch_async(dispatch_get_main_queue(), ^{
219+
// Emit willBlur with willDismiss
220+
if ([self.delegate respondsToSelector:@selector(viewControllerWillBlur)]) {
221+
[self.delegate viewControllerWillBlur];
222+
}
223+
224+
if ([self.delegate respondsToSelector:@selector(viewControllerWillDismiss)]) {
225+
[self.delegate viewControllerWillDismiss];
226+
}
227+
});
210228

211229
// Notify the parent sheet (if any) that it is about to regain focus
212230
if (_parentSheetController) {
@@ -235,6 +253,11 @@ - (void)viewDidDisappear:(BOOL)animated {
235253
_parentSheetController = nil;
236254
}
237255

256+
// Emit didBlur with didDismiss
257+
if ([self.delegate respondsToSelector:@selector(viewControllerDidBlur)]) {
258+
[self.delegate viewControllerDidBlur];
259+
}
260+
238261
if ([self.delegate respondsToSelector:@selector(viewControllerDidDismiss)]) {
239262
[self.delegate viewControllerDidDismiss];
240263
}

src/__mocks__/index.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ export class TrueSheet extends React.Component {
66
static instances = {};
77

88
// Static methods
9-
static dismiss = jest.fn((name) => Promise.resolve());
10-
static present = jest.fn((name, index = 0) => Promise.resolve());
9+
static dismiss = jest.fn((name, animated = true) => Promise.resolve());
10+
static present = jest.fn((name, index = 0, animated = true) => Promise.resolve());
1111
static resize = jest.fn((name, index) => Promise.resolve());
1212

1313
// Instance methods
14-
dismiss = jest.fn(() => Promise.resolve());
15-
present = jest.fn((index = 0) => Promise.resolve());
14+
dismiss = jest.fn((animated = true) => Promise.resolve());
15+
present = jest.fn((index = 0, animated = true) => Promise.resolve());
1616
resize = jest.fn((index) => Promise.resolve());
1717

1818
componentDidMount() {
@@ -30,9 +30,10 @@ export class TrueSheet extends React.Component {
3030
}
3131

3232
render() {
33-
const { children, footer, style, ...rest } = this.props;
33+
const { children, header, footer, style, ...rest } = this.props;
3434
return (
3535
<View style={style} {...rest}>
36+
{header}
3637
{children}
3738
{footer}
3839
</View>

0 commit comments

Comments
 (0)