Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.animation.AnimatorListenerAdapter
import android.animation.AnimatorSet
import android.animation.ValueAnimator
import android.view.View
import androidx.core.view.doOnPreDraw
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.swmansion.rnscreens.gamma.modals.dimmingview.DimmingViewManager

Expand All @@ -16,6 +17,12 @@ internal class FormSheetAnimationCoordinator(

private var currentAnimatorSet: AnimatorSet? = null

internal fun prepareViewForAnimation(view: View) {
view.doOnPreDraw {
it.translationY = it.height.toFloat()
}
}

internal fun runEnterAnimation(view: View) {
val isAnimating = currentAnimatorSet?.isRunning == true

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@ import android.content.Context
import android.util.Log
import android.view.ContextThemeWrapper
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.swmansion.rnscreens.gamma.modals.dimmingview.DimmingViewManager

Expand All @@ -22,9 +19,6 @@ class FormSheetDialogManager(

private var shouldReconfigureDetents = false

private var lastTopInset = 0
private var lastBottomInset = 0

private val themedContext =
ContextThemeWrapper(
context,
Expand All @@ -50,6 +44,14 @@ class FormSheetDialogManager(

private val animationCoordinator = FormSheetAnimationCoordinator(dimmingManager)

private val dimensionsCoordinator =
FormSheetDimensionsCoordinator(
dialog = dialog,
container = container,
bottomSheetView = bottomSheetView,
behaviorController = behaviorController,
)

private val lifecycleCoordinator =
FormSheetLifecycleCoordinator(
dialog = dialog,
Expand All @@ -65,11 +67,11 @@ class FormSheetDialogManager(

init {
bottomSheetView?.let { view ->
setupBehaviorCallbacksForDimmingView(view)
setupOffscreenPositionBeforeFirstDraw(view)
dimmingManager.attachToBehavior(BottomSheetBehavior.from(view))
animationCoordinator.prepareViewForAnimation(view)
}
lifecycleCoordinator.setup()
setupWindowInsetsListener()
dimensionsCoordinator.setup()
}

internal fun applyConfig(newConfig: FormSheetConfig) {
Expand Down Expand Up @@ -101,7 +103,8 @@ class FormSheetDialogManager(
}

if (shouldReconfigureDetents) {
updateNativeContainerHeight()
dimensionsCoordinator.updateFormSheetDetents(resolvedDetents, shouldReconfigureDetents)
shouldReconfigureDetents = false
}

formSheetConfig = newConfig
Expand All @@ -124,98 +127,9 @@ class FormSheetDialogManager(
}
}

private fun setupBehaviorCallbacksForDimmingView(view: FrameLayout) {
// TODO: @t0maboro - BottomSheetBehavior override might be needed at some point
val behavior = BottomSheetBehavior.from(view)
dimmingManager.attachToBehavior(behavior)
}

private fun setupOffscreenPositionBeforeFirstDraw(view: FrameLayout) {
view.viewTreeObserver.addOnPreDrawListener(
object : android.view.ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
view.viewTreeObserver.removeOnPreDrawListener(this)
view.translationY = view.height.toFloat()
disableMaterialInsetsAnimationCallback(view)
return true
}
},
)
}

/**
* BottomSheetBehavior registers an internal `WindowInsetsAnimationCallback` on the
* sheet view during its first `onLayoutChild`. That callback drives `translationY` to follow
* animated inset changes, what interferes with our slide-in custom animation.
*
* We manage insets ourselves by setting a fixed height for FormSheetContainer, so we can
* clear the Material's callback to remove the conflict entirely.
*
* This method must run after the first layout pass.
*/
private fun disableMaterialInsetsAnimationCallback(view: FrameLayout) {
ViewCompat.setWindowInsetsAnimationCallback(view, null)
}

private fun setupWindowInsetsListener() {
ViewCompat.setOnApplyWindowInsetsListener(container) { _, insets ->
lastTopInset = getTopInset(insets)
lastBottomInset = getBottomInset(insets)
updateNativeContainerHeight()
insets
}
}

/**
* For Yoga we require the container height to be "stable" to avoid updating content size in flight.
* If left as MATCH_PARENT, BottomSheetDialog dynamically applies insets as padding when sheet overflows
* status bar or display cutout. This causes Yoga to recalculate the layout, resulting in UI flickering
* during the drag gesture. By calculating and enforcing a static height that explicitly subtracts
* the system insets, we completely bypass these redundant layout passes.
*/
private fun updateNativeContainerHeight() {
val dialogDecorHeight = dialog.window?.decorView?.height ?: 0

if (dialogDecorHeight > 0) {
resolvedDetents?.let { detents ->
behaviorController?.updateSheetBehavior(
detents = detents,
sheetAvailableSpace = dialogDecorHeight,
applyInitialState = shouldReconfigureDetents,
)
shouldReconfigureDetents = false
}

val sheetContainerHeight =
resolvedDetents?.sheetContainerHeight(dialogDecorHeight, lastTopInset, lastBottomInset)
?: (dialogDecorHeight - lastTopInset - lastBottomInset).coerceAtLeast(0)

val layoutParams =
container.layoutParams
?: FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, sheetContainerHeight)
if (layoutParams.width != ViewGroup.LayoutParams.MATCH_PARENT || layoutParams.height != sheetContainerHeight) {
layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT
layoutParams.height = sheetContainerHeight
container.layoutParams = layoutParams
}
}
}

private fun getTopInset(insetsCompat: WindowInsetsCompat): Int =
insetsCompat
.getInsets(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
).top

private fun getBottomInset(insetsCompat: WindowInsetsCompat): Int =
insetsCompat
.getInsets(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
).bottom

internal fun destroy() {
lifecycleCoordinator.destroy()
ViewCompat.setOnApplyWindowInsetsListener(container, null)
dimensionsCoordinator.destroy()
dialog.dismiss()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.swmansion.rnscreens.gamma.modals.formsheet

import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.doOnLayout
import com.google.android.material.bottomsheet.BottomSheetDialog

internal class FormSheetDimensionsCoordinator(
private val dialog: BottomSheetDialog,
private val container: FormSheetContainer,
private val bottomSheetView: FrameLayout?,
private val behaviorController: FormSheetBehaviorController?,
) {
private var lastTopInset = 0
private var lastBottomInset = 0
private var currentDetents: FormSheetDetents? = null
private var pendingInitialState = false

internal fun setup() {
setupWindowInsetsListener()

bottomSheetView?.let { view ->
disableMaterialInsetsAnimationCallback(view)
}
}

private fun setupWindowInsetsListener() {
ViewCompat.setOnApplyWindowInsetsListener(container) { _, insets ->
lastTopInset = getTopInset(insets)
lastBottomInset = getBottomInset(insets)
updateNativeContainerHeight()
insets
}
}

/**
* BottomSheetBehavior registers an internal `WindowInsetsAnimationCallback` on the
* sheet view during its first `onLayoutChild`. That callback drives `translationY` to follow
* animated inset changes, what interferes with our slide-in custom animation.
*
* We manage insets ourselves by setting a fixed height for FormSheetContainer, so we can
* clear the Material's callback to remove the conflict entirely.
*/
private fun disableMaterialInsetsAnimationCallback(view: FrameLayout) {
view.doOnLayout {
ViewCompat.setWindowInsetsAnimationCallback(it, null)
}
}

internal fun updateFormSheetDetents(
detents: FormSheetDetents?,
applyInitialState: Boolean,
) {
currentDetents = detents
if (applyInitialState) {
pendingInitialState = true
}
updateNativeContainerHeight()
}

/**
* For Yoga we require the container height to be "stable" to avoid updating content size in flight.
* If left as MATCH_PARENT, BottomSheetDialog dynamically applies insets as padding when sheet overflows
* status bar or display cutout. This causes Yoga to recalculate the layout, resulting in UI flickering
* during the drag gesture. By calculating and enforcing a static height that explicitly subtracts
* the system insets, we completely bypass these redundant layout passes.
*/
private fun updateNativeContainerHeight() {
val dialogDecorHeight = dialog.window?.decorView?.height ?: 0

if (dialogDecorHeight > 0) {
currentDetents?.let { detents ->
behaviorController?.updateSheetBehavior(
detents = detents,
sheetAvailableSpace = dialogDecorHeight,
applyInitialState = pendingInitialState,
)
pendingInitialState = false
}

val sheetContainerHeight =
currentDetents?.sheetContainerHeight(dialogDecorHeight, lastTopInset, lastBottomInset)
?: (dialogDecorHeight - lastTopInset - lastBottomInset).coerceAtLeast(0)

val layoutParams =
container.layoutParams
?: FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, sheetContainerHeight)

if (layoutParams.width != ViewGroup.LayoutParams.MATCH_PARENT || layoutParams.height != sheetContainerHeight) {
layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT
layoutParams.height = sheetContainerHeight
container.layoutParams = layoutParams
}
}
}

private fun getTopInset(insetsCompat: WindowInsetsCompat): Int =
insetsCompat
.getInsets(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
).top

private fun getBottomInset(insetsCompat: WindowInsetsCompat): Int =
insetsCompat
.getInsets(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
).bottom

internal fun destroy() {
ViewCompat.setOnApplyWindowInsetsListener(container, null)
}
}