@lodev09/react-native-true-sheet 3.11.3 → 3.11.4

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 (41) hide show
  1. package/RNTrueSheet.podspec +6 -2
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +62 -4
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +2 -1
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +5 -0
  5. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +22 -1
  6. package/ios/TrueSheetContainerView.h +5 -0
  7. package/ios/TrueSheetContainerView.mm +6 -0
  8. package/ios/TrueSheetContentView.mm +82 -4
  9. package/ios/TrueSheetView.mm +7 -0
  10. package/ios/TrueSheetViewController.h +1 -0
  11. package/ios/TrueSheetViewController.mm +9 -0
  12. package/ios/core/TrueSheetDetentCalculator.h +9 -0
  13. package/ios/core/TrueSheetDetentCalculator.mm +8 -0
  14. package/ios/tvos/TrueSheetStubs.mm +141 -0
  15. package/lib/module/TrueSheet.js +2 -1
  16. package/lib/module/TrueSheet.js.map +1 -1
  17. package/lib/module/TrueSheet.web.js +24 -4
  18. package/lib/module/TrueSheet.web.js.map +1 -1
  19. package/lib/module/web/vaul/constants.js +1 -0
  20. package/lib/module/web/vaul/constants.js.map +1 -1
  21. package/lib/module/web/vaul/index.js +2 -0
  22. package/lib/module/web/vaul/index.js.map +1 -1
  23. package/lib/module/web/vaul/use-snap-points.js +6 -4
  24. package/lib/module/web/vaul/use-snap-points.js.map +1 -1
  25. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  26. package/lib/typescript/src/TrueSheet.types.d.ts +8 -0
  27. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  28. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  29. package/lib/typescript/src/web/vaul/constants.d.ts +1 -0
  30. package/lib/typescript/src/web/vaul/constants.d.ts.map +1 -1
  31. package/lib/typescript/src/web/vaul/index.d.ts +6 -1
  32. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  33. package/lib/typescript/src/web/vaul/use-snap-points.d.ts +2 -1
  34. package/lib/typescript/src/web/vaul/use-snap-points.d.ts.map +1 -1
  35. package/package.json +1 -1
  36. package/src/TrueSheet.tsx +2 -1
  37. package/src/TrueSheet.types.ts +9 -0
  38. package/src/TrueSheet.web.tsx +32 -7
  39. package/src/web/vaul/constants.ts +2 -0
  40. package/src/web/vaul/index.tsx +7 -0
  41. package/src/web/vaul/use-snap-points.ts +17 -4
@@ -10,10 +10,14 @@ Pod::Spec.new do |s|
10
10
  s.license = package["license"]
11
11
  s.authors = package["author"]
12
12
 
13
- s.platforms = { :ios => min_ios_version_supported }
13
+ s.platforms = { :ios => min_ios_version_supported, :tvos => min_ios_version_supported }
14
14
  s.source = { :git => "https://github.com/lodev09/react-native-true-sheet.git", :tag => "#{s.version}" }
15
15
 
16
- s.source_files = "ios/**/*.{h,m,mm,swift,cpp}", "common/cpp/**/*.{cpp,h}"
16
+ # tvOS compiles stubs only — sheets are not supported there, but codegen
17
+ # still registers the components so the classes must exist (#722)
18
+ s.ios.source_files = "ios/**/*.{h,m,mm,swift,cpp}", "common/cpp/**/*.{cpp,h}"
19
+ s.ios.exclude_files = "ios/tvos/**/*"
20
+ s.tvos.source_files = "ios/tvos/**/*.mm", "common/cpp/**/*.{cpp,h}"
17
21
  s.private_header_files = "ios/**/*.h"
18
22
 
19
23
  s.pod_target_xcconfig = {
@@ -1,8 +1,11 @@
1
1
  package com.lodev09.truesheet
2
2
 
3
3
  import android.annotation.SuppressLint
4
+ import android.text.Editable
5
+ import android.text.TextWatcher
4
6
  import android.view.View
5
7
  import android.view.ViewGroup
8
+ import android.widget.EditText
6
9
  import android.widget.ScrollView
7
10
  import androidx.core.widget.NestedScrollView
8
11
  import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@@ -45,6 +48,9 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
45
48
  private var keyboardScrollOffset: Float = 0f
46
49
  private var keyboardObserver: TrueSheetKeyboardObserver? = null
47
50
 
51
+ private var watchedEditText: EditText? = null
52
+ private var caretTextWatcher: TextWatcher? = null
53
+
48
54
  var scrollableOptions: ScrollableOptions? = null
49
55
  set(value) {
50
56
  field = value
@@ -190,14 +196,17 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
190
196
  }
191
197
 
192
198
  override fun keyboardDidShow(height: Int) {
199
+ attachCaretListener()
193
200
  scrollToFocusedInput()
194
201
  }
195
202
 
196
203
  override fun keyboardWillHide() {
204
+ detachCaretListener()
197
205
  updateScrollViewInsetForKeyboard(0)
198
206
  }
199
207
 
200
208
  override fun focusDidChange(newFocus: View) {
209
+ attachCaretListener()
201
210
  scrollToFocusedInput()
202
211
  }
203
212
  }
@@ -206,10 +215,37 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
206
215
  }
207
216
 
208
217
  fun cleanupKeyboardHandler() {
218
+ detachCaretListener()
209
219
  keyboardObserver?.stop()
210
220
  keyboardObserver = null
211
221
  }
212
222
 
223
+ private fun attachCaretListener() {
224
+ val focused = findFocus() as? EditText
225
+ if (focused === watchedEditText) return
226
+
227
+ detachCaretListener()
228
+ if (focused == null) return
229
+
230
+ val watcher = object : TextWatcher {
231
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
232
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
233
+ override fun afterTextChanged(s: Editable?) {
234
+ focused.post { scrollToFocusedInput() }
235
+ }
236
+ }
237
+
238
+ focused.addTextChangedListener(watcher)
239
+ watchedEditText = focused
240
+ caretTextWatcher = watcher
241
+ }
242
+
243
+ private fun detachCaretListener() {
244
+ caretTextWatcher?.let { watchedEditText?.removeTextChangedListener(it) }
245
+ caretTextWatcher = null
246
+ watchedEditText = null
247
+ }
248
+
213
249
  private fun updateScrollViewInsetForKeyboard(keyboardHeight: Int) {
214
250
  val scrollView = pinnedScrollView ?: return
215
251
 
@@ -234,14 +270,36 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
234
270
  focusedView.getLocationOnScreen(focusedLocation)
235
271
  scrollView.getLocationOnScreen(scrollViewLocation)
236
272
 
237
- val relativeTop = focusedLocation[1] - scrollViewLocation[1] + scrollView.scrollY
238
- val relativeBottom = relativeTop + focusedView.height + keyboardScrollOffset.toInt()
273
+ val viewTop = focusedLocation[1] - scrollViewLocation[1] + scrollView.scrollY
274
+
275
+ // Resolve the caret's line within the focused input so we follow the cursor
276
+ // rather than the input's full bounds (which over-scrolls tall inputs).
277
+ val editText = focusedView as? EditText
278
+ val layout = editText?.layout
279
+
280
+ val caretTop: Int
281
+ val caretBottom: Int
282
+ if (editText != null && layout != null) {
283
+ val line = layout.getLineForOffset(editText.selectionEnd.coerceAtLeast(0))
284
+ val textTop = viewTop + editText.totalPaddingTop - editText.scrollY
285
+ caretTop = textTop + layout.getLineTop(line)
286
+ caretBottom = textTop + layout.getLineBottom(line)
287
+ } else {
288
+ caretTop = viewTop
289
+ caretBottom = viewTop + focusedView.height
290
+ }
239
291
 
292
+ val offset = keyboardScrollOffset.toInt()
240
293
  val visibleHeight = scrollView.height - scrollView.paddingBottom
294
+ val visibleTop = scrollView.scrollY
241
295
  val visibleBottom = scrollView.scrollY + visibleHeight
242
296
 
243
- if (relativeBottom > visibleBottom) {
244
- scrollView.smoothScrollTo(0, relativeBottom - visibleHeight)
297
+ when {
298
+ caretBottom + offset > visibleBottom ->
299
+ scrollView.smoothScrollTo(0, caretBottom + offset - visibleHeight)
300
+
301
+ caretTop - offset < visibleTop ->
302
+ scrollView.smoothScrollTo(0, (caretTop - offset).coerceAtLeast(0))
245
303
  }
246
304
  }
247
305
 
@@ -615,7 +615,8 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
615
615
  }
616
616
 
617
617
  override fun containerViewFooterDidChangeSize(width: Int, height: Int) {
618
- // Footer changes don't affect detents, only reposition it
618
+ // Footer height affects peek detents
619
+ updateSheetIfNeeded()
619
620
  viewController.positionFooter()
620
621
  }
621
622
 
@@ -275,6 +275,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
275
275
  // Cached values used during dismiss when container is unmounted
276
276
  private var cachedContentHeight: Int = 0
277
277
  private var cachedHeaderHeight: Int = 0
278
+ private var cachedFooterHeight: Int = 0
278
279
 
279
280
  override val contentHeight: Int
280
281
  get() = containerView?.contentHeight ?: cachedContentHeight
@@ -282,6 +283,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
282
283
  override val headerHeight: Int
283
284
  get() = containerView?.headerHeight ?: cachedHeaderHeight
284
285
 
286
+ override val footerHeight: Int
287
+ get() = containerView?.footerHeight ?: cachedFooterHeight
288
+
285
289
  // Insets
286
290
  // Target keyboard height used for detent calculations
287
291
  override val keyboardInset: Int
@@ -836,6 +840,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
836
840
  containerView?.let {
837
841
  cachedContentHeight = it.contentHeight
838
842
  cachedHeaderHeight = it.headerHeight
843
+ cachedFooterHeight = it.footerHeight
839
844
  }
840
845
 
841
846
  interactionState = InteractionState.Reconfiguring
@@ -1,5 +1,6 @@
1
1
  package com.lodev09.truesheet.core
2
2
 
3
+ import com.facebook.react.uimanager.PixelUtil.dpToPx
3
4
  import com.facebook.react.uimanager.PixelUtil.pxToDp
4
5
  import com.facebook.react.uimanager.ThemedReactContext
5
6
  import com.facebook.react.util.RNLog
@@ -14,6 +15,7 @@ interface TrueSheetDetentCalculatorDelegate {
14
15
  val detents: MutableList<Double>
15
16
  val contentHeight: Int
16
17
  val headerHeight: Int
18
+ val footerHeight: Int
17
19
  val contentBottomInset: Int
18
20
  val maxContentHeight: Int?
19
21
  val keyboardInset: Int
@@ -31,17 +33,30 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) {
31
33
  private val detents: List<Double> get() = delegate?.detents ?: emptyList()
32
34
  private val contentHeight: Int get() = delegate?.contentHeight ?: 0
33
35
  private val headerHeight: Int get() = delegate?.headerHeight ?: 0
36
+ private val footerHeight: Int get() = delegate?.footerHeight ?: 0
34
37
  private val contentBottomInset: Int get() = delegate?.contentBottomInset ?: 0
35
38
  private val maxContentHeight: Int? get() = delegate?.maxContentHeight
36
39
  private val keyboardInset: Int get() = delegate?.keyboardInset ?: 0
37
40
 
41
+ /**
42
+ * Height for peek (-2.0) detents: header + footer height.
43
+ * Falls back to 150dp when neither is present.
44
+ */
45
+ private val peekDetentHeight: Int
46
+ get() {
47
+ val height = headerHeight + footerHeight
48
+ return if (height > 0) height else DEFAULT_PEEK_HEIGHT.dpToPx().toInt()
49
+ }
50
+
38
51
  /**
39
52
  * Calculate the height in pixels for a given detent value.
40
- * @param detent The detent value: -1.0 for content-fit, or 0.0-1.0 for screen fraction
53
+ * @param detent The detent value: -1.0 for content-fit, -2.0 for peek, or 0.0-1.0 for screen fraction
41
54
  */
42
55
  fun getDetentHeight(detent: Double, includeKeyboard: Boolean = true): Int {
43
56
  val baseHeight = if (detent == -1.0) {
44
57
  contentHeight + headerHeight + contentBottomInset
58
+ } else if (detent == -2.0) {
59
+ peekDetentHeight + contentBottomInset
45
60
  } else {
46
61
  if (detent <= 0.0 || detent > 1.0) {
47
62
  throw IllegalArgumentException("TrueSheet: detent fraction ($detent) must be between 0 and 1")
@@ -83,6 +98,8 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) {
83
98
  val value = detents[index]
84
99
  return if (value == -1.0) {
85
100
  (contentHeight + headerHeight).toFloat() / screenHeight.toFloat()
101
+ } else if (value == -2.0) {
102
+ peekDetentHeight.toFloat() / screenHeight.toFloat()
86
103
  } else {
87
104
  value.toFloat()
88
105
  }
@@ -211,4 +228,8 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) {
211
228
  val toDetent = getDetentValueForIndex(toIndex)
212
229
  return fromDetent + progress * (toDetent - fromDetent)
213
230
  }
231
+
232
+ companion object {
233
+ private const val DEFAULT_PEEK_HEIGHT = 150f
234
+ }
214
235
  }
@@ -67,6 +67,11 @@ NS_ASSUME_NONNULL_BEGIN
67
67
  */
68
68
  - (CGFloat)headerHeight;
69
69
 
70
+ /**
71
+ * Returns the current footer height
72
+ */
73
+ - (CGFloat)footerHeight;
74
+
70
75
  /**
71
76
  * Updates footer layout constraints if needed
72
77
  */
@@ -119,6 +119,10 @@ using namespace facebook::react;
119
119
  return _headerView ? _headerView.frame.size.height : 0;
120
120
  }
121
121
 
122
+ - (CGFloat)footerHeight {
123
+ return _footerView ? _footerView.frame.size.height : 0;
124
+ }
125
+
122
126
  - (void)layoutFooter {
123
127
  if (_footerView) {
124
128
  CGFloat height = _footerView.frame.size.height;
@@ -211,6 +215,7 @@ using namespace facebook::react;
211
215
  }
212
216
  _footerView = (TrueSheetFooterView *)childComponentView;
213
217
  _footerView.delegate = self;
218
+ [self footerViewDidChangeSize:_footerView.frame.size];
214
219
  }
215
220
  }
216
221
 
@@ -229,6 +234,7 @@ using namespace facebook::react;
229
234
  if ([childComponentView isKindOfClass:[TrueSheetFooterView class]]) {
230
235
  _footerView.delegate = nil;
231
236
  _footerView = nil;
237
+ [self footerViewDidChangeSize:CGSizeZero];
232
238
  }
233
239
 
234
240
  [super unmountChildComponentView:childComponentView index:index];
@@ -28,6 +28,7 @@ using namespace facebook::react;
28
28
  CGFloat _bottomInset;
29
29
  CGFloat _originalScrollViewHeight;
30
30
  CGFloat _originalIndicatorBottomInset;
31
+ BOOL _observingTextChanges;
31
32
  }
32
33
 
33
34
  + (ComponentDescriptorProvider)componentDescriptorProvider {
@@ -42,6 +43,32 @@ using namespace facebook::react;
42
43
  return self;
43
44
  }
44
45
 
46
+ - (void)dealloc {
47
+ [self stopObservingTextChanges];
48
+ }
49
+
50
+ #pragma mark - Text Change Observing
51
+
52
+ // The notification is app-wide (object:nil), so only observe while the keyboard is up
53
+ - (void)startObservingTextChanges {
54
+ if (_observingTextChanges) {
55
+ return;
56
+ }
57
+ _observingTextChanges = YES;
58
+ [[NSNotificationCenter defaultCenter] addObserver:self
59
+ selector:@selector(focusedInputTextDidChange:)
60
+ name:UITextViewTextDidChangeNotification
61
+ object:nil];
62
+ }
63
+
64
+ - (void)stopObservingTextChanges {
65
+ if (!_observingTextChanges) {
66
+ return;
67
+ }
68
+ _observingTextChanges = NO;
69
+ [[NSNotificationCenter defaultCenter] removeObserver:self name:UITextViewTextDidChangeNotification object:nil];
70
+ }
71
+
45
72
  #pragma mark - Layout
46
73
 
47
74
  - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics
@@ -250,6 +277,8 @@ using namespace facebook::react;
250
277
  return;
251
278
  }
252
279
 
280
+ [self startObservingTextChanges];
281
+
253
282
  TrueSheetViewController *sheetController = _keyboardObserver.viewController;
254
283
  UIView *firstResponder = sheetController ? [sheetController.view findFirstResponder] : nil;
255
284
 
@@ -265,15 +294,63 @@ using namespace facebook::react;
265
294
  // Defer scroll until the next run loop so content insets are applied first
266
295
  if (firstResponder) {
267
296
  dispatch_async(dispatch_get_main_queue(), ^{
268
- CGRect responderFrame = [firstResponder convertRect:firstResponder.bounds
269
- toView:self->_pinnedScrollView.scrollView];
270
- responderFrame.size.height += self.keyboardScrollOffset;
271
- [self->_pinnedScrollView.scrollView scrollRectToVisible:responderFrame animated:YES];
297
+ [self scrollToFocusedCaretAnimated:YES];
272
298
  });
273
299
  }
274
300
  }
275
301
 
302
+ - (void)focusedInputTextDidChange:(NSNotification *)notification {
303
+ if (!_pinnedScrollView || !_keyboardObserver || _keyboardObserver.currentHeight <= 0) {
304
+ return;
305
+ }
306
+
307
+ TrueSheetViewController *sheetController = _keyboardObserver.viewController;
308
+ UIView *firstResponder = sheetController ? [sheetController.view findFirstResponder] : nil;
309
+ if (!firstResponder || notification.object != firstResponder) {
310
+ return;
311
+ }
312
+
313
+ dispatch_async(dispatch_get_main_queue(), ^{
314
+ [self scrollToFocusedCaretAnimated:YES];
315
+ });
316
+ }
317
+
318
+ - (void)scrollToFocusedCaretAnimated:(BOOL)animated {
319
+ if (!_pinnedScrollView) {
320
+ return;
321
+ }
322
+
323
+ TrueSheetViewController *sheetController = _keyboardObserver.viewController;
324
+ UIView *firstResponder = sheetController ? [sheetController.view findFirstResponder] : nil;
325
+ if (!firstResponder) {
326
+ return;
327
+ }
328
+
329
+ UIScrollView *scrollView = _pinnedScrollView.scrollView;
330
+ CGRect targetRect = [firstResponder convertRect:firstResponder.bounds toView:scrollView];
331
+
332
+ if ([firstResponder conformsToProtocol:@protocol(UITextInput)]) {
333
+ id<UITextInput> textInput = (id<UITextInput>)firstResponder;
334
+ UITextRange *selectedRange = textInput.selectedTextRange;
335
+ if (selectedRange) {
336
+ CGRect caretRect = [textInput caretRectForPosition:selectedRange.end];
337
+ // caretRectForPosition: can return non-finite coordinates during layout/selection transitions
338
+ BOOL caretRectValid = !CGRectIsNull(caretRect) && !CGRectIsInfinite(caretRect) && isfinite(caretRect.origin.x) &&
339
+ isfinite(caretRect.origin.y) && isfinite(caretRect.size.width) &&
340
+ isfinite(caretRect.size.height);
341
+ if (caretRectValid) {
342
+ targetRect = [firstResponder convertRect:caretRect toView:scrollView];
343
+ }
344
+ }
345
+ }
346
+
347
+ targetRect.size.height += self.keyboardScrollOffset;
348
+ [scrollView scrollRectToVisible:targetRect animated:animated];
349
+ }
350
+
276
351
  - (void)keyboardWillHide:(NSTimeInterval)duration curve:(UIViewAnimationOptions)curve {
352
+ [self stopObservingTextChanges];
353
+
277
354
  if (!_pinnedScrollView) {
278
355
  return;
279
356
  }
@@ -292,6 +369,7 @@ using namespace facebook::react;
292
369
 
293
370
  - (void)prepareForRecycle {
294
371
  [super prepareForRecycle];
372
+ [self stopObservingTextChanges];
295
373
  [self clearScrollable];
296
374
  }
297
375
 
@@ -393,6 +393,11 @@ using namespace facebook::react;
393
393
  _controller.headerHeight = @(headerHeight);
394
394
  }
395
395
 
396
+ CGFloat footerHeight = [_containerView footerHeight];
397
+ if (footerHeight > 0) {
398
+ _controller.footerHeight = @(footerHeight);
399
+ }
400
+
396
401
  if (_eventEmitter) {
397
402
  [TrueSheetLifecycleEvents emitMount:_eventEmitter];
398
403
  } else {
@@ -580,6 +585,8 @@ using namespace facebook::react;
580
585
  }
581
586
 
582
587
  - (void)containerViewFooterDidChangeSize:(CGSize)newSize {
588
+ _controller.footerHeight = @(newSize.height);
589
+ [self setupSheetDetentsForSizeChange];
583
590
  [_controller setupAccessibilityContainer];
584
591
  }
585
592
 
@@ -57,6 +57,7 @@ NS_ASSUME_NONNULL_BEGIN
57
57
  @property (nonatomic, strong, nullable) NSNumber *maxContentWidth;
58
58
  @property (nonatomic, strong, nullable) NSNumber *contentHeight;
59
59
  @property (nonatomic, strong, nullable) NSNumber *headerHeight;
60
+ @property (nonatomic, strong, nullable) NSNumber *footerHeight;
60
61
  @property (nonatomic, strong, nullable) UIColor *backgroundColor;
61
62
  @property (nonatomic, strong, nullable) NSNumber *cornerRadius;
62
63
  @property (nonatomic, assign) BOOL grabber;
@@ -91,6 +91,7 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
91
91
  _detents = @[ @0.5, @1 ];
92
92
  _contentHeight = @(0);
93
93
  _headerHeight = @(0);
94
+ _footerHeight = @(0);
94
95
  _grabber = YES;
95
96
  _draggable = YES;
96
97
  _scrollingExpandsSheet = YES;
@@ -898,6 +899,14 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
898
899
  }
899
900
  }
900
901
 
902
+ if (value == -2) {
903
+ if (@available(iOS 16.0, *)) {
904
+ return [self customDetentWithIdentifier:@"custom-peek" height:[_detentCalculator peekHeight] atIndex:index];
905
+ } else {
906
+ return [UISheetPresentationControllerDetent mediumDetent];
907
+ }
908
+ }
909
+
901
910
  if (value <= 0 || value > 1) {
902
911
  RCTLogError(@"TrueSheet: detent fraction (%f) must be between 0 and 1", value);
903
912
  return [UISheetPresentationControllerDetent mediumDetent];
@@ -21,6 +21,7 @@ NS_ASSUME_NONNULL_BEGIN
21
21
  @property (nonatomic, strong, readonly) NSArray<NSNumber *> *detents;
22
22
  @property (nonatomic, strong, readonly, nullable) NSNumber *contentHeight;
23
23
  @property (nonatomic, strong, readonly, nullable) NSNumber *headerHeight;
24
+ @property (nonatomic, strong, readonly, nullable) NSNumber *footerHeight;
24
25
 
25
26
  @end
26
27
 
@@ -36,9 +37,17 @@ NS_ASSUME_NONNULL_BEGIN
36
37
  /**
37
38
  Returns the detent value (0-1 fraction) for a given index.
38
39
  For auto (-1) detents, calculates based on content + header height.
40
+ For peek (-2) detents, calculates based on header + footer height.
39
41
  */
40
42
  - (CGFloat)detentValueForIndex:(NSInteger)index;
41
43
 
44
+ /**
45
+ Returns the height for peek (-2) detents: header + footer height.
46
+ Falls back to 150 when neither is present — matches the iOS 26
47
+ floating small-detent threshold.
48
+ */
49
+ - (CGFloat)peekHeight;
50
+
42
51
  /**
43
52
  Learns the offset between resolver height and actual presented height for a detent.
44
53
  Called when the sheet settles at a detent.
@@ -22,11 +22,19 @@
22
22
  CGFloat autoHeight = [self.delegate.contentHeight floatValue] + [self.delegate.headerHeight floatValue];
23
23
  return autoHeight / self.delegate.screenHeight;
24
24
  }
25
+ if (value == -2) {
26
+ return [self peekHeight] / self.delegate.screenHeight;
27
+ }
25
28
  return value;
26
29
  }
27
30
  return 0;
28
31
  }
29
32
 
33
+ - (CGFloat)peekHeight {
34
+ CGFloat height = [self.delegate.headerHeight floatValue] + [self.delegate.footerHeight floatValue];
35
+ return height > 0 ? height : 150;
36
+ }
37
+
30
38
  - (void)learnOffsetForDetentIndex:(NSInteger)index {
31
39
  if (index < 0 || !_resolvedDetentHeights || index >= (NSInteger)_resolvedDetentHeights.count) {
32
40
  return;
@@ -0,0 +1,141 @@
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
+ // tvOS stubs. Codegen registers TrueSheet components and looks them up via
10
+ // NSClassFromString on all Apple platforms, so the classes must exist on tvOS
11
+ // even though sheets are not supported there.
12
+
13
+ #import <TargetConditionals.h>
14
+
15
+ #if TARGET_OS_TV && defined(RCT_NEW_ARCH_ENABLED)
16
+
17
+ #import <React/RCTBridgeModule.h>
18
+ #import <React/RCTViewComponentView.h>
19
+
20
+ #import <react/renderer/components/TrueSheetSpec/ComponentDescriptors.h>
21
+ #import <react/renderer/components/TrueSheetSpec/TrueSheetViewComponentDescriptor.h>
22
+
23
+ #import <TrueSheetSpec/TrueSheetSpec.h>
24
+
25
+ using namespace facebook::react;
26
+
27
+ #pragma mark - Component Views
28
+
29
+ @interface TrueSheetView : RCTViewComponentView
30
+ @end
31
+
32
+ @implementation TrueSheetView
33
+
34
+ + (ComponentDescriptorProvider)componentDescriptorProvider {
35
+ return concreteComponentDescriptorProvider<TrueSheetViewComponentDescriptor>();
36
+ }
37
+
38
+ @end
39
+
40
+ @interface TrueSheetContainerView : RCTViewComponentView
41
+ @end
42
+
43
+ @implementation TrueSheetContainerView
44
+
45
+ + (ComponentDescriptorProvider)componentDescriptorProvider {
46
+ return concreteComponentDescriptorProvider<TrueSheetContainerViewComponentDescriptor>();
47
+ }
48
+
49
+ @end
50
+
51
+ @interface TrueSheetContentView : RCTViewComponentView
52
+ @end
53
+
54
+ @implementation TrueSheetContentView
55
+
56
+ + (ComponentDescriptorProvider)componentDescriptorProvider {
57
+ return concreteComponentDescriptorProvider<TrueSheetContentViewComponentDescriptor>();
58
+ }
59
+
60
+ @end
61
+
62
+ @interface TrueSheetHeaderView : RCTViewComponentView
63
+ @end
64
+
65
+ @implementation TrueSheetHeaderView
66
+
67
+ + (ComponentDescriptorProvider)componentDescriptorProvider {
68
+ return concreteComponentDescriptorProvider<TrueSheetHeaderViewComponentDescriptor>();
69
+ }
70
+
71
+ @end
72
+
73
+ @interface TrueSheetFooterView : RCTViewComponentView
74
+ @end
75
+
76
+ @implementation TrueSheetFooterView
77
+
78
+ + (ComponentDescriptorProvider)componentDescriptorProvider {
79
+ return concreteComponentDescriptorProvider<TrueSheetFooterViewComponentDescriptor>();
80
+ }
81
+
82
+ @end
83
+
84
+ #pragma mark - Module
85
+
86
+ @interface TrueSheetModule : NSObject <RCTBridgeModule, NativeTrueSheetModuleSpec>
87
+ @end
88
+
89
+ @implementation TrueSheetModule
90
+
91
+ RCT_EXPORT_MODULE(TrueSheetModule)
92
+
93
+ + (BOOL)requiresMainQueueSetup {
94
+ return NO;
95
+ }
96
+
97
+ - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
98
+ (const facebook::react::ObjCTurboModule::InitParams &)params {
99
+ return std::make_shared<facebook::react::NativeTrueSheetModuleSpecJSI>(params);
100
+ }
101
+
102
+ - (void)presentByRef:(double)viewTag
103
+ index:(double)index
104
+ animated:(BOOL)animated
105
+ resolve:(RCTPromiseResolveBlock)resolve
106
+ reject:(RCTPromiseRejectBlock)reject {
107
+ reject(@"PRESENT_FAILED", @"TrueSheet is not supported on tvOS", nil);
108
+ }
109
+
110
+ - (void)dismissByRef:(double)viewTag
111
+ animated:(BOOL)animated
112
+ resolve:(RCTPromiseResolveBlock)resolve
113
+ reject:(RCTPromiseRejectBlock)reject {
114
+ resolve(nil);
115
+ }
116
+
117
+ - (void)dismissStackByRef:(double)viewTag
118
+ animated:(BOOL)animated
119
+ resolve:(RCTPromiseResolveBlock)resolve
120
+ reject:(RCTPromiseRejectBlock)reject {
121
+ resolve(nil);
122
+ }
123
+
124
+ - (void)resizeByRef:(double)viewTag
125
+ index:(double)index
126
+ resolve:(RCTPromiseResolveBlock)resolve
127
+ reject:(RCTPromiseRejectBlock)reject {
128
+ resolve(nil);
129
+ }
130
+
131
+ - (void)handleBackPress:(double)viewTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
132
+ resolve(nil);
133
+ }
134
+
135
+ - (void)dismissAll:(BOOL)animated resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
136
+ resolve(nil);
137
+ }
138
+
139
+ @end
140
+
141
+ #endif // TARGET_OS_TV && RCT_NEW_ARCH_ENABLED
@@ -77,7 +77,7 @@ export class TrueSheet extends PureComponent {
77
77
  // Warn for invalid detent fractions
78
78
  if (detents) {
79
79
  detents.forEach((detent, index) => {
80
- if (detent !== 'auto' && typeof detent === 'number') {
80
+ if (typeof detent === 'number' && detent !== -1 && detent !== -2) {
81
81
  if (detent <= 0 || detent > 1) {
82
82
  console.warn(`TrueSheet: detent at index ${index} (${detent}) should be between 0 and 1. It will be clamped.`);
83
83
  }
@@ -369,6 +369,7 @@ export class TrueSheet extends PureComponent {
369
369
  // Trim to max 3 detents and clamp fractions
370
370
  const resolvedDetents = detents.slice(0, 3).map(detent => {
371
371
  if (detent === 'auto' || detent === -1) return -1;
372
+ if (detent === 'peek' || detent === -2) return -2;
372
373
 
373
374
  // Default to 0.1 if zero or below
374
375
  if (detent <= 0) return 0.1;