@rn-tools/sheets 0.1.4 → 3.0.2

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 723c379: Incorporate sheets into navigation and cleanup APIs
8
+ - Updated dependencies [723c379]
9
+ - @rn-tools/core@3.0.2
10
+
11
+ ## 3.0.1
12
+
13
+ ### Patch Changes
14
+
15
+ - e62a766: Initial changeset publish
16
+ - Updated dependencies [e62a766]
17
+ - @rn-tools/core@3.0.1
18
+
3
19
  ## Unpublished
4
20
 
5
21
  ### 🛠 Breaking changes
package/README.md CHANGED
@@ -1,102 +1,142 @@
1
1
  # @rn-tools/sheets
2
2
 
3
- An expo module for rendering native bottom sheet components in iOS and Android.
3
+ Native bottom sheets for React Native + Expo with iOS `UISheetPresentationController` and Android `BottomSheetDialog`.
4
4
 
5
- Uses SwiftUI's sheet API and Android's BottomSheetDialog to render React Native children in a modal bottom sheet
5
+ ## Install
6
6
 
7
+ ```bash
8
+ yarn add @rn-tools/sheets expo-build-properties
9
+ ```
7
10
 
8
- https://github.com/user-attachments/assets/426c77e6-74c6-4748-8010-477267fa9433
9
-
10
-
11
- ## Motivation
12
-
13
- - Better performance and responsiveness than JS based solutions
14
-
15
- - Native OS handling for gestures, keyboard, and navigation
16
-
17
- ## Installation
18
-
19
- `yarn add @rntools/sheets expo-build-properties`
20
-
21
- Update your minimum iOS deployment target to 16 in `app.json`:
11
+ Set iOS deployment target to `16.0` in `app.json`:
22
12
 
23
13
  ```json
24
14
  {
25
- "plugins": [
26
- [
27
- "expo-build-properties",
28
- {
29
- "ios": {
30
- "deploymentTarget": "16.0"
31
- }
15
+ "plugins": [
16
+ [
17
+ "expo-build-properties",
18
+ {
19
+ "ios": {
20
+ "deploymentTarget": "16.0"
32
21
  }
33
- ]
22
+ }
23
+ ]
24
+ ]
34
25
  }
35
-
36
26
  ```
37
27
 
38
- As with most non-core expo modules this requires a new native build
28
+ Then rebuild the native app.
29
+
30
+ ## APIs
39
31
 
32
+ This package supports two usage styles:
40
33
 
41
- ## Usage
34
+ 1. Declarative `BottomSheet`
35
+ 2. Store-driven `createSheets` + `SheetsProvider`
36
+
37
+ ### Declarative `BottomSheet`
42
38
 
43
39
  ```tsx
44
- import { BottomSheet } from '@rn-tools/sheets'
40
+ import * as React from "react";
41
+ import { Button, View } from "react-native";
42
+ import { BottomSheet } from "@rn-tools/sheets";
45
43
 
46
- export default function App() {
44
+ export default function Example() {
47
45
  const [isOpen, setIsOpen] = React.useState(false);
48
46
 
49
47
  return (
50
- <View className="flex-1">
51
- <Button title="Show sheet" onPress={() => setIsOpen(true)} />
52
-
53
- <BottomSheet
54
- isOpen={isOpen}
55
- onOpenChange={setIsOpen}
56
- openToIndex={1}
57
- onStateChange={(event) => console.log({ event })}
58
- snapPoints={[400, 600, 750]}
59
- appearanceAndroid={{
60
- dimAmount: 0,
61
- cornerRadius: 32.0,
62
- backgroundColor: "#ffffff",
63
- }}
64
- appearanceIOS={{
65
- cornerRadius: 16.0,
66
- grabberVisible: true,
67
- backgroundColor: "#ffffff",
68
- }}
69
- >
70
- {isOpen && <MyContent />}
71
- </BottomSheet>
48
+ <View style={{ flex: 1 }}>
49
+ <Button title="Open" onPress={() => setIsOpen(true)} />
50
+
51
+ <BottomSheet
52
+ isOpen={isOpen}
53
+ setIsOpen={setIsOpen}
54
+ snapPoints={[300, 500]}
55
+ initialIndex={0}
56
+ >
57
+ <View style={{ padding: 24 }}>{/* content */}</View>
58
+ </BottomSheet>
72
59
  </View>
73
60
  );
74
61
  }
75
62
  ```
76
63
 
77
- ## Props
78
-
79
- - `isOpen / onOpenChange` - Controller props for toggling the sheet open and closed - this is required
64
+ ### Store-driven sheets
80
65
 
81
- - `openToIndex` - will open the bottom sheet to the defined snapPoint index
66
+ Use this for imperative sheet presentation from anywhere in your app.
67
+ You do not need a hook for this pattern; you can call the external sheets store directly.
82
68
 
83
- - `onStateChange` - callback to track the internal state of the sheet. The following events are emitted:
69
+ ```tsx
70
+ import * as React from "react";
71
+ import { Button, View } from "react-native";
72
+ import { createSheets, SheetsProvider } from "@rn-tools/sheets";
84
73
 
85
- - { type: "HIDDEN" }
86
- - { type: "OPEN", payload: { index: number }}
87
- - { type: "SETTLING" }
88
- - { type: "DRAGGING" }
74
+ const sheets = createSheets();
89
75
 
90
- - `snapPoints` - a list of sizes that the sheet will "snap" to
76
+ export default function App() {
77
+ return (
78
+ <SheetsProvider sheets={sheets}>
79
+ <Screen />
80
+ </SheetsProvider>
81
+ );
82
+ }
91
83
 
92
- - if you do not specify snapPoints, the sheet will size to its content. This means any flex based layout needs to have an explicit container size
84
+ function Screen() {
85
+ return (
86
+ <View>
87
+ <Button
88
+ title="Present"
89
+ onPress={() => {
90
+ sheets.present(<SheetContent />, {
91
+ id: "edit",
92
+ snapPoints: [320, 520],
93
+ });
94
+ }}
95
+ />
96
+ <Button title="Dismiss" onPress={() => sheets.dismiss()} />
97
+ <Button title="Dismiss all" onPress={() => sheets.dismissAll()} />
98
+ </View>
99
+ );
100
+ }
93
101
 
94
- - **Android will only use the first two snapPoints!**
95
-
102
+ function SheetContent() {
103
+ return <View style={{ padding: 24 }} />;
104
+ }
105
+ ```
96
106
 
107
+ `useSheets()` is still available when you prefer resolving the client from context.
97
108
 
98
- ## Caveats
109
+ ## `createSheets` client
99
110
 
100
- - (Android) can have a maximum of 2 snap points
111
+ ```ts
112
+ type SheetsClient = {
113
+ store: SheetsStore;
114
+ present: (element: React.ReactElement, options?: SheetOptions) => string;
115
+ dismiss: (id?: string) => void;
116
+ dismissAll: () => void;
117
+ };
118
+ ```
101
119
 
102
- - (Android) use the `nestedScrollEnabled` prop for nested scrollviews
120
+ - `present` returns a sheet key.
121
+ - `options.id` lets you target a logical sheet instance.
122
+ - `dismiss(id?)` closes by key/id, or top-most if omitted.
123
+ - `dismissAll()` closes all active sheets.
124
+
125
+ ## `BottomSheet` props
126
+
127
+ - `isOpen`: whether the sheet should be open.
128
+ - `setIsOpen(next)`: called when native requests a visibility change.
129
+ - `snapPoints?: number[]`: snap heights (dp). Android uses first 2 only.
130
+ - `initialIndex?: number`: initial snap point index.
131
+ - `canDismiss?: boolean`: allow swipe/back dismissal (default `true`).
132
+ - `onDismissPrevented?: () => void`: called when dismissal is blocked.
133
+ - `onStateChange?: (event) => void`: emits `{ type: "OPEN" }` and `{ type: "HIDDEN" }`.
134
+ - `containerStyle?: ViewStyle`
135
+ - `appearanceIOS?: { grabberVisible?: boolean; backgroundColor?: string; cornerRadius?: number }`
136
+ - `appearanceAndroid?: { dimAmount?: number; backgroundColor?: string; cornerRadius?: number }`
137
+
138
+ ## Notes
139
+
140
+ - If `snapPoints` is omitted, the sheet auto-sizes to measured content height.
141
+ - On Android, nested scroll content should use `nestedScrollEnabled` where needed.
142
+ - iOS uses an overlay window to host the presented sheet.
@@ -31,17 +31,21 @@ class RNToolsSheetsModule : Module() {
31
31
  }
32
32
  }
33
33
 
34
- Events("onDismiss", "onStateChange")
34
+ Events("onDismiss", "onStateChange", "onDismissPrevented")
35
35
 
36
36
  Prop("isOpen") { view: RNToolsSheetsView, isOpen: Boolean ->
37
37
  view.props.isOpen = isOpen
38
38
  }
39
39
 
40
- Prop("openToIndex") { view: RNToolsSheetsView, openToIndex: Int ->
41
- view.props.openToIndex = openToIndex
40
+ Prop("initialIndex") { view: RNToolsSheetsView, initialIndex: Int ->
41
+ view.props.initialIndex = initialIndex
42
42
  }
43
43
 
44
- Prop("snapPoints") { view: RNToolsSheetsView, snapPoints: List<Int> ->
44
+ Prop("canDismiss") { view: RNToolsSheetsView, canDismiss: Boolean ->
45
+ view.props.canDismiss = canDismiss
46
+ }
47
+
48
+ Prop("snapPoints") { view: RNToolsSheetsView, snapPoints: List<Double> ->
45
49
  view.props.snapPoints = snapPoints.map { view.convertToPx(it) }
46
50
  }
47
51
 
@@ -13,10 +13,12 @@ import expo.modules.kotlin.AppContext
13
13
  import expo.modules.kotlin.viewevent.EventDispatcher
14
14
  import expo.modules.kotlin.views.ExpoView
15
15
  import android.graphics.Color
16
+ import com.facebook.react.bridge.UiThreadUtil
16
17
 
17
18
  class RNToolsSheetsView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
18
19
  val onDismiss by EventDispatcher()
19
20
  val onStateChange by EventDispatcher()
21
+ val onDismissPrevented by EventDispatcher()
20
22
 
21
23
  var rootViewGroup = SheetRootView(context, appContext)
22
24
  private var composeView: ComposeView
@@ -42,6 +44,15 @@ class RNToolsSheetsView(context: Context, appContext: AppContext) : ExpoView(con
42
44
  hideSheet()
43
45
  }
44
46
  }
47
+
48
+ LaunchedEffect(props.canDismiss) {
49
+ UiThreadUtil.runOnUiThread {
50
+ bottomSheetDialog?.apply {
51
+ setCancelable(true)
52
+ behavior.isHideable = props.canDismiss
53
+ }
54
+ }
55
+ }
45
56
  }
46
57
  }
47
58
 
@@ -54,95 +65,116 @@ class RNToolsSheetsView(context: Context, appContext: AppContext) : ExpoView(con
54
65
  }
55
66
 
56
67
  private fun hideSheet() {
57
- bottomSheetDialog?.dismiss()
58
- bottomSheetDialog = null
68
+ UiThreadUtil.runOnUiThread {
69
+ bottomSheetDialog?.dismiss()
70
+ bottomSheetDialog = null
71
+ }
59
72
  }
60
73
 
61
74
  private fun showSheet() {
62
- (rootViewGroup.parent as? ViewGroup)?.removeView(rootViewGroup)
75
+ UiThreadUtil.runOnUiThread {
76
+ (rootViewGroup.parent as? ViewGroup)?.removeView(rootViewGroup)
63
77
 
64
- val frameLayout = FrameLayout(context).apply {
65
- layoutParams = LayoutParams(
66
- ViewGroup.LayoutParams.MATCH_PARENT,
67
- ViewGroup.LayoutParams.WRAP_CONTENT
68
- )
78
+ val frameLayout = FrameLayout(context).apply {
79
+ layoutParams = LayoutParams(
80
+ ViewGroup.LayoutParams.MATCH_PARENT,
81
+ ViewGroup.LayoutParams.WRAP_CONTENT
82
+ )
69
83
 
70
- addView(rootViewGroup)
71
- }
72
-
73
- val snapPoints = props.snapPoints
74
- val initialIndex = props.openToIndex
75
-
76
- val hasTwoSnapPoints = snapPoints.size >= 2
77
- val peekHeight = if (hasTwoSnapPoints) snapPoints[0] else -1
78
- val expandedHeight = if (snapPoints.isNotEmpty()) snapPoints.getOrNull(1) ?: snapPoints[0] else -1
79
- val initialHeight = snapPoints.getOrNull(initialIndex) ?: peekHeight
84
+ addView(rootViewGroup)
85
+ }
80
86
 
81
- bottomSheetDialog = BottomSheetDialog(context).apply {
82
- setContentView(frameLayout)
87
+ val snapPoints = props.snapPoints
88
+ val initialIndex = props.initialIndex
89
+
90
+ val hasTwoSnapPoints = snapPoints.size >= 2
91
+ val peekHeight = if (hasTwoSnapPoints) snapPoints[0] else -1
92
+ val expandedHeight = if (snapPoints.isNotEmpty()) snapPoints.getOrNull(1) ?: snapPoints[0] else -1
93
+ val initialHeight = snapPoints.getOrNull(initialIndex) ?: peekHeight
94
+
95
+ bottomSheetDialog = PreventDismissBottomSheetDialog(
96
+ context = context,
97
+ canDismiss = { props.canDismiss },
98
+ onDismissPrevented = { onDismissPrevented(mapOf()) }
99
+ ).apply {
100
+ setCancelable(true)
101
+ setContentView(frameLayout)
102
+
103
+ window?.setDimAmount(props.dimAmount)
104
+
105
+ behavior.isHideable = props.canDismiss
106
+
107
+ window?.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)?.let { bottomSheet ->
108
+ val backgroundColor = props.backgroundColor?.let {
109
+ try {
110
+ Color.parseColor(it)
111
+ } catch (e: IllegalArgumentException) {
112
+ Color.WHITE
113
+ }
114
+ } ?: Color.WHITE
115
+
116
+ val radius = props.cornerRadius ?: 32f
117
+
118
+ val drawable = GradientDrawable().apply {
119
+ setColor(backgroundColor)
120
+ cornerRadii = floatArrayOf(
121
+ radius, radius,
122
+ radius, radius,
123
+ 0f, 0f,
124
+ 0f, 0f
125
+ )
126
+ }
83
127
 
84
- window?.setDimAmount(props.dimAmount)
128
+ bottomSheet.background = drawable
129
+ }
85
130
 
86
- window?.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)?.let { bottomSheet ->
131
+ val behavior = behavior
87
132
 
88
- val backgroundColor = props.backgroundColor?.let {
89
- try {
90
- Color.parseColor(it) // Convert hex string to Color
91
- } catch (e: IllegalArgumentException) {
92
- Color.WHITE
133
+ setOnDismissListener {
134
+ onDismiss(mapOf())
135
+ if (behavior.state != BottomSheetBehavior.STATE_HIDDEN) {
136
+ onStateChange(mapOf(
137
+ "type" to "HIDDEN",
138
+ ))
93
139
  }
94
- } ?: Color.WHITE
95
-
96
- val drawable = GradientDrawable().apply {
97
- setColor(backgroundColor)
98
- cornerRadius = props.cornerRadius ?: 0f
99
140
  }
100
141
 
101
- bottomSheet.background = drawable
102
- }
103
-
104
- val behavior = behavior
142
+ if (peekHeight > 0) {
143
+ behavior.peekHeight = peekHeight
144
+ }
105
145
 
106
- setOnDismissListener {
107
- onDismiss(mapOf())
108
- if (behavior.state != BottomSheetBehavior.STATE_HIDDEN) {
109
- onStateChange(mapOf(
110
- "type" to "HIDDEN",
111
- ))
146
+ if (expandedHeight > 0) {
147
+ frameLayout.layoutParams.height = expandedHeight
148
+ frameLayout.requestLayout()
112
149
  }
113
- }
114
150
 
115
- if (peekHeight > 0) {
116
- behavior.peekHeight = peekHeight
117
- }
151
+ behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
152
+ override fun onStateChanged(bottomSheet: android.view.View, newState: Int) {
153
+ if (!props.canDismiss && newState == BottomSheetBehavior.STATE_HIDDEN) {
154
+ onDismissPrevented(mapOf())
155
+ }
118
156
 
119
- if (expandedHeight > 0) {
120
- frameLayout.layoutParams.height = expandedHeight
121
- frameLayout.requestLayout()
122
- }
157
+ handleSheetStateChange(newState)
158
+ }
123
159
 
124
- behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
125
- override fun onStateChanged(bottomSheet: android.view.View, newState: Int) {
126
- handleSheetStateChange(newState)
127
- }
160
+ override fun onSlide(bottomSheet: android.view.View, slideOffset: Float) {
161
+ }
162
+ })
128
163
 
129
- override fun onSlide(bottomSheet: android.view.View, slideOffset: Float) {
130
- }
131
- })
164
+ show()
132
165
 
133
- show()
166
+ if (initialHeight == peekHeight) {
167
+ behavior.state = BottomSheetBehavior.STATE_COLLAPSED
168
+ } else {
169
+ behavior.state = BottomSheetBehavior.STATE_EXPANDED
170
+ }
134
171
 
135
- if (initialHeight == peekHeight) {
136
- behavior.state = BottomSheetBehavior.STATE_COLLAPSED
137
- } else {
138
- behavior.state = BottomSheetBehavior.STATE_EXPANDED
172
+ handleSheetStateChange(behavior.state)
139
173
  }
140
-
141
- handleSheetStateChange(behavior.state)
142
174
  }
143
175
  }
144
176
 
145
- fun convertToPx(height: Int): Int {
177
+ fun convertToPx(height: Double): Int {
146
178
  val density = context.resources.displayMetrics.density
147
179
  return (height * density).toInt()
148
180
  }
@@ -155,12 +187,6 @@ class RNToolsSheetsView(context: Context, appContext: AppContext) : ExpoView(con
155
187
  ))
156
188
 
157
189
  }
158
- BottomSheetBehavior.STATE_SETTLING -> {
159
- onStateChange(mapOf(
160
- "type" to "SETTLING",
161
- ))
162
- }
163
-
164
190
  BottomSheetBehavior.STATE_COLLAPSED -> {
165
191
  onStateChange(mapOf(
166
192
  "type" to "OPEN",
@@ -188,12 +214,20 @@ class RNToolsSheetsView(context: Context, appContext: AppContext) : ExpoView(con
188
214
  ))
189
215
  }
190
216
 
191
- BottomSheetBehavior.STATE_DRAGGING -> {
192
- onStateChange(mapOf(
193
- "type" to "DRAGGING",
194
- ))
195
- }
196
217
  }
197
218
  }
198
219
  }
199
220
 
221
+ class PreventDismissBottomSheetDialog(
222
+ context: Context,
223
+ private val canDismiss: () -> Boolean,
224
+ private val onDismissPrevented: () -> Unit
225
+ ) : BottomSheetDialog(context) {
226
+ override fun cancel() {
227
+ if (canDismiss()) super.cancel() else onDismissPrevented()
228
+ }
229
+
230
+ override fun onBackPressed() {
231
+ if (canDismiss()) super.onBackPressed() else onDismissPrevented()
232
+ }
233
+ }
@@ -9,9 +9,10 @@ import expo.modules.kotlin.records.Record
9
9
 
10
10
  class SheetProps {
11
11
  var isOpen by mutableStateOf(false)
12
- var openToIndex by mutableIntStateOf(0)
12
+ var initialIndex by mutableIntStateOf(0)
13
13
  var snapPoints by mutableStateOf<List<Int>>(emptyList())
14
14
  lateinit var rootViewGroup: SheetRootView
15
+ var canDismiss by mutableStateOf(true)
15
16
 
16
17
  // Appearance props
17
18
  var dimAmount by mutableStateOf(0.56f)
@@ -1,5 +1,6 @@
1
1
  require 'json'
2
2
 
3
+ ENV['RCT_NEW_ARCH_ENABLED'] ||= '1'
3
4
  package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
5
  new_arch_enabled = ENV['RCT_NEW_ARCH_ENABLED'] == '1'
5
6
 
@@ -21,12 +22,11 @@ Pod::Spec.new do |s|
21
22
  s.static_framework = true
22
23
 
23
24
  s.dependency 'ExpoModulesCore'
24
- s.public_header_files = 'Sources/RNTSurfaceTouchHandlerWrapper.h'
25
-
25
+ s.public_header_files = 'Sources/RNToolsTouchHandlerHelper.h'
26
26
  # Swift/Objective-C compatibility
27
27
  s.pod_target_xcconfig = {
28
28
  'DEFINES_MODULE' => 'YES',
29
- 'OTHER_SWIFT_FLAGS' => new_arch_enabled ? '-DRCT_NEW_ARCH_ENABLED' : ''
29
+ 'OTHER_SWIFT_FLAGS' => '$(inherited)'
30
30
  }
31
31
 
32
32
  s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
@@ -1,28 +1,45 @@
1
1
  import ExpoModulesCore
2
2
 
3
+ struct SheetAppearance: Record {
4
+ @Field
5
+ var grabberVisible: Bool?
6
+
7
+ @Field
8
+ var backgroundColor: String?
9
+
10
+ @Field
11
+ var cornerRadius: Float?
12
+ }
13
+
3
14
  public class RNToolsSheetsModule: Module {
4
15
  public func definition() -> ModuleDefinition {
5
16
  Name("RNToolsSheets")
6
17
 
7
18
  View(RNToolsSheetsView.self) {
8
- Events("onDismiss", "onStateChange")
19
+ Events("onDismiss", "onStateChange", "onDismissPrevented")
9
20
 
10
- Prop("snapPoints") { (view, snapPoints: [Int]) in
11
- view.props.snapPoints = snapPoints
21
+ Prop("snapPoints") { (view, snapPoints: [CGFloat]) in
22
+ view.updateSnapPoints(snapPoints)
12
23
  }
13
24
 
14
25
  Prop("isOpen") { (view, isOpen: Bool) in
15
- view.props.isOpen = isOpen
26
+ view.updateIsOpen(isOpen)
16
27
  }
17
28
 
18
- Prop("openToIndex") { (view, openToIndex: Int) in
19
- view.props.openToIndex = openToIndex
29
+ Prop("initialIndex") { (view, initialIndex: Int) in
30
+ view.updateInitialIndex(initialIndex)
20
31
  }
21
-
32
+
22
33
  Prop("appearanceIOS") { (view, appearance: SheetAppearance) in
23
- view.props.grabberVisible = appearance.grabberVisible ?? true
24
- view.props.backgroundColor = appearance.backgroundColor
25
- view.props.cornerRadius = appearance.cornerRadius
34
+ view.updateAppearance(
35
+ grabberVisible: appearance.grabberVisible ?? true,
36
+ backgroundColor: appearance.backgroundColor,
37
+ cornerRadius: appearance.cornerRadius
38
+ )
39
+ }
40
+
41
+ Prop("canDismiss") { (view, canDismiss: Bool) in
42
+ view.updateCanDismiss(canDismiss)
26
43
  }
27
44
  }
28
45
  }