diff options
Diffstat (limited to 'modules/bottom-sheet/android/src')
5 files changed, 593 insertions, 0 deletions
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() + } + } + } +} |