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