about summary refs log tree commit diff
path: root/modules/bottom-sheet/android/src/main/java
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/bottom-sheet/android/src/main/java
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/bottom-sheet/android/src/main/java')
-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
4 files changed, 591 insertions, 0 deletions
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()
+      }
+    }
+  }
+}