fix edge to edge mode, lint

This commit is contained in:
Samuel Newman
2025-11-24 14:29:45 +02:00
parent 2111c5470f
commit efa75662dc
6 changed files with 113 additions and 134 deletions
+1 -1
View File
@@ -44,6 +44,6 @@ android {
dependencies { dependencies {
implementation project(':expo-modules-core') implementation project(':expo-modules-core')
implementation 'com.google.android.material:material:1.12.0' implementation 'com.google.android.material:material:1.13.0'
implementation "com.facebook.react:react-native:+" implementation "com.facebook.react:react-native:+"
} }
@@ -5,8 +5,10 @@ import android.util.DisplayMetrics
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewStructure import android.view.ViewStructure
import android.view.Window
import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityEvent
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.allViews import androidx.core.view.allViews
import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.ReactContext
@@ -15,6 +17,7 @@ import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.events.EventDispatcher import com.facebook.react.uimanager.events.EventDispatcher
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.internal.EdgeToEdgeUtils
import expo.modules.kotlin.AppContext import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView import expo.modules.kotlin.views.ExpoView
@@ -30,7 +33,9 @@ class BottomSheetView(
private lateinit var dialogRootViewGroup: DialogRootViewGroup private lateinit var dialogRootViewGroup: DialogRootViewGroup
private var eventDispatcher: EventDispatcher? = null private var eventDispatcher: EventDispatcher? = null
private val screenHeight = context.resources.displayMetrics.heightPixels.toFloat() private val screenHeight =
context.resources.displayMetrics.heightPixels
.toFloat()
private fun getNavigationBarHeight(): Int { private fun getNavigationBarHeight(): Int {
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android") val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
@@ -38,15 +43,14 @@ class BottomSheetView(
} }
private fun getStatusBarHeight(): Int { private fun getStatusBarHeight(): Int {
val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android") val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android")
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else 0 return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else 0
} }
private val onAttemptDismiss by EventDispatcher() private val onAttemptDismiss by EventDispatcher()
private val onSnapPointChange by EventDispatcher() private val onSnapPointChange by EventDispatcher()
private val onStateChange by EventDispatcher() private val onStateChange by EventDispatcher()
// Props
var disableDrag = false var disableDrag = false
set(value) { set(value) {
field = value field = value
@@ -58,48 +62,31 @@ class BottomSheetView(
field = value field = value
this.dialog?.setCancelable(!value) this.dialog?.setCancelable(!value)
} }
var preventExpansion = false var preventExpansion = false
var minHeight = 0f var minHeight = 0f
set(value) { set(value) {
field = field = if (value < 0) 0f else dpToPx(value)
if (value < 0) {
0f
} else {
dpToPx(value)
}
} }
var maxHeight = this.screenHeight var maxHeight = this.screenHeight
set(value) { set(value) {
val px = dpToPx(value) val px = dpToPx(value)
field = field = if (px > this.screenHeight) this.screenHeight else px
if (px > this.screenHeight) {
this.screenHeight
} else {
px
}
} }
private var isOpen: Boolean = false private var isOpen: Boolean = false
set(value) { set(value) {
field = value field = value
onStateChange( onStateChange(mapOf("state" to if (value) "open" else "closed"))
mapOf(
"state" to if (value) "open" else "closed",
),
)
} }
private var isOpening: Boolean = false private var isOpening: Boolean = false
set(value) { set(value) {
field = value field = value
if (value) { if (value) {
onStateChange( onStateChange(mapOf("state" to "opening"))
mapOf(
"state" to "opening",
),
)
} }
} }
@@ -107,33 +94,21 @@ class BottomSheetView(
set(value) { set(value) {
field = value field = value
if (value) { if (value) {
onStateChange( onStateChange(mapOf("state" to "closing"))
mapOf(
"state" to "closing",
),
)
} }
} }
private var selectedSnapPoint = 0 private var selectedSnapPoint = 0
set(value) { set(value) {
if (field == value) return if (field == value) return
field = value field = value
onSnapPointChange( onSnapPointChange(mapOf("snapPoint" to value))
mapOf(
"snapPoint" to value,
),
)
} }
// Lifecycle
init { init {
(appContext.reactContext as? ReactContext)?.let { (appContext.reactContext as? ReactContext)?.let {
it.addLifecycleEventListener(this) it.addLifecycleEventListener(this)
this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id) this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id)
this.dialogRootViewGroup = DialogRootViewGroup(context) this.dialogRootViewGroup = DialogRootViewGroup(context)
this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher
} }
@@ -164,53 +139,58 @@ class BottomSheetView(
when { when {
// Full height sheets // Full height sheets
contentHeight >= screenHeight -> 0.99f contentHeight >= screenHeight -> 0.99f
else -> else -> this.clampRatio(this.getTargetHeight() / screenHeight)
this.clampRatio(this.getTargetHeight() / screenHeight)
} }
private fun present() { private fun present() {
if (this.isOpen || this.isOpening || this.isClosing) return if (this.isOpen || this.isOpening || this.isClosing) return
val contentHeight = this.getContentHeight() val contentHeight = this.getContentHeight()
val dialog = BottomSheetDialog(context)
var activityWindow: Window? = null
var currentContext = context
while (currentContext != null) {
if (currentContext is android.app.Activity) {
activityWindow = currentContext.window
break
}
currentContext = (currentContext as? android.content.ContextWrapper)?.baseContext
}
val originalStatusBarAppearance =
activityWindow?.let { window ->
WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars
}
val originalNavBarAppearance =
activityWindow?.let { window ->
WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightNavigationBars
}
val dialog = BottomSheetDialog(context, R.style.EdgeToEdgeBottomSheetDialogTheme)
dialog.setContentView(dialogRootViewGroup) dialog.setContentView(dialogRootViewGroup)
dialog.setCancelable(!preventDismiss) dialog.setCancelable(!preventDismiss)
dialog.setDismissWithAnimation(true)
dialog.setOnDismissListener { dialog.setOnDismissListener {
this.isClosing = true this.isClosing = true
this.destroy() this.destroy()
} }
// Configure dialog window for edge-to-edge mode dialog.setOnShowListener {
dialog.window?.apply { dialog.window?.let { window ->
// Make status bar and navigation bar transparent val insetsController = WindowInsetsControllerCompat(window, window.decorView)
setNavigationBarColor(android.graphics.Color.TRANSPARENT) if (originalNavBarAppearance != null) {
setStatusBarColor(android.graphics.Color.TRANSPARENT) insetsController.isAppearanceLightNavigationBars = originalNavBarAppearance
}
// Set FLAG_LAYOUT_NO_LIMITS to allow layout to extend beyond system UI boundaries if (originalStatusBarAppearance != null) {
// Use FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS to properly handle transparent system bars EdgeToEdgeUtils.setLightStatusBar(window, originalStatusBarAppearance)
setFlags( }
android.view.WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or }
android.view.WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS,
android.view.WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
android.view.WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS
)
// Configure system UI visibility to allow layout behind both status and navigation bars
decorView.systemUiVisibility = android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
} }
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet) val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
bottomSheet?.let { bottomSheet?.let {
it.setBackgroundColor(0) it.setBackgroundColor(0)
it.fitsSystemWindows = false
// Add padding to respect the status bar but not the navigation bar
val statusBarHeight = getStatusBarHeight()
it.setPadding(0, statusBarHeight, 0, 0)
val behavior = BottomSheetBehavior.from(it) val behavior = BottomSheetBehavior.from(it)
behavior.state = BottomSheetBehavior.STATE_HIDDEN behavior.state = BottomSheetBehavior.STATE_HIDDEN
behavior.isFitToContents = true behavior.isFitToContents = true
@@ -219,7 +199,17 @@ class BottomSheetView(
behavior.isDraggable = true behavior.isDraggable = true
behavior.isHideable = true behavior.isHideable = true
if (contentHeight >= this.screenHeight || this.minHeight >= this.screenHeight) { if (preventExpansion) {
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
} else {
behavior.maxHeight = (screenHeight - getStatusBarHeight()).toInt()
}
val targetHeight = this.getTargetHeight()
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
val shouldBeExpanded = targetHeight >= availableHeight
if (shouldBeExpanded) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED behavior.state = BottomSheetBehavior.STATE_EXPANDED
this.selectedSnapPoint = 2 this.selectedSnapPoint = 2
} else { } else {
@@ -234,18 +224,10 @@ class BottomSheetView(
newState: Int, newState: Int,
) { ) {
when (newState) { when (newState) {
BottomSheetBehavior.STATE_EXPANDED -> { BottomSheetBehavior.STATE_EXPANDED -> selectedSnapPoint = 2
selectedSnapPoint = 2 BottomSheetBehavior.STATE_COLLAPSED -> selectedSnapPoint = 1
} BottomSheetBehavior.STATE_HALF_EXPANDED -> selectedSnapPoint = 1
BottomSheetBehavior.STATE_COLLAPSED -> { BottomSheetBehavior.STATE_HIDDEN -> selectedSnapPoint = 0
selectedSnapPoint = 1
}
BottomSheetBehavior.STATE_HALF_EXPANDED -> {
selectedSnapPoint = 1
}
BottomSheetBehavior.STATE_HIDDEN -> {
selectedSnapPoint = 0
}
} }
} }
@@ -265,43 +247,26 @@ class BottomSheetView(
val dialog = this.dialog ?: return val dialog = this.dialog ?: return
val contentHeight = this.getContentHeight() val contentHeight = this.getContentHeight()
// Ensure edge-to-edge mode settings are maintained
dialog.window?.apply {
// Maintain transparent status and navigation bars
setNavigationBarColor(android.graphics.Color.TRANSPARENT)
setStatusBarColor(android.graphics.Color.TRANSPARENT)
// Ensure layout flags are still set
setFlags(
android.view.WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
android.view.WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS,
android.view.WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
android.view.WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS
)
// Re-apply system UI visibility settings
decorView.systemUiVisibility = android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
}
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet) val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
bottomSheet?.let { bottomSheet?.let {
it.fitsSystemWindows = false
val statusBarHeight = getStatusBarHeight()
it.setPadding(0, statusBarHeight, 0, 0)
val behavior = BottomSheetBehavior.from(it) val behavior = BottomSheetBehavior.from(it)
val currentState = behavior.state val currentState = behavior.state
val oldRatio = behavior.halfExpandedRatio val oldRatio = behavior.halfExpandedRatio
var newRatio = getHalfExpandedRatio(contentHeight) val newRatio = getHalfExpandedRatio(contentHeight)
behavior.halfExpandedRatio = newRatio behavior.halfExpandedRatio = newRatio
if (contentHeight > this.screenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) { if (preventExpansion) {
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
}
val targetHeight = this.getTargetHeight()
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
val shouldBeExpanded = targetHeight >= availableHeight
if (shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_EXPANDED && !preventExpansion) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED behavior.state = BottomSheetBehavior.STATE_EXPANDED
} else if (contentHeight < this.screenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) { } else if (!shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
} else if (currentState == BottomSheetBehavior.STATE_HALF_EXPANDED && oldRatio != newRatio) { } else if (currentState == BottomSheetBehavior.STATE_HALF_EXPANDED && oldRatio != newRatio) {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
@@ -329,25 +294,19 @@ class BottomSheetView(
private fun getTargetHeight(): Float { private fun getTargetHeight(): Float {
val contentHeight = this.getContentHeight() val contentHeight = this.getContentHeight()
val height = return when {
if (contentHeight > maxHeight) { contentHeight > maxHeight -> maxHeight
maxHeight contentHeight < minHeight -> minHeight
} else if (contentHeight < minHeight) { else -> contentHeight
minHeight }
} else {
contentHeight
}
return height
} }
private fun clampRatio(ratio: Float): Float { private fun clampRatio(ratio: Float): Float =
if (ratio < 0.01) { when {
return 0.01f ratio < 0.01 -> 0.01f
} else if (ratio > 0.99) { ratio > 0.99 -> 0.99f
return 0.99f else -> ratio
} }
return ratio
}
private fun setDraggable(draggable: Boolean) { private fun setDraggable(draggable: Boolean) {
val dialog = this.dialog ?: return val dialog = this.dialog ?: return
@@ -372,9 +331,7 @@ class BottomSheetView(
// View overrides to pass to DialogRootViewGroup instead // View overrides to pass to DialogRootViewGroup instead
override fun dispatchProvideStructure(structure: ViewStructure?) { override fun dispatchProvideStructure(structure: ViewStructure?) {
if (structure == null) { if (structure == null) return
return
}
dialogRootViewGroup.dispatchProvideStructure(structure) dialogRootViewGroup.dispatchProvideStructure(structure)
} }
@@ -413,7 +370,6 @@ class BottomSheetView(
// https://stackoverflow.com/questions/11862391/getheight-px-or-dpi // https://stackoverflow.com/questions/11862391/getheight-px-or-dpi
fun dpToPx(dp: Float): Float { fun dpToPx(dp: Float): Float {
val displayMetrics = context.resources.displayMetrics val displayMetrics = context.resources.displayMetrics
val px = dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT) return dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT)
return px
} }
} }
@@ -52,7 +52,7 @@ class DialogRootViewGroup(
if (ReactFeatureFlags.dispatchPointerEvents) { if (ReactFeatureFlags.dispatchPointerEvents) {
jSPointerDispatcher = JSPointerDispatcher(this) jSPointerDispatcher = JSPointerDispatcher(this)
} }
fitsSystemWindows = false fitsSystemWindows = false
} }
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="EdgeToEdgeBottomSheetDialogTheme" parent="Theme.Material3.DayNight.BottomSheetDialog">
<!-- Enable edge-to-edge -->
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowIsFloating">false</item>
<item name="enableEdgeToEdge">true</item>
<!-- Configure bottom sheet to respect system window insets -->
<item name="bottomSheetStyle">@style/EdgeToEdgeBottomSheet</item>
</style>
<style name="EdgeToEdgeBottomSheet" parent="Widget.Material3.BottomSheet">
<item name="paddingBottomSystemWindowInsets">false</item>
<item name="paddingLeftSystemWindowInsets">true</item>
<item name="paddingRightSystemWindowInsets">true</item>
<item name="paddingTopSystemWindowInsets">false</item>
</style>
</resources>
@@ -34,12 +34,15 @@ class NotificationPrefs(
is Boolean -> { is Boolean -> {
putBoolean(key, value) putBoolean(key, value)
} }
is String -> { is String -> {
putString(key, value) putString(key, value)
} }
is Array<*> -> { is Array<*> -> {
putStringSet(key, value.map { it.toString() }.toSet()) putStringSet(key, value.map { it.toString() }.toSet())
} }
is Map<*, *> -> { is Map<*, *> -> {
putStringSet(key, value.map { it.toString() }.toSet()) putStringSet(key, value.map { it.toString() }.toSet())
} }
@@ -117,7 +117,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
private fun handleImageIntents( private fun handleImageIntents(
uris: List<Uri>, uris: List<Uri>,
text: String? text: String?,
) { ) {
var allParams = "" var allParams = ""
@@ -145,7 +145,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
private fun handleVideoIntents( private fun handleVideoIntents(
uris: List<Uri>, uris: List<Uri>,
text: String? text: String?,
) { ) {
val uri = uris[0] val uri = uris[0]
// If there is no extension for the file, substringAfterLast returns the original string - not // If there is no extension for the file, substringAfterLast returns the original string - not