react-native-nitro-modal 0.2.0 → 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 (83) hide show
  1. package/README.md +48 -4
  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 +38 -5
  14. package/lib/module/NitroModal.js.map +1 -1
  15. package/lib/module/NitroModalView.web.js +55 -25
  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 +8 -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 +54 -29
  27. package/lib/module/web/SheetPresenter.js.map +1 -1
  28. package/lib/module/web/dom.js +13 -0
  29. package/lib/module/web/dom.js.map +1 -1
  30. package/lib/typescript/src/NitroModal.d.ts +33 -2
  31. package/lib/typescript/src/NitroModal.d.ts.map +1 -1
  32. package/lib/typescript/src/NitroModal.nitro.d.ts +29 -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 +6 -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 +9 -0
  44. package/lib/typescript/src/web/ModalPresenter.d.ts.map +1 -1
  45. package/lib/typescript/src/web/SheetPresenter.d.ts +1 -1
  46. package/lib/typescript/src/web/SheetPresenter.d.ts.map +1 -1
  47. package/lib/typescript/src/web/dom.d.ts +2 -0
  48. package/lib/typescript/src/web/dom.d.ts.map +1 -1
  49. package/nitrogen/generated/android/c++/JHybridNitroModalSpec.cpp +60 -6
  50. package/nitrogen/generated/android/c++/JHybridNitroModalSpec.hpp +10 -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 +20 -0
  55. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/HybridNitroModalSpec.kt +40 -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 +53 -8
  60. package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Umbrella.hpp +4 -3
  61. package/nitrogen/generated/ios/c++/HybridNitroModalSpecSwift.hpp +32 -5
  62. package/nitrogen/generated/ios/c++/views/HybridNitroModalComponent.mm +24 -0
  63. package/nitrogen/generated/ios/swift/HybridNitroModalSpec.swift +4 -0
  64. package/nitrogen/generated/ios/swift/HybridNitroModalSpec_cxx.swift +110 -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 +8 -0
  68. package/nitrogen/generated/shared/c++/HybridNitroModalSpec.hpp +14 -5
  69. package/nitrogen/generated/shared/c++/{SheetDetent.hpp → NamedSheetDetent.hpp} +17 -17
  70. package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.cpp +9 -1
  71. package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.hpp +15 -2
  72. package/nitrogen/generated/shared/json/NitroModalConfig.json +4 -0
  73. package/package.json +1 -1
  74. package/src/NitroModal.nitro.ts +30 -2
  75. package/src/NitroModal.tsx +68 -4
  76. package/src/NitroModalView.web.tsx +54 -12
  77. package/src/index.tsx +2 -0
  78. package/src/web/AnimatedValue.ts +4 -0
  79. package/src/web/ModalConfig.ts +13 -3
  80. package/src/web/ModalController.ts +35 -2
  81. package/src/web/ModalPresenter.ts +46 -11
  82. package/src/web/SheetPresenter.ts +98 -33
  83. package/src/web/dom.ts +17 -0
package/README.md CHANGED
@@ -17,7 +17,9 @@
17
17
 
18
18
  ## Features
19
19
 
20
- - 📄 **Native bottom sheets** with multiple detents (`small`, `medium`, `large`, `fitContent`) and swipe-to-dismiss
20
+ - 📄 **Native bottom sheets** with multiple detents (`small`, `medium`, `large`, `fitContent` or a height) and swipe-to-dismiss
21
+ - 📌 **Inline sheets** — a persistent sheet that is part of your screen, with the views behind it still usable
22
+ - 🔄 **Pull to refresh** — pulling a sheet down past its lowest detent
21
23
  - 🪟 **Centered popups** with native `scale` / `fade` transitions
22
24
  - 📏 **Content-sized sheets** — `fitContent` measures your React content and grows with it
23
25
  - ⌨️ **Keyboard aware** — the sheet or card follows the keyboard animation (`pan` or `resize`)
@@ -111,6 +113,38 @@ Detents are listed smallest first. `initialDetentIndex` selects where the sheet
111
113
  </NitroModal>
112
114
  ```
113
115
 
116
+ ### Inline (persistent) bottom sheet
117
+
118
+ `presentation="inline"` keeps the sheet inside the component's own frame instead of presenting it over the app — like a sheet that is part of the screen. There is no backdrop, the views behind it stay interactive, it moves with your screen during navigation, and the user can't dismiss it. Position the frame with `style` (it fills its parent by default); the sheet rests against its bottom edge and its largest detent reaches its top edge.
119
+
120
+ ```tsx
121
+ <View style={{ flex: 1 }}>
122
+ <ScreenContent />
123
+
124
+ <NitroModal
125
+ isOpen
126
+ presentation="inline"
127
+ style={{ top: headerHeight }}
128
+ detents={[320, 'large']}
129
+ onPullToRefresh={refresh}
130
+ >
131
+ <FlatList data={rows} renderItem={renderRow} nestedScrollEnabled />
132
+ </NitroModal>
133
+ </View>
134
+ ```
135
+
136
+ A number in `detents` is the content height in dp/pt above the bottom safe area.
137
+
138
+ ### Scrolling content and pull to refresh
139
+
140
+ A sheet hands drags off to the scroll view under the finger:
141
+
142
+ - Dragging up expands the sheet first, then scrolls the content.
143
+ - Dragging down scrolls the content back to its top. Once the content has scrolled during a drag, that drag never moves the sheet; the next drag collapses it.
144
+ - With `onPullToRefresh` set, a drag that starts with the sheet resting on its lowest detent can pull it further down. Letting go past the threshold calls `onPullToRefresh` (with haptic feedback when it is crossed). This needs a sheet that can't be swiped away: inline, or `dismissOnSwipe={false}`.
145
+
146
+ On Android, scrollable content takes part through nested scrolling, so set `nestedScrollEnabled` on it.
147
+
114
148
  ### Popup
115
149
 
116
150
  ```tsx
@@ -168,7 +202,9 @@ const sheet = useRef<NitroModalRef>(null);
168
202
  | ------------------------ | ---------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------- |
169
203
  | `isOpen` | `boolean` | — | Controls visibility. Leave undefined to use the [ref API](#nitromodalref) instead. |
170
204
  | `mode` | `'bottomSheet' \| 'popup'` | `'bottomSheet'` | How the modal is presented. |
171
- | `detents` | `SheetDetent[]` | `['fitContent']` | Sheet heights, smallest first. Android uses at most three. |
205
+ | `presentation` | `'modal' \| 'inline'` | `'modal'` | `inline` keeps a bottom sheet inside the component's frame, as part of the screen. See [Inline](#inline-persistent-bottom-sheet). |
206
+ | `style` | `StyleProp<ViewStyle>` | `absoluteFill` | Inline only: the frame the sheet lives in. |
207
+ | `detents` | `SheetDetent[]` | `['fitContent']` | Sheet heights, smallest first: named sizes or a content height in dp/pt. Android uses at most three. |
172
208
  | `initialDetentIndex` | `number` | `0` | Index into `detents` the sheet opens at. |
173
209
  | `backdropColor` | `ColorValue` | `'black'` | Backdrop color. |
174
210
  | `backdropOpacity` | `number` | `0.4` | Backdrop opacity, `0`–`1`. |
@@ -194,6 +230,7 @@ const sheet = useRef<NitroModalRef>(null);
194
230
  | `onDetentChange` | `(index: number) => void` | A bottom sheet settled on a different detent. |
195
231
  | `onBackdropPress` | `() => void` | The backdrop was tapped (fires even when `dismissOnBackdropPress` is `false`). |
196
232
  | `onBackButtonPress` | `() => void` | The Android hardware/gesture back (Escape on web) was pressed. |
233
+ | `onPullToRefresh` | `() => void` | The sheet was pulled down past its lowest detent and released. See [pull to refresh](#scrolling-content-and-pull-to-refresh). |
197
234
 
198
235
  ### `NitroModalRef`
199
236
 
@@ -208,12 +245,17 @@ const sheet = useRef<NitroModalRef>(null);
208
245
  ```ts
209
246
  type ModalMode = 'bottomSheet' | 'popup';
210
247
 
211
- type SheetDetent =
248
+ type ModalPresentation = 'modal' | 'inline';
249
+
250
+ type NamedSheetDetent =
212
251
  | 'small' // ~25% of the available height
213
252
  | 'medium' // ~50% of the available height
214
253
  | 'large' // the full available height
215
254
  | 'fitContent'; // the measured height of your content
216
255
 
256
+ // A named height, or the content height in dp/pt above the bottom safe area.
257
+ type SheetDetent = NamedSheetDetent | number;
258
+
217
259
  type KeyboardBehavior = 'pan' | 'resize' | 'none';
218
260
 
219
261
  type PopupAnimation = 'scale' | 'fade' | 'none';
@@ -228,6 +270,8 @@ import type {
228
270
  DismissReason,
229
271
  KeyboardBehavior,
230
272
  ModalMode,
273
+ ModalPresentation,
274
+ NamedSheetDetent,
231
275
  NitroModalProps,
232
276
  NitroModalRef,
233
277
  PopupAnimation,
@@ -237,7 +281,7 @@ import type {
237
281
 
238
282
  ## How it works
239
283
 
240
- `<NitroModal>` mounts a zero-size placeholder in your React tree. When opened, the native side presents a real view controller (iOS) or window (Android) and hosts your React children inside it. Children mount when the modal opens and stay mounted until the native exit animation completes, so content never disappears mid-transition. The native side also reports the exact area available to the content (accounting for rotation, keyboard and sheet size), and the content container is sized accordingly.
284
+ `<NitroModal>` mounts a zero-size placeholder in your React tree. When opened, the native side presents a real view controller (iOS) or window (Android) and hosts your React children inside it. An inline sheet instead lives in a view laid out with `style`, in your screen's own hierarchy: touches that miss the sheet go to the views behind it, and `keyboardBehavior` doesn't apply (the screen's own keyboard handling does). Children mount when the modal opens and stay mounted until the native exit animation completes, so content never disappears mid-transition. The native side also reports the exact area available to the content (accounting for rotation, keyboard and sheet size), and the content container is sized accordingly.
241
285
 
242
286
  ## Platform notes
243
287
 
@@ -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
  }