@lodev09/react-native-true-sheet 3.9.9 → 3.10.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +1 -0
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +1 -2
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +41 -14
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetModule.kt +10 -0
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +10 -12
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +49 -43
  7. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +11 -2
  8. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetBehavior.kt +36 -0
  9. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt +14 -3
  10. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +3 -3
  11. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDimView.kt +3 -1
  12. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetGrabberView.kt +51 -0
  13. package/android/src/main/java/com/lodev09/truesheet/events/TrueSheetLifecycleEvents.kt +8 -5
  14. package/android/src/main/java/com/lodev09/truesheet/utils/ViewUtils.kt +17 -0
  15. package/ios/TrueSheetContainerView.h +8 -1
  16. package/ios/TrueSheetContainerView.mm +14 -7
  17. package/ios/TrueSheetContentView.mm +10 -7
  18. package/ios/TrueSheetModule.mm +5 -0
  19. package/ios/TrueSheetView.mm +40 -40
  20. package/ios/TrueSheetViewController.h +3 -1
  21. package/ios/TrueSheetViewController.mm +67 -18
  22. package/ios/core/TrueSheetGrabberView.h +20 -0
  23. package/ios/core/TrueSheetGrabberView.mm +58 -12
  24. package/lib/module/TrueSheet.js +27 -7
  25. package/lib/module/TrueSheet.js.map +1 -1
  26. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +2 -1
  27. package/lib/module/specs/NativeTrueSheetModule.js.map +1 -1
  28. package/lib/typescript/src/TrueSheet.d.ts +5 -1
  29. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  30. package/lib/typescript/src/TrueSheet.types.d.ts +9 -3
  31. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  32. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +4 -1
  33. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  34. package/lib/typescript/src/specs/NativeTrueSheetModule.d.ts +6 -0
  35. package/lib/typescript/src/specs/NativeTrueSheetModule.d.ts.map +1 -1
  36. package/package.json +2 -2
  37. package/src/TrueSheet.tsx +40 -8
  38. package/src/TrueSheet.types.ts +10 -3
  39. package/src/fabric/TrueSheetViewNativeComponent.ts +2 -1
  40. package/src/specs/NativeTrueSheetModule.ts +7 -0
@@ -5,8 +5,10 @@ import android.content.Context
5
5
  import android.content.res.Configuration
6
6
  import android.graphics.Color
7
7
  import android.graphics.drawable.GradientDrawable
8
+ import android.os.Bundle
8
9
  import android.view.Gravity
9
10
  import android.view.View
11
+ import android.view.accessibility.AccessibilityNodeInfo
10
12
  import android.widget.FrameLayout
11
13
  import androidx.core.graphics.ColorUtils
12
14
  import com.facebook.react.uimanager.PixelUtil.dpToPx
@@ -58,6 +60,9 @@ class TrueSheetGrabberView(context: Context, private val options: GrabberOptions
58
60
  private val grabberColor: Int
59
61
  get() = if (isAdaptive) getAdaptiveColor(options?.color) else options?.color ?: DEFAULT_COLOR
60
62
 
63
+ var onAccessibilityIncrement: (() -> Unit)? = null
64
+ var onAccessibilityDecrement: (() -> Unit)? = null
65
+
61
66
  init {
62
67
  val hitboxWidth = grabberWidth + (HITBOX_PADDING_HORIZONTAL * 2)
63
68
  val hitboxHeight = grabberHeight + (HITBOX_PADDING_VERTICAL * 2)
@@ -86,6 +91,52 @@ class TrueSheetGrabberView(context: Context, private val options: GrabberOptions
86
91
  }
87
92
 
88
93
  addView(pillView)
94
+
95
+ isFocusable = true
96
+ contentDescription = "Sheet Grabber"
97
+
98
+ accessibilityDelegate = object : View.AccessibilityDelegate() {
99
+ override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo) {
100
+ super.onInitializeAccessibilityNodeInfo(host, info)
101
+ info.addAction(
102
+ AccessibilityNodeInfo.AccessibilityAction(
103
+ AccessibilityNodeInfo.ACTION_SCROLL_FORWARD,
104
+ "Expand"
105
+ )
106
+ )
107
+ info.addAction(
108
+ AccessibilityNodeInfo.AccessibilityAction(
109
+ AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD,
110
+ "Collapse"
111
+ )
112
+ )
113
+ info.className = "android.widget.SeekBar"
114
+ }
115
+
116
+ override fun performAccessibilityAction(host: View, action: Int, args: Bundle?): Boolean =
117
+ when (action) {
118
+ AccessibilityNodeInfo.ACTION_SCROLL_FORWARD -> {
119
+ onAccessibilityIncrement?.invoke()
120
+ true
121
+ }
122
+
123
+ AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD -> {
124
+ onAccessibilityDecrement?.invoke()
125
+ true
126
+ }
127
+
128
+ else -> super.performAccessibilityAction(host, action, args)
129
+ }
130
+ }
131
+ }
132
+
133
+ fun updateAccessibilityValue(index: Int, detentCount: Int) {
134
+ stateDescription = when {
135
+ index < 0 || detentCount <= 0 -> null
136
+ index >= detentCount - 1 -> "Expanded"
137
+ index == 0 -> "Collapsed"
138
+ else -> "Detent ${index + 1} of $detentCount"
139
+ }
89
140
  }
90
141
 
91
142
  private fun getAdaptiveColor(baseColor: Int? = null): Int {
@@ -94,16 +94,19 @@ class DidDismissEvent(surfaceId: Int, viewId: Int) : Event<DidDismissEvent>(surf
94
94
  }
95
95
 
96
96
  /**
97
- * Fired when the hardware back button is pressed (Android only)
97
+ * Fired when the sheet visibility changes due to screen transitions
98
98
  */
99
- class BackPressEvent(surfaceId: Int, viewId: Int) : Event<BackPressEvent>(surfaceId, viewId) {
99
+ class VisibilityChangeEvent(surfaceId: Int, viewId: Int, private val visible: Boolean) : Event<VisibilityChangeEvent>(surfaceId, viewId) {
100
100
 
101
101
  override fun getEventName(): String = EVENT_NAME
102
102
 
103
- override fun getEventData(): WritableMap = Arguments.createMap()
103
+ override fun getEventData(): WritableMap =
104
+ Arguments.createMap().apply {
105
+ putBoolean("visible", visible)
106
+ }
104
107
 
105
108
  companion object {
106
- const val EVENT_NAME = "topBackPress"
107
- const val REGISTRATION_NAME = "onBackPress"
109
+ const val EVENT_NAME = "topVisibilityChange"
110
+ const val REGISTRATION_NAME = "onVisibilityChange"
108
111
  }
109
112
  }
@@ -1,6 +1,9 @@
1
1
  package com.lodev09.truesheet.utils
2
2
 
3
3
  import android.view.View
4
+ import android.view.ViewGroup
5
+ import android.widget.ScrollView
6
+ import androidx.core.widget.NestedScrollView
4
7
 
5
8
  fun View.isDescendantOf(ancestor: View): Boolean {
6
9
  if (!isAttachedToWindow) return false
@@ -11,3 +14,17 @@ fun View.isDescendantOf(ancestor: View): Boolean {
11
14
  }
12
15
  return false
13
16
  }
17
+
18
+ fun ViewGroup.smoothScrollBy(dx: Int, dy: Int) {
19
+ when (this) {
20
+ is ScrollView -> smoothScrollBy(dx, dy)
21
+ is NestedScrollView -> smoothScrollBy(dx, dy)
22
+ }
23
+ }
24
+
25
+ fun ViewGroup.smoothScrollTo(x: Int, y: Int) {
26
+ when (this) {
27
+ is ScrollView -> smoothScrollTo(x, y)
28
+ is NestedScrollView -> smoothScrollTo(x, y)
29
+ }
30
+ }
@@ -13,6 +13,13 @@
13
13
 
14
14
  NS_ASSUME_NONNULL_BEGIN
15
15
 
16
+ @interface ScrollableOptions : NSObject
17
+
18
+ @property (nonatomic, assign) CGFloat keyboardScrollOffset;
19
+ @property (nonatomic, assign) BOOL scrollingExpandsSheet;
20
+
21
+ @end
22
+
16
23
  @protocol TrueSheetContainerViewDelegate <NSObject>
17
24
 
18
25
  - (void)containerViewContentDidChangeSize:(CGSize)newSize;
@@ -44,7 +51,7 @@ NS_ASSUME_NONNULL_BEGIN
44
51
  /**
45
52
  * Options for scrollable behavior
46
53
  */
47
- @property (nonatomic, strong, nullable) NSDictionary *scrollableOptions;
54
+ @property (nonatomic, strong, nullable) ScrollableOptions *scrollableOptions;
48
55
 
49
56
  /**
50
57
  * Returns the current content height
@@ -27,6 +27,18 @@
27
27
 
28
28
  using namespace facebook::react;
29
29
 
30
+ @implementation ScrollableOptions
31
+
32
+ - (instancetype)init {
33
+ if (self = [super init]) {
34
+ _keyboardScrollOffset = 0;
35
+ _scrollingExpandsSheet = YES;
36
+ }
37
+ return self;
38
+ }
39
+
40
+ @end
41
+
30
42
  @interface TrueSheetContainerView () <TrueSheetContentViewDelegate, TrueSheetHeaderViewDelegate>
31
43
  @end
32
44
 
@@ -87,14 +99,9 @@ using namespace facebook::react;
87
99
  _scrollableSet = YES;
88
100
  }
89
101
 
90
- - (void)setScrollableOptions:(NSDictionary *)scrollableOptions {
102
+ - (void)setScrollableOptions:(ScrollableOptions *)scrollableOptions {
91
103
  _scrollableOptions = scrollableOptions;
92
- if (scrollableOptions) {
93
- NSNumber *offset = scrollableOptions[@"keyboardScrollOffset"];
94
- _contentView.keyboardScrollOffset = offset ? [offset floatValue] : 0;
95
- } else {
96
- _contentView.keyboardScrollOffset = 0;
97
- }
104
+ _contentView.keyboardScrollOffset = scrollableOptions ? scrollableOptions.keyboardScrollOffset : 0;
98
105
  }
99
106
 
100
107
  - (void)setupScrollable {
@@ -216,15 +216,18 @@ using namespace facebook::react;
216
216
  animations:^{
217
217
  [self setScrollViewContentInset:height
218
218
  indicatorInset:self->_originalIndicatorBottomInset + height];
219
-
220
- if (firstResponder) {
221
- CGRect responderFrame = [firstResponder convertRect:firstResponder.bounds
222
- toView:self->_pinnedScrollView.scrollView];
223
- responderFrame.size.height += self.keyboardScrollOffset;
224
- [self->_pinnedScrollView.scrollView scrollRectToVisible:responderFrame animated:NO];
225
- }
226
219
  }
227
220
  completion:nil];
221
+
222
+ // Defer scroll until the next run loop so content insets are applied first
223
+ if (firstResponder) {
224
+ dispatch_async(dispatch_get_main_queue(), ^{
225
+ CGRect responderFrame = [firstResponder convertRect:firstResponder.bounds
226
+ toView:self->_pinnedScrollView.scrollView];
227
+ responderFrame.size.height += self.keyboardScrollOffset;
228
+ [self->_pinnedScrollView.scrollView scrollRectToVisible:responderFrame animated:YES];
229
+ });
230
+ }
228
231
  }
229
232
 
230
233
  - (void)keyboardWillHide:(NSTimeInterval)duration curve:(UIViewAnimationOptions)curve {
@@ -140,6 +140,11 @@ RCT_EXPORT_MODULE(TrueSheetModule)
140
140
  });
141
141
  }
142
142
 
143
+ - (void)handleBackPress:(double)viewTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
144
+ // No-op on iOS — no hardware back button
145
+ resolve(nil);
146
+ }
147
+
143
148
  - (void)dismissAll:(BOOL)animated resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
144
149
  RCTExecuteOnMainQueue(^{
145
150
  @synchronized(viewRegistry) {
@@ -53,7 +53,7 @@ using namespace facebook::react;
53
53
  NSInteger _initialDetentIndex;
54
54
  NSInteger _insetAdjustment;
55
55
  BOOL _scrollable;
56
- NSDictionary *_scrollableOptions;
56
+ ScrollableOptions *_scrollableOptions;
57
57
  BOOL _initialDetentAnimated;
58
58
  BOOL _isSheetUpdatePending;
59
59
  BOOL _pendingLayoutUpdate;
@@ -213,25 +213,18 @@ using namespace facebook::react;
213
213
  grabberOpts.cornerRadius >= 0 || grabberColor != nil || !grabberOpts.adaptive;
214
214
 
215
215
  if (hasGrabberOptions) {
216
- NSMutableDictionary *options = [NSMutableDictionary dictionary];
217
-
218
- if (grabberOpts.width > 0) {
219
- options[@"width"] = @(grabberOpts.width);
220
- }
221
- if (grabberOpts.height > 0) {
222
- options[@"height"] = @(grabberOpts.height);
223
- }
224
- if (grabberOpts.topMargin > 0) {
225
- options[@"topMargin"] = @(grabberOpts.topMargin);
226
- }
227
- if (grabberOpts.cornerRadius >= 0) {
228
- options[@"cornerRadius"] = @(grabberOpts.cornerRadius);
229
- }
230
- if (grabberColor) {
231
- options[@"color"] = grabberColor;
232
- }
233
- options[@"adaptive"] = @(grabberOpts.adaptive);
234
-
216
+ GrabberOptions *options = [[GrabberOptions alloc] init];
217
+ if (grabberOpts.width > 0)
218
+ options.width = @(grabberOpts.width);
219
+ if (grabberOpts.height > 0)
220
+ options.height = @(grabberOpts.height);
221
+ if (grabberOpts.topMargin > 0)
222
+ options.topMargin = @(grabberOpts.topMargin);
223
+ if (grabberOpts.cornerRadius >= 0)
224
+ options.cornerRadius = @(grabberOpts.cornerRadius);
225
+ if (grabberColor)
226
+ options.color = grabberColor;
227
+ options.adaptive = grabberOpts.adaptive;
235
228
  _controller.grabberOptions = options;
236
229
  } else {
237
230
  _controller.grabberOptions = nil;
@@ -251,26 +244,24 @@ using namespace facebook::react;
251
244
  _scrollable = newProps.scrollable;
252
245
 
253
246
  const auto &scrollableOpts = newProps.scrollableOptions;
254
- BOOL hasScrollableOptions = scrollableOpts.keyboardScrollOffset > 0;
247
+ BOOL scrollingExpandsSheet = scrollableOpts.scrollingExpandsSheet;
248
+ BOOL hasScrollableOptions = scrollableOpts.keyboardScrollOffset > 0 || !scrollingExpandsSheet;
255
249
 
256
250
  if (hasScrollableOptions) {
257
- NSMutableDictionary *options = [NSMutableDictionary dictionary];
258
- if (scrollableOpts.keyboardScrollOffset > 0) {
259
- options[@"keyboardScrollOffset"] = @(scrollableOpts.keyboardScrollOffset);
260
- }
251
+ ScrollableOptions *options = [[ScrollableOptions alloc] init];
252
+ options.keyboardScrollOffset = scrollableOpts.keyboardScrollOffset;
253
+ options.scrollingExpandsSheet = scrollingExpandsSheet;
261
254
  _scrollableOptions = options;
262
255
  } else {
263
256
  _scrollableOptions = nil;
264
257
  }
265
258
 
259
+ _controller.scrollingExpandsSheet = scrollingExpandsSheet;
260
+
266
261
  _insetAdjustment = (NSInteger)newProps.insetAdjustment;
267
262
  _controller.insetAdjustment = _insetAdjustment;
268
263
 
269
- if (_containerView) {
270
- _containerView.scrollableEnabled = _scrollable;
271
- _containerView.insetAdjustment = _insetAdjustment;
272
- _containerView.scrollableOptions = _scrollableOptions;
273
- }
264
+ [self setupScrollable];
274
265
  }
275
266
 
276
267
  - (void)updateState:(const State::Shared &)state oldState:(const State::Shared &)oldState {
@@ -319,9 +310,7 @@ using namespace facebook::react;
319
310
  if (!(updateMask & RNComponentViewUpdateMaskProps) || !_controller)
320
311
  return;
321
312
 
322
- if (_containerView) {
323
- [_containerView setupScrollable];
324
- }
313
+ [self setupScrollable];
325
314
 
326
315
  if (_controller.isPresented) {
327
316
  [self applySheetPropsUpdate];
@@ -389,11 +378,6 @@ using namespace facebook::react;
389
378
  _controller.headerHeight = @(headerHeight);
390
379
  }
391
380
 
392
- _containerView.scrollableEnabled = _scrollable;
393
- _containerView.insetAdjustment = _insetAdjustment;
394
- _containerView.scrollableOptions = _scrollableOptions;
395
- [_containerView setupScrollable];
396
-
397
381
  if (_eventEmitter) {
398
382
  [TrueSheetLifecycleEvents emitMount:_eventEmitter];
399
383
  } else {
@@ -454,6 +438,8 @@ using namespace facebook::react;
454
438
  [_controller setupSheetDetents];
455
439
  [_controller setupActiveDetentWithIndex:index];
456
440
 
441
+ [self setupScrollable];
442
+
457
443
  [_screensEventObserver capturePresenterScreenFromView:self];
458
444
  [_screensEventObserver startObservingWithState:_state.get()->getData()];
459
445
 
@@ -489,7 +475,11 @@ using namespace facebook::react;
489
475
  }
490
476
 
491
477
  - (void)emitDismissedPosition {
492
- [self viewControllerDidChangePosition:-1 position:_controller.screenHeight detent:0 realtime:NO];
478
+ [TrueSheetStateEvents emitPositionChange:_eventEmitter
479
+ index:-1
480
+ position:_controller.screenHeight
481
+ detent:0
482
+ realtime:NO];
493
483
  }
494
484
 
495
485
  - (void)dismissAnimated:(BOOL)animated completion:(nullable TrueSheetCompletionBlock)completion {
@@ -576,7 +566,7 @@ using namespace facebook::react;
576
566
 
577
567
  // When the ScrollView changes (e.g. conditional remount), re-pin the new ScrollView.
578
568
  - (void)containerViewScrollViewDidChange {
579
- [_containerView setupScrollable];
569
+ [self setupScrollable];
580
570
  }
581
571
 
582
572
  #pragma mark - TrueSheetViewControllerDelegate
@@ -698,6 +688,16 @@ using namespace facebook::react;
698
688
 
699
689
  #pragma mark - Private Helpers
700
690
 
691
+ - (void)setupScrollable {
692
+ if (!_containerView)
693
+ return;
694
+
695
+ _containerView.scrollableEnabled = _scrollable;
696
+ _containerView.insetAdjustment = _insetAdjustment;
697
+ _containerView.scrollableOptions = _scrollableOptions;
698
+ [_containerView setupScrollable];
699
+ }
700
+
701
701
  - (void)applySheetPropsUpdate {
702
702
  BOOL pendingLayoutUpdate = _pendingLayoutUpdate;
703
703
  _pendingLayoutUpdate = NO;
@@ -8,6 +8,7 @@
8
8
 
9
9
  #import <UIKit/UIKit.h>
10
10
  #import "core/TrueSheetDetentCalculator.h"
11
+ #import "core/TrueSheetGrabberView.h"
11
12
 
12
13
  #if __has_include(<RNScreens/RNSDismissibleModalProtocol.h>)
13
14
  #import <RNScreens/RNSDismissibleModalProtocol.h>
@@ -58,7 +59,7 @@ NS_ASSUME_NONNULL_BEGIN
58
59
  @property (nonatomic, strong, nullable) UIColor *backgroundColor;
59
60
  @property (nonatomic, strong, nullable) NSNumber *cornerRadius;
60
61
  @property (nonatomic, assign) BOOL grabber;
61
- @property (nonatomic, strong, nullable) NSDictionary *grabberOptions;
62
+ @property (nonatomic, strong, nullable) GrabberOptions *grabberOptions;
62
63
  @property (nonatomic, assign) BOOL draggable;
63
64
  @property (nonatomic, assign) BOOL dimmed;
64
65
  @property (nonatomic, strong, nullable) NSNumber *dimmedDetentIndex;
@@ -68,6 +69,7 @@ NS_ASSUME_NONNULL_BEGIN
68
69
  @property (nonatomic, assign) BOOL pageSizing;
69
70
  @property (nonatomic, assign) NSInteger anchor;
70
71
  @property (nonatomic, assign) NSInteger insetAdjustment;
72
+ @property (nonatomic, assign) BOOL scrollingExpandsSheet;
71
73
  @property (nonatomic, assign) BOOL dismissible;
72
74
  @property (nonatomic, assign) BOOL isPresented;
73
75
  @property (nonatomic, assign) NSInteger activeDetentIndex;
@@ -22,12 +22,22 @@
22
22
 
23
23
  using namespace facebook::react;
24
24
 
25
+ typedef struct {
26
+ CGFloat position;
27
+ CGFloat detent;
28
+ CGFloat index;
29
+ } TrueSheetPositionState;
30
+
31
+ static BOOL TrueSheetPositionStateEquals(TrueSheetPositionState a, TrueSheetPositionState b) {
32
+ return fabs(a.position - b.position) <= 0.01 && fabs(a.detent - b.detent) <= 0.01 && fabs(a.index - b.index) <= 0.01;
33
+ }
34
+
25
35
  @interface TrueSheetViewController ()
26
36
 
27
37
  @end
28
38
 
29
39
  @implementation TrueSheetViewController {
30
- CGFloat _lastPosition;
40
+ TrueSheetPositionState _lastEmittedPositionState;
31
41
  CGFloat _lastWidth;
32
42
  NSInteger _pendingDetentIndex;
33
43
  BOOL _pendingContentSizeChange;
@@ -59,11 +69,12 @@ using namespace facebook::react;
59
69
  _headerHeight = @(0);
60
70
  _grabber = YES;
61
71
  _draggable = YES;
72
+ _scrollingExpandsSheet = YES;
62
73
  _dismissible = YES;
63
74
  _dimmed = YES;
64
75
  _dimmedDetentIndex = @(0);
65
76
  _pageSizing = YES;
66
- _lastPosition = 0;
77
+ _lastEmittedPositionState = (TrueSheetPositionState){0, 0, 0};
67
78
  _isDragging = NO;
68
79
  _isPresented = NO;
69
80
  _isTransitioning = NO;
@@ -223,6 +234,7 @@ using namespace facebook::react;
223
234
  [self.delegate viewControllerDidPresentAtIndex:index position:self.currentPosition detent:detent];
224
235
  [self.delegate viewControllerDidFocus];
225
236
 
237
+ [_grabberView updateAccessibilityValueWithIndex:index detentCount:_detents.count];
226
238
  [self emitChangePositionDelegateWithPosition:self.currentPosition realtime:NO debug:@"did present"];
227
239
  });
228
240
 
@@ -315,6 +327,7 @@ using namespace facebook::react;
315
327
  [self learnOffsetForDetentIndex:pendingIndex];
316
328
  CGFloat detent = [self detentValueForIndex:pendingIndex];
317
329
  [self.delegate viewControllerDidChangeDetent:pendingIndex position:self.currentPosition detent:detent];
330
+ [self->_grabberView updateAccessibilityValueWithIndex:pendingIndex detentCount:self->_detents.count];
318
331
  [self emitChangePositionDelegateWithPosition:self.currentPosition realtime:NO debug:@"pending detent change"];
319
332
  });
320
333
  }
@@ -389,7 +402,9 @@ using namespace facebook::react;
389
402
  case UIGestureRecognizerStateCancelled: {
390
403
  if (!_isTransitioning) {
391
404
  dispatch_async(dispatch_get_main_queue(), ^{
392
- [self learnOffsetForDetentIndex:self.currentDetentIndex];
405
+ NSInteger index = self.currentDetentIndex;
406
+ [self learnOffsetForDetentIndex:index];
407
+ [self->_grabberView updateAccessibilityValueWithIndex:index detentCount:self->_detents.count];
393
408
  [self emitChangePositionDelegateWithPosition:self.currentPosition realtime:NO debug:@"drag end"];
394
409
  });
395
410
  }
@@ -458,7 +473,7 @@ using namespace facebook::react;
458
473
  CGFloat layerPosition = layer.presentationLayer.frame.origin.y;
459
474
 
460
475
  if (self.currentPosition >= self.screenHeight) {
461
- CGFloat position = fmax(_lastPosition, layerPosition);
476
+ CGFloat position = fmax(_lastEmittedPositionState.position, layerPosition);
462
477
 
463
478
  [self emitWillDismissEvents];
464
479
  [self emitChangePositionDelegateWithPosition:position realtime:YES debug:@"transition out"];
@@ -466,7 +481,8 @@ using namespace facebook::react;
466
481
  } else {
467
482
  CGFloat position = fmax(self.currentPosition, layerPosition);
468
483
  // Detect drag → snap transition jump; stay non-realtime for the rest of the animation
469
- if (!_isTransitionSnapping && _isPresented && _lastPosition > 0 && fabs(_lastPosition - position) > 20) {
484
+ if (!_isTransitionSnapping && _isPresented && _lastEmittedPositionState.position > 0 &&
485
+ fabs(_lastEmittedPositionState.position - position) > 20) {
470
486
  _isTransitionSnapping = YES;
471
487
  }
472
488
  BOOL realtime = !_isTransitionSnapping;
@@ -485,13 +501,19 @@ using namespace facebook::react;
485
501
  }
486
502
  }
487
503
 
488
- if (fabs(_lastPosition - position) > 0.01) {
489
- _lastPosition = position;
504
+ TrueSheetPositionState state = {
505
+ .position = position,
506
+ .detent = [self interpolatedDetentForPosition:position],
507
+ .index = [self interpolatedIndexForPosition:position],
508
+ };
490
509
 
491
- CGFloat index = [self interpolatedIndexForPosition:position];
492
- CGFloat detent = [self interpolatedDetentForPosition:position];
510
+ if (!TrueSheetPositionStateEquals(_lastEmittedPositionState, state)) {
511
+ _lastEmittedPositionState = state;
493
512
 
494
- [self.delegate viewControllerDidChangePosition:index position:position detent:detent realtime:realtime];
513
+ [self.delegate viewControllerDidChangePosition:state.index
514
+ position:state.position
515
+ detent:state.detent
516
+ realtime:realtime];
495
517
  }
496
518
  }
497
519
 
@@ -733,13 +755,13 @@ using namespace facebook::react;
733
755
  if (self.grabberOptions) {
734
756
  self.sheet.prefersGrabberVisible = NO;
735
757
 
736
- NSDictionary *options = self.grabberOptions;
737
- _grabberView.grabberWidth = options[@"width"];
738
- _grabberView.grabberHeight = options[@"height"];
739
- _grabberView.topMargin = options[@"topMargin"];
740
- _grabberView.cornerRadius = options[@"cornerRadius"];
741
- _grabberView.color = options[@"color"];
742
- _grabberView.adaptive = options[@"adaptive"];
758
+ GrabberOptions *options = self.grabberOptions;
759
+ _grabberView.grabberWidth = options.width;
760
+ _grabberView.grabberHeight = options.height;
761
+ _grabberView.topMargin = options.topMargin;
762
+ _grabberView.cornerRadius = options.cornerRadius;
763
+ _grabberView.color = options.color;
764
+ _grabberView.adaptive = @(options.adaptive);
743
765
  [_grabberView applyConfiguration];
744
766
  _grabberView.hidden = !showGrabber;
745
767
 
@@ -747,12 +769,39 @@ using namespace facebook::react;
747
769
  _grabberView.onTap = ^{
748
770
  [weakSelf handleGrabberTap];
749
771
  };
772
+ _grabberView.onIncrement = ^{
773
+ __strong __typeof(weakSelf) strongSelf = weakSelf;
774
+ if (!strongSelf)
775
+ return;
776
+ NSInteger current = strongSelf.currentDetentIndex;
777
+ NSInteger count = strongSelf->_detents.count;
778
+ if (current >= 0 && current < count - 1) {
779
+ [strongSelf.sheet animateChanges:^{
780
+ [strongSelf resizeToDetentIndex:current + 1];
781
+ }];
782
+ }
783
+ };
784
+ _grabberView.onDecrement = ^{
785
+ __strong __typeof(weakSelf) strongSelf = weakSelf;
786
+ if (!strongSelf)
787
+ return;
788
+ NSInteger current = strongSelf.currentDetentIndex;
789
+ if (current > 0) {
790
+ [strongSelf.sheet animateChanges:^{
791
+ [strongSelf resizeToDetentIndex:current - 1];
792
+ }];
793
+ } else if (strongSelf.dismissible) {
794
+ [strongSelf.presentingViewController dismissViewControllerAnimated:YES completion:nil];
795
+ }
796
+ };
750
797
 
751
798
  [self.view bringSubviewToFront:_grabberView];
752
799
  } else {
753
800
  self.sheet.prefersGrabberVisible = showGrabber;
754
801
  _grabberView.hidden = YES;
755
802
  _grabberView.onTap = nil;
803
+ _grabberView.onIncrement = nil;
804
+ _grabberView.onDecrement = nil;
756
805
  }
757
806
  }
758
807
 
@@ -835,7 +884,7 @@ using namespace facebook::react;
835
884
 
836
885
  sheet.delegate = self;
837
886
  sheet.prefersEdgeAttachedInCompactHeight = YES;
838
- sheet.prefersScrollingExpandsWhenScrolledToEdge = self.draggable;
887
+ sheet.prefersScrollingExpandsWhenScrolledToEdge = self.draggable && self.scrollingExpandsSheet;
839
888
 
840
889
  if (self.cornerRadius) {
841
890
  sheet.preferredCornerRadius = [self.cornerRadius floatValue];
@@ -10,6 +10,17 @@
10
10
 
11
11
  NS_ASSUME_NONNULL_BEGIN
12
12
 
13
+ @interface GrabberOptions : NSObject
14
+
15
+ @property (nonatomic, strong, nullable) NSNumber *width;
16
+ @property (nonatomic, strong, nullable) NSNumber *height;
17
+ @property (nonatomic, strong, nullable) NSNumber *topMargin;
18
+ @property (nonatomic, strong, nullable) NSNumber *cornerRadius;
19
+ @property (nonatomic, strong, nullable) UIColor *color;
20
+ @property (nonatomic, assign) BOOL adaptive;
21
+
22
+ @end
23
+
13
24
  /**
14
25
  * Native grabber (drag handle) view for the bottom sheet.
15
26
  * Uses UIVibrancyEffect to adapt color based on the background.
@@ -37,12 +48,21 @@ NS_ASSUME_NONNULL_BEGIN
37
48
  /// Called when the grabber is tapped
38
49
  @property (nonatomic, copy, nullable) void (^onTap)(void);
39
50
 
51
+ /// Called when VoiceOver user swipes up (expand)
52
+ @property (nonatomic, copy, nullable) void (^onIncrement)(void);
53
+
54
+ /// Called when VoiceOver user swipes down (collapse)
55
+ @property (nonatomic, copy, nullable) void (^onDecrement)(void);
56
+
40
57
  /// Adds the grabber view to a parent view with proper constraints
41
58
  - (void)addToView:(UIView *)parentView;
42
59
 
43
60
  /// Applies the current configuration to the grabber view
44
61
  - (void)applyConfiguration;
45
62
 
63
+ /// Updates the accessibility value based on the current detent position
64
+ - (void)updateAccessibilityValueWithIndex:(NSInteger)index detentCount:(NSInteger)count;
65
+
46
66
  @end
47
67
 
48
68
  NS_ASSUME_NONNULL_END