[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>
This commit is contained in:
+53
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+339
@@ -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
|
||||
}
|
||||
+171
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user