@swmansion/react-native-bottom-sheet 0.10.0-next.1 → 0.10.0-next.3

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.
@@ -62,6 +62,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
62
62
  private var pendingIndex: Int? = null
63
63
  private var hasLaidOut = false
64
64
  private var isPanning = false
65
+ private var panStartingIndex: Int? = null
65
66
 
66
67
  // MARK: - Internal
67
68
 
@@ -292,12 +293,15 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
292
293
  return
293
294
  }
294
295
 
296
+ val previousMaxHeight = detentSpecs.maxOfOrNull { it.height } ?: resolvedMaxDetentHeight()
295
297
  detentSpecs = resolvedDetents
296
298
  if (width > 0 && height > 0 && detentSpecs.isNotEmpty()) {
297
299
  layoutSheetContainer(width, height)
298
300
 
299
301
  if (hasLaidOut && !isPanning) {
300
302
  targetIndex = targetIndex.coerceIn(0, detentSpecs.size - 1)
303
+ val newMaxHeight = detentSpecs.maxOfOrNull { it.height } ?: resolvedMaxDetentHeight()
304
+ val targetTy = translationY(targetIndex)
301
305
  if (activeAnimation != null && isTargetingClosedDetent) {
302
306
  suppressScrimForClosingTarget = true
303
307
  hideScrim()
@@ -309,17 +313,26 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
309
313
  activeAnimation = null
310
314
  activeAnimationEmitsSettle = false
311
315
  stopChoreographer()
312
- sheetContainer.translationY =
313
- currentTy.coerceIn(0f, detentSpecs.maxOfOrNull { it.height } ?: currentTy)
316
+ // Re-anchor the in-flight position to the new container height so the
317
+ // sheet surface keeps the same on-screen height across the resize.
318
+ val visibleHeight = previousMaxHeight - currentTy
319
+ sheetContainer.translationY = (newMaxHeight - visibleHeight).coerceIn(0f, newMaxHeight)
314
320
  emitPosition()
315
321
  snapToIndex(targetIndex, 0f, emitIndexChange = false, emitSettle = shouldEmitSettle)
316
322
  } else {
317
- val targetTy = translationY(targetIndex)
318
- val currentTy = sheetContainer.translationY
319
- if (abs(targetTy - currentTy) <= 0.5f) {
323
+ val currentVisibleHeight = previousMaxHeight - sheetContainer.translationY
324
+ val targetHeight = detentSpecs.getOrNull(targetIndex)?.height ?: 0f
325
+ if (targetHeight <= currentVisibleHeight + 0.5f) {
326
+ // Content shrank (or is unchanged): snap immediately. Animating here
327
+ // would expose blank space below the shrunken content.
320
328
  sheetContainer.translationY = targetTy
321
329
  emitPosition()
322
330
  } else {
331
+ // Content grew: re-anchor at the current visible height, then animate
332
+ // up to the taller detent.
333
+ sheetContainer.translationY =
334
+ (newMaxHeight - currentVisibleHeight).coerceIn(0f, newMaxHeight)
335
+ emitPosition()
323
336
  snapToIndex(targetIndex, 0f, emitIndexChange = false, emitSettle = false)
324
337
  }
325
338
  }
@@ -381,20 +394,29 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
381
394
  private val isTargetingClosedDetent: Boolean
382
395
  get() = closedIndex?.let { targetIndex == it } == true
383
396
 
384
- private val draggableMinTy: Float
385
- get() {
386
- val highestIndex = detentSpecs.indices.lastOrNull { !detentSpecs[it].programmatic } ?: 0
387
- return translationY(highestIndex)
397
+ private fun snapCandidateIndices(includeIndex: Int? = null): List<Int> {
398
+ val indices = detentSpecs.indices.filter { !detentSpecs[it].programmatic }.toMutableList()
399
+ if (
400
+ includeIndex != null &&
401
+ includeIndex in detentSpecs.indices &&
402
+ detentSpecs[includeIndex].programmatic
403
+ ) {
404
+ indices.add(includeIndex)
388
405
  }
406
+ return indices.distinct().sortedBy { detentSpecs[it].height }
407
+ }
389
408
 
390
- private val draggableMaxTy: Float
391
- get() {
392
- val lowestIndex = detentSpecs.indices.firstOrNull { !detentSpecs[it].programmatic } ?: 0
393
- return translationY(lowestIndex)
394
- }
409
+ private fun draggableRange(includeIndex: Int? = null): ClosedFloatingPointRange<Float> {
410
+ val candidates = snapCandidateIndices(includeIndex)
411
+ if (candidates.isEmpty()) return 0f..0f
412
+ val translations = candidates.map(::translationY)
413
+ return (translations.minOrNull() ?: 0f)..(translations.maxOrNull() ?: 0f)
414
+ }
395
415
 
396
- private val isAtMaxDraggable: Boolean
397
- get() = sheetContainer.translationY <= draggableMinTy + 1f
416
+ private fun isAtMaxDragCandidate(includeIndex: Int? = null): Boolean {
417
+ val range = draggableRange(includeIndex)
418
+ return sheetContainer.translationY <= range.start + 1f
419
+ }
398
420
 
399
421
  private fun emitPosition() {
400
422
  val maxHeight = detentSpecs.maxOfOrNull { it.height } ?: resolvedMaxDetentHeight()
@@ -498,24 +520,24 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
498
520
  spring.start()
499
521
  }
500
522
 
501
- private fun bestSnapIndex(currentHeight: Float, velocity: Float): Int {
502
- val draggable = detentSpecs.withIndex().filter { !it.value.programmatic }
503
- if (draggable.isEmpty()) return targetIndex
523
+ private fun bestSnapIndex(currentHeight: Float, velocity: Float, includeIndex: Int? = null): Int {
524
+ val candidates = snapCandidateIndices(includeIndex)
525
+ if (candidates.isEmpty()) return targetIndex
504
526
 
505
527
  val flickThreshold = 600f * density
506
528
 
507
529
  if (velocity < -flickThreshold) {
508
- return draggable.firstOrNull { it.value.height > currentHeight }?.index
509
- ?: draggable.lastOrNull()?.index
530
+ return candidates.firstOrNull { detentSpecs[it].height > currentHeight }
531
+ ?: candidates.lastOrNull()
510
532
  ?: targetIndex
511
533
  }
512
534
  if (velocity > flickThreshold) {
513
- return draggable.lastOrNull { it.value.height < currentHeight }?.index
514
- ?: draggable.firstOrNull()?.index
535
+ return candidates.lastOrNull { detentSpecs[it].height < currentHeight }
536
+ ?: candidates.firstOrNull()
515
537
  ?: targetIndex
516
538
  }
517
539
 
518
- return draggable.minByOrNull { abs(it.value.height - currentHeight) }?.index ?: targetIndex
540
+ return candidates.minByOrNull { abs(detentSpecs[it].height - currentHeight) } ?: targetIndex
519
541
  }
520
542
 
521
543
  // MARK: - Touch handling
@@ -551,11 +573,12 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
551
573
  val dx = x - initialTouchX
552
574
  val dy = y - initialTouchY
553
575
 
554
- if (abs(dy) > touchSlop && abs(dy) > abs(dx) && draggableMinTy < draggableMaxTy) {
576
+ val dragRange = draggableRange(targetIndex)
577
+ if (abs(dy) > touchSlop && abs(dy) > abs(dx) && dragRange.start < dragRange.endInclusive) {
555
578
  if (disableScrollableNegotiation && findScrollableAtTouch() != null) {
556
579
  return false
557
580
  }
558
- if (!isAtMaxDraggable) {
581
+ if (!isAtMaxDragCandidate(targetIndex)) {
559
582
  lastTouchY = y
560
583
  requestDisallowInterceptTouchEvent(false)
561
584
  // Cancel in-flight JS touches. React Native's JSTouchDispatcher
@@ -628,7 +651,9 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
628
651
  val dy = y - lastTouchY
629
652
  lastTouchY = y
630
653
 
631
- val newTy = (sheetContainer.translationY + dy).coerceIn(draggableMinTy, draggableMaxTy)
654
+ val dragRange = draggableRange(panStartingIndex)
655
+ val newTy =
656
+ (sheetContainer.translationY + dy).coerceIn(dragRange.start, dragRange.endInclusive)
632
657
  sheetContainer.translationY = newTy
633
658
  emitPosition()
634
659
  return true
@@ -647,7 +672,8 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
647
672
  velocityTracker = null
648
673
  val maxHeight = detentSpecs.maxOfOrNull { it.height } ?: resolvedMaxDetentHeight()
649
674
  val currentHeight = maxHeight - sheetContainer.translationY
650
- val index = bestSnapIndex(currentHeight, velocity)
675
+ val index = bestSnapIndex(currentHeight, velocity, panStartingIndex)
676
+ panStartingIndex = null
651
677
  snapToIndex(index, velocity)
652
678
  return true
653
679
  }
@@ -667,6 +693,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
667
693
 
668
694
  private fun beginPan(event: MotionEvent) {
669
695
  isPanning = true
696
+ panStartingIndex = targetIndex
670
697
  activePointerId = event.getPointerId(0)
671
698
  lastTouchY = event.y
672
699
  velocityTracker?.recycle()
@@ -759,6 +786,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
759
786
  pendingIndex = null
760
787
  hasLaidOut = false
761
788
  isPanning = false
789
+ panStartingIndex = null
762
790
  initialTouchY = 0f
763
791
  initialTouchX = 0f
764
792
  lastTouchY = 0f
@@ -59,8 +59,10 @@ public final class RNSBottomSheetHostingView: UIView {
59
59
  private var pendingIndex: Int?
60
60
  private var hasLaidOut = false
61
61
  private var isPanning = false
62
+ private var panStartingIndex: Int?
62
63
  private var isContentInteractionDisabled = false
63
- private weak var contentHeightMarker: UIView?
64
+ private var contentHeightMarker: UIView?
65
+ private static var markerObservationContext = 0
64
66
 
65
67
  override public init(frame: CGRect) {
66
68
  super.init(frame: frame)
@@ -236,8 +238,9 @@ public final class RNSBottomSheetHostingView: UIView {
236
238
  pendingIndex = nil
237
239
  hasLaidOut = false
238
240
  isPanning = false
241
+ panStartingIndex = nil
239
242
  setContentInteractionEnabled(true)
240
- contentHeightMarker = nil
243
+ stopObservingContentHeightMarker()
241
244
  sheetContainer.transform = .identity
242
245
  scrimView.alpha = 0
243
246
  scrimView.isHidden = true
@@ -259,11 +262,27 @@ public final class RNSBottomSheetHostingView: UIView {
259
262
  return maxHeight - snapHeight
260
263
  }
261
264
 
262
- private var draggableRange: (minTy: CGFloat, maxTy: CGFloat) {
263
- let draggable = detentSpecs.enumerated().filter { !$0.element.programmatic }
264
- let highestIndex = draggable.last?.offset ?? 0
265
- let lowestIndex = draggable.first?.offset ?? 0
266
- return (minTy: translationY(for: highestIndex), maxTy: translationY(for: lowestIndex))
265
+ private func snapCandidateIndices(including index: Int? = nil) -> [Int] {
266
+ var indices = detentSpecs.indices.filter { !detentSpecs[$0].programmatic }
267
+ if
268
+ let index,
269
+ detentSpecs.indices.contains(index),
270
+ detentSpecs[index].programmatic
271
+ {
272
+ indices.append(index)
273
+ }
274
+ return Array(Set(indices)).sorted {
275
+ detentSpecs[$0].height < detentSpecs[$1].height
276
+ }
277
+ }
278
+
279
+ private func draggableRange(including index: Int? = nil) -> (minTy: CGFloat, maxTy: CGFloat) {
280
+ let candidates = snapCandidateIndices(including: index)
281
+ guard !candidates.isEmpty else { return (minTy: 0, maxTy: 0) }
282
+ return (
283
+ minTy: candidates.map { translationY(for: $0) }.min() ?? 0,
284
+ maxTy: candidates.map { translationY(for: $0) }.max() ?? 0
285
+ )
267
286
  }
268
287
 
269
288
  private var closedIndex: Int? {
@@ -392,6 +411,7 @@ public final class RNSBottomSheetHostingView: UIView {
392
411
  switch gesture.state {
393
412
  case .began:
394
413
  isPanning = true
414
+ panStartingIndex = targetIndex
395
415
  sheetContainer.endEditing(true)
396
416
  setContentInteractionEnabled(false)
397
417
  if let handler = surfaceTouchHandler {
@@ -410,8 +430,9 @@ public final class RNSBottomSheetHostingView: UIView {
410
430
  case .changed:
411
431
  let delta = gesture.translation(in: self).y
412
432
  gesture.setTranslation(.zero, in: self)
413
- let minTy = draggableRange.minTy
414
- let maxTy = draggableRange.maxTy
433
+ let range = draggableRange(including: panStartingIndex)
434
+ let minTy = range.minTy
435
+ let maxTy = range.maxTy
415
436
  let newTy = max(minTy, min(maxTy, sheetContainer.transform.ty + delta))
416
437
  sheetContainer.transform = CGAffineTransform(translationX: 0, y: newTy)
417
438
  emitPosition()
@@ -420,7 +441,8 @@ public final class RNSBottomSheetHostingView: UIView {
420
441
  isPanning = false
421
442
  let velocity = gesture.velocity(in: self).y
422
443
  let currentHeight = maxHeight - sheetContainer.transform.ty
423
- let index = bestSnapIndex(for: currentHeight, velocity: velocity)
444
+ let index = bestSnapIndex(for: currentHeight, velocity: velocity, including: panStartingIndex)
445
+ panStartingIndex = nil
424
446
  snapToIndex(index, velocity: velocity)
425
447
 
426
448
  case .cancelled:
@@ -428,11 +450,17 @@ public final class RNSBottomSheetHostingView: UIView {
428
450
  setContentInteractionEnabled(true)
429
451
  let cancelVelocity = gesture.velocity(in: self).y
430
452
  let cancelHeight = maxHeight - sheetContainer.transform.ty
431
- let cancelIndex = bestSnapIndex(for: cancelHeight, velocity: cancelVelocity)
453
+ let cancelIndex = bestSnapIndex(
454
+ for: cancelHeight,
455
+ velocity: cancelVelocity,
456
+ including: panStartingIndex
457
+ )
458
+ panStartingIndex = nil
432
459
  snapToIndex(cancelIndex, velocity: cancelVelocity)
433
460
 
434
461
  case .failed:
435
462
  isPanning = false
463
+ panStartingIndex = nil
436
464
  setContentInteractionEnabled(true)
437
465
 
438
466
  default:
@@ -440,24 +468,28 @@ public final class RNSBottomSheetHostingView: UIView {
440
468
  }
441
469
  }
442
470
 
443
- private func bestSnapIndex(for height: CGFloat, velocity: CGFloat) -> Int {
444
- let draggable = detentSpecs.enumerated().filter { !$0.element.programmatic }
445
- guard !draggable.isEmpty else { return targetIndex }
471
+ private func bestSnapIndex(
472
+ for height: CGFloat,
473
+ velocity: CGFloat,
474
+ including index: Int? = nil
475
+ ) -> Int {
476
+ let candidates = snapCandidateIndices(including: index)
477
+ guard !candidates.isEmpty else { return targetIndex }
446
478
 
447
479
  let flickThreshold: CGFloat = 600
448
480
 
449
481
  if velocity < -flickThreshold {
450
- return draggable.first(where: { $0.element.height > height })?.offset
451
- ?? draggable.last?.offset ?? targetIndex
482
+ return candidates.first(where: { detentSpecs[$0].height > height })
483
+ ?? candidates.last ?? targetIndex
452
484
  }
453
485
  if velocity > flickThreshold {
454
- return draggable.last(where: { $0.element.height < height })?.offset
455
- ?? draggable.first?.offset ?? targetIndex
486
+ return candidates.last(where: { detentSpecs[$0].height < height })
487
+ ?? candidates.first ?? targetIndex
456
488
  }
457
489
 
458
- return draggable.min(by: {
459
- abs($0.element.height - height) < abs($1.element.height - height)
460
- })?.offset ?? targetIndex
490
+ return candidates.min(by: {
491
+ abs(detentSpecs[$0].height - height) < abs(detentSpecs[$1].height - height)
492
+ }) ?? targetIndex
461
493
  }
462
494
 
463
495
  private func isVerticallyScrollable(_ scrollView: UIScrollView) -> Bool {
@@ -497,8 +529,8 @@ public final class RNSBottomSheetHostingView: UIView {
497
529
  let velocity = panGesture.velocity(in: self)
498
530
  guard abs(velocity.y) > abs(velocity.x) else { return false }
499
531
 
500
- let draggable = detentSpecs.enumerated().filter { !$0.element.programmatic }
501
- guard draggable.count > 1 else { return false }
532
+ let candidates = snapCandidateIndices(including: targetIndex)
533
+ guard candidates.count > 1 else { return false }
502
534
 
503
535
  if disableScrollableNegotiation {
504
536
  let locationInContainer = panGesture.location(in: sheetContainer)
@@ -507,9 +539,9 @@ public final class RNSBottomSheetHostingView: UIView {
507
539
  }
508
540
  }
509
541
 
510
- let maxDraggableIndex = draggable.last?.offset ?? 0
542
+ let maxCandidateHeight = candidates.map { detentSpecs[$0].height }.max() ?? 0
511
543
  // Below max: allow drag in either direction to reach other detents.
512
- guard targetIndex >= maxDraggableIndex else { return true }
544
+ guard currentSheetHeight >= maxCandidateHeight - 0.5 else { return true }
513
545
  // At max: only allow downward drag, and only when the scroll view (if any)
514
546
  // is at its top edge — otherwise the scroll view should handle the gesture.
515
547
  if velocity.y < 0 {
@@ -576,6 +608,7 @@ public final class RNSBottomSheetHostingView: UIView {
576
608
  return
577
609
  }
578
610
 
611
+ let previousMaxHeight = maximumResolvedDetentHeight ?? resolvedMaxDetentHeight
579
612
  detentSpecs = resolvedDetents
580
613
 
581
614
  guard bounds.width > 0, bounds.height > 0, !detentSpecs.isEmpty else {
@@ -584,6 +617,8 @@ public final class RNSBottomSheetHostingView: UIView {
584
617
 
585
618
  if hasLaidOut, !isPanning {
586
619
  targetIndex = max(0, min(detentSpecs.count - 1, targetIndex))
620
+ let newMaxHeight = maximumResolvedDetentHeight ?? resolvedMaxDetentHeight
621
+ let targetTy = translationY(for: targetIndex)
587
622
 
588
623
  if let animator = activeAnimator {
589
624
  stopDisplayLink()
@@ -592,19 +627,27 @@ public final class RNSBottomSheetHostingView: UIView {
592
627
  animator.stopAnimation(true)
593
628
  activeAnimator = nil
594
629
  activeAnimatorEmitsSettle = false
595
- sheetContainer.transform = CGAffineTransform(
596
- translationX: 0,
597
- y: min(max(visualTy, 0), maximumResolvedDetentHeight ?? visualTy)
598
- )
630
+ // Re-anchor the in-flight position to the new container height so the
631
+ // sheet surface keeps the same on-screen height across the resize.
632
+ let visibleHeight = previousMaxHeight - visualTy
633
+ let reanchoredTy = min(max(newMaxHeight - visibleHeight, 0), newMaxHeight)
634
+ sheetContainer.transform = CGAffineTransform(translationX: 0, y: reanchoredTy)
599
635
  emitPosition()
600
636
  snapToIndex(targetIndex, velocity: 0, emitIndexChange: false, emitSettle: shouldEmitSettle)
601
637
  } else {
602
- let targetTy = translationY(for: targetIndex)
603
- let currentTy = currentTranslationY
604
- if abs(targetTy - currentTy) <= 0.5 {
638
+ let currentVisibleHeight = previousMaxHeight - currentTranslationY
639
+ let targetHeight = detent(at: targetIndex).height
640
+ if targetHeight <= currentVisibleHeight + 0.5 {
641
+ // Content shrank (or is unchanged): snap immediately. Animating here
642
+ // would expose blank space below the shrunken content.
605
643
  sheetContainer.transform = CGAffineTransform(translationX: 0, y: targetTy)
606
644
  emitPosition()
607
645
  } else {
646
+ // Content grew: re-anchor at the current visible height, then animate
647
+ // up to the taller detent.
648
+ let startTy = min(max(newMaxHeight - currentVisibleHeight, 0), newMaxHeight)
649
+ sheetContainer.transform = CGAffineTransform(translationX: 0, y: startTy)
650
+ emitPosition()
608
651
  snapToIndex(targetIndex, velocity: 0, emitIndexChange: false, emitSettle: false)
609
652
  }
610
653
  }
@@ -612,7 +655,50 @@ public final class RNSBottomSheetHostingView: UIView {
612
655
  }
613
656
 
614
657
  private func refreshContentHeightMarker() {
615
- contentHeightMarker = findContentHeightMarker()
658
+ let marker = findContentHeightMarker()
659
+ guard marker !== contentHeightMarker else { return }
660
+ stopObservingContentHeightMarker()
661
+ contentHeightMarker = marker
662
+ if let marker {
663
+ // The marker's frame is updated by React Native when content above it
664
+ // resizes; observe its layer so we can re-resolve detents immediately
665
+ // instead of waiting for an unrelated layout pass. This is the iOS
666
+ // counterpart to Android's OnLayoutChangeListener on the marker.
667
+ marker.layer.addObserver(
668
+ self, forKeyPath: "position", options: [], context: &Self.markerObservationContext
669
+ )
670
+ marker.layer.addObserver(
671
+ self, forKeyPath: "bounds", options: [], context: &Self.markerObservationContext
672
+ )
673
+ }
674
+ }
675
+
676
+ private func stopObservingContentHeightMarker() {
677
+ guard let marker = contentHeightMarker else { return }
678
+ marker.layer.removeObserver(
679
+ self, forKeyPath: "position", context: &Self.markerObservationContext
680
+ )
681
+ marker.layer.removeObserver(
682
+ self, forKeyPath: "bounds", context: &Self.markerObservationContext
683
+ )
684
+ contentHeightMarker = nil
685
+ }
686
+
687
+ override public func observeValue(
688
+ forKeyPath keyPath: String?,
689
+ of object: Any?,
690
+ change: [NSKeyValueChangeKey: Any]?,
691
+ context: UnsafeMutableRawPointer?
692
+ ) {
693
+ if context == &Self.markerObservationContext {
694
+ refreshDetentsFromLayout()
695
+ } else {
696
+ super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
697
+ }
698
+ }
699
+
700
+ deinit {
701
+ stopObservingContentHeightMarker()
616
702
  }
617
703
 
618
704
  private func findContentHeightMarker() -> UIView? {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swmansion/react-native-bottom-sheet",
3
- "version": "0.10.0-next.1",
3
+ "version": "0.10.0-next.3",
4
4
  "description": "Provides bottom-sheet components for React Native.",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",