@lodev09/react-native-true-sheet 3.11.9 → 3.11.10

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 (34) hide show
  1. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +6 -0
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +7 -0
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +21 -3
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +20 -0
  5. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt +17 -4
  6. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +12 -1
  7. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetGrabberView.kt +30 -7
  8. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetStackManager.kt +52 -0
  9. package/ios/TrueSheetView.mm +3 -0
  10. package/ios/TrueSheetViewController.h +8 -0
  11. package/ios/TrueSheetViewController.mm +43 -5
  12. package/ios/core/TrueSheetDetentCalculator.h +6 -0
  13. package/ios/core/TrueSheetGrabberView.h +4 -0
  14. package/ios/core/TrueSheetGrabberView.mm +15 -5
  15. package/ios/core/TrueSheetKeyboardObserver.mm +15 -0
  16. package/lib/module/TrueSheet.js +20 -1
  17. package/lib/module/TrueSheet.js.map +1 -1
  18. package/lib/module/TrueSheet.web.js +2 -1
  19. package/lib/module/TrueSheet.web.js.map +1 -1
  20. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +12 -0
  21. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  22. package/lib/typescript/src/TrueSheet.types.d.ts +69 -0
  23. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  24. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  25. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +11 -0
  26. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  27. package/lib/typescript/src/navigation/types.d.ts +1 -1
  28. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/TrueSheet.tsx +22 -1
  31. package/src/TrueSheet.types.ts +71 -0
  32. package/src/TrueSheet.web.tsx +4 -1
  33. package/src/fabric/TrueSheetViewNativeComponent.ts +12 -0
  34. package/src/navigation/types.ts +1 -0
@@ -68,6 +68,12 @@ class TrueSheetFooterView(private val reactContext: ThemedReactContext) :
68
68
  return super.onInterceptTouchEvent(event)
69
69
  }
70
70
 
71
+ override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
72
+ // Mirrors ReactSurfaceView: keep receiving onInterceptTouchEvent so
73
+ // jsTouchDispatcher sees the gesture end, but forward the request up the tree.
74
+ parent?.requestDisallowInterceptTouchEvent(disallowIntercept)
75
+ }
76
+
71
77
  override fun onTouchEvent(event: MotionEvent): Boolean {
72
78
  if (pointerEvents == PointerEvents.NONE || pointerEvents == PointerEvents.BOX_NONE) {
73
79
  return false
@@ -15,6 +15,7 @@ import com.facebook.react.uimanager.UIManagerHelper
15
15
  import com.facebook.react.uimanager.events.EventDispatcher
16
16
  import com.facebook.react.util.RNLog
17
17
  import com.facebook.react.views.view.ReactViewGroup
18
+ import com.lodev09.truesheet.core.AccessibilityOptions
18
19
  import com.lodev09.truesheet.core.GrabberOptions
19
20
  import com.lodev09.truesheet.core.RNScreensEventObserver
20
21
  import com.lodev09.truesheet.core.RNScreensEventObserverDelegate
@@ -223,6 +224,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
223
224
  if (viewController.isPresented) {
224
225
  viewController.setupDimmedBackground()
225
226
  viewController.updateDimAmount()
227
+ TrueSheetStackManager.updateBackgroundAccessibility()
226
228
  }
227
229
  }
228
230
 
@@ -232,6 +234,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
232
234
  if (viewController.isPresented) {
233
235
  viewController.setupDimmedBackground()
234
236
  viewController.updateDimAmount()
237
+ TrueSheetStackManager.updateBackgroundAccessibility()
235
238
  }
236
239
  }
237
240
 
@@ -261,6 +264,10 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
261
264
  viewController.grabberOptions = options
262
265
  }
263
266
 
267
+ fun setAccessibilityOptions(options: AccessibilityOptions?) {
268
+ viewController.accessibilityOptions = options
269
+ }
270
+
264
271
  fun setSheetElevation(elevation: Float) {
265
272
  viewController.sheetElevation = elevation
266
273
  }
@@ -6,6 +6,7 @@ import android.os.Build
6
6
  import android.view.MotionEvent
7
7
  import android.view.View
8
8
  import android.view.ViewGroup
9
+ import android.view.accessibility.AccessibilityEvent
9
10
  import android.view.accessibility.AccessibilityNodeInfo
10
11
  import android.widget.ImageView
11
12
  import androidx.coordinatorlayout.widget.CoordinatorLayout
@@ -22,6 +23,7 @@ import com.facebook.react.uimanager.events.EventDispatcher
22
23
  import com.facebook.react.util.RNLog
23
24
  import com.facebook.react.views.view.ReactViewGroup
24
25
  import com.google.android.material.bottomsheet.BottomSheetBehavior
26
+ import com.lodev09.truesheet.core.AccessibilityOptions
25
27
  import com.lodev09.truesheet.core.GrabberOptions
26
28
  import com.lodev09.truesheet.core.TrueSheetBottomSheetBehavior
27
29
  import com.lodev09.truesheet.core.TrueSheetBottomSheetView
@@ -212,6 +214,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
212
214
  var dimmedDetentIndex = 0
213
215
  override var grabber: Boolean = true
214
216
  override var grabberOptions: GrabberOptions? = null
217
+ override var accessibilityOptions: AccessibilityOptions? = null
215
218
  override var sheetBackgroundColor: Int? = null
216
219
  var insetAdjustment: TrueSheetInsetAdjustment = TrueSheetInsetAdjustment.AUTOMATIC
217
220
 
@@ -310,7 +313,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
310
313
  val bottomInset: Int
311
314
  get() = if (edgeToEdgeEnabled) ScreenUtils.getInsets(reactContext).bottom else 0
312
315
 
313
- val topInset: Int
316
+ override val topInset: Int
314
317
  get() = if (edgeToEdgeEnabled) ScreenUtils.getInsets(reactContext).top else 0
315
318
 
316
319
  override val contentBottomInset: Int
@@ -634,6 +637,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
634
637
  if (!isKeyboardTransitioning) {
635
638
  updateDimAmount(animated = true)
636
639
  }
640
+
641
+ TrueSheetStackManager.updateBackgroundAccessibility()
637
642
  }
638
643
 
639
644
  // =============================================================================
@@ -653,6 +658,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
653
658
 
654
659
  isSheetVisible = false
655
660
  wasHiddenByScreen = true
661
+ TrueSheetStackManager.updateBackgroundAccessibility()
656
662
  delegate?.viewControllerDidChangeVisibility(false)
657
663
  dimViews.forEach { it.animate().alpha(0f).setDuration(SCREEN_FADE_DURATION).start() }
658
664
  sheet.animate()
@@ -673,6 +679,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
673
679
  setSheetVisibility(true)
674
680
  sheetView?.alpha = 1f
675
681
  updateDimAmount(animated = true)
682
+ TrueSheetStackManager.updateBackgroundAccessibility()
676
683
  }
677
684
 
678
685
  /**
@@ -709,6 +716,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
709
716
  sheet.setupBackground()
710
717
  sheet.setupElevation()
711
718
  sheet.setupGrabber()
719
+ sheet.setupAccessibility()
712
720
 
713
721
  if (shouldAnimatePresent) {
714
722
  isPresentAnimating = true
@@ -821,11 +829,16 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
821
829
  delegate?.viewControllerDidFocus()
822
830
  sheetView?.updateGrabberAccessibilityValue(index, detents.size)
823
831
 
832
+ TrueSheetStackManager.updateBackgroundAccessibility()
833
+ // Move accessibility focus into the sheet
834
+ sheetView?.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED)
835
+
824
836
  presentPromise?.invoke()
825
837
  presentPromise = null
826
838
  }
827
839
 
828
840
  private fun finishDismiss() {
841
+ TrueSheetStackManager.updateBackgroundAccessibility()
829
842
  restoreFocusedView()
830
843
  emitDidDismissEvents()
831
844
  cleanupSheet()
@@ -1145,8 +1158,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1145
1158
  // keyboard-expanded position and a non-keyboard detent position. If so, dismiss
1146
1159
  // keyboard and commit to that detent.
1147
1160
  val maxAvailableHeight = realScreenHeight - topInset
1148
- val keyboardHeight = minOf(detentCalculator.getDetentHeight(detents.last()), maxAvailableHeight)
1149
- val keyboardTop = realScreenHeight - keyboardHeight
1161
+ val keyboardTop = detentCalculator.getSheetTopForDetentIndex(detents.size - 1)
1150
1162
 
1151
1163
  for (i in detents.indices) {
1152
1164
  val nonKeyboardHeight = minOf(detentCalculator.getDetentHeight(detents[i], includeKeyboard = false), maxAvailableHeight)
@@ -1348,6 +1360,12 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1348
1360
  return super.onInterceptTouchEvent(event)
1349
1361
  }
1350
1362
 
1363
+ override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
1364
+ // Mirrors ReactSurfaceView: keep receiving onInterceptTouchEvent so
1365
+ // jsTouchDispatcher sees the gesture end, but forward the request up the tree.
1366
+ parent?.requestDisallowInterceptTouchEvent(disallowIntercept)
1367
+ }
1368
+
1351
1369
  override fun onTouchEvent(event: MotionEvent): Boolean {
1352
1370
  eventDispatcher?.let {
1353
1371
  jsTouchDispatcher.handleTouchEvent(event, it, reactContext)
@@ -14,6 +14,7 @@ import com.facebook.react.uimanager.ViewManagerDelegate
14
14
  import com.facebook.react.uimanager.annotations.ReactProp
15
15
  import com.facebook.react.viewmanagers.TrueSheetViewManagerDelegate
16
16
  import com.facebook.react.viewmanagers.TrueSheetViewManagerInterface
17
+ import com.lodev09.truesheet.core.AccessibilityOptions
17
18
  import com.lodev09.truesheet.core.GrabberOptions
18
19
  import com.lodev09.truesheet.events.*
19
20
 
@@ -136,6 +137,25 @@ class TrueSheetViewManager :
136
137
  view.setGrabberOptions(grabberOptions)
137
138
  }
138
139
 
140
+ @ReactProp(name = "accessibilityOptions")
141
+ override fun setAccessibilityOptions(view: TrueSheetView, options: ReadableMap?) {
142
+ if (options == null) {
143
+ view.setAccessibilityOptions(null)
144
+ return
145
+ }
146
+
147
+ val accessibilityOptions = AccessibilityOptions(
148
+ grabberLabel = if (options.hasKey("grabberLabel")) options.getString("grabberLabel") else null,
149
+ expandedValue = if (options.hasKey("expandedValue")) options.getString("expandedValue") else null,
150
+ collapsedValue = if (options.hasKey("collapsedValue")) options.getString("collapsedValue") else null,
151
+ detentValue = if (options.hasKey("detentValue")) options.getString("detentValue") else null,
152
+ expandActionLabel = if (options.hasKey("expandActionLabel")) options.getString("expandActionLabel") else null,
153
+ collapseActionLabel = if (options.hasKey("collapseActionLabel")) options.getString("collapseActionLabel") else null,
154
+ paneTitle = if (options.hasKey("paneTitle")) options.getString("paneTitle") else null
155
+ )
156
+ view.setAccessibilityOptions(accessibilityOptions)
157
+ }
158
+
139
159
  @ReactProp(name = "dismissible", defaultBoolean = true)
140
160
  override fun setDismissible(view: TrueSheetView, dismissible: Boolean) {
141
161
  view.setDismissible(dismissible)
@@ -31,6 +31,7 @@ interface TrueSheetBottomSheetViewDelegate {
31
31
  val anchorOffset: Int
32
32
  val grabber: Boolean
33
33
  val grabberOptions: GrabberOptions?
34
+ val accessibilityOptions: AccessibilityOptions?
34
35
  val draggable: Boolean
35
36
  fun bottomSheetViewDidTapGrabber()
36
37
  fun bottomSheetViewDidAccessibilityIncrement()
@@ -78,8 +79,6 @@ class TrueSheetBottomSheetView(private val reactContext: ThemedReactContext) : F
78
79
  // Allow content to extend beyond bounds (for footer positioning)
79
80
  clipChildren = false
80
81
  clipToPadding = false
81
-
82
- ViewCompat.setAccessibilityPaneTitle(this, "Bottom sheet")
83
82
  }
84
83
 
85
84
  override fun setTranslationY(translationY: Float) {
@@ -195,23 +194,37 @@ class TrueSheetBottomSheetView(private val reactContext: ThemedReactContext) : F
195
194
  fun setupGrabber() {
196
195
  findViewWithTag<View>(GRABBER_TAG)?.let { removeView(it) }
197
196
 
197
+ // Content view is the controller, added before the grabber (delegate is the controller)
198
+ val contentView = delegate as? View
199
+
198
200
  val isEnabled = delegate?.grabber ?: true
199
201
  val isDraggable = delegate?.draggable ?: true
200
- if (!isEnabled || !isDraggable) return
202
+ if (!isEnabled || !isDraggable) {
203
+ contentView?.accessibilityTraversalAfter = View.NO_ID
204
+ return
205
+ }
201
206
 
202
- val grabberView = TrueSheetGrabberView(reactContext, delegate?.grabberOptions).apply {
207
+ val grabberView = TrueSheetGrabberView(reactContext, delegate?.grabberOptions, delegate?.accessibilityOptions).apply {
203
208
  tag = GRABBER_TAG
209
+ id = View.generateViewId()
204
210
  onAccessibilityIncrement = { delegate?.bottomSheetViewDidAccessibilityIncrement() }
205
211
  onAccessibilityDecrement = { delegate?.bottomSheetViewDidAccessibilityDecrement() }
206
212
  }
207
213
 
208
214
  addView(grabberView)
215
+
216
+ // Grabber is added after content for z-ordering, but should be announced first
217
+ contentView?.accessibilityTraversalAfter = grabberView.id
209
218
  }
210
219
 
211
220
  fun updateGrabberAccessibilityValue(index: Int, detentCount: Int) {
212
221
  findViewWithTag<TrueSheetGrabberView>(GRABBER_TAG)?.updateAccessibilityValue(index, detentCount)
213
222
  }
214
223
 
224
+ fun setupAccessibility() {
225
+ ViewCompat.setAccessibilityPaneTitle(this, delegate?.accessibilityOptions?.paneTitle ?: "Bottom sheet")
226
+ }
227
+
215
228
  // =============================================================================
216
229
  // MARK: - Grabber Tap Detection
217
230
  // =============================================================================
@@ -20,6 +20,7 @@ interface TrueSheetDetentCalculatorDelegate {
20
20
  val contentBottomInset: Int
21
21
  val maxContentHeight: Int?
22
22
  val keyboardInset: Int
23
+ val topInset: Int
23
24
  }
24
25
 
25
26
  /**
@@ -39,6 +40,7 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) {
39
40
  private val contentBottomInset: Int get() = delegate?.contentBottomInset ?: 0
40
41
  private val maxContentHeight: Int? get() = delegate?.maxContentHeight
41
42
  private val keyboardInset: Int get() = delegate?.keyboardInset ?: 0
43
+ private val topInset: Int get() = delegate?.topInset ?: 0
42
44
 
43
45
  /**
44
46
  * Height for peek (-2.0) detents: header + footer + peek content height.
@@ -79,7 +81,12 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) {
79
81
  RNLog.w(reactContext, "TrueSheet: Detent index ($index) is out of bounds (0..${detents.size - 1})")
80
82
  return realScreenHeight
81
83
  }
82
- return realScreenHeight - getDetentHeight(detents[index])
84
+ // Clamp to the space the sheet can actually occupy — matching
85
+ // setupSheetDetents. A keyboard-inflated detent height can exceed it,
86
+ // placing the expected top above the screen while the real sheet stops
87
+ // at the top inset.
88
+ val maxAvailableHeight = realScreenHeight - topInset
89
+ return realScreenHeight - minOf(getDetentHeight(detents[index]), maxAvailableHeight)
83
90
  }
84
91
 
85
92
  /**
@@ -187,6 +194,10 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) {
187
194
  val pos = getSheetTopForDetentIndex(i)
188
195
  val nextPos = getSheetTopForDetentIndex(i + 1)
189
196
 
197
+ // Skip degenerate segments — keyboard growth can clamp adjacent detents
198
+ // to the same top; falling through reports the topmost detent
199
+ if (pos == nextPos) continue
200
+
190
201
  if (positionPx in nextPos..pos) {
191
202
  val range = pos - nextPos
192
203
  val progress = if (range > 0) (pos - positionPx).toFloat() / range else 0f
@@ -26,12 +26,29 @@ data class GrabberOptions(
26
26
  val adaptive: Boolean = true
27
27
  )
28
28
 
29
+ /**
30
+ * Options for customizing (e.g. localizing) accessibility strings.
31
+ */
32
+ data class AccessibilityOptions(
33
+ val grabberLabel: String? = null,
34
+ val expandedValue: String? = null,
35
+ val collapsedValue: String? = null,
36
+ val detentValue: String? = null,
37
+ val expandActionLabel: String? = null,
38
+ val collapseActionLabel: String? = null,
39
+ val paneTitle: String? = null
40
+ )
41
+
29
42
  /**
30
43
  * Native grabber (drag handle) view for the bottom sheet.
31
44
  * Displays a small pill-shaped indicator at the top of the sheet with a tappable hitbox.
32
45
  */
33
46
  @SuppressLint("ViewConstructor")
34
- class TrueSheetGrabberView(context: Context, private val options: GrabberOptions? = null) : FrameLayout(context) {
47
+ class TrueSheetGrabberView(
48
+ context: Context,
49
+ private val options: GrabberOptions? = null,
50
+ private val accessibilityOptions: AccessibilityOptions? = null
51
+ ) : FrameLayout(context) {
35
52
 
36
53
  companion object {
37
54
  private const val DEFAULT_WIDTH = 32f // dp
@@ -94,7 +111,7 @@ class TrueSheetGrabberView(context: Context, private val options: GrabberOptions
94
111
  addView(pillView)
95
112
 
96
113
  isFocusable = true
97
- contentDescription = "Sheet Grabber"
114
+ contentDescription = accessibilityOptions?.grabberLabel ?: "Drag handle"
98
115
 
99
116
  accessibilityDelegate = object : View.AccessibilityDelegate() {
100
117
  override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo) {
@@ -102,13 +119,13 @@ class TrueSheetGrabberView(context: Context, private val options: GrabberOptions
102
119
  info.addAction(
103
120
  AccessibilityNodeInfo.AccessibilityAction(
104
121
  AccessibilityNodeInfo.ACTION_SCROLL_FORWARD,
105
- "Expand"
122
+ accessibilityOptions?.expandActionLabel ?: "Expand"
106
123
  )
107
124
  )
108
125
  info.addAction(
109
126
  AccessibilityNodeInfo.AccessibilityAction(
110
127
  AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD,
111
- "Collapse"
128
+ accessibilityOptions?.collapseActionLabel ?: "Collapse"
112
129
  )
113
130
  )
114
131
  info.className = "android.widget.SeekBar"
@@ -134,9 +151,15 @@ class TrueSheetGrabberView(context: Context, private val options: GrabberOptions
134
151
  fun updateAccessibilityValue(index: Int, detentCount: Int) {
135
152
  val description: CharSequence? = when {
136
153
  index < 0 || detentCount <= 0 -> null
137
- index >= detentCount - 1 -> "Expanded"
138
- index == 0 -> "Collapsed"
139
- else -> "Detent ${index + 1} of $detentCount"
154
+
155
+ index >= detentCount - 1 -> accessibilityOptions?.expandedValue ?: "Expanded"
156
+
157
+ index == 0 -> accessibilityOptions?.collapsedValue ?: "Collapsed"
158
+
159
+ else ->
160
+ (accessibilityOptions?.detentValue ?: "Detent {index} of {count}")
161
+ .replace("{index}", "${index + 1}")
162
+ .replace("{count}", "$detentCount")
140
163
  }
141
164
  ViewCompat.setStateDescription(this, description)
142
165
  }
@@ -1,5 +1,6 @@
1
1
  package com.lodev09.truesheet.core
2
2
 
3
+ import android.view.View
3
4
  import android.view.ViewGroup
4
5
  import com.lodev09.truesheet.TrueSheetView
5
6
 
@@ -143,6 +144,57 @@ object TrueSheetStackManager {
143
144
  }
144
145
  }
145
146
 
147
+ private val savedBackgroundImportance = HashMap<View, Int>()
148
+
149
+ /**
150
+ * Hides views behind the topmost dimmed (modal) sheet from accessibility,
151
+ * mirroring BottomSheetDialog behavior. Sheets live in the same window as
152
+ * the app content, so without this TalkBack can traverse behind the sheet.
153
+ *
154
+ * Derived from the current stack state and idempotent — call whenever a
155
+ * sheet presents, dismisses, settles, or changes its dim state.
156
+ */
157
+ @JvmStatic
158
+ fun updateBackgroundAccessibility() {
159
+ synchronized(presentedSheetStack) {
160
+ val topDimmedIndex = presentedSheetStack.indexOfLast {
161
+ it.viewController.run { isPresented && isSheetVisible && !isBeingDismissed && isDimmedAtCurrentDetent }
162
+ }
163
+ val topDimmed = presentedSheetStack.getOrNull(topDimmedIndex)
164
+ val root = topDimmed?.viewController?.coordinatorLayout?.parent as? ViewGroup
165
+
166
+ if (root == null) {
167
+ restoreBackgroundAccessibility()
168
+ return
169
+ }
170
+
171
+ // The dimmed sheet and any sheets above it stay accessible
172
+ val accessibleCoordinators = presentedSheetStack
173
+ .subList(topDimmedIndex, presentedSheetStack.size)
174
+ .mapNotNull { it.viewController.coordinatorLayout }
175
+ .toSet()
176
+
177
+ for (i in 0 until root.childCount) {
178
+ val child = root.getChildAt(i)
179
+ if (child in accessibleCoordinators) {
180
+ savedBackgroundImportance.remove(child)?.let { child.importantForAccessibility = it }
181
+ continue
182
+ }
183
+ if (!savedBackgroundImportance.containsKey(child)) {
184
+ savedBackgroundImportance[child] = child.importantForAccessibility
185
+ }
186
+ child.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
187
+ }
188
+ }
189
+ }
190
+
191
+ private fun restoreBackgroundAccessibility() {
192
+ savedBackgroundImportance.forEach { (view, importance) ->
193
+ view.importantForAccessibility = importance
194
+ }
195
+ savedBackgroundImportance.clear()
196
+ }
197
+
146
198
  /**
147
199
  * Returns the root presented sheet for dismissAll.
148
200
  * Starts from the topmost sheet and walks up to find the root of its stack.
@@ -230,6 +230,9 @@ using namespace facebook::react;
230
230
  _controller.grabberOptions = nil;
231
231
  }
232
232
 
233
+ // Accessibility options - applied to the custom grabber
234
+ _controller.accessibilityOptions = newProps.accessibilityOptions;
235
+
233
236
  _controller.presentation = newProps.presentation;
234
237
  _controller.dismissible = newProps.dismissible;
235
238
  _controller.draggable = newProps.draggable;
@@ -63,6 +63,7 @@ NS_ASSUME_NONNULL_BEGIN
63
63
  @property (nonatomic, strong, nullable) NSNumber *cornerRadius;
64
64
  @property (nonatomic, assign) BOOL grabber;
65
65
  @property (nonatomic, strong, nullable) GrabberOptions *grabberOptions;
66
+ @property (nonatomic, assign) facebook::react::TrueSheetViewAccessibilityOptionsStruct accessibilityOptions;
66
67
  @property (nonatomic, assign) BOOL draggable;
67
68
  @property (nonatomic, assign) BOOL dimmed;
68
69
  @property (nonatomic, strong, nullable) NSNumber *dimmedDetentIndex;
@@ -77,6 +78,13 @@ NS_ASSUME_NONNULL_BEGIN
77
78
  @property (nonatomic, assign) BOOL dismissible;
78
79
  @property (nonatomic, assign) BOOL isPresented;
79
80
  @property (nonatomic, assign) NSInteger activeDetentIndex;
81
+
82
+ /**
83
+ * YES while the keyboard has the sheet grown beyond its detent height.
84
+ * Offset learning is skipped in this state — the measured frame would bake
85
+ * ~keyboardHeight into the learned offset.
86
+ */
87
+ @property (nonatomic, assign) BOOL keyboardSheetGrown;
80
88
  @property (nonatomic, readonly) BOOL isTopmostPresentedController;
81
89
  @property (nonatomic, weak, nullable) UIView *accessibilityContentView;
82
90
 
@@ -460,6 +460,10 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
460
460
  [self endInteractiveDismissState];
461
461
  }
462
462
 
463
+ // Dismissing with the keyboard up skips the hide notification — don't let
464
+ // the stale flag block learning on the next present.
465
+ _keyboardSheetGrown = NO;
466
+
463
467
  [self emitDidDismissEvents];
464
468
  }
465
469
 
@@ -478,6 +482,21 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
478
482
  } else {
479
483
  UIViewController *presented = self.presentedViewController;
480
484
  BOOL hasPresentedController = presented != nil && !presented.isBeingPresented && !presented.isBeingDismissed;
485
+
486
+ // A layout pass at rest can nudge the frame by a sub-pixel amount
487
+ // (pixel-grid snapping after present), leaving the interpolated index
488
+ // slightly off the whole number. Re-learn when the frame is effectively
489
+ // at the current detent so the drift is absorbed instead of emitted.
490
+ // The tight threshold keeps real movement from being absorbed.
491
+ if (presented == nil && !_isDragging) {
492
+ NSInteger index = self.currentDetentIndex;
493
+ CGFloat expectedHeight = [_detentCalculator resolvedHeightForIndex:index];
494
+ CGFloat actualHeight = self.screenHeight - self.currentPosition;
495
+ if (expectedHeight > 0 && fabs(actualHeight - expectedHeight) < 2) {
496
+ [self learnOffsetForDetentIndex:index];
497
+ }
498
+ }
499
+
481
500
  [self emitChangePositionDelegateWithPosition:self.currentPosition
482
501
  realtime:!hasPresentedController
483
502
  debug:@"layout"];
@@ -636,13 +655,13 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
636
655
  strongSelf->_isTransitioning = NO;
637
656
  strongSelf->_isTransitionSnapping = NO;
638
657
 
639
- // Emit settled position after detent snap.
658
+ // Settle after a present or detent-snap transition.
640
659
  // Uses dispatch_after because presentedView frame isn't final until UIKit
641
- // completes its layout pass after the transition animation.
660
+ // completes its layout pass after the transition animation — learning
661
+ // here absorbs any sub-pixel drift since the earlier learns.
642
662
  if (strongSelf->_isPresented) {
643
663
  dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
644
- CGFloat position = strongSelf.currentPosition;
645
- [strongSelf emitChangePositionDelegateWithPosition:position realtime:NO debug:@"transition end"];
664
+ [strongSelf settleAtDetentIndex:strongSelf.currentDetentIndex debug:@"transition end"];
646
665
  });
647
666
  }
648
667
  }];
@@ -798,7 +817,10 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
798
817
  .index = [self interpolatedIndexForPosition:position],
799
818
  };
800
819
 
801
- if (!TrueSheetPositionStateEquals(_lastEmittedPositionState, state)) {
820
+ // Settle (non-realtime) emits are authoritative and bypass the dedupe — a
821
+ // learn right before them can correct the interpolated index by less than
822
+ // the tolerance, and the corrected value must still reach JS.
823
+ if (!realtime || !TrueSheetPositionStateEquals(_lastEmittedPositionState, state)) {
802
824
  _lastEmittedPositionState = state;
803
825
 
804
826
  [self.delegate viewControllerDidChangePosition:state.index
@@ -809,9 +831,24 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
809
831
  }
810
832
 
811
833
  - (void)learnOffsetForDetentIndex:(NSInteger)index {
834
+ if (_keyboardSheetGrown) {
835
+ return;
836
+ }
812
837
  [_detentCalculator learnOffsetForDetentIndex:index];
813
838
  }
814
839
 
840
+ - (void)setKeyboardSheetGrown:(BOOL)keyboardSheetGrown {
841
+ BOOL wasGrown = _keyboardSheetGrown;
842
+ _keyboardSheetGrown = keyboardSheetGrown;
843
+
844
+ // Settles are skipped or measure a mid-animation frame while the keyboard
845
+ // has the sheet grown (e.g. a drag-end during the shrink-back) — re-settle
846
+ // at the resting detent once the keyboard is fully away.
847
+ if (wasGrown && !keyboardSheetGrown && self.isPresented && !_isDragging) {
848
+ [self settleAtDetentIndex:self.currentDetentIndex debug:@"keyboard settled"];
849
+ }
850
+ }
851
+
815
852
  /**
816
853
  * Learn the resolver-vs-actual offset at a settled detent, then emit the corrected
817
854
  * position. Only valid when presentedView's frame is final — never mid-drag or
@@ -1065,6 +1102,7 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
1065
1102
  self.sheet.prefersGrabberVisible = NO;
1066
1103
 
1067
1104
  GrabberOptions *options = self.grabberOptions;
1105
+ _grabberView.accessibilityOptions = self.accessibilityOptions;
1068
1106
  _grabberView.grabberWidth = options.width;
1069
1107
  _grabberView.grabberHeight = options.height;
1070
1108
  _grabberView.topMargin = options.topMargin;
@@ -55,6 +55,12 @@ NS_ASSUME_NONNULL_BEGIN
55
55
  */
56
56
  - (void)learnOffsetForDetentIndex:(NSInteger)index;
57
57
 
58
+ /**
59
+ Returns the expected sheet height for a detent index: the UIKit-resolved
60
+ height plus the learned offset.
61
+ */
62
+ - (CGFloat)resolvedHeightForIndex:(NSInteger)index;
63
+
58
64
  /**
59
65
  Finds the segment between detents for a given position.
60
66
  Returns YES if position is between two detents, NO if at edges.
@@ -7,6 +7,7 @@
7
7
  //
8
8
 
9
9
  #import <UIKit/UIKit.h>
10
+ #import <react/renderer/components/TrueSheetSpec/Props.h>
10
11
 
11
12
  NS_ASSUME_NONNULL_BEGIN
12
13
 
@@ -45,6 +46,9 @@ NS_ASSUME_NONNULL_BEGIN
45
46
  /// Whether the grabber color adapts to the background (default: YES)
46
47
  @property (nonatomic, strong, nullable) NSNumber *adaptive;
47
48
 
49
+ /// Accessibility strings (defaults come from the codegen prop defaults)
50
+ @property (nonatomic, assign) facebook::react::TrueSheetViewAccessibilityOptionsStruct accessibilityOptions;
51
+
48
52
  /// Called when the grabber is tapped
49
53
  @property (nonatomic, copy, nullable) void (^onTap)(void);
50
54
 
@@ -8,6 +8,10 @@
8
8
 
9
9
  #import "TrueSheetGrabberView.h"
10
10
 
11
+ #import <React/RCTConversions.h>
12
+
13
+ using namespace facebook::react;
14
+
11
15
  @implementation GrabberOptions
12
16
 
13
17
  - (instancetype)init {
@@ -66,9 +70,7 @@ static const CGFloat kHitPaddingVertical = 10.0;
66
70
  - (void)setupView {
67
71
  self.clipsToBounds = NO;
68
72
  self.isAccessibilityElement = YES;
69
- self.accessibilityLabel = @"Sheet Grabber";
70
73
  self.accessibilityTraits = UIAccessibilityTraitAdjustable | UIAccessibilityTraitButton;
71
- self.accessibilityHint = @"Double-tap to expand. Swipe up or down to resize the sheet";
72
74
 
73
75
  UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTap)];
74
76
  [self addGestureRecognizer:tap];
@@ -103,6 +105,12 @@ static const CGFloat kHitPaddingVertical = 10.0;
103
105
 
104
106
  #pragma mark - Public
105
107
 
108
+ - (void)setAccessibilityOptions:(TrueSheetViewAccessibilityOptionsStruct)accessibilityOptions {
109
+ _accessibilityOptions = accessibilityOptions;
110
+ self.accessibilityLabel = RCTNSStringFromStringNilIfEmpty(accessibilityOptions.grabberLabel);
111
+ self.accessibilityHint = RCTNSStringFromStringNilIfEmpty(accessibilityOptions.grabberHint);
112
+ }
113
+
106
114
  - (void)updateAccessibilityValueWithIndex:(NSInteger)index detentCount:(NSInteger)count {
107
115
  if (index < 0 || count <= 0) {
108
116
  self.accessibilityValue = nil;
@@ -110,11 +118,13 @@ static const CGFloat kHitPaddingVertical = 10.0;
110
118
  }
111
119
 
112
120
  if (index >= count - 1) {
113
- self.accessibilityValue = @"Expanded";
121
+ self.accessibilityValue = RCTNSStringFromStringNilIfEmpty(_accessibilityOptions.expandedValue);
114
122
  } else if (index == 0) {
115
- self.accessibilityValue = @"Collapsed";
123
+ self.accessibilityValue = RCTNSStringFromStringNilIfEmpty(_accessibilityOptions.collapsedValue);
116
124
  } else {
117
- self.accessibilityValue = [NSString stringWithFormat:@"Detent %ld of %ld", (long)(index + 1), (long)count];
125
+ NSString *value = RCTNSStringFromStringNilIfEmpty(_accessibilityOptions.detentValue);
126
+ value = [value stringByReplacingOccurrencesOfString:@"{index}" withString:@(index + 1).stringValue];
127
+ self.accessibilityValue = [value stringByReplacingOccurrencesOfString:@"{count}" withString:@(count).stringValue];
118
128
  }
119
129
  }
120
130
 
@@ -83,6 +83,21 @@
83
83
 
84
84
  _currentHeight = keyboardHeight;
85
85
 
86
+ if (keyboardHeight > 0) {
87
+ _viewController.keyboardSheetGrown = YES;
88
+ } else {
89
+ // The sheet stays grown until UIKit finishes the shrink-back animation —
90
+ // clear after it completes, unless the keyboard came back in the meantime.
91
+ __weak __typeof(self) weakSelf = self;
92
+ dispatch_after(
93
+ dispatch_time(DISPATCH_TIME_NOW, (int64_t)((duration + 0.1) * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
94
+ __strong __typeof(weakSelf) strongSelf = weakSelf;
95
+ if (strongSelf && strongSelf->_currentHeight == 0) {
96
+ strongSelf->_viewController.keyboardSheetGrown = NO;
97
+ }
98
+ });
99
+ }
100
+
86
101
  for (id<TrueSheetKeyboardObserverDelegate> delegate in _delegates) {
87
102
  if (keyboardHeight > 0) {
88
103
  [delegate keyboardWillShow:keyboardHeight duration:duration curve:curve];