@rn-tools/sheets 0.1.1

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/.eslintrc.js ADDED
@@ -0,0 +1 @@
1
+ module.expores = require("@rn-tools/eslint-config")
package/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## Unpublished
4
+
5
+ ### 🛠 Breaking changes
6
+
7
+ ### 🎉 New features
8
+
9
+ ### 🐛 Bug fixes
10
+
11
+ ### 💡 Others
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # @rn-tools/sheets
2
+
3
+ An expo module for rendering native bottom sheet components in iOS and Android.
4
+
5
+ Uses SwiftUI's sheet API and Android's BottomSheetDialog component to render React Native children in a modal bottom sheet
6
+
7
+ ## Motivation
8
+
9
+ - Better performance and responsiveness than JS based solutions
10
+
11
+ - Native OS handling for gestures, keyboard, and navigation
12
+
13
+ ## Installation
14
+
15
+ `yarn add @rntools/sheets`
16
+
17
+
18
+ As with most non-core expo modules this requires a new native build
19
+
20
+
21
+ ## Usage
22
+
23
+ ```tsx
24
+ import { BottomSheet } from '@rn-tools/sheets'
25
+
26
+ export default function App() {
27
+ const [isOpen, setIsOpen] = React.useState(false);
28
+
29
+ return (
30
+ <View className="flex-1">
31
+ <Button title="Show sheet" onPress={() => setIsOpen(!isOpen)} />
32
+
33
+ <BottomSheet
34
+ isOpen={isOpen}
35
+ onOpenChange={setIsOpen}
36
+ openToIndex={1}
37
+ onStateChange={(event) => console.log({ event })}
38
+ snapPoints={[400, 600, 750]}
39
+ appearanceAndroid={{
40
+ dimAmount: 0,
41
+ cornerRadius: 32.0,
42
+ backgroundColor: "#ffffff",
43
+ }}
44
+ appearanceIOS={{
45
+ cornerRadius: 16.0,
46
+ grabberVisible: true,
47
+ backgroundColor: "#ffffff",
48
+ }}
49
+ >
50
+ {isOpen && <MyContent />}
51
+ </BottomSheet>
52
+ </View>
53
+ );
54
+ }
55
+ ```
56
+
57
+ ## Props
58
+
59
+ - `isOpen / onOpenChange` - Controller props for toggling the sheet open and closed - this is required
60
+
61
+ - `openToIndex` - will open the bottom sheet to the defined snapPoint index
62
+
63
+ - `onStateChange` - callback to track the internal state of the sheet. The following events are emitted:
64
+
65
+ - { type: "HIDDEN" }
66
+ - { type: "OPEN", payload: { index: number }}
67
+ - { type: "SETTLING" }
68
+ - { type: "DRAGGING" }
69
+
70
+ - `snapPoints` - a list of sizes that the sheet will "snap" to
71
+
72
+ - 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
73
+
74
+ - **Android will only use the first two snapPoints!**
75
+
76
+
77
+
78
+ ## Caveats
79
+
80
+ - (Android) can have a maximum of 2 snap points
81
+
82
+ - (Android) use the `nestedScrollEnabled` prop for nested scrollviews
@@ -0,0 +1,75 @@
1
+ apply plugin: 'com.android.library'
2
+
3
+ group = 'expo.modules.sheets'
4
+ version = '0.1.0'
5
+
6
+ def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
7
+ apply from: expoModulesCorePlugin
8
+ applyKotlinExpoModulesCorePlugin()
9
+ useCoreDependencies()
10
+ useExpoPublishing()
11
+
12
+ // If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
13
+ // The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
14
+ // Most of the time, you may like to manage the Android SDK versions yourself.
15
+ def useManagedAndroidSdkVersions = false
16
+ if (useManagedAndroidSdkVersions) {
17
+ useDefaultAndroidSdkVersions()
18
+ } else {
19
+ buildscript {
20
+ // Simple helper that allows the root project to override versions declared by this library.
21
+ ext.safeExtGet = { prop, fallback ->
22
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
23
+ }
24
+ }
25
+ project.android {
26
+ compileSdkVersion safeExtGet("compileSdkVersion", 34)
27
+ defaultConfig {
28
+ minSdkVersion safeExtGet("minSdkVersion", 21)
29
+ targetSdkVersion safeExtGet("targetSdkVersion", 34)
30
+ }
31
+ }
32
+ }
33
+
34
+ android {
35
+ namespace "expo.modules.sheets"
36
+ defaultConfig {
37
+ versionCode 1
38
+ versionName "0.1.0"
39
+ }
40
+ lintOptions {
41
+ abortOnError false
42
+ }
43
+
44
+
45
+ // Enables Compose functionality
46
+ buildFeatures {
47
+ compose true
48
+ }
49
+ composeOptions {
50
+ kotlinCompilerExtensionVersion = "1.5.15"
51
+ }
52
+
53
+ lintOptions {
54
+ abortOnError false
55
+ }
56
+
57
+ repositories {
58
+ mavenCentral()
59
+ }
60
+
61
+ dependencies {
62
+ implementation(platform("androidx.compose:compose-bom:2024.10.01"))
63
+ implementation "androidx.compose.material:material:1.7.5"
64
+ implementation "com.google.android.material:material:1.12.0"
65
+ // Possible view model props passer not used yet
66
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
67
+
68
+ // OPTIONAL - Android Studio Preview support
69
+ implementation("androidx.compose.ui:ui-tooling-preview")
70
+ debugImplementation("androidx.compose.ui:ui-tooling")
71
+
72
+ implementation "androidx.navigation:navigation-compose:2.8.5"
73
+ implementation 'com.facebook.react:react-native:+'
74
+ }
75
+ }
@@ -0,0 +1,2 @@
1
+ <manifest>
2
+ </manifest>
@@ -0,0 +1,55 @@
1
+ package expo.modules.sheets
2
+
3
+ import android.view.View
4
+ import expo.modules.kotlin.modules.Module
5
+ import expo.modules.kotlin.modules.ModuleDefinition
6
+
7
+ class RNToolsSheetsModule : Module() {
8
+ override fun definition() = ModuleDefinition {
9
+ Name("RNToolsSheets")
10
+
11
+ View(RNToolsSheetsView::class) {
12
+ GroupView<RNToolsSheetsView> {
13
+ AddChildView { parent, child: View, index ->
14
+ parent.rootViewGroup.addView(child, index)
15
+ }
16
+
17
+ GetChildCount { parent ->
18
+ return@GetChildCount parent.rootViewGroup.childCount
19
+ }
20
+
21
+ GetChildViewAt { parent, index ->
22
+ parent.rootViewGroup.getChildAt(index)
23
+ }
24
+
25
+ RemoveChildView { parent, child: View ->
26
+ parent.rootViewGroup.removeView(child)
27
+ }
28
+
29
+ RemoveChildViewAt { parent, index ->
30
+ parent.rootViewGroup.removeViewAt(index)
31
+ }
32
+ }
33
+
34
+ Events("onDismiss", "onStateChange")
35
+
36
+ Prop("isOpen") { view: RNToolsSheetsView, isOpen: Boolean ->
37
+ view.props.isOpen = isOpen
38
+ }
39
+
40
+ Prop("openToIndex") { view: RNToolsSheetsView, openToIndex: Int ->
41
+ view.props.openToIndex = openToIndex
42
+ }
43
+
44
+ Prop("snapPoints") { view: RNToolsSheetsView, snapPoints: List<Int> ->
45
+ view.props.snapPoints = snapPoints.map { view.convertToPx(it) }
46
+ }
47
+
48
+ Prop("appearanceAndroid") { view: RNToolsSheetsView, appearance: SheetAppearance ->
49
+ view.props.dimAmount = appearance.dimAmount ?: 0.56f
50
+ view.props.backgroundColor = appearance.backgroundColor
51
+ view.props.cornerRadius = appearance.cornerRadius?.toFloat()
52
+ }
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,199 @@
1
+ package expo.modules.sheets
2
+
3
+ import android.content.Context
4
+ import android.graphics.drawable.GradientDrawable
5
+ import android.view.View
6
+ import android.view.ViewGroup
7
+ import android.widget.FrameLayout
8
+ import androidx.compose.runtime.LaunchedEffect
9
+ import androidx.compose.ui.platform.ComposeView
10
+ import com.google.android.material.bottomsheet.BottomSheetBehavior
11
+ import com.google.android.material.bottomsheet.BottomSheetDialog
12
+ import expo.modules.kotlin.AppContext
13
+ import expo.modules.kotlin.viewevent.EventDispatcher
14
+ import expo.modules.kotlin.views.ExpoView
15
+ import android.graphics.Color
16
+
17
+ class RNToolsSheetsView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
18
+ val onDismiss by EventDispatcher()
19
+ val onStateChange by EventDispatcher()
20
+
21
+ var rootViewGroup = SheetRootView(context, appContext)
22
+ private var composeView: ComposeView
23
+
24
+ private var bottomSheetDialog: BottomSheetDialog? = null
25
+
26
+ val props = SheetProps()
27
+
28
+ init {
29
+ layoutParams = LayoutParams(
30
+ ViewGroup.LayoutParams.MATCH_PARENT,
31
+ ViewGroup.LayoutParams.WRAP_CONTENT
32
+ )
33
+
34
+ props.rootViewGroup = rootViewGroup
35
+
36
+ composeView = ComposeView(context).apply {
37
+ setContent {
38
+ LaunchedEffect(props.isOpen) {
39
+ if (props.isOpen) {
40
+ showSheet()
41
+ } else {
42
+ hideSheet()
43
+ }
44
+ }
45
+ }
46
+ }
47
+
48
+ addView(composeView)
49
+ }
50
+
51
+ override fun setId(id: Int) {
52
+ super.setId(id)
53
+ rootViewGroup.id = id
54
+ }
55
+
56
+ private fun hideSheet() {
57
+ bottomSheetDialog?.dismiss()
58
+ bottomSheetDialog = null
59
+ }
60
+
61
+ private fun showSheet() {
62
+ (rootViewGroup.parent as? ViewGroup)?.removeView(rootViewGroup)
63
+
64
+ val frameLayout = FrameLayout(context).apply {
65
+ layoutParams = LayoutParams(
66
+ ViewGroup.LayoutParams.MATCH_PARENT,
67
+ ViewGroup.LayoutParams.WRAP_CONTENT
68
+ )
69
+
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
80
+
81
+ bottomSheetDialog = BottomSheetDialog(context).apply {
82
+ setContentView(frameLayout)
83
+
84
+ window?.setDimAmount(props.dimAmount)
85
+
86
+ window?.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)?.let { bottomSheet ->
87
+
88
+ val backgroundColor = props.backgroundColor?.let {
89
+ try {
90
+ Color.parseColor(it) // Convert hex string to Color
91
+ } catch (e: IllegalArgumentException) {
92
+ Color.TRANSPARENT
93
+ }
94
+ } ?: Color.TRANSPARENT
95
+
96
+ val drawable = GradientDrawable().apply {
97
+ setColor(backgroundColor)
98
+ cornerRadius = props.cornerRadius ?: 0f
99
+ }
100
+
101
+ bottomSheet.background = drawable
102
+ }
103
+
104
+ val behavior = behavior
105
+
106
+ setOnDismissListener {
107
+ onDismiss(mapOf())
108
+ if (behavior.state != BottomSheetBehavior.STATE_HIDDEN) {
109
+ onStateChange(mapOf(
110
+ "type" to "HIDDEN",
111
+ ))
112
+ }
113
+ }
114
+
115
+ if (peekHeight > 0) {
116
+ behavior.peekHeight = peekHeight
117
+ }
118
+
119
+ if (expandedHeight > 0) {
120
+ frameLayout.layoutParams.height = expandedHeight
121
+ frameLayout.requestLayout()
122
+ }
123
+
124
+ behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
125
+ override fun onStateChanged(bottomSheet: android.view.View, newState: Int) {
126
+ handleSheetStateChange(newState)
127
+ }
128
+
129
+ override fun onSlide(bottomSheet: android.view.View, slideOffset: Float) {
130
+ }
131
+ })
132
+
133
+ show()
134
+
135
+ if (initialHeight == peekHeight) {
136
+ behavior.state = BottomSheetBehavior.STATE_COLLAPSED
137
+ } else {
138
+ behavior.state = BottomSheetBehavior.STATE_EXPANDED
139
+ }
140
+
141
+ handleSheetStateChange(behavior.state)
142
+ }
143
+ }
144
+
145
+ fun convertToPx(height: Int): Int {
146
+ val density = context.resources.displayMetrics.density
147
+ return (height * density).toInt()
148
+ }
149
+
150
+ fun handleSheetStateChange(newState: Int) {
151
+ when (newState) {
152
+ BottomSheetBehavior.STATE_HIDDEN -> {
153
+ onStateChange(mapOf(
154
+ "type" to "HIDDEN",
155
+ ))
156
+
157
+ }
158
+ BottomSheetBehavior.STATE_SETTLING -> {
159
+ onStateChange(mapOf(
160
+ "type" to "SETTLING",
161
+ ))
162
+ }
163
+
164
+ BottomSheetBehavior.STATE_COLLAPSED -> {
165
+ onStateChange(mapOf(
166
+ "type" to "OPEN",
167
+ "payload" to mapOf(
168
+ "index" to 0
169
+ )
170
+ ))
171
+ }
172
+
173
+ BottomSheetBehavior.STATE_HALF_EXPANDED -> {
174
+ onStateChange(mapOf(
175
+ "type" to "OPEN",
176
+ "payload" to mapOf(
177
+ "index" to 0
178
+ )
179
+ ))
180
+ }
181
+
182
+ BottomSheetBehavior.STATE_EXPANDED -> {
183
+ onStateChange(mapOf(
184
+ "type" to "OPEN",
185
+ "payload" to mapOf(
186
+ "index" to 1
187
+ )
188
+ ))
189
+ }
190
+
191
+ BottomSheetBehavior.STATE_DRAGGING -> {
192
+ onStateChange(mapOf(
193
+ "type" to "DRAGGING",
194
+ ))
195
+ }
196
+ }
197
+ }
198
+ }
199
+
@@ -0,0 +1,31 @@
1
+ package expo.modules.sheets
2
+
3
+ import androidx.compose.runtime.getValue
4
+ import androidx.compose.runtime.mutableIntStateOf
5
+ import androidx.compose.runtime.mutableStateOf
6
+ import androidx.compose.runtime.setValue
7
+ import expo.modules.kotlin.records.Field
8
+ import expo.modules.kotlin.records.Record
9
+
10
+ class SheetProps {
11
+ var isOpen by mutableStateOf(false)
12
+ var openToIndex by mutableIntStateOf(0)
13
+ var snapPoints by mutableStateOf<List<Int>>(emptyList())
14
+ lateinit var rootViewGroup: SheetRootView
15
+
16
+ // Appearance props
17
+ var dimAmount by mutableStateOf(0.56f)
18
+ var backgroundColor by mutableStateOf<String?>(null)
19
+ var cornerRadius by mutableStateOf<Float?>(null)
20
+ }
21
+
22
+ class SheetAppearance : Record {
23
+ @Field
24
+ var dimAmount: Float? = 0.56f
25
+
26
+ @Field
27
+ var backgroundColor: String? = null
28
+
29
+ @Field
30
+ var cornerRadius: Float? = null
31
+ }
@@ -0,0 +1,87 @@
1
+ package expo.modules.sheets
2
+
3
+ import android.content.Context
4
+ import android.view.MotionEvent
5
+ import android.view.View
6
+ import com.facebook.infer.annotation.SuppressLint
7
+ import com.facebook.react.config.ReactFeatureFlags
8
+ import com.facebook.react.uimanager.JSPointerDispatcher
9
+ import com.facebook.react.uimanager.JSTouchDispatcher
10
+ import com.facebook.react.uimanager.RootView
11
+ import com.facebook.react.uimanager.ThemedReactContext
12
+ import com.facebook.react.uimanager.UIManagerHelper
13
+ import com.facebook.react.uimanager.events.EventDispatcher
14
+ import com.facebook.react.views.view.ReactViewGroup
15
+
16
+ import expo.modules.kotlin.AppContext
17
+
18
+
19
+ class SheetRootView internal constructor(context: Context, appContext: AppContext) : ReactViewGroup(context),
20
+ RootView {
21
+ internal var eventDispatcher: EventDispatcher? = null
22
+
23
+ private val jSTouchDispatcher: JSTouchDispatcher = JSTouchDispatcher(this)
24
+ private var jSPointerDispatcher: JSPointerDispatcher? = null
25
+
26
+ private val reactContext: ThemedReactContext
27
+ get() = context as ThemedReactContext
28
+
29
+ init {
30
+ eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)
31
+
32
+ if (ReactFeatureFlags.dispatchPointerEvents) {
33
+ jSPointerDispatcher = JSPointerDispatcher(this)
34
+ }
35
+ }
36
+
37
+ override fun handleException(t: Throwable) {
38
+ reactContext.reactApplicationContext.handleException(RuntimeException(t))
39
+ }
40
+
41
+ override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
42
+ eventDispatcher?.let { eventDispatcher ->
43
+ jSTouchDispatcher.handleTouchEvent(event, eventDispatcher, reactContext)
44
+ jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, true)
45
+ }
46
+ return super.onInterceptTouchEvent(event)
47
+ }
48
+
49
+ @SuppressLint("ClickableViewAccessibility")
50
+ override fun onTouchEvent(event: MotionEvent): Boolean {
51
+ eventDispatcher?.let { eventDispatcher ->
52
+ jSTouchDispatcher.handleTouchEvent(event, eventDispatcher, reactContext)
53
+ jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, false)
54
+ }
55
+ super.onTouchEvent(event)
56
+ // In case when there is no children interested in handling touch event, we return true from
57
+ // the root view in order to receive subsequent events related to that gesture
58
+ return true
59
+ }
60
+
61
+ override fun onInterceptHoverEvent(event: MotionEvent): Boolean {
62
+ eventDispatcher?.let { jSPointerDispatcher?.handleMotionEvent(event, it, true) }
63
+ return super.onHoverEvent(event)
64
+ }
65
+
66
+ override fun onHoverEvent(event: MotionEvent): Boolean {
67
+ eventDispatcher?.let { jSPointerDispatcher?.handleMotionEvent(event, it, false) }
68
+ return super.onHoverEvent(event)
69
+ }
70
+
71
+ override fun onChildStartedNativeGesture(childView: View?, ev: MotionEvent) {
72
+ eventDispatcher?.let { eventDispatcher ->
73
+ jSTouchDispatcher.onChildStartedNativeGesture(ev, eventDispatcher)
74
+ jSPointerDispatcher?.onChildStartedNativeGesture(childView, ev, eventDispatcher)
75
+ }
76
+ }
77
+
78
+ override fun onChildEndedNativeGesture(childView: View, ev: MotionEvent) {
79
+ eventDispatcher?.let { jSTouchDispatcher.onChildEndedNativeGesture(ev, it) }
80
+ jSPointerDispatcher?.onChildEndedNativeGesture()
81
+ }
82
+
83
+ override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
84
+ // No-op - override in order to still receive events to onInterceptTouchEvent
85
+ // even when some other view disallow that
86
+ }
87
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "platforms": [
3
+ "apple",
4
+ "android",
5
+ "web"
6
+ ],
7
+ "apple": {
8
+ "modules": [
9
+ "RNToolsSheetsModule"
10
+ ]
11
+ },
12
+ "android": {
13
+ "modules": [
14
+ "expo.modules.sheets.RNToolsSheetsModule"
15
+ ]
16
+ }
17
+ }
@@ -0,0 +1,29 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'RNToolsSheets'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.description = package['description']
10
+ s.license = package['license']
11
+ s.author = package['author']
12
+ s.homepage = package['homepage']
13
+ s.platforms = {
14
+ :ios => '16.4',
15
+ :tvos => '16.4'
16
+ }
17
+ s.swift_version = '5.4'
18
+ s.source = { git: 'https://github.com/ajsmth/rn-tools' }
19
+ s.static_framework = true
20
+
21
+ s.dependency 'ExpoModulesCore'
22
+
23
+ # Swift/Objective-C compatibility
24
+ s.pod_target_xcconfig = {
25
+ 'DEFINES_MODULE' => 'YES',
26
+ }
27
+
28
+ s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
29
+ end
@@ -0,0 +1,29 @@
1
+ import ExpoModulesCore
2
+
3
+ public class RNToolsSheetsModule: Module {
4
+ public func definition() -> ModuleDefinition {
5
+ Name("RNToolsSheets")
6
+
7
+ View(RNToolsSheetsView.self) {
8
+ Events("onDismiss", "onStateChange")
9
+
10
+ Prop("snapPoints") { (view, snapPoints: [Int]) in
11
+ view.props.snapPoints = snapPoints
12
+ }
13
+
14
+ Prop("isOpen") { (view, isOpen: Bool) in
15
+ view.props.isOpen = isOpen
16
+ }
17
+
18
+ Prop("openToIndex") { (view, openToIndex: Int) in
19
+ view.props.openToIndex = openToIndex
20
+ }
21
+
22
+ 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
26
+ }
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,219 @@
1
+ import ExpoModulesCore
2
+ import SwiftUI
3
+
4
+ public class SheetProps: ObservableObject {
5
+ @Published var children: [UIView] = []
6
+ @Published var isOpen: Bool = false
7
+ @Published var openToIndex: Int = 0
8
+ @Published var snapPoints: [Int] = []
9
+
10
+ // Appearance props
11
+ @Published var grabberVisible: Bool = true
12
+ @Published var backgroundColor: String? = nil
13
+ @Published var cornerRadius: Float? = nil
14
+
15
+ }
16
+
17
+ struct SheetAppearance: Record {
18
+ @Field
19
+ var grabberVisible: Bool?
20
+
21
+ @Field
22
+ var backgroundColor: String?
23
+
24
+ @Field
25
+ var cornerRadius: Float?
26
+ }
27
+
28
+ public class RNToolsSheetsView: ExpoView {
29
+ public var props = SheetProps()
30
+ var onDismiss = EventDispatcher()
31
+ var onStateChange = EventDispatcher()
32
+
33
+ var touchHandler: RCTTouchHandler?
34
+
35
+ lazy var hostingController = UIHostingController(
36
+ rootView: ContentView(
37
+ props: props, onDismiss: onDismiss, onStateChange: onStateChange))
38
+
39
+ required init(appContext: AppContext? = nil) {
40
+ super.init(appContext: appContext)
41
+
42
+ if let bridge = appContext?.reactBridge {
43
+ touchHandler = RCTTouchHandler(bridge: bridge)
44
+ }
45
+
46
+ hostingController.view.autoresizingMask = [
47
+ .flexibleWidth, .flexibleHeight,
48
+ ]
49
+ hostingController.view.backgroundColor = UIColor.clear
50
+ hostingController.rootView = ContentView(
51
+ props: props, onDismiss: onDismiss, onStateChange: onStateChange)
52
+
53
+ addSubview(hostingController.view)
54
+ }
55
+
56
+ public override func reactSubviews() -> [UIView]! {
57
+ return []
58
+ }
59
+
60
+ public override func insertReactSubview(_ subview: UIView!, at atIndex: Int)
61
+ {
62
+ super.insertReactSubview(subview, at: atIndex)
63
+ props.children.insert(subview, at: atIndex)
64
+ if atIndex == 0 {
65
+ touchHandler?.attach(to: subview)
66
+ }
67
+ }
68
+
69
+ public override func removeReactSubview(_ subview: UIView!) {
70
+ super.removeReactSubview(subview)
71
+ if let index = props.children.firstIndex(of: subview) {
72
+ props.children.remove(at: index)
73
+ }
74
+ }
75
+ }
76
+
77
+ struct ContentView: View {
78
+ @ObservedObject var props: SheetProps
79
+ var onDismiss: EventDispatcher
80
+ var onStateChange: EventDispatcher
81
+
82
+ @State private var selectedDetent: PresentationDetent = .height(400.0)
83
+ @State private var lastHeight: CGFloat = 0
84
+ @State private var isDragging = false
85
+ @State private var settleTimer: Timer?
86
+
87
+ private var detents: [PresentationDetent] {
88
+ props.snapPoints.map { .height(CGFloat($0)) }
89
+ }
90
+
91
+ private func detent(for index: Int?) -> PresentationDetent {
92
+ guard
93
+ let i = index,
94
+ detents.indices.contains(i)
95
+ else { return detents.first! }
96
+ return detents[i]
97
+ }
98
+
99
+ private func upperSnapIndex(
100
+ for height: CGFloat,
101
+ snapPoints: [Int]
102
+ ) -> Int {
103
+ guard !snapPoints.isEmpty else { return 0 }
104
+
105
+ let sorted = snapPoints.sorted()
106
+ if let i = sorted.firstIndex(where: { CGFloat($0) >= height }) {
107
+ return i
108
+ }
109
+ return sorted.count - 1
110
+ }
111
+
112
+ var body: some View {
113
+
114
+ Color.clear
115
+ .sheet(
116
+ isPresented: $props.isOpen,
117
+ onDismiss: {
118
+ onDismiss([:])
119
+ onStateChange(["type": "HIDDEN"])
120
+ }
121
+ ) {
122
+ VStack {
123
+ ForEach(Array(props.children.enumerated()), id: \.offset) {
124
+ index, child in
125
+ RepresentableView(view: child)
126
+ }
127
+ }
128
+ .background(
129
+ GeometryReader { geometry in
130
+ Color.clear
131
+ .onChange(of: geometry.size.height) { newHeight in
132
+ if abs(newHeight - lastHeight) > 2 {
133
+ if !isDragging {
134
+ isDragging = true
135
+ onStateChange(["type": "DRAGGING"])
136
+ }
137
+
138
+ settleTimer?.invalidate()
139
+ settleTimer = Timer.scheduledTimer(
140
+ withTimeInterval: 0.15, repeats: false
141
+ ) { _ in
142
+ isDragging = false
143
+ onStateChange(["type": "SETTLING"])
144
+
145
+ DispatchQueue.main.asyncAfter(
146
+ deadline: .now() + 0.15
147
+ ) {
148
+ let idx = upperSnapIndex(
149
+ for: newHeight,
150
+ snapPoints: props.snapPoints
151
+ )
152
+ onStateChange([
153
+ "type": "OPEN",
154
+ "payload": ["index": idx],
155
+ ])
156
+ }
157
+ }
158
+ }
159
+
160
+ lastHeight = newHeight
161
+ }
162
+ }
163
+ )
164
+ .presentationBackground(props.backgroundColor != nil ? Color(hex: props.backgroundColor!) : Color.white)
165
+ .presentationDragIndicator(
166
+ props.grabberVisible ? .visible : .hidden
167
+ )
168
+ .presentationDetents(
169
+ Set(detents),
170
+ selection: $selectedDetent
171
+ )
172
+ .presentationCornerRadius(props.cornerRadius.map { CGFloat($0) })
173
+ .onAppear {
174
+ selectedDetent = detent(for: props.openToIndex)
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ struct RepresentableView: UIViewRepresentable {
181
+ var view: UIView
182
+
183
+ func makeUIView(context: Context) -> UIView {
184
+ let containerView = UIView()
185
+ containerView.backgroundColor = .clear
186
+
187
+ view.translatesAutoresizingMaskIntoConstraints = false
188
+ containerView.addSubview(view)
189
+
190
+ NSLayoutConstraint.activate([
191
+ view.topAnchor.constraint(equalTo: containerView.topAnchor),
192
+ view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
193
+ view.trailingAnchor.constraint(
194
+ equalTo: containerView.trailingAnchor),
195
+ view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
196
+ ])
197
+
198
+ return containerView
199
+ }
200
+
201
+ func updateUIView(_ uiView: UIView, context: Context) {}
202
+ }
203
+
204
+
205
+ extension Color {
206
+ init(hex: String) {
207
+ var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
208
+ hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
209
+
210
+ var rgb: UInt64 = 0
211
+ Scanner(string: hexSanitized).scanHexInt64(&rgb)
212
+
213
+ let red = Double((rgb & 0xFF0000) >> 16) / 255.0
214
+ let green = Double((rgb & 0x00FF00) >> 8) / 255.0
215
+ let blue = Double(rgb & 0x0000FF) / 255.0
216
+
217
+ self.init(red: red, green: green, blue: blue)
218
+ }
219
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@rn-tools/sheets",
3
+ "version": "0.1.1",
4
+ "description": "My new module",
5
+ "main": "src/index.ts",
6
+ "scripts": {
7
+ "build": "expo-module build",
8
+ "clean": "expo-module clean",
9
+ "lint": "expo-module lint",
10
+ "test": "expo-module test",
11
+ "prepare": "expo-module prepare",
12
+ "prepublishOnly": "expo-module prepublishOnly",
13
+ "expo-module": "expo-module",
14
+ "open:ios": "xed example/ios",
15
+ "open:android": "open -a \"Android Studio\" example/android"
16
+ },
17
+ "keywords": [
18
+ "react-native",
19
+ "expo",
20
+ "sheets",
21
+ "RNToolsSheets"
22
+ ],
23
+ "repository": "https://github.com/ajsmth/rn-tools",
24
+ "bugs": {
25
+ "url": "https://github.com/ajsmth/rn-tools/issues"
26
+ },
27
+ "author": "andy <andydevs123@gmail.com> (ajsmth)",
28
+ "license": "MIT",
29
+ "homepage": "https://github.com/ajsmth/rn-tools#readme",
30
+ "devDependencies": {
31
+ "@types/react": "~18.3.12",
32
+ "expo": "~52.0.0",
33
+ "expo-module-scripts": "^4.0.3",
34
+ "react-native": "0.76.0"
35
+ },
36
+ "peerDependencies": {
37
+ "expo": "*",
38
+ "react": "*",
39
+ "react-native": "*"
40
+ }
41
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './native-sheets-view'
@@ -0,0 +1,147 @@
1
+ import * as React from "react";
2
+ import {
3
+ LayoutRectangle,
4
+ NativeSyntheticEvent,
5
+ View,
6
+ ViewStyle,
7
+ Platform,
8
+ LayoutChangeEvent,
9
+ } from "react-native";
10
+ import { requireNativeViewManager } from "expo-modules-core";
11
+
12
+ type SheetState = "DRAGGING" | "OPEN" | "SETTLING" | "HIDDEN";
13
+
14
+ type ChangeEvent<T extends SheetState, P = unknown> = {
15
+ type: T;
16
+ payload?: P;
17
+ };
18
+
19
+ type OpenChangeEvent = ChangeEvent<"OPEN", { index: number }>;
20
+ type DraggingChangeEvent = ChangeEvent<"DRAGGING">;
21
+ type SettlingChangeEvent = ChangeEvent<"SETTLING">;
22
+ type HiddenChangeEvent = ChangeEvent<"HIDDEN">;
23
+
24
+ type SheetChangeEvent =
25
+ | OpenChangeEvent
26
+ | DraggingChangeEvent
27
+ | SettlingChangeEvent
28
+ | HiddenChangeEvent;
29
+
30
+ type NativeOnChangeEvent = NativeSyntheticEvent<SheetChangeEvent>;
31
+
32
+ type AppearanceIOS = {
33
+ grabberVisible?: boolean;
34
+ backgroundColor?: string;
35
+ cornerRadius?: number;
36
+ };
37
+
38
+ type AppearanceAndroid = {
39
+ dimAmount?: number;
40
+ cornerRadius?: number;
41
+ backgroundColor?: string;
42
+ };
43
+
44
+ type NativeSheetViewProps = {
45
+ children: React.ReactNode;
46
+ snapPoints?: number[];
47
+ isOpen: boolean;
48
+ openToIndex: number;
49
+ onDismiss: () => void;
50
+ onStateChange: (event: NativeOnChangeEvent) => void;
51
+ appearanceAndroid?: AppearanceAndroid;
52
+ appearanceIOS?: AppearanceIOS;
53
+ };
54
+
55
+ const NativeSheetsView =
56
+ requireNativeViewManager<NativeSheetViewProps>("RNToolsSheets");
57
+
58
+ export type BottomSheetProps = {
59
+ children: React.ReactNode;
60
+ containerStyle?: ViewStyle;
61
+ snapPoints?: number[];
62
+ isOpen: boolean;
63
+ openToIndex?: number;
64
+ onOpenChange: (isOpen: boolean) => void;
65
+ onStateChange?: (event: SheetChangeEvent) => void;
66
+ appearanceAndroid?: AppearanceAndroid;
67
+ appearanceIOS?: AppearanceIOS;
68
+ };
69
+
70
+ // TODO:
71
+ // - get sheet container height from native side and clamp maxHeight to that value
72
+ //
73
+
74
+ export function BottomSheet(props: BottomSheetProps) {
75
+ const {
76
+ onStateChange,
77
+ children,
78
+ snapPoints = [],
79
+ containerStyle,
80
+ isOpen,
81
+ openToIndex = 0,
82
+ onOpenChange: setIsOpen,
83
+ appearanceAndroid,
84
+ appearanceIOS,
85
+ } = props;
86
+
87
+ const [layout, setLayout] = React.useState<LayoutRectangle>({
88
+ height: 0,
89
+ width: 0,
90
+ x: 0,
91
+ y: 0,
92
+ });
93
+
94
+ const computedSnapPoints = React.useMemo(() => {
95
+ if (snapPoints.length === 0 && layout.height > 0) {
96
+ return [layout.height];
97
+ }
98
+
99
+ return Platform.OS === "android" ? snapPoints.slice(0, 2) : [...snapPoints];
100
+ }, [snapPoints, layout]);
101
+
102
+ const maxHeight = React.useMemo(
103
+ () =>
104
+ computedSnapPoints.length === 0
105
+ ? undefined
106
+ : Math.max(...computedSnapPoints),
107
+ [computedSnapPoints],
108
+ );
109
+
110
+ const style = React.useMemo(() => {
111
+ return {
112
+ height: maxHeight,
113
+ ...containerStyle,
114
+ };
115
+ }, [maxHeight]);
116
+
117
+ const handleOnDismiss = React.useCallback(() => {
118
+ setIsOpen(false);
119
+ }, []);
120
+
121
+ const handleStateChange = React.useCallback(
122
+ (event: NativeOnChangeEvent) => {
123
+ onStateChange?.(event.nativeEvent);
124
+ },
125
+ [onStateChange],
126
+ );
127
+
128
+ const handleLayout = React.useCallback((event: LayoutChangeEvent) => {
129
+ setLayout(event.nativeEvent.layout);
130
+ }, []);
131
+
132
+ return (
133
+ <NativeSheetsView
134
+ isOpen={isOpen}
135
+ openToIndex={openToIndex}
136
+ onDismiss={handleOnDismiss}
137
+ onStateChange={handleStateChange}
138
+ snapPoints={computedSnapPoints}
139
+ appearanceAndroid={appearanceAndroid}
140
+ appearanceIOS={appearanceIOS}
141
+ >
142
+ <View style={style} onLayout={handleLayout}>
143
+ {children}
144
+ </View>
145
+ </NativeSheetsView>
146
+ );
147
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "compilerOptions": {
3
+ "baseUrl": ".",
4
+ "paths": {
5
+ "~/*": ["src/*"]
6
+ }
7
+ },
8
+ "extends": "expo/tsconfig.base",
9
+ "include": ["**/*.ts", "**/*.tsx"]
10
+ }