about summary refs log tree commit diff
path: root/modules
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-10-04 13:24:12 -0700
committerGitHub <noreply@github.com>2024-10-04 13:24:12 -0700
commit00486e94991f344353ffb083dd631283a84c3ad3 (patch)
treea5dc4da5e5e71912d73a099e84761517fa8c62a9 /modules
parent9802ebe20d32dc1867a069dc377b3d4c43ce45f0 (diff)
downloadvoidsky-00486e94991f344353ffb083dd631283a84c3ad3.tar.zst
[Sheets] [Pt. 1] Root PR (#5557)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: dan <dan.abramov@gmail.com>
Co-authored-by: Hailey <me@haileyok.com>
Diffstat (limited to 'modules')
-rw-r--r--modules/bottom-sheet/android/build.gradle49
-rw-r--r--modules/bottom-sheet/android/src/main/AndroidManifest.xml2
-rw-r--r--modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt53
-rw-r--r--modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt339
-rw-r--r--modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt171
-rw-r--r--modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/SheetManager.kt28
-rw-r--r--modules/bottom-sheet/expo-module.config.json9
-rw-r--r--modules/bottom-sheet/index.ts13
-rw-r--r--modules/bottom-sheet/ios/BottomSheet.podspec21
-rw-r--r--modules/bottom-sheet/ios/BottomSheetModule.swift47
-rw-r--r--modules/bottom-sheet/ios/SheetManager.swift28
-rw-r--r--modules/bottom-sheet/ios/SheetView.swift189
-rw-r--r--modules/bottom-sheet/ios/SheetViewController.swift76
-rw-r--r--modules/bottom-sheet/ios/Util.swift18
-rw-r--r--modules/bottom-sheet/src/BottomSheet.tsx100
-rw-r--r--modules/bottom-sheet/src/BottomSheet.types.ts35
-rw-r--r--modules/bottom-sheet/src/BottomSheet.web.tsx5
17 files changed, 1183 insertions, 0 deletions
diff --git a/modules/bottom-sheet/android/build.gradle b/modules/bottom-sheet/android/build.gradle
new file mode 100644
index 000000000..a1d423044
--- /dev/null
+++ b/modules/bottom-sheet/android/build.gradle
@@ -0,0 +1,49 @@
+apply plugin: 'com.android.library'
+
+group = 'expo.modules.bottomsheet'
+version = '0.1.0'
+
+def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
+apply from: expoModulesCorePlugin
+applyKotlinExpoModulesCorePlugin()
+useCoreDependencies()
+useExpoPublishing()
+
+// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
+// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
+// Most of the time, you may like to manage the Android SDK versions yourself.
+def useManagedAndroidSdkVersions = false
+if (useManagedAndroidSdkVersions) {
+  useDefaultAndroidSdkVersions()
+} else {
+  buildscript {
+    // Simple helper that allows the root project to override versions declared by this library.
+    ext.safeExtGet = { prop, fallback ->
+      rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
+    }
+  }
+  project.android {
+    compileSdkVersion safeExtGet("compileSdkVersion", 34)
+    defaultConfig {
+      minSdkVersion safeExtGet("minSdkVersion", 21)
+      targetSdkVersion safeExtGet("targetSdkVersion", 34)
+    }
+  }
+}
+
+android {
+  namespace "expo.modules.bottomsheet"
+  defaultConfig {
+    versionCode 1
+    versionName "0.1.0"
+  }
+  lintOptions {
+    abortOnError false
+  }
+}
+
+dependencies {
+  implementation project(':expo-modules-core')
+  implementation 'com.google.android.material:material:1.12.0'
+  implementation "com.facebook.react:react-native:+"
+}
diff --git a/modules/bottom-sheet/android/src/main/AndroidManifest.xml b/modules/bottom-sheet/android/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..bdae66c8f
--- /dev/null
+++ b/modules/bottom-sheet/android/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+<manifest>
+</manifest>
diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt
new file mode 100644
index 000000000..057e6ed2e
--- /dev/null
+++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt
@@ -0,0 +1,53 @@
+package expo.modules.bottomsheet
+
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class BottomSheetModule : Module() {
+  override fun definition() =
+    ModuleDefinition {
+      Name("BottomSheet")
+
+      AsyncFunction("dismissAll") {
+        SheetManager.dismissAll()
+      }
+
+      View(BottomSheetView::class) {
+        Events(
+          arrayOf(
+            "onAttemptDismiss",
+            "onSnapPointChange",
+            "onStateChange",
+          ),
+        )
+
+        AsyncFunction("dismiss") { view: BottomSheetView ->
+          view.dismiss()
+        }
+
+        AsyncFunction("updateLayout") { view: BottomSheetView ->
+          view.updateLayout()
+        }
+
+        Prop("disableDrag") { view: BottomSheetView, prop: Boolean ->
+          view.disableDrag = prop
+        }
+
+        Prop("minHeight") { view: BottomSheetView, prop: Float ->
+          view.minHeight = prop
+        }
+
+        Prop("maxHeight") { view: BottomSheetView, prop: Float ->
+          view.maxHeight = prop
+        }
+
+        Prop("preventDismiss") { view: BottomSheetView, prop: Boolean ->
+          view.preventDismiss = prop
+        }
+
+        Prop("preventExpansion") { view: BottomSheetView, prop: Boolean ->
+          view.preventExpansion = prop
+        }
+      }
+    }
+}
diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt
new file mode 100644
index 000000000..a5a84ec3d
--- /dev/null
+++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt
@@ -0,0 +1,339 @@
+package expo.modules.bottomsheet
+
+import android.content.Context
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewStructure
+import android.view.accessibility.AccessibilityEvent
+import android.widget.FrameLayout
+import androidx.core.view.allViews
+import com.facebook.react.bridge.LifecycleEventListener
+import com.facebook.react.bridge.ReactContext
+import com.facebook.react.bridge.UiThreadUtil
+import com.facebook.react.uimanager.UIManagerHelper
+import com.facebook.react.uimanager.events.EventDispatcher
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import expo.modules.kotlin.AppContext
+import expo.modules.kotlin.viewevent.EventDispatcher
+import expo.modules.kotlin.views.ExpoView
+import java.util.ArrayList
+
+class BottomSheetView(
+  context: Context,
+  appContext: AppContext,
+) : ExpoView(context, appContext),
+  LifecycleEventListener {
+  private var innerView: View? = null
+  private var dialog: BottomSheetDialog? = null
+
+  private lateinit var dialogRootViewGroup: DialogRootViewGroup
+  private var eventDispatcher: EventDispatcher? = null
+
+  private val screenHeight =
+    context.resources.displayMetrics.heightPixels
+      .toFloat()
+
+  private val onAttemptDismiss by EventDispatcher()
+  private val onSnapPointChange by EventDispatcher()
+  private val onStateChange by EventDispatcher()
+
+  // Props
+  var disableDrag = false
+    set (value) {
+      field = value
+      this.setDraggable(!value)
+    }
+
+  var preventDismiss = false
+    set(value) {
+      field = value
+      this.dialog?.setCancelable(!value)
+    }
+  var preventExpansion = false
+
+  var minHeight = 0f
+    set(value) {
+      field =
+        if (value < 0) {
+          0f
+        } else {
+          value
+        }
+    }
+
+  var maxHeight = this.screenHeight
+    set(value) {
+      field =
+        if (value > this.screenHeight) {
+          this.screenHeight.toFloat()
+        } else {
+          value
+        }
+    }
+
+  private var isOpen: Boolean = false
+    set(value) {
+      field = value
+      onStateChange(
+        mapOf(
+          "state" to if (value) "open" else "closed",
+        ),
+      )
+    }
+
+  private var isOpening: Boolean = false
+    set(value) {
+      field = value
+      if (value) {
+        onStateChange(
+          mapOf(
+            "state" to "opening",
+          ),
+        )
+      }
+    }
+
+  private var isClosing: Boolean = false
+    set(value) {
+      field = value
+      if (value) {
+        onStateChange(
+          mapOf(
+            "state" to "closing",
+          ),
+        )
+      }
+    }
+
+  private var selectedSnapPoint = 0
+    set(value) {
+      if (field == value) return
+
+      field = value
+      onSnapPointChange(
+        mapOf(
+          "snapPoint" to value,
+        ),
+      )
+    }
+
+  // Lifecycle
+
+  init {
+    (appContext.reactContext as? ReactContext)?.let {
+      it.addLifecycleEventListener(this)
+      this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id)
+
+      this.dialogRootViewGroup = DialogRootViewGroup(context)
+      this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher
+    }
+    SheetManager.add(this)
+  }
+
+  override fun onLayout(
+    changed: Boolean,
+    l: Int,
+    t: Int,
+    r: Int,
+    b: Int,
+  ) {
+    this.present()
+  }
+
+  private fun destroy() {
+    this.isClosing = false
+    this.isOpen = false
+    this.dialog = null
+    this.innerView = null
+    SheetManager.remove(this)
+  }
+
+  // Presentation
+
+  private fun present() {
+    if (this.isOpen || this.isOpening || this.isClosing) return
+
+    val contentHeight = this.getContentHeight()
+
+    val dialog = BottomSheetDialog(context)
+    dialog.setContentView(dialogRootViewGroup)
+    dialog.setCancelable(!preventDismiss)
+    dialog.setOnShowListener {
+      val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
+      bottomSheet?.let {
+        // Let the outside view handle the background color on its own, the default for this is
+        // white and we don't want that.
+        it.setBackgroundColor(0)
+
+        val behavior = BottomSheetBehavior.from(it)
+
+        behavior.isFitToContents = true
+        behavior.halfExpandedRatio = this.clampRatio(this.getTargetHeight() / this.screenHeight)
+        if (contentHeight > this.screenHeight) {
+          behavior.state = BottomSheetBehavior.STATE_EXPANDED
+          this.selectedSnapPoint = 2
+        } else {
+          behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
+          this.selectedSnapPoint = 1
+        }
+        behavior.skipCollapsed = true
+        behavior.isDraggable = true
+        behavior.isHideable = true
+
+        behavior.addBottomSheetCallback(
+          object : BottomSheetBehavior.BottomSheetCallback() {
+            override fun onStateChanged(
+              bottomSheet: View,
+              newState: Int,
+            ) {
+              when (newState) {
+                BottomSheetBehavior.STATE_EXPANDED -> {
+                  selectedSnapPoint = 2
+                }
+                BottomSheetBehavior.STATE_COLLAPSED -> {
+                  selectedSnapPoint = 1
+                }
+                BottomSheetBehavior.STATE_HALF_EXPANDED -> {
+                  selectedSnapPoint = 1
+                }
+                BottomSheetBehavior.STATE_HIDDEN -> {
+                  selectedSnapPoint = 0
+                }
+              }
+            }
+
+            override fun onSlide(
+              bottomSheet: View,
+              slideOffset: Float,
+            ) { }
+          },
+        )
+      }
+    }
+    dialog.setOnDismissListener {
+      this.isClosing = true
+      this.destroy()
+    }
+
+    this.isOpening = true
+    dialog.show()
+    this.dialog = dialog
+  }
+
+  fun updateLayout() {
+    val dialog = this.dialog ?: return
+    val contentHeight = this.getContentHeight()
+
+    val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
+    bottomSheet?.let {
+      val behavior = BottomSheetBehavior.from(it)
+
+      behavior.halfExpandedRatio = this.clampRatio(this.getTargetHeight() / this.screenHeight)
+
+      if (contentHeight > this.screenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
+        behavior.state = BottomSheetBehavior.STATE_EXPANDED
+      } else if (contentHeight < this.screenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
+        behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
+      }
+    }
+  }
+
+  fun dismiss() {
+    this.dialog?.dismiss()
+  }
+
+  // Util
+
+  private fun getContentHeight(): Float {
+    val innerView = this.innerView ?: return 0f
+    var index = 0
+    innerView.allViews.forEach {
+      if (index == 1) {
+        return it.height.toFloat()
+      }
+      index++
+    }
+    return 0f
+  }
+
+  private fun getTargetHeight(): Float {
+    val contentHeight = this.getContentHeight()
+    val height =
+      if (contentHeight > maxHeight) {
+        maxHeight
+      } else if (contentHeight < minHeight) {
+        minHeight
+      } else {
+        contentHeight
+      }
+    return height
+  }
+
+  private fun clampRatio(ratio: Float): Float {
+    if (ratio < 0.01) {
+      return 0.01f
+    } else if (ratio > 0.99) {
+      return 0.99f
+    }
+    return ratio
+  }
+
+  private fun setDraggable(draggable: Boolean) {
+    val dialog = this.dialog ?: return
+    val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
+    bottomSheet?.let {
+      val behavior = BottomSheetBehavior.from(it)
+      behavior.isDraggable = draggable
+    }
+  }
+
+  override fun onHostResume() { }
+
+  override fun onHostPause() { }
+
+  override fun onHostDestroy() {
+    (appContext.reactContext as? ReactContext)?.let {
+      it.removeLifecycleEventListener(this)
+      this.destroy()
+    }
+  }
+
+  // View overrides to pass to DialogRootViewGroup instead
+
+  override fun dispatchProvideStructure(structure: ViewStructure?) {
+    dialogRootViewGroup.dispatchProvideStructure(structure)
+  }
+
+  override fun setId(id: Int) {
+    super.setId(id)
+    dialogRootViewGroup.id = id
+  }
+
+  override fun addView(
+    child: View?,
+    index: Int,
+  ) {
+    this.innerView = child
+    (child as ViewGroup).let {
+      dialogRootViewGroup.addView(child, index)
+    }
+  }
+
+  override fun removeView(view: View?) {
+    UiThreadUtil.assertOnUiThread()
+    if (view != null) {
+      dialogRootViewGroup.removeView(view)
+    }
+  }
+
+  override fun removeViewAt(index: Int) {
+    UiThreadUtil.assertOnUiThread()
+    val child = getChildAt(index)
+    dialogRootViewGroup.removeView(child)
+  }
+
+  override fun addChildrenForAccessibility(outChildren: ArrayList<View>?) { }
+
+  override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent?): Boolean = false
+}
diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt
new file mode 100644
index 000000000..c022924e9
--- /dev/null
+++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt
@@ -0,0 +1,171 @@
+package expo.modules.bottomsheet
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.view.MotionEvent
+import android.view.View
+import com.facebook.react.bridge.GuardedRunnable
+import com.facebook.react.config.ReactFeatureFlags
+import com.facebook.react.uimanager.JSPointerDispatcher
+import com.facebook.react.uimanager.JSTouchDispatcher
+import com.facebook.react.uimanager.RootView
+import com.facebook.react.uimanager.ThemedReactContext
+import com.facebook.react.uimanager.UIManagerModule
+import com.facebook.react.uimanager.events.EventDispatcher
+import com.facebook.react.views.view.ReactViewGroup
+
+// SEE https://github.com/facebook/react-native/blob/309cdea337101cfe2212cfb6abebf1e783e43282/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt#L378
+
+/**
+ * DialogRootViewGroup is the ViewGroup which contains all the children of a Modal. It gets all
+ * child information forwarded from [ReactModalHostView] and uses that to create children. It is
+ * also responsible for acting as a RootView and handling touch events. It does this the same way
+ * as ReactRootView.
+ *
+ * To get layout to work properly, we need to layout all the elements within the Modal as if they
+ * can fill the entire window. To do that, we need to explicitly set the styleWidth and
+ * styleHeight on the LayoutShadowNode to be the window size. This is done through the
+ * UIManagerModule, and will then cause the children to layout as if they can fill the window.
+ */
+class DialogRootViewGroup(
+  private val context: Context?,
+) : ReactViewGroup(context),
+  RootView {
+  private var hasAdjustedSize = false
+  private var viewWidth = 0
+  private var viewHeight = 0
+
+  private val jSTouchDispatcher = JSTouchDispatcher(this)
+  private var jSPointerDispatcher: JSPointerDispatcher? = null
+  private var sizeChangeListener: OnSizeChangeListener? = null
+
+  var eventDispatcher: EventDispatcher? = null
+
+  interface OnSizeChangeListener {
+    fun onSizeChange(
+      width: Int,
+      height: Int,
+    )
+  }
+
+  init {
+    if (ReactFeatureFlags.dispatchPointerEvents) {
+      jSPointerDispatcher = JSPointerDispatcher(this)
+    }
+  }
+
+  override fun onSizeChanged(
+    w: Int,
+    h: Int,
+    oldw: Int,
+    oldh: Int,
+  ) {
+    super.onSizeChanged(w, h, oldw, oldh)
+
+    viewWidth = w
+    viewHeight = h
+    updateFirstChildView()
+
+    sizeChangeListener?.onSizeChange(w, h)
+  }
+
+  fun setOnSizeChangeListener(listener: OnSizeChangeListener) {
+    sizeChangeListener = listener
+  }
+
+  private fun updateFirstChildView() {
+    if (childCount > 0) {
+      hasAdjustedSize = false
+      val viewTag = getChildAt(0).id
+      reactContext.runOnNativeModulesQueueThread(
+        object : GuardedRunnable(reactContext) {
+          override fun runGuarded() {
+            val uiManager: UIManagerModule =
+              reactContext
+                .reactApplicationContext
+                .getNativeModule(UIManagerModule::class.java) ?: return
+
+            uiManager.updateNodeSize(viewTag, viewWidth, viewHeight)
+          }
+        },
+      )
+    } else {
+      hasAdjustedSize = true
+    }
+  }
+
+  override fun addView(
+    child: View,
+    index: Int,
+    params: LayoutParams,
+  ) {
+    super.addView(child, index, params)
+    if (hasAdjustedSize) {
+      updateFirstChildView()
+    }
+  }
+
+  override fun handleException(t: Throwable) {
+    reactContext.reactApplicationContext.handleException(RuntimeException(t))
+  }
+
+  private val reactContext: ThemedReactContext
+    get() = context as ThemedReactContext
+
+  override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
+    eventDispatcher?.let { jSTouchDispatcher.handleTouchEvent(event, it) }
+    jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, true)
+    return super.onInterceptTouchEvent(event)
+  }
+
+  @SuppressLint("ClickableViewAccessibility")
+  override fun onTouchEvent(event: MotionEvent): Boolean {
+    eventDispatcher?.let { jSTouchDispatcher.handleTouchEvent(event, it) }
+    jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, false)
+    super.onTouchEvent(event)
+
+    // In case when there is no children interested in handling touch event, we return true from
+    // the root view in order to receive subsequent events related to that gesture
+    return true
+  }
+
+  override fun onInterceptHoverEvent(event: MotionEvent): Boolean {
+    jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, true)
+    return super.onHoverEvent(event)
+  }
+
+  override fun onHoverEvent(event: MotionEvent): Boolean {
+    jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, false)
+    return super.onHoverEvent(event)
+  }
+
+  @Deprecated("Deprecated in Java")
+  override fun onChildStartedNativeGesture(ev: MotionEvent?) {
+    eventDispatcher?.let {
+      if (ev != null) {
+        jSTouchDispatcher.onChildStartedNativeGesture(ev, it)
+      }
+    }
+  }
+
+  override fun onChildStartedNativeGesture(
+    childView: View,
+    ev: MotionEvent,
+  ) {
+    eventDispatcher?.let { jSTouchDispatcher.onChildStartedNativeGesture(ev, it) }
+    jSPointerDispatcher?.onChildStartedNativeGesture(childView, ev, eventDispatcher)
+  }
+
+  override fun onChildEndedNativeGesture(
+    childView: View,
+    ev: MotionEvent,
+  ) {
+    eventDispatcher?.let { jSTouchDispatcher.onChildEndedNativeGesture(ev, it) }
+    jSPointerDispatcher?.onChildEndedNativeGesture()
+  }
+
+  override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
+    // No-op - override in order to still receive events to onInterceptTouchEvent
+    // even when some other view disallow that
+  }
+}
diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/SheetManager.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/SheetManager.kt
new file mode 100644
index 000000000..be7884998
--- /dev/null
+++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/SheetManager.kt
@@ -0,0 +1,28 @@
+package expo.modules.bottomsheet
+
+import java.lang.ref.WeakReference
+
+class SheetManager {
+  companion object {
+    private val sheets = mutableSetOf<WeakReference<BottomSheetView>>()
+
+    fun add(view: BottomSheetView) {
+      sheets.add(WeakReference(view))
+    }
+
+    fun remove(view: BottomSheetView) {
+      sheets.forEach {
+        if (it.get() == view) {
+          sheets.remove(it)
+          return
+        }
+      }
+    }
+
+    fun dismissAll() {
+      sheets.forEach {
+        it.get()?.dismiss()
+      }
+    }
+  }
+}
diff --git a/modules/bottom-sheet/expo-module.config.json b/modules/bottom-sheet/expo-module.config.json
new file mode 100644
index 000000000..81b5b078e
--- /dev/null
+++ b/modules/bottom-sheet/expo-module.config.json
@@ -0,0 +1,9 @@
+{
+  "platforms": ["ios", "android"],
+  "ios": {
+    "modules": ["BottomSheetModule"]
+  },
+  "android": {
+    "modules": ["expo.modules.bottomsheet.BottomSheetModule"]
+  }
+}
diff --git a/modules/bottom-sheet/index.ts b/modules/bottom-sheet/index.ts
new file mode 100644
index 000000000..1fe3dac0e
--- /dev/null
+++ b/modules/bottom-sheet/index.ts
@@ -0,0 +1,13 @@
+import {BottomSheet} from './src/BottomSheet'
+import {
+  BottomSheetSnapPoint,
+  BottomSheetState,
+  BottomSheetViewProps,
+} from './src/BottomSheet.types'
+
+export {
+  BottomSheet,
+  BottomSheetSnapPoint,
+  type BottomSheetState,
+  type BottomSheetViewProps,
+}
diff --git a/modules/bottom-sheet/ios/BottomSheet.podspec b/modules/bottom-sheet/ios/BottomSheet.podspec
new file mode 100644
index 000000000..a42356f61
--- /dev/null
+++ b/modules/bottom-sheet/ios/BottomSheet.podspec
@@ -0,0 +1,21 @@
+Pod::Spec.new do |s|
+  s.name           = 'BottomSheet'
+  s.version        = '1.0.0'
+  s.summary        = 'A bottom sheet for use in Bluesky'
+  s.description    = 'A bottom sheet for use in Bluesky'
+  s.author         = ''
+  s.homepage       = 'https://github.com/bluesky-social/social-app'
+  s.platforms      = { :ios => '15.0', :tvos => '15.0' }
+  s.source         = { git: '' }
+  s.static_framework = true
+
+  s.dependency 'ExpoModulesCore'
+
+  # Swift/Objective-C compatibility
+  s.pod_target_xcconfig = {
+    'DEFINES_MODULE' => 'YES',
+    'SWIFT_COMPILATION_MODE' => 'wholemodule'
+  }
+
+  s.source_files = "**/*.{h,m,swift}"
+end
diff --git a/modules/bottom-sheet/ios/BottomSheetModule.swift b/modules/bottom-sheet/ios/BottomSheetModule.swift
new file mode 100644
index 000000000..579608e75
--- /dev/null
+++ b/modules/bottom-sheet/ios/BottomSheetModule.swift
@@ -0,0 +1,47 @@
+import ExpoModulesCore
+
+public class BottomSheetModule: Module {
+  public func definition() -> ModuleDefinition {
+    Name("BottomSheet")
+
+    AsyncFunction("dismissAll") {
+      SheetManager.shared.dismissAll()
+    }
+
+    View(SheetView.self) {
+      Events([
+        "onAttemptDismiss",
+        "onSnapPointChange",
+        "onStateChange"
+      ])
+
+      AsyncFunction("dismiss") { (view: SheetView) in
+        view.dismiss()
+      }
+
+      AsyncFunction("updateLayout") { (view: SheetView) in
+        view.updateLayout()
+      }
+
+      Prop("cornerRadius") { (view: SheetView, prop: Float) in
+        view.cornerRadius = CGFloat(prop)
+      }
+
+      Prop("minHeight") { (view: SheetView, prop: Double) in
+        view.minHeight = prop
+      }
+
+      Prop("maxHeight") { (view: SheetView, prop: Double) in
+        view.maxHeight = prop
+      }
+
+      Prop("preventDismiss") { (view: SheetView, prop: Bool) in
+        view.preventDismiss = prop
+      }
+
+      Prop("preventExpansion") { (view: SheetView, prop: Bool) in
+        view.preventExpansion = prop
+      }
+    }
+  }
+}
diff --git a/modules/bottom-sheet/ios/SheetManager.swift b/modules/bottom-sheet/ios/SheetManager.swift
new file mode 100644
index 000000000..e4e843bea
--- /dev/null
+++ b/modules/bottom-sheet/ios/SheetManager.swift
@@ -0,0 +1,28 @@
+//
+//  SheetManager.swift
+//  Pods
+//
+//  Created by Hailey on 10/1/24.
+//
+
+import ExpoModulesCore
+
+class SheetManager {
+  static let shared = SheetManager()
+
+  private var sheetViews = NSHashTable<SheetView>(options: .weakMemory)
+
+  func add(_ view: SheetView) {
+    sheetViews.add(view)
+  }
+
+  func remove(_ view: SheetView) {
+    sheetViews.remove(view)
+  }
+
+  func dismissAll() {
+    sheetViews.allObjects.forEach { sheetView in
+      sheetView.dismiss()
+    }
+  }
+}
diff --git a/modules/bottom-sheet/ios/SheetView.swift b/modules/bottom-sheet/ios/SheetView.swift
new file mode 100644
index 000000000..cf2019c6a
--- /dev/null
+++ b/modules/bottom-sheet/ios/SheetView.swift
@@ -0,0 +1,189 @@
+import ExpoModulesCore
+import UIKit
+
+class SheetView: ExpoView, UISheetPresentationControllerDelegate {
+  // Views
+  private var sheetVc: SheetViewController?
+  private var innerView: UIView?
+  private var touchHandler: RCTTouchHandler?
+
+  // Events
+  private let onAttemptDismiss = EventDispatcher()
+  private let onSnapPointChange = EventDispatcher()
+  private let onStateChange = EventDispatcher()
+
+  // Open event firing
+  private var isOpen: Bool = false {
+    didSet {
+      onStateChange([
+        "state": isOpen ? "open" : "closed"
+      ])
+    }
+  }
+
+  // React view props
+  var preventDismiss = false
+  var preventExpansion = false
+  var cornerRadius: CGFloat?
+  var minHeight = 0.0
+  var maxHeight: CGFloat! {
+    didSet {
+      let screenHeight = Util.getScreenHeight() ?? 0
+      if maxHeight > screenHeight {
+        maxHeight = screenHeight
+      }
+    }
+  }
+
+  private var isOpening = false {
+    didSet {
+      if isOpening {
+        onStateChange([
+          "state": "opening"
+        ])
+      }
+    }
+  }
+  private var isClosing = false {
+    didSet {
+      if isClosing {
+        onStateChange([
+          "state": "closing"
+        ])
+      }
+    }
+  }
+  private var selectedDetentIdentifier: UISheetPresentationController.Detent.Identifier? {
+    didSet {
+      if selectedDetentIdentifier == .large {
+        onSnapPointChange([
+          "snapPoint": 2
+        ])
+      } else {
+        onSnapPointChange([
+          "snapPoint": 1
+        ])
+      }
+    }
+  }
+
+  // MARK: - Lifecycle
+
+  required init (appContext: AppContext? = nil) {
+    super.init(appContext: appContext)
+    self.maxHeight = Util.getScreenHeight()
+    self.touchHandler = RCTTouchHandler(bridge: appContext?.reactBridge)
+    SheetManager.shared.add(self)
+  }
+
+  deinit {
+    self.destroy()
+  }
+
+  // We don't want this view to actually get added to the tree, so we'll simply store it for adding
+  // to the SheetViewController
+  override func insertReactSubview(_ subview: UIView!, at atIndex: Int) {
+    self.touchHandler?.attach(to: subview)
+    self.innerView = subview
+  }
+
+  // We'll grab the content height from here so we know the initial detent to set
+  override func layoutSubviews() {
+    super.layoutSubviews()
+
+    guard let innerView = self.innerView else {
+      return
+    }
+
+    if innerView.subviews.count != 1 {
+      return
+    }
+
+    self.present()
+  }
+
+  private func destroy() {
+    self.isClosing = false
+    self.isOpen = false
+    self.sheetVc = nil
+    self.touchHandler?.detach(from: self.innerView)
+    self.touchHandler = nil
+    self.innerView = nil
+    SheetManager.shared.remove(self)
+  }
+
+  // MARK: - Presentation
+
+  func present() {
+    guard !self.isOpen,
+          !self.isOpening,
+          !self.isClosing,
+          let innerView = self.innerView,
+          let contentHeight = innerView.subviews.first?.frame.height,
+          let rvc = self.reactViewController() else {
+      return
+    }
+
+    let sheetVc = SheetViewController()
+    sheetVc.setDetents(contentHeight: self.clampHeight(contentHeight), preventExpansion: self.preventExpansion)
+    if let sheet = sheetVc.sheetPresentationController {
+      sheet.delegate = self
+      sheet.preferredCornerRadius = self.cornerRadius
+      self.selectedDetentIdentifier = sheet.selectedDetentIdentifier
+    }
+    sheetVc.view.addSubview(innerView)
+
+    self.sheetVc = sheetVc
+    self.isOpening = true
+
+    rvc.present(sheetVc, animated: true) { [weak self] in
+      self?.isOpening = false
+      self?.isOpen = true
+    }
+  }
+
+  func updateLayout() {
+    if let contentHeight = self.innerView?.subviews.first?.frame.size.height {
+      self.sheetVc?.updateDetents(contentHeight: self.clampHeight(contentHeight),
+                               preventExpansion: self.preventExpansion)
+      self.selectedDetentIdentifier = self.sheetVc?.getCurrentDetentIdentifier()
+    }
+  }
+
+  func dismiss() {
+    self.isClosing = true
+    self.sheetVc?.dismiss(animated: true) { [weak self] in
+      self?.destroy()
+    }
+  }
+
+  // MARK: - Utils
+
+  private func clampHeight(_ height: CGFloat) -> CGFloat {
+    if height < self.minHeight {
+      return self.minHeight
+    } else if height > self.maxHeight {
+      return self.maxHeight
+    }
+    return height
+  }
+
+  // MARK: - UISheetPresentationControllerDelegate
+
+  func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
+    self.onAttemptDismiss()
+    return !self.preventDismiss
+  }
+
+  func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
+    self.isClosing = true
+  }
+
+  func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
+    self.destroy()
+  }
+
+  func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) {
+    self.selectedDetentIdentifier = sheetPresentationController.selectedDetentIdentifier
+  }
+}
diff --git a/modules/bottom-sheet/ios/SheetViewController.swift b/modules/bottom-sheet/ios/SheetViewController.swift
new file mode 100644
index 000000000..56473b21c
--- /dev/null
+++ b/modules/bottom-sheet/ios/SheetViewController.swift
@@ -0,0 +1,76 @@
+//
+//  SheetViewController.swift
+//  Pods
+//
+//  Created by Hailey on 9/30/24.
+//
+
+import Foundation
+import UIKit
+
+class SheetViewController: UIViewController {
+  init() {
+    super.init(nibName: nil, bundle: nil)
+
+    self.modalPresentationStyle = .formSheet
+    self.isModalInPresentation = false
+
+    if let sheet = self.sheetPresentationController {
+      sheet.prefersGrabberVisible = false
+    }
+  }
+
+  func setDetents(contentHeight: CGFloat, preventExpansion: Bool) {
+    guard let sheet = self.sheetPresentationController,
+          let screenHeight = Util.getScreenHeight()
+    else {
+      return
+    }
+
+    if contentHeight > screenHeight - 100 {
+      sheet.detents = [
+        .large()
+      ]
+      sheet.selectedDetentIdentifier = .large
+    } else {
+      if #available(iOS 16.0, *) {
+        sheet.detents = [
+          .custom { _ in
+            return contentHeight
+          }
+        ]
+      } else {
+        sheet.detents = [
+          .medium()
+        ]
+      }
+
+      if !preventExpansion {
+        sheet.detents.append(.large())
+      }
+      sheet.selectedDetentIdentifier = .medium
+    }
+  }
+
+  func updateDetents(contentHeight: CGFloat, preventExpansion: Bool) {
+    if let sheet = self.sheetPresentationController {
+      sheet.animateChanges {
+        self.setDetents(contentHeight: contentHeight, preventExpansion: preventExpansion)
+        if #available(iOS 16.0, *) {
+          sheet.invalidateDetents()
+        }
+      }
+    }
+  }
+  
+  func getCurrentDetentIdentifier() -> UISheetPresentationController.Detent.Identifier? {
+    guard let sheet = self.sheetPresentationController else {
+      return nil
+    }
+    return sheet.selectedDetentIdentifier
+  }
+
+  required init?(coder: NSCoder) {
+    fatalError("init(coder:) has not been implemented")
+  }
+}
diff --git a/modules/bottom-sheet/ios/Util.swift b/modules/bottom-sheet/ios/Util.swift
new file mode 100644
index 000000000..c654596a7
--- /dev/null
+++ b/modules/bottom-sheet/ios/Util.swift
@@ -0,0 +1,18 @@
+//
+//  Util.swift
+//  Pods
+//
+//  Created by Hailey on 10/2/24.
+//
+
+class Util {
+  static func getScreenHeight() -> CGFloat? {
+    if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+     let window = windowScene.windows.first {
+      let safeAreaInsets = window.safeAreaInsets
+      let fullScreenHeight = UIScreen.main.bounds.height
+      return fullScreenHeight - (safeAreaInsets.top + safeAreaInsets.bottom)
+    }
+    return nil
+  }
+}
diff --git a/modules/bottom-sheet/src/BottomSheet.tsx b/modules/bottom-sheet/src/BottomSheet.tsx
new file mode 100644
index 000000000..9e7d0c209
--- /dev/null
+++ b/modules/bottom-sheet/src/BottomSheet.tsx
@@ -0,0 +1,100 @@
+import * as React from 'react'
+import {
+  Dimensions,
+  NativeSyntheticEvent,
+  Platform,
+  StyleProp,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
+
+import {BottomSheetState, BottomSheetViewProps} from './BottomSheet.types'
+
+const screenHeight = Dimensions.get('screen').height
+
+const NativeView: React.ComponentType<
+  BottomSheetViewProps & {
+    ref: React.RefObject<any>
+    style: StyleProp<ViewStyle>
+  }
+> = requireNativeViewManager('BottomSheet')
+
+const NativeModule = requireNativeModule('BottomSheet')
+
+export class BottomSheet extends React.Component<
+  BottomSheetViewProps,
+  {
+    open: boolean
+  }
+> {
+  ref = React.createRef<any>()
+
+  constructor(props: BottomSheetViewProps) {
+    super(props)
+    this.state = {
+      open: false,
+    }
+  }
+
+  present() {
+    this.setState({open: true})
+  }
+
+  dismiss() {
+    this.ref.current?.dismiss()
+  }
+
+  private onStateChange = (
+    event: NativeSyntheticEvent<{state: BottomSheetState}>,
+  ) => {
+    const {state} = event.nativeEvent
+    const isOpen = state !== 'closed'
+    this.setState({open: isOpen})
+    this.props.onStateChange?.(event)
+  }
+
+  private updateLayout = () => {
+    this.ref.current?.updateLayout()
+  }
+
+  static dismissAll = async () => {
+    await NativeModule.dismissAll()
+  }
+
+  render() {
+    const {children, backgroundColor, ...rest} = this.props
+    const cornerRadius = rest.cornerRadius ?? 0
+
+    if (!this.state.open) {
+      return null
+    }
+
+    return (
+      <NativeView
+        {...rest}
+        onStateChange={this.onStateChange}
+        ref={this.ref}
+        style={{
+          position: 'absolute',
+          height: screenHeight,
+          width: '100%',
+        }}
+        containerBackgroundColor={backgroundColor}>
+        <View
+          style={[
+            {
+              flex: 1,
+              backgroundColor,
+            },
+            Platform.OS === 'android' && {
+              borderTopLeftRadius: cornerRadius,
+              borderTopRightRadius: cornerRadius,
+            },
+          ]}>
+          <View onLayout={this.updateLayout}>{children}</View>
+        </View>
+      </NativeView>
+    )
+  }
+}
diff --git a/modules/bottom-sheet/src/BottomSheet.types.ts b/modules/bottom-sheet/src/BottomSheet.types.ts
new file mode 100644
index 000000000..150932d42
--- /dev/null
+++ b/modules/bottom-sheet/src/BottomSheet.types.ts
@@ -0,0 +1,35 @@
+import React from 'react'
+import {ColorValue, NativeSyntheticEvent} from 'react-native'
+
+export type BottomSheetState = 'closed' | 'closing' | 'open' | 'opening'
+
+export enum BottomSheetSnapPoint {
+  Hidden,
+  Partial,
+  Full,
+}
+
+export type BottomSheetAttemptDismissEvent = NativeSyntheticEvent<object>
+export type BottomSheetSnapPointChangeEvent = NativeSyntheticEvent<{
+  snapPoint: BottomSheetSnapPoint
+}>
+export type BottomSheetStateChangeEvent = NativeSyntheticEvent<{
+  state: BottomSheetState
+}>
+
+export interface BottomSheetViewProps {
+  children: React.ReactNode
+  cornerRadius?: number
+  preventDismiss?: boolean
+  preventExpansion?: boolean
+  backgroundColor?: ColorValue
+  containerBackgroundColor?: ColorValue
+  disableDrag?: boolean
+
+  minHeight?: number
+  maxHeight?: number
+
+  onAttemptDismiss?: (event: BottomSheetAttemptDismissEvent) => void
+  onSnapPointChange?: (event: BottomSheetSnapPointChangeEvent) => void
+  onStateChange?: (event: BottomSheetStateChangeEvent) => void
+}
diff --git a/modules/bottom-sheet/src/BottomSheet.web.tsx b/modules/bottom-sheet/src/BottomSheet.web.tsx
new file mode 100644
index 000000000..4573604eb
--- /dev/null
+++ b/modules/bottom-sheet/src/BottomSheet.web.tsx
@@ -0,0 +1,5 @@
+import {BottomSheetViewProps} from './BottomSheet.types'
+
+export function BottomSheet(_: BottomSheetViewProps) {
+  throw new Error('BottomSheetView is not available on web')
+}