react-native-nitro-modal 0.1.5 → 0.3.0-rc

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 (99) hide show
  1. package/README.md +65 -11
  2. package/android/src/main/java/com/margelo/nitro/nitromodal/BottomSheetPresenter.kt +335 -34
  3. package/android/src/main/java/com/margelo/nitro/nitromodal/HybridNitroModal.kt +63 -22
  4. package/android/src/main/java/com/margelo/nitro/nitromodal/ModalConfig.kt +31 -1
  5. package/android/src/main/java/com/margelo/nitro/nitromodal/ModalContentRoot.kt +48 -5
  6. package/android/src/main/java/com/margelo/nitro/nitromodal/ModalPresenter.kt +63 -4
  7. package/android/src/main/java/com/margelo/nitro/nitromodal/NitroModalHostView.kt +68 -8
  8. package/ios/HybridNitroModal.swift +111 -20
  9. package/ios/ModalConfig.swift +44 -2
  10. package/ios/NitroModalHostView.swift +73 -8
  11. package/ios/SheetView.swift +575 -0
  12. package/ios/SheetViewController.swift +19 -450
  13. package/lib/module/NitroModal.js +39 -7
  14. package/lib/module/NitroModal.js.map +1 -1
  15. package/lib/module/NitroModalView.js +4 -3
  16. package/lib/module/NitroModalView.js.map +1 -1
  17. package/lib/module/NitroModalView.native.js +3 -0
  18. package/lib/module/NitroModalView.native.js.map +1 -1
  19. package/lib/module/NitroModalView.web.js +159 -0
  20. package/lib/module/NitroModalView.web.js.map +1 -0
  21. package/lib/module/index.js.map +1 -1
  22. package/lib/module/web/AnimatedValue.js +126 -0
  23. package/lib/module/web/AnimatedValue.js.map +1 -0
  24. package/lib/module/web/ModalConfig.js +30 -0
  25. package/lib/module/web/ModalConfig.js.map +1 -0
  26. package/lib/module/web/ModalController.js +306 -0
  27. package/lib/module/web/ModalController.js.map +1 -0
  28. package/lib/module/web/ModalPresenter.js +280 -0
  29. package/lib/module/web/ModalPresenter.js.map +1 -0
  30. package/lib/module/web/PopupPresenter.js +117 -0
  31. package/lib/module/web/PopupPresenter.js.map +1 -0
  32. package/lib/module/web/SheetPresenter.js +494 -0
  33. package/lib/module/web/SheetPresenter.js.map +1 -0
  34. package/lib/module/web/dom.js +138 -0
  35. package/lib/module/web/dom.js.map +1 -0
  36. package/lib/typescript/src/NitroModal.d.ts +35 -4
  37. package/lib/typescript/src/NitroModal.d.ts.map +1 -1
  38. package/lib/typescript/src/NitroModal.nitro.d.ts +29 -2
  39. package/lib/typescript/src/NitroModal.nitro.d.ts.map +1 -1
  40. package/lib/typescript/src/NitroModalView.d.ts +2 -1
  41. package/lib/typescript/src/NitroModalView.d.ts.map +1 -1
  42. package/lib/typescript/src/NitroModalView.native.d.ts +1 -0
  43. package/lib/typescript/src/NitroModalView.native.d.ts.map +1 -1
  44. package/lib/typescript/src/NitroModalView.web.d.ts +6 -0
  45. package/lib/typescript/src/NitroModalView.web.d.ts.map +1 -0
  46. package/lib/typescript/src/index.d.ts +2 -2
  47. package/lib/typescript/src/index.d.ts.map +1 -1
  48. package/lib/typescript/src/web/AnimatedValue.d.ts +49 -0
  49. package/lib/typescript/src/web/AnimatedValue.d.ts.map +1 -0
  50. package/lib/typescript/src/web/ModalConfig.d.ts +30 -0
  51. package/lib/typescript/src/web/ModalConfig.d.ts.map +1 -0
  52. package/lib/typescript/src/web/ModalController.d.ts +70 -0
  53. package/lib/typescript/src/web/ModalController.d.ts.map +1 -0
  54. package/lib/typescript/src/web/ModalPresenter.d.ts +107 -0
  55. package/lib/typescript/src/web/ModalPresenter.d.ts.map +1 -0
  56. package/lib/typescript/src/web/PopupPresenter.d.ts +20 -0
  57. package/lib/typescript/src/web/PopupPresenter.d.ts.map +1 -0
  58. package/lib/typescript/src/web/SheetPresenter.d.ts +65 -0
  59. package/lib/typescript/src/web/SheetPresenter.d.ts.map +1 -0
  60. package/lib/typescript/src/web/dom.d.ts +42 -0
  61. package/lib/typescript/src/web/dom.d.ts.map +1 -0
  62. package/nitrogen/generated/android/c++/JHybridNitroModalSpec.cpp +60 -6
  63. package/nitrogen/generated/android/c++/JHybridNitroModalSpec.hpp +10 -2
  64. package/nitrogen/generated/android/c++/JNamedSheetDetent.hpp +64 -0
  65. package/nitrogen/generated/android/c++/JSheetDetent.cpp +26 -0
  66. package/nitrogen/generated/android/c++/JSheetDetent.hpp +42 -37
  67. package/nitrogen/generated/android/c++/views/JHybridNitroModalStateUpdater.cpp +20 -0
  68. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/HybridNitroModalSpec.kt +40 -0
  69. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/NamedSheetDetent.kt +25 -0
  70. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/SheetDetent.kt +47 -10
  71. package/nitrogen/generated/android/nitromodal+autolinking.cmake +1 -0
  72. package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Bridge.hpp +53 -8
  73. package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Umbrella.hpp +4 -3
  74. package/nitrogen/generated/ios/c++/HybridNitroModalSpecSwift.hpp +32 -5
  75. package/nitrogen/generated/ios/c++/views/HybridNitroModalComponent.mm +24 -0
  76. package/nitrogen/generated/ios/swift/HybridNitroModalSpec.swift +4 -0
  77. package/nitrogen/generated/ios/swift/HybridNitroModalSpec_cxx.swift +110 -5
  78. package/nitrogen/generated/ios/swift/NamedSheetDetent.swift +48 -0
  79. package/nitrogen/generated/ios/swift/SheetDetent.swift +15 -33
  80. package/nitrogen/generated/shared/c++/HybridNitroModalSpec.cpp +8 -0
  81. package/nitrogen/generated/shared/c++/HybridNitroModalSpec.hpp +14 -5
  82. package/nitrogen/generated/shared/c++/{SheetDetent.hpp → NamedSheetDetent.hpp} +17 -17
  83. package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.cpp +9 -1
  84. package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.hpp +15 -2
  85. package/nitrogen/generated/shared/json/NitroModalConfig.json +4 -0
  86. package/package.json +3 -2
  87. package/src/NitroModal.nitro.ts +30 -2
  88. package/src/NitroModal.tsx +71 -8
  89. package/src/NitroModalView.native.tsx +4 -0
  90. package/src/NitroModalView.tsx +9 -4
  91. package/src/NitroModalView.web.tsx +180 -0
  92. package/src/index.tsx +2 -0
  93. package/src/web/AnimatedValue.ts +193 -0
  94. package/src/web/ModalConfig.ts +67 -0
  95. package/src/web/ModalController.ts +338 -0
  96. package/src/web/ModalPresenter.ts +333 -0
  97. package/src/web/PopupPresenter.ts +129 -0
  98. package/src/web/SheetPresenter.ts +647 -0
  99. package/src/web/dom.ts +171 -0
@@ -1,21 +1,34 @@
1
1
  package com.margelo.nitro.nitromodal
2
2
 
3
+ import android.animation.ValueAnimator
4
+ import android.annotation.SuppressLint
3
5
  import android.app.Activity
6
+ import android.content.Context
4
7
  import android.graphics.Color
5
8
  import android.graphics.Outline
6
9
  import android.graphics.drawable.GradientDrawable
10
+ import android.os.Build
7
11
  import android.view.Gravity
12
+ import android.view.HapticFeedbackConstants
13
+ import android.view.MotionEvent
8
14
  import android.view.View
15
+ import android.view.ViewConfiguration
9
16
  import android.view.ViewGroup
10
17
  import android.view.ViewOutlineProvider
18
+ import android.view.animation.DecelerateInterpolator
11
19
  import android.view.animation.PathInterpolator
12
20
  import android.widget.FrameLayout
13
21
  import androidx.activity.BackEventCompat
14
22
  import androidx.coordinatorlayout.widget.CoordinatorLayout
15
23
  import androidx.core.graphics.Insets
24
+ import androidx.core.view.ViewCompat
16
25
  import androidx.core.view.doOnLayout
26
+ import com.facebook.react.uimanager.PointerEvents
27
+ import com.facebook.react.uimanager.ReactPointerEventsView
17
28
  import com.facebook.react.uimanager.ThemedReactContext
29
+ import com.facebook.react.uimanager.events.NativeGestureUtil
18
30
  import com.google.android.material.bottomsheet.BottomSheetBehavior
31
+ import kotlin.math.abs
19
32
  import kotlin.math.ceil
20
33
 
21
34
  /**
@@ -25,6 +38,9 @@ import kotlin.math.ceil
25
38
  * `BottomSheetBehavior` supports up to three resting states, so detents map
26
39
  * onto collapsed / half-expanded / expanded, smallest first. Predictive back
27
40
  * uses the behavior's own Material animation, like `BottomSheetDialog`.
41
+ *
42
+ * Inline, the sheet fills the host view instead of a window, with no
43
+ * backdrop, and touches that miss the sheet reach the views behind it.
28
44
  */
29
45
  internal class BottomSheetPresenter(
30
46
  activity: Activity,
@@ -32,22 +48,30 @@ internal class BottomSheetPresenter(
32
48
  contentRoot: ModalContentRoot,
33
49
  config: ModalConfig,
34
50
  listener: Listener,
35
- ) : ModalPresenter(activity, reactContext, contentRoot, config, listener) {
51
+ inlineHost: NitroModalHostView? = null,
52
+ ) : ModalPresenter(activity, reactContext, contentRoot, config, listener, inlineHost) {
36
53
 
37
- private class Detent(val index: Int, val height: Int)
54
+ private class RestingHeight(val index: Int, val height: Int)
38
55
 
39
- private val coordinator = CoordinatorLayout(activity)
56
+ private val coordinator = SheetLayout(activity)
40
57
  private val sheet = FrameLayout(activity)
41
58
  private val grabber = View(activity)
42
- private val behavior = BottomSheetBehavior<FrameLayout>()
59
+ private val behavior = SheetBehavior()
43
60
  private val sheetBackground = GradientDrawable()
44
61
  private var cornerRadiusPx = 0f
45
62
 
46
63
  /** Resting detents sorted by height; position i maps to [states][i]. */
47
- private var sortedDetents: List<Detent> = emptyList()
64
+ private var sortedDetents: List<RestingHeight> = emptyList()
48
65
  private var maxContentHeight = 0
49
66
  private var presented = false
50
67
  private var lastDetentIndex = -1
68
+ private var reportedRestingTop = Int.MIN_VALUE
69
+
70
+ /** The behavior is dragging the sheet (as opposed to a nested scroll moving it). */
71
+ private var sheetDragging = false
72
+
73
+ /** How far the sheet is pulled below its lowest detent, in px. */
74
+ private var pullOffset = 0f
51
75
 
52
76
  /** A predictive back gesture was committed; the behavior is sliding the sheet out. */
53
77
  private var committingBack = false
@@ -59,12 +83,20 @@ internal class BottomSheetPresenter(
59
83
  finishUserDismissal(DismissReason.SWIPE)
60
84
  return
61
85
  }
86
+ if (newState == BottomSheetBehavior.STATE_DRAGGING) {
87
+ sheetDragging = true
88
+ coordinator.notifyNativeGesture(started = true)
89
+ } else if (sheetDragging) {
90
+ sheetDragging = false
91
+ coordinator.notifyNativeGesture(started = false)
92
+ }
62
93
  val position = states().indexOf(newState)
63
94
  if (position < 0 || isDismissing) return
64
95
  presented = true
65
96
  // Hideable while entering/exiting; at rest, swipe-to-dismiss follows the prop.
66
97
  behavior.isHideable = this@BottomSheetPresenter.config.dismissOnSwipe
67
98
  notifyPresented()
99
+ reportRestingTop()
68
100
  val index = sortedDetents.getOrNull(position)?.index ?: return
69
101
  if (index != lastDetentIndex) {
70
102
  lastDetentIndex = index
@@ -79,10 +111,12 @@ internal class BottomSheetPresenter(
79
111
  }
80
112
 
81
113
  override fun createLayout(): ViewGroup {
82
- coordinator.addView(backdrop, CoordinatorLayout.LayoutParams(MATCH, MATCH))
114
+ if (!isInline) coordinator.addView(backdrop, CoordinatorLayout.LayoutParams(MATCH, MATCH))
83
115
  coordinator.addOnLayoutChangeListener { _, l, t, r, b, oldL, oldT, oldR, oldB ->
84
116
  // Rotation / window resize. Posted: detents change the sheet's layout params.
85
117
  if (r - l != oldR - oldL || b - t != oldB - oldT) coordinator.post { updateDetents() }
118
+ // Detent heights can move the resting sheet without a state change.
119
+ reportRestingTop()
86
120
  }
87
121
 
88
122
  sheet.background = sheetBackground
@@ -106,7 +140,8 @@ internal class BottomSheetPresenter(
106
140
  applyAppearance()
107
141
 
108
142
  behavior.isGestureInsetBottomIgnored = true
109
- behavior.maxWidth = dp(MAX_WIDTH_DP)
143
+ // Inline, the host view decides the width.
144
+ if (!isInline) behavior.maxWidth = dp(MAX_WIDTH_DP)
110
145
  behavior.isHideable = true
111
146
  behavior.state = BottomSheetBehavior.STATE_HIDDEN
112
147
  // Registered after the initial HIDDEN state so it doesn't read as a dismissal.
@@ -134,6 +169,7 @@ internal class BottomSheetPresenter(
134
169
  }
135
170
 
136
171
  override fun animateOut(fromBackGesture: Boolean, onEnd: () -> Unit) {
172
+ coordinator.cancelPull()
137
173
  if (behavior.state == BottomSheetBehavior.STATE_HIDDEN || !coordinator.isLaidOut) {
138
174
  onEnd()
139
175
  return
@@ -180,13 +216,13 @@ internal class BottomSheetPresenter(
180
216
  override fun onInsetsChanged() {
181
217
  sheet.setPadding(systemInsets.left, 0, systemInsets.right, systemInsets.bottom)
182
218
  updateDetents()
183
- applyKeyboardOffset()
219
+ applyTranslation()
184
220
  }
185
221
 
186
- override fun onKeyboardProgress() = applyKeyboardOffset()
222
+ override fun onKeyboardProgress() = applyTranslation()
187
223
 
188
224
  override fun onContentSizeChanged() {
189
- if (config.detents.contains(SheetDetent.FITCONTENT)) updateDetents()
225
+ if (config.detents.contains(Detent.FitContent)) updateDetents()
190
226
  }
191
227
 
192
228
  override fun snapToDetent(index: Int) {
@@ -194,6 +230,8 @@ internal class BottomSheetPresenter(
194
230
  behavior.state = stateFor(index)
195
231
  }
196
232
 
233
+ override fun onTeardown() = coordinator.release()
234
+
197
235
  private fun applyAppearance() {
198
236
  val radius = activity.dpToPx(config.cornerRadius ?: DEFAULT_CORNER_RADIUS_DP)
199
237
  if (radius != cornerRadiusPx) {
@@ -216,9 +254,9 @@ internal class BottomSheetPresenter(
216
254
  val bottomInset = systemInsets.bottom
217
255
  maxContentHeight = (parentHeight - systemInsets.top - bottomInset).coerceAtLeast(1)
218
256
 
219
- val requested = config.detents.ifEmpty { listOf(SheetDetent.FITCONTENT) }
257
+ val requested = config.detents.ifEmpty { listOf(Detent.FitContent) }
220
258
  val detents = requested
221
- .mapIndexed { index, detent -> Detent(index, contentHeightFor(detent) + bottomInset) }
259
+ .mapIndexed { index, detent -> RestingHeight(index, contentHeightFor(detent) + bottomInset) }
222
260
  .distinctBy { it.height }
223
261
  .sortedBy { it.height }
224
262
  sortedDetents = when {
@@ -256,18 +294,19 @@ internal class BottomSheetPresenter(
256
294
  }
257
295
 
258
296
  val availableHeight =
259
- if (SheetDetent.FITCONTENT in requested) maxContentHeight else sheetHeight - bottomInset
297
+ if (Detent.FitContent in requested) maxContentHeight else sheetHeight - bottomInset
260
298
  val areaHeight = availableHeight -
261
- if (config.keyboardBehavior == KeyboardBehavior.RESIZE) keyboardOverlap else 0
299
+ if (!isInline && config.keyboardBehavior == KeyboardBehavior.RESIZE) keyboardOverlap else 0
262
300
  val sheetWidth = minOf(coordinator.width, behavior.maxWidth.takeIf { it > 0 } ?: Int.MAX_VALUE)
263
301
  reportContentArea(sheetWidth - systemInsets.left - systemInsets.right, areaHeight)
264
302
  }
265
303
 
266
- private fun contentHeightFor(detent: SheetDetent): Int = when (detent) {
267
- SheetDetent.SMALL -> (maxContentHeight * 0.25f).toInt()
268
- SheetDetent.MEDIUM -> (maxContentHeight * 0.5f).toInt()
269
- SheetDetent.LARGE -> maxContentHeight
270
- SheetDetent.FITCONTENT -> contentRoot.contentHeight.coerceIn(1, maxContentHeight)
304
+ private fun contentHeightFor(detent: Detent): Int = when (detent) {
305
+ Detent.Small -> (maxContentHeight * 0.25f).toInt()
306
+ Detent.Medium -> (maxContentHeight * 0.5f).toInt()
307
+ Detent.Large -> maxContentHeight
308
+ Detent.FitContent -> contentRoot.contentHeight.coerceIn(1, maxContentHeight)
309
+ is Detent.Height -> activity.dpToPx(detent.dp).toInt().coerceIn(1, maxContentHeight)
271
310
  }
272
311
 
273
312
  private fun states(): List<Int> = when (sortedDetents.size) {
@@ -289,21 +328,241 @@ internal class BottomSheetPresenter(
289
328
  val position = if (target == null) {
290
329
  states.lastIndex
291
330
  } else {
292
- sortedDetents.indices.minBy { kotlin.math.abs(sortedDetents[it].height - target) }
331
+ sortedDetents.indices.minBy { abs(sortedDetents[it].height - target) }
293
332
  }
294
333
  return states[position]
295
334
  }
296
335
 
297
- /** Lifts the sheet above the keyboard without relayout (`pan` and `resize`). */
298
- private fun applyKeyboardOffset() {
336
+ /**
337
+ * Inline: React lays the content out at the sheet's resting top, so what
338
+ * `measure()` reports (and presses rely on) matches the screen.
339
+ */
340
+ private fun reportRestingTop() {
341
+ if (!isInline || isDismissing || behavior.state !in states()) return
342
+ val top = sheet.top
343
+ if (top == reportedRestingTop) return
344
+ reportedRestingTop = top
345
+ listener.onRestingTopChange(top)
346
+ }
347
+
348
+ /**
349
+ * Lifts the sheet above the keyboard without relayout (`pan` and `resize`,
350
+ * modal only), and moves it down while it's pulled to refresh.
351
+ */
352
+ private fun applyTranslation() {
299
353
  // The back commit animation owns translationY until the window goes.
300
354
  if (committingBack) return
301
- if (config.keyboardBehavior == KeyboardBehavior.NONE) {
302
- sheet.translationY = 0f
303
- return
355
+ val lift = if (isInline || config.keyboardBehavior == KeyboardBehavior.NONE) {
356
+ 0
357
+ } else {
358
+ val headroom = (sheet.top - systemInsets.top).coerceAtLeast(0)
359
+ keyboardOverlap.coerceAtMost(headroom)
360
+ }
361
+ sheet.translationY = pullOffset - lift
362
+ }
363
+
364
+ private fun setPullOffset(offset: Float) {
365
+ pullOffset = offset
366
+ applyTranslation()
367
+ }
368
+
369
+ /**
370
+ * Gives up dragging the sheet down on nested scrollable content once that
371
+ * content scrolled during the gesture: it scrolls back to its top, and only
372
+ * a new gesture collapses the sheet.
373
+ */
374
+ private inner class SheetBehavior : BottomSheetBehavior<FrameLayout>() {
375
+ private var contentScrolled = false
376
+
377
+ override fun onInterceptTouchEvent(parent: CoordinatorLayout, child: FrameLayout, event: MotionEvent): Boolean {
378
+ if (event.actionMasked == MotionEvent.ACTION_DOWN && state in states()) {
379
+ // React may have mounted the scrollable content after the last layout
380
+ // pass; laying out again re-finds it. The resting sheet doesn't move.
381
+ onLayoutChild(parent, child, parent.layoutDirection)
382
+ }
383
+ return super.onInterceptTouchEvent(parent, child, event)
384
+ }
385
+
386
+ override fun onStartNestedScroll(
387
+ coordinatorLayout: CoordinatorLayout,
388
+ child: FrameLayout,
389
+ directTargetChild: View,
390
+ target: View,
391
+ axes: Int,
392
+ type: Int,
393
+ ): Boolean {
394
+ if (type == ViewCompat.TYPE_TOUCH) contentScrolled = false
395
+ return super.onStartNestedScroll(coordinatorLayout, child, directTargetChild, target, axes, type)
396
+ }
397
+
398
+ override fun onNestedPreScroll(
399
+ coordinatorLayout: CoordinatorLayout,
400
+ child: FrameLayout,
401
+ target: View,
402
+ dx: Int,
403
+ dy: Int,
404
+ consumed: IntArray,
405
+ type: Int,
406
+ ) {
407
+ if (dy < 0 && contentScrolled) return
408
+ super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
409
+ }
410
+
411
+ override fun onNestedScroll(
412
+ coordinatorLayout: CoordinatorLayout,
413
+ child: FrameLayout,
414
+ target: View,
415
+ dxConsumed: Int,
416
+ dyConsumed: Int,
417
+ dxUnconsumed: Int,
418
+ dyUnconsumed: Int,
419
+ type: Int,
420
+ consumed: IntArray,
421
+ ) {
422
+ if (type == ViewCompat.TYPE_TOUCH && dyConsumed != 0) contentScrolled = true
423
+ super.onNestedScroll(
424
+ coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, consumed,
425
+ )
426
+ }
427
+ }
428
+
429
+ /**
430
+ * The sheet's parent. Touches that miss the sheet fall through to the views
431
+ * behind it (inline), JS touches are cancelled when the sheet takes over a
432
+ * gesture, and pulling the sheet down past its lowest detent refreshes:
433
+ * `BottomSheetBehavior` stops there, so that pull is handled here.
434
+ */
435
+ @SuppressLint("ViewConstructor")
436
+ private inner class SheetLayout(context: Context) : CoordinatorLayout(context), ReactPointerEventsView {
437
+ private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
438
+ private val refreshThreshold = context.dpToPx(REFRESH_THRESHOLD_DP)
439
+ private var lastEvent: MotionEvent? = null
440
+ private var downX = 0f
441
+ private var downY = 0f
442
+ private var canPull = false
443
+ private var pulling = false
444
+ private var armed = false
445
+ private var pullAnimator: ValueAnimator? = null
446
+
447
+ override val pointerEvents: PointerEvents
448
+ get() = PointerEvents.BOX_NONE
449
+
450
+ override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
451
+ lastEvent?.recycle()
452
+ lastEvent = MotionEvent.obtain(ev)
453
+ return super.dispatchTouchEvent(ev)
454
+ }
455
+
456
+ override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
457
+ if (trackPull(ev)) return true
458
+ return super.onInterceptTouchEvent(ev)
459
+ }
460
+
461
+ @SuppressLint("ClickableViewAccessibility")
462
+ override fun onTouchEvent(ev: MotionEvent): Boolean {
463
+ if (!pulling && trackPull(ev)) {
464
+ // The behavior had the gesture: let it go of it.
465
+ val cancel = MotionEvent.obtain(ev).apply { action = MotionEvent.ACTION_CANCEL }
466
+ super.onTouchEvent(cancel)
467
+ cancel.recycle()
468
+ return true
469
+ }
470
+ if (!pulling) return super.onTouchEvent(ev)
471
+ when (ev.actionMasked) {
472
+ MotionEvent.ACTION_MOVE -> {
473
+ val offset = rubberBand((ev.y - downY - touchSlop).coerceAtLeast(0f), height.toFloat())
474
+ setPullOffset(offset)
475
+ val nowArmed = offset >= refreshThreshold
476
+ if (nowArmed && !armed) performHapticFeedback(THRESHOLD_FEEDBACK)
477
+ armed = nowArmed
478
+ }
479
+ MotionEvent.ACTION_UP -> endPull(ev, refresh = armed)
480
+ MotionEvent.ACTION_CANCEL -> endPull(ev, refresh = false)
481
+ }
482
+ return true
483
+ }
484
+
485
+ /** Follows a gesture until it turns out to be a pull. Returns true once it is. */
486
+ private fun trackPull(ev: MotionEvent): Boolean {
487
+ when (ev.actionMasked) {
488
+ MotionEvent.ACTION_DOWN -> {
489
+ downX = ev.x
490
+ downY = ev.y
491
+ canPull = canStartPull(ev.x, ev.y)
492
+ }
493
+ MotionEvent.ACTION_POINTER_DOWN -> canPull = false
494
+ MotionEvent.ACTION_MOVE -> if (canPull && !pulling) {
495
+ val dx = ev.x - downX
496
+ val dy = ev.y - downY
497
+ if (dy > touchSlop && dy > abs(dx)) {
498
+ pulling = true
499
+ armed = false
500
+ parent?.requestDisallowInterceptTouchEvent(true)
501
+ notifyNativeGesture(started = true)
502
+ return true
503
+ }
504
+ if (abs(dx) > touchSlop || dy < -touchSlop) canPull = false
505
+ }
506
+ }
507
+ return false
508
+ }
509
+
510
+ /**
511
+ * Only from the lowest detent at rest, on the sheet, and where nothing
512
+ * under the finger can scroll up instead.
513
+ */
514
+ private fun canStartPull(x: Float, y: Float): Boolean {
515
+ if (!config.pullToRefreshEnabled || config.dismissOnSwipe || !presented || isDismissing) return false
516
+ if (pullOffset != 0f || behavior.state != states().firstOrNull()) return false
517
+ val top = sheet.top + sheet.translationY
518
+ if (y < top || x < sheet.left || x >= sheet.right) return false
519
+ return !canScrollUpAt(sheet, x - sheet.left, y - top)
520
+ }
521
+
522
+ private fun endPull(ev: MotionEvent, refresh: Boolean) {
523
+ pulling = false
524
+ armed = false
525
+ canPull = false
526
+ notifyNativeGesture(started = false, event = ev)
527
+ pullAnimator = ValueAnimator.ofFloat(pullOffset, 0f).apply {
528
+ duration = PULL_RELEASE_DURATION
529
+ interpolator = DecelerateInterpolator()
530
+ addUpdateListener { setPullOffset(it.animatedValue as Float) }
531
+ start()
532
+ }
533
+ if (refresh) listener.onPullToRefresh()
534
+ }
535
+
536
+ fun cancelPull() {
537
+ pulling = false
538
+ canPull = false
539
+ pullAnimator?.cancel()
540
+ pullAnimator = null
541
+ setPullOffset(0f)
542
+ }
543
+
544
+ /**
545
+ * Inline, the sheet lives inside the React root view, which forwards
546
+ * touches to JS. Tell it when the sheet takes over (or lets go of) a
547
+ * gesture, so a press under the finger is cancelled. A modal's content
548
+ * root hears about it from the touch cancellation instead.
549
+ */
550
+ fun notifyNativeGesture(started: Boolean, event: MotionEvent? = lastEvent) {
551
+ if (!isInline) return
552
+ val ev = event ?: return
553
+ if (started) {
554
+ NativeGestureUtil.notifyNativeGestureStarted(this, ev)
555
+ } else {
556
+ NativeGestureUtil.notifyNativeGestureEnded(this, ev)
557
+ }
558
+ }
559
+
560
+ fun release() {
561
+ pullAnimator?.cancel()
562
+ pullAnimator = null
563
+ lastEvent?.recycle()
564
+ lastEvent = null
304
565
  }
305
- val headroom = (sheet.top - systemInsets.top).coerceAtLeast(0)
306
- sheet.translationY = -keyboardOverlap.coerceAtMost(headroom).toFloat()
307
566
  }
308
567
 
309
568
  companion object {
@@ -311,27 +570,69 @@ internal class BottomSheetPresenter(
311
570
  private const val DEFAULT_CORNER_RADIUS_DP = 28f
312
571
  private const val MAX_WIDTH_DP = 640f
313
572
 
573
+ /** How far below its lowest detent the sheet must be pulled to refresh. */
574
+ private const val REFRESH_THRESHOLD_DP = 56f
575
+ private const val PULL_RELEASE_DURATION = 300L
576
+ private val THRESHOLD_FEEDBACK =
577
+ if (Build.VERSION.SDK_INT >= 34) {
578
+ HapticFeedbackConstants.GESTURE_THRESHOLD_ACTIVATE
579
+ } else {
580
+ HapticFeedbackConstants.CONTEXT_CLICK
581
+ }
582
+
314
583
  /** `MaterialBottomContainerBackHelper`'s default hide durations. */
315
584
  private const val BACK_HIDE_MAX_DURATION = 300L
316
585
  private const val BACK_HIDE_MIN_DURATION = 150L
317
586
  private val FAST_OUT_SLOW_IN = PathInterpolator(0.4f, 0f, 0.2f, 1f)
318
587
 
588
+ private fun rubberBand(offset: Float, dimension: Float): Float {
589
+ if (dimension <= 0f) return 0f
590
+ return (1f - 1f / (offset * 0.55f / dimension + 1f)) * dimension
591
+ }
592
+
593
+ /** Whether a view under (x, y), in [view]'s coordinates, can scroll up. */
594
+ private fun canScrollUpAt(view: View, x: Float, y: Float): Boolean {
595
+ if (view.canScrollVertically(-1)) return true
596
+ if (view !is ViewGroup) return false
597
+ for (i in view.childCount - 1 downTo 0) {
598
+ val child = view.getChildAt(i)
599
+ if (child.visibility != View.VISIBLE) continue
600
+ val childX = x + view.scrollX - child.left - child.translationX
601
+ val childY = y + view.scrollY - child.top - child.translationY
602
+ if (childX >= 0 && childY >= 0 && childX < child.width && childY < child.height &&
603
+ canScrollUpAt(child, childX, childY)
604
+ ) {
605
+ return true
606
+ }
607
+ }
608
+ return false
609
+ }
610
+
319
611
  /** Content area before the sheet is on screen, in px. */
320
612
  fun estimateContentArea(
321
613
  activity: Activity,
322
- detents: List<SheetDetent>,
614
+ detents: List<Detent>,
323
615
  width: Int,
324
616
  height: Int,
325
617
  insets: Insets,
618
+ isInline: Boolean,
326
619
  ): Pair<Int, Int> {
327
620
  val maxContent = height - insets.top - insets.bottom
328
- val fraction = when {
329
- detents.isEmpty() || SheetDetent.LARGE in detents || SheetDetent.FITCONTENT in detents -> 1f
330
- SheetDetent.MEDIUM in detents -> 0.5f
331
- else -> 0.25f
621
+ val contentHeight = if (detents.isEmpty()) {
622
+ maxContent
623
+ } else {
624
+ detents.maxOf {
625
+ when (it) {
626
+ Detent.Small -> (maxContent * 0.25f).toInt()
627
+ Detent.Medium -> (maxContent * 0.5f).toInt()
628
+ // fitContent content can grow up to the maximum.
629
+ Detent.Large, Detent.FitContent -> maxContent
630
+ is Detent.Height -> activity.dpToPx(it.dp).toInt().coerceIn(1, maxContent.coerceAtLeast(1))
631
+ }
632
+ }
332
633
  }
333
- val sheetWidth = minOf(width, activity.dpToPx(MAX_WIDTH_DP).toInt())
334
- return Pair(sheetWidth - insets.left - insets.right, (maxContent * fraction).toInt())
634
+ val sheetWidth = if (isInline) width else minOf(width, activity.dpToPx(MAX_WIDTH_DP).toInt())
635
+ return Pair(sheetWidth - insets.left - insets.right, contentHeight)
335
636
  }
336
637
  }
337
638
  }
@@ -37,7 +37,8 @@ class HybridNitroModal(private val reactContext: ThemedReactContext) :
37
37
  }
38
38
 
39
39
  override var mode: ModalMode = ModalMode.BOTTOMSHEET
40
- override var detents: Array<SheetDetent> = arrayOf(SheetDetent.FITCONTENT)
40
+ override var isInline: Boolean = false
41
+ override var detents: Array<SheetDetent> = arrayOf(SheetDetent.create(NamedSheetDetent.FITCONTENT))
41
42
  override var initialDetentIndex: Double = 0.0
42
43
  override var backdropColor: Double = 0xFF000000.toDouble()
43
44
  override var backdropOpacity: Double = 0.4
@@ -50,6 +51,7 @@ class HybridNitroModal(private val reactContext: ThemedReactContext) :
50
51
  override var contentBackgroundColor: Double? = null
51
52
  override var keyboardBehavior: KeyboardBehavior = KeyboardBehavior.PAN
52
53
  override var popupAnimation: PopupAnimation = PopupAnimation.SCALE
54
+ override var pullToRefreshEnabled: Boolean = false
53
55
 
54
56
  override var onPresent: (() -> Unit)? = null
55
57
  override var onDismiss: ((reason: DismissReason) -> Unit)? = null
@@ -57,6 +59,8 @@ class HybridNitroModal(private val reactContext: ThemedReactContext) :
57
59
  override var onBackdropPress: (() -> Unit)? = null
58
60
  override var onBackButtonPress: (() -> Unit)? = null
59
61
  override var onContentAreaChange: ((area: ModalContentArea) -> Unit)? = null
62
+ override var onPullToRefresh: (() -> Unit)? = null
63
+ override var onRestingTopChange: ((top: Double) -> Unit)? = null
60
64
 
61
65
  // endregion
62
66
 
@@ -93,6 +97,14 @@ class HybridNitroModal(private val reactContext: ThemedReactContext) :
93
97
  scheduleReconcile()
94
98
  }
95
99
  }
100
+ view.onLayoutChange = {
101
+ presenter?.onHostLayoutChanged() ?: run {
102
+ if (config.isInline) {
103
+ reportEstimatedContentArea()
104
+ if (wantsOpen) scheduleReconcile()
105
+ }
106
+ }
107
+ }
96
108
  view.contentRoot.onContentSizeChange = { presenter?.onContentSizeChanged() }
97
109
  }
98
110
 
@@ -102,6 +114,7 @@ class HybridNitroModal(private val reactContext: ThemedReactContext) :
102
114
  val next = makeConfig()
103
115
  if (next == config) return
104
116
  config = next
117
+ view.isInline = next.isInline
105
118
  presenter?.update(next) ?: reportEstimatedContentArea()
106
119
  }
107
120
 
@@ -176,10 +189,14 @@ class HybridNitroModal(private val reactContext: ThemedReactContext) :
176
189
  if (!view.isAttachedToWindow) return
177
190
  val activity = reactContext.currentActivity ?: return
178
191
  if (activity.isFinishing || activity.isDestroyed) return
179
-
180
- val next = when (config.mode) {
181
- ModalMode.POPUP -> PopupPresenter(activity, reactContext, view.contentRoot, config, this)
182
- ModalMode.BOTTOMSHEET -> BottomSheetPresenter(activity, reactContext, view.contentRoot, config, this)
192
+ // An inline sheet needs its laid-out area; the host's layout retries.
193
+ if (config.isInline && (view.width == 0 || view.height == 0)) return
194
+
195
+ val next = when {
196
+ config.isInline ->
197
+ BottomSheetPresenter(activity, reactContext, view.contentRoot, config, this, inlineHost = view)
198
+ config.mode == ModalMode.POPUP -> PopupPresenter(activity, reactContext, view.contentRoot, config, this)
199
+ else -> BottomSheetPresenter(activity, reactContext, view.contentRoot, config, this)
183
200
  }
184
201
  presenter = next
185
202
  phase = Phase.PRESENTING
@@ -263,6 +280,14 @@ class HybridNitroModal(private val reactContext: ThemedReactContext) :
263
280
  onDetentChange?.invoke(index.toDouble())
264
281
  }
265
282
 
283
+ override fun onPullToRefresh() {
284
+ if (!isDropped) onPullToRefresh?.invoke()
285
+ }
286
+
287
+ override fun onRestingTopChange(top: Int) {
288
+ if (!isDropped) onRestingTopChange?.invoke(kotlin.math.round(reactContext.pxToDp(top)))
289
+ }
290
+
266
291
  override fun onContentAreaChange(width: Int, height: Int) {
267
292
  val area = ModalContentArea(
268
293
  kotlin.math.floor(reactContext.pxToDp(width)),
@@ -292,26 +317,41 @@ class HybridNitroModal(private val reactContext: ThemedReactContext) :
292
317
 
293
318
  // endregion
294
319
 
295
- private fun makeConfig() = ModalConfig(
296
- mode = mode,
297
- detents = detents.toList(),
298
- initialDetentIndex = if (initialDetentIndex.isNaN()) 0 else initialDetentIndex.toInt(),
299
- backdropColor = backdropColor.toProcessedColor(),
300
- backdropOpacity = backdropOpacity.toFloat(),
301
- backdropBlurRadius = backdropBlurRadius.toFloat(),
302
- dismissOnBackdropPress = dismissOnBackdropPress,
303
- dismissOnSwipe = dismissOnSwipe,
304
- dismissOnBackButton = dismissOnBackButton,
305
- grabberVisible = grabberVisible,
306
- cornerRadius = cornerRadius.takeIf { it >= 0 }?.toFloat(),
307
- contentBackgroundColor = contentBackgroundColor?.toProcessedColor(),
308
- keyboardBehavior = keyboardBehavior,
309
- popupAnimation = popupAnimation,
310
- )
320
+ private fun makeConfig(): ModalConfig {
321
+ // An inline sheet is part of the screen; the user can't dismiss it.
322
+ val inline = isInline && mode == ModalMode.BOTTOMSHEET
323
+ return ModalConfig(
324
+ mode = mode,
325
+ inlinePresentation = isInline,
326
+ detents = detents.map(Detent::from),
327
+ initialDetentIndex = if (initialDetentIndex.isNaN()) 0 else initialDetentIndex.toInt(),
328
+ backdropColor = backdropColor.toProcessedColor(),
329
+ backdropOpacity = backdropOpacity.toFloat(),
330
+ backdropBlurRadius = backdropBlurRadius.toFloat(),
331
+ dismissOnBackdropPress = dismissOnBackdropPress && !inline,
332
+ dismissOnSwipe = dismissOnSwipe && !inline,
333
+ dismissOnBackButton = dismissOnBackButton && !inline,
334
+ grabberVisible = grabberVisible,
335
+ cornerRadius = cornerRadius.takeIf { it >= 0 }?.toFloat(),
336
+ contentBackgroundColor = contentBackgroundColor?.toProcessedColor(),
337
+ keyboardBehavior = keyboardBehavior,
338
+ popupAnimation = popupAnimation,
339
+ pullToRefreshEnabled = pullToRefreshEnabled,
340
+ )
341
+ }
311
342
 
312
343
  /** Lets React size the content before the first presentation. */
313
344
  private fun reportEstimatedContentArea() {
314
345
  val activity = reactContext.currentActivity ?: return
346
+ if (config.isInline) {
347
+ if (view.width == 0 || view.height == 0) return
348
+ // Insets are refined once the sheet is shown.
349
+ val (width, height) = BottomSheetPresenter.estimateContentArea(
350
+ activity, config.detents, view.width, view.height, Insets.NONE, isInline = true,
351
+ )
352
+ onContentAreaChange(width, height)
353
+ return
354
+ }
315
355
  val decor = activity.window?.decorView ?: return
316
356
  if (decor.width == 0 || decor.height == 0) return
317
357
  val insets = ViewCompat.getRootWindowInsets(decor)
@@ -319,7 +359,8 @@ class HybridNitroModal(private val reactContext: ThemedReactContext) :
319
359
  ?: Insets.NONE
320
360
  val (width, height) = when (config.mode) {
321
361
  ModalMode.POPUP -> PopupPresenter.estimateContentArea(activity, decor.width, decor.height, insets)
322
- ModalMode.BOTTOMSHEET -> BottomSheetPresenter.estimateContentArea(activity, config.detents, decor.width, decor.height, insets)
362
+ ModalMode.BOTTOMSHEET ->
363
+ BottomSheetPresenter.estimateContentArea(activity, config.detents, decor.width, decor.height, insets, isInline = false)
323
364
  }
324
365
  onContentAreaChange(width, height)
325
366
  }