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

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 (38) hide show
  1. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +1 -2
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +41 -14
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetModule.kt +10 -0
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +8 -4
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +49 -43
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +11 -2
  7. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetBehavior.kt +36 -0
  8. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt +14 -3
  9. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +3 -3
  10. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDimView.kt +3 -1
  11. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetGrabberView.kt +51 -0
  12. package/android/src/main/java/com/lodev09/truesheet/events/TrueSheetLifecycleEvents.kt +8 -5
  13. package/android/src/main/java/com/lodev09/truesheet/utils/ViewUtils.kt +17 -0
  14. package/ios/TrueSheetContainerView.h +8 -1
  15. package/ios/TrueSheetContainerView.mm +14 -7
  16. package/ios/TrueSheetModule.mm +5 -0
  17. package/ios/TrueSheetView.mm +25 -26
  18. package/ios/TrueSheetViewController.h +3 -1
  19. package/ios/TrueSheetViewController.mm +67 -18
  20. package/ios/core/TrueSheetGrabberView.h +20 -0
  21. package/ios/core/TrueSheetGrabberView.mm +58 -12
  22. package/lib/module/TrueSheet.js +27 -7
  23. package/lib/module/TrueSheet.js.map +1 -1
  24. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +2 -1
  25. package/lib/module/specs/NativeTrueSheetModule.js.map +1 -1
  26. package/lib/typescript/src/TrueSheet.d.ts +5 -1
  27. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  28. package/lib/typescript/src/TrueSheet.types.d.ts +9 -3
  29. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  30. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +4 -1
  31. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  32. package/lib/typescript/src/specs/NativeTrueSheetModule.d.ts +6 -0
  33. package/lib/typescript/src/specs/NativeTrueSheetModule.d.ts.map +1 -1
  34. package/package.json +2 -2
  35. package/src/TrueSheet.tsx +40 -8
  36. package/src/TrueSheet.types.ts +10 -3
  37. package/src/fabric/TrueSheetViewNativeComponent.ts +2 -1
  38. 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 {
@@ -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,18 +244,20 @@ 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
 
@@ -489,7 +484,11 @@ using namespace facebook::react;
489
484
  }
490
485
 
491
486
  - (void)emitDismissedPosition {
492
- [self viewControllerDidChangePosition:-1 position:_controller.screenHeight detent:0 realtime:NO];
487
+ [TrueSheetStateEvents emitPositionChange:_eventEmitter
488
+ index:-1
489
+ position:_controller.screenHeight
490
+ detent:0
491
+ realtime:NO];
493
492
  }
494
493
 
495
494
  - (void)dismissAnimated:(BOOL)animated completion:(nullable TrueSheetCompletionBlock)completion {
@@ -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
@@ -8,9 +8,22 @@
8
8
 
9
9
  #import "TrueSheetGrabberView.h"
10
10
 
11
+ @implementation GrabberOptions
12
+
13
+ - (instancetype)init {
14
+ if (self = [super init]) {
15
+ _adaptive = YES;
16
+ }
17
+ return self;
18
+ }
19
+
20
+ @end
21
+
11
22
  static const CGFloat kDefaultGrabberWidth = 36.0;
12
23
  static const CGFloat kDefaultGrabberHeight = 5.0;
13
24
  static const CGFloat kDefaultGrabberTopMargin = 5.0;
25
+ static const CGFloat kHitPaddingHorizontal = 20.0;
26
+ static const CGFloat kHitPaddingVertical = 10.0;
14
27
 
15
28
  @implementation TrueSheetGrabberView {
16
29
  UIVisualEffectView *_vibrancyView;
@@ -51,17 +64,19 @@ static const CGFloat kDefaultGrabberTopMargin = 5.0;
51
64
  #pragma mark - Setup
52
65
 
53
66
  - (void)setupView {
54
- self.clipsToBounds = YES;
67
+ self.clipsToBounds = NO;
68
+ self.isAccessibilityElement = YES;
69
+ self.accessibilityLabel = @"Sheet Grabber";
70
+ self.accessibilityTraits = UIAccessibilityTraitAdjustable | UIAccessibilityTraitButton;
71
+ self.accessibilityHint = @"Double-tap to expand. Swipe up or down to resize the sheet";
55
72
 
56
73
  UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTap)];
57
74
  [self addGestureRecognizer:tap];
58
75
 
59
76
  _vibrancyView = [[UIVisualEffectView alloc] initWithEffect:nil];
60
- _vibrancyView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
61
77
  [self addSubview:_vibrancyView];
62
78
 
63
79
  _fillView = [[UIView alloc] init];
64
- _fillView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
65
80
  _fillView.backgroundColor = [UIColor.darkGrayColor colorWithAlphaComponent:0.7];
66
81
  [_vibrancyView.contentView addSubview:_fillView];
67
82
  }
@@ -74,8 +89,35 @@ static const CGFloat kDefaultGrabberTopMargin = 5.0;
74
89
  }
75
90
  }
76
91
 
92
+ - (void)accessibilityIncrement {
93
+ if (_onIncrement) {
94
+ _onIncrement();
95
+ }
96
+ }
97
+
98
+ - (void)accessibilityDecrement {
99
+ if (_onDecrement) {
100
+ _onDecrement();
101
+ }
102
+ }
103
+
77
104
  #pragma mark - Public
78
105
 
106
+ - (void)updateAccessibilityValueWithIndex:(NSInteger)index detentCount:(NSInteger)count {
107
+ if (index < 0 || count <= 0) {
108
+ self.accessibilityValue = nil;
109
+ return;
110
+ }
111
+
112
+ if (index >= count - 1) {
113
+ self.accessibilityValue = @"Expanded";
114
+ } else if (index == 0) {
115
+ self.accessibilityValue = @"Collapsed";
116
+ } else {
117
+ self.accessibilityValue = [NSString stringWithFormat:@"Detent %ld of %ld", (long)(index + 1), (long)count];
118
+ }
119
+ }
120
+
79
121
  - (void)addToView:(UIView *)parentView {
80
122
  if (self.superview == parentView) {
81
123
  return;
@@ -87,17 +129,22 @@ static const CGFloat kDefaultGrabberTopMargin = 5.0;
87
129
  }
88
130
 
89
131
  - (void)applyConfiguration {
90
- CGFloat width = [self effectiveWidth];
91
- CGFloat height = [self effectiveHeight];
132
+ CGFloat pillWidth = [self effectiveWidth];
133
+ CGFloat pillHeight = [self effectiveHeight];
92
134
  CGFloat topMargin = [self effectiveTopMargin];
93
135
  CGFloat parentWidth = self.superview ? self.superview.bounds.size.width : UIScreen.mainScreen.bounds.size.width;
94
136
 
95
- // Position the grabber: centered horizontally, with top margin
96
- self.frame = CGRectMake((parentWidth - width) / 2.0, topMargin, width, height);
97
- self.layer.cornerRadius = [self effectiveCornerRadius];
137
+ CGFloat frameWidth = pillWidth + kHitPaddingHorizontal * 2;
138
+ CGFloat frameHeight = pillHeight + kHitPaddingVertical * 2;
139
+ CGFloat frameY = topMargin - kHitPaddingVertical;
140
+
141
+ self.frame = CGRectMake((parentWidth - frameWidth) / 2.0, frameY, frameWidth, frameHeight);
142
+ self.backgroundColor = UIColor.clearColor;
98
143
 
99
- // Update vibrancy and fill view frames
100
- _vibrancyView.frame = self.bounds;
144
+ CGRect pillRect = CGRectMake(kHitPaddingHorizontal, kHitPaddingVertical, pillWidth, pillHeight);
145
+ _vibrancyView.frame = pillRect;
146
+ _vibrancyView.layer.cornerRadius = [self effectiveCornerRadius];
147
+ _vibrancyView.clipsToBounds = YES;
101
148
  _fillView.frame = _vibrancyView.contentView.bounds;
102
149
 
103
150
  if (self.isAdaptive) {
@@ -108,9 +155,8 @@ static const CGFloat kDefaultGrabberTopMargin = 5.0;
108
155
  _fillView.hidden = NO;
109
156
  } else {
110
157
  _vibrancyView.effect = nil;
111
- _vibrancyView.backgroundColor = nil;
112
158
  _fillView.hidden = YES;
113
- self.backgroundColor = _color ?: [UIColor.darkGrayColor colorWithAlphaComponent:0.7];
159
+ _vibrancyView.backgroundColor = _color ?: [UIColor.darkGrayColor colorWithAlphaComponent:0.7];
114
160
  }
115
161
  }
116
162