@Composable
private fun SubcomposeTABDrawer(
drawerContent: @Composable ((offsetY: () -> Dp, minvalue: Dp, dragModifier: Modifier) -> Unit),
fadeColor: Color = DrawerDefaults.scrimColor,
scope: CoroutineScope = rememberCoroutineScope(),
screenHeight: Dp = getScreenHeightInDp(),
offsetHeight: Dp = 90.dp,
@SuppressLint("ModifierParameter") dragModifier: Modifier,
offsetAnimationState: AnchoredDraggableState<DragAnchorsAt>,
// contentWindowInsets: WindowInsets,
content: @Composable (() -> Unit)
) {
val density = LocalDensity.current
// for animations to be passed to drawer content
var offsetY by remember { mutableStateOf(0.dp) }
val minValue by remember(offsetHeight, screenHeight) {
derivedStateOf { -screenHeight + offsetHeight }
}
val offsetProvider = remember(density) {
{ with(density) { offsetAnimationState.requireOffset().toDp() } }
}
SubcomposeLayout{ constraints ->
val layoutWidth = constraints.maxWidth
val layoutHeight = constraints.maxHeight
val looseConstraints = constraints.copy(
minWidth = 0,
maxWidth = layoutWidth
)
val topAppBarPlaceables = subcompose("topAppBar") {
Box(
modifier = Modifier
// .offset {
// offsetY = with(density) { offsetAnimationState.requireOffset().toDp() }
// IntOffset(0, ((0f + offsetAnimationState.requireOffset()).roundToInt()) )
// }
.offset {
IntOffset(0, offsetAnimationState.requireOffset().roundToInt())
}
.zIndex(1f)
) {
drawerContent(offsetProvider, minValue, dragModifier)
}
}.fastMap {
it.measure(looseConstraints)
}
val contentPlaceables = subcompose("content") {
Surface {
content()
Scrim(
open = offsetAnimationState.currentValue == DragAnchorsAt.Bottom,
onClose = {
scope.launch {
offsetAnimationState.animateTo(DragAnchorsAt.Top)
}
},
fraction = {
calculateFraction((with(density) { minValue.toPx()} ), offsetAnimationState.requireOffset())
},
color = fadeColor
)
}
}.fastMap { it.measure(constraints.copy(
minHeight = 0,
maxHeight = layoutHeight - with(density) {
(offsetHeight).toPx().roundToInt()
}
)) }
layout(layoutWidth, layoutHeight) {
topAppBarPlaceables.fastForEach {
it.place(0, 0) }
contentPlaceables.fastForEach {
it.place(
0,
(with(density) {
(offsetHeight).toPx().roundToInt()
} )
)
}
}
}
}
package com.example.app.ui.components.drawer
import android.annotation.SuppressLint
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.gestures.animateTo
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.DrawerDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.offset
import androidx.compose.material3.Surface
import androidx.compose.foundation.layout.MutableWindowInsets
import androidx.compose.foundation.layout.exclude
import androidx.compose.foundation.layout.onConsumedWindowInsetsChanged
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastMap
import androidx.compose.ui.zIndex
import com.example.app.data.model.DragAnchorsAt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@SuppressLint("ConfigurationScreenWidthHeight")
@Composable
fun getScreenHeightInDp(): Dp {
// V1
// val density = LocalDensity.current
// return with(density) { getAppScreenHeight().toDp() }
// V2
val configuration = LocalConfiguration.current
return configuration.screenHeightDp.dp // already in dp
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun TABDrawer(
drawerContent: @Composable ((offsetY: () -> Dp, minvalue: Dp, dragModifier: Modifier) -> Unit),
modifier: Modifier = Modifier,
drawerGesturesEnabled: Boolean = true,
fadeColor: Color = DrawerDefaults.scrimColor,
screenHeight: Dp = getScreenHeightInDp(),
offsetHeight: Dp = 90.dp,
offsetAnimationState: AnchoredDraggableState<DragAnchorsAt>,
scope: CoroutineScope = rememberCoroutineScope(),
contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
containerColor: Color = MaterialTheme.colorScheme.background,
contentColor: Color = contentColorFor(containerColor),
content: @Composable (() -> Unit)
) {
val density = LocalDensity.current
val minValue by remember(offsetHeight, screenHeight) {
derivedStateOf { -screenHeight + offsetHeight }
}
val dragModifier = if (drawerGesturesEnabled) {
Modifier.anchoredDraggable(
state = offsetAnimationState,
orientation = Orientation.Vertical,
)
} else Modifier
// val staticOffset = -screenHeight + offsetHeight
// var placedHeaderHeight by remember { mutableStateOf(0.dp) }
// FIX: Was first remember(minValue) but to key on what I actually read
val anchors = remember(minValue) {
DraggableAnchors {
DragAnchorsAt.Top at (with(density) { (-screenHeight + offsetHeight).toPx() } )
DragAnchorsAt.Bottom at 0f
}
}
val isDrawerOpen = offsetAnimationState.currentValue == DragAnchorsAt.Bottom
// FIX: SideEffect { updateAnchors(anchors) } runs on every recomposition, trying on launched effect
// UPDATE: More harm than good, delays the initialization
SideEffect { offsetAnimationState.updateAnchors(anchors) }
LaunchedEffect(minValue) {
// Only animate if the drawer is currently at the 'Top' position
// and the new minValue is different from its current actual offset for 'Top'
if (offsetAnimationState.currentValue == DragAnchorsAt.Top && offsetAnimationState.requireOffset() != anchors.positionOf(DragAnchorsAt.Top)) {
offsetAnimationState.animateTo(DragAnchorsAt.Top)
}
}
BackHandler(enabled = isDrawerOpen) {
scope.launch {
offsetAnimationState.animateTo(DragAnchorsAt.Top)
}
}
val safeInsets = remember(contentWindowInsets) { MutableWindowInsets(contentWindowInsets) }
Surface(
modifier = modifier
.onConsumedWindowInsetsChanged{consumedWindowInsets ->
// Exclude currently consumed window insets from user provided contentWindowInsets
safeInsets.insets = contentWindowInsets.exclude(consumedWindowInsets)
}
.fillMaxSize()
.offset(
0.dp , 0.dp
)
// .anchoredDraggable(
// state = offsetAnimationState,
// orientation = Orientation.Vertical,
// enabled = drawerGesturesEnabled
// )
,
color = containerColor,
contentColor = contentColor
) {
SubcomposeTABDrawer(
drawerContent = drawerContent,
screenHeight = screenHeight,
offsetHeight = offsetHeight,
offsetAnimationState = offsetAnimationState,
fadeColor = fadeColor,
scope = scope,
dragModifier = dragModifier,
// contentWindowInsets = contentWindowInsets,
content = content
)
}
}
//for my sanity
@Composable
private fun SubcomposeTABDrawer(
drawerContent: @Composable ((offsetY: () -> Dp, minvalue: Dp, dragModifier: Modifier) -> Unit),
fadeColor: Color = DrawerDefaults.scrimColor,
scope: CoroutineScope = rememberCoroutineScope(),
screenHeight: Dp = getScreenHeightInDp(),
offsetHeight: Dp = 90.dp,
@SuppressLint("ModifierParameter") dragModifier: Modifier,
offsetAnimationState: AnchoredDraggableState<DragAnchorsAt>,
// contentWindowInsets: WindowInsets,
content: @Composable (() -> Unit)
) {
val density = LocalDensity.current
// for animations to be passed to drawer content
var offsetY by remember { mutableStateOf(0.dp) }
val minValue by remember(offsetHeight, screenHeight) {
derivedStateOf { -screenHeight + offsetHeight }
}
val offsetProvider = remember(density) {
{ with(density) { offsetAnimationState.requireOffset().toDp() } }
}
SubcomposeLayout{ constraints ->
val layoutWidth = constraints.maxWidth
val layoutHeight = constraints.maxHeight
val looseConstraints = constraints.copy(
minWidth = 0,
maxWidth = layoutWidth
)
val topAppBarPlaceables = subcompose("topAppBar") {
Box(
modifier = Modifier
// .offset {
// offsetY = with(density) { offsetAnimationState.requireOffset().toDp() }
// IntOffset(0, ((0f + offsetAnimationState.requireOffset()).roundToInt()) )
// }
.offset {
IntOffset(0, offsetAnimationState.requireOffset().roundToInt())
}
.zIndex(1f)
) {
drawerContent(offsetProvider, minValue, dragModifier)
}
}.fastMap {
it.measure(looseConstraints)
}
val contentPlaceables = subcompose("content") {
Surface {
content()
Scrim(
open = offsetAnimationState.currentValue == DragAnchorsAt.Bottom,
onClose = {
scope.launch {
offsetAnimationState.animateTo(DragAnchorsAt.Top)
}
},
fraction = {
calculateFraction((with(density) { minValue.toPx()} ), offsetAnimationState.requireOffset())
},
color = fadeColor
)
}
}.fastMap { it.measure(constraints.copy(
minHeight = 0,
maxHeight = layoutHeight - with(density) {
(offsetHeight).toPx().roundToInt()
}
)) }
layout(layoutWidth, layoutHeight) {
topAppBarPlaceables.fastForEach {
it.place(0, 0) }
contentPlaceables.fastForEach {
it.place(
0,
(with(density) {
(offsetHeight).toPx().roundToInt()
} )
)
}
}
}
}
/**
* A scrim that can be displayed on top of content.
*
* @param open Whether the scrim is open.
* @param onClose Callback to close the scrim.
* @param fraction The fraction of the scrim that is visible.
* @param color The color of the scrim.
*/
@Composable
private fun Scrim(
open: Boolean,
onClose: () -> Unit,
fraction: () -> Float,
color: Color
) {
val dismissModifier = if (open) {
Modifier.pointerInput(onClose) {
detectTapGestures { onClose() }
}
} else Modifier
Canvas(
Modifier
.fillMaxSize()
.then(dismissModifier)
) {
drawRect(color, alpha = fraction())
}
}
private fun calculateFraction(
a: Float,
pos: Float,
b: Float = 0f,
) =
((pos - a) / (b - a)).coerceIn(0f, 1f)
Now my main issue sideEffect will apparently run after every composition rather than keyed.
But launchedEffect also seems to be not running before first composition so anything calling requiresOffset() then errors before start and my app crashes.
SideEffect works, but it is optimal?