Daniele Segato
12/06/2022, 5:33 PMBox(Modifier.fillMaxWidth) {
Header()
Footer(Modifier.align(Alignement.BottomCenter))
Main(
modifier = Modifier.fillMaxWidth()
.verticalScroll(state)
.padding(headerFooterPadding)
)
}
Is it intended for the scrollable container to block clicks happening in Header and Footer?
What are my options?Oleksandr Balan
12/06/2022, 5:51 PMHeader
and Footer
composables after Main
in the Box
, so they are “above” your main scrollable contentDaniele Segato
12/06/2022, 5:52 PMOleksandr Balan
12/06/2022, 5:57 PMMain
) will intercept clicks 🤷
Why do you want them to be behind? 🤔Daniele Segato
12/06/2022, 6:00 PMDaniele Segato
12/06/2022, 6:01 PMDaniele Segato
12/06/2022, 6:01 PMOleksandr Balan
12/06/2022, 6:03 PM.verticalScroll
and .padding
?Daniele Segato
12/06/2022, 6:04 PMDaniele Segato
12/06/2022, 6:05 PMDaniele Segato
12/06/2022, 6:08 PMOleksandr Balan
12/06/2022, 6:17 PMDaniele Segato
12/06/2022, 6:22 PMDaniele Segato
12/07/2022, 12:27 AMChris Fillmore
12/07/2022, 12:38 AMDaniele Segato
12/07/2022, 10:09 AMDaniele Segato
12/07/2022, 10:21 AM@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AuthScreen(
header: @Composable BoxScope.() -> Unit = {},
footer: @Composable BoxScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit
) {
val scrollState = rememberScrollState()
// this is used to make room for footer an header below/above the main scrolling content
var headerHeightPx by remember { mutableStateOf(0) }
var footerHeightPx by remember { mutableStateOf(0) }
val (headerHeight, footerHeight) = with(LocalDensity.current) {
headerHeightPx.toDp() to footerHeightPx.toDp()
}
val headerFooterPadding = PaddingValues(
top = animateDpAsState(headerHeight + 24.dp).value,
bottom = animateDpAsState(footerHeight + 24.dp).value,
start = 24.dp,
end = 24.dp,
)
// these are used to detect overlapping and changing zIndex of header/footer
// knowing this will allow us to change the zIndex
// low zeta index (-1f) = view draw behind and doesn't get touch events
// high zeta index (1f) = view draw above and do get touch events
// we would like the view to ALWAYS draw behind but receive touch events only when visible
// I could NOT find a way to do this, and so this ugly code try to figure out if the components
// overlap, if they do we send them behind, if they don't we keep tham in front
// this is not optimal, the user can see the content but cannot click it sometimes if it is just
// a bit overlapped
var headerLayoutCoord by remember { mutableStateOf<LayoutCoordinates?>(null) }
var footerLayoutCoord by remember { mutableStateOf<LayoutCoordinates?>(null) }
var mainLayoutCoord by remember { mutableStateOf<LayoutCoordinates?>(null) }
// movements of the ui do not trigger a change in the LayoutCoordinates
// for example an inset changing or some external padding
// this means we need to recompute the overlap every time some of those change
val imeBottom = WindowInsets.ime.getBottom(LocalDensity.current)
val headerOverlapping = remember(
scrollState.isScrollInProgress,
headerLayoutCoord,
mainLayoutCoord,
LocalContainerPadding.current,
imeBottom
) {
// while scroll in progress --> always behind
scrollState.isScrollInProgress ||
// we have a tolerance for overlapping
headerLayoutCoord?.isCoveredBy(mainLayoutCoord, .2f)
// default is we hide behind
?: true
}
val footerOverlapping = remember(
scrollState.isScrollInProgress,
footerLayoutCoord,
mainLayoutCoord,
LocalContainerPadding.current,
imeBottom
) {
scrollState.isScrollInProgress ||
footerLayoutCoord?.isCoveredBy(mainLayoutCoord, .2f)
?: true
}
val headerComposable: @Composable () -> Unit = {
Box(
modifier = Modifier
.zIndex(if (headerOverlapping) -1f else 1f)
.fillMaxSize()
.padding(LocalContainerPadding.current)
.consumedWindowInsets(LocalContainerPadding.current)
.imePadding()
.padding(top = 24.dp) // no padding bottom to minimize overlapping
.padding(horizontal = 24.dp)
) {
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.onSizeChanged { headerHeightPx = it.height }
.onGloballyPositioned {
headerLayoutCoord = it
},
content = header,
)
}
}
val footerComposable: @Composable () -> Unit = {
Box(
modifier = Modifier
.zIndex(if (footerOverlapping) -1f else 1f)
.fillMaxSize()
.padding(LocalContainerPadding.current)
.consumedWindowInsets(LocalContainerPadding.current)
.imePadding()
.padding(bottom = 24.dp) // no padding top to minimize overlapping
.padding(horizontal = 24.dp)
) {
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.onSizeChanged { footerHeightPx = it.height }
.onGloballyPositioned {
footerLayoutCoord = it
},
content = footer,
)
}
}
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
headerComposable.invoke()
footerComposable.invoke()
Box(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(LocalContainerPadding.current)
.consumedWindowInsets(LocalContainerPadding.current)
.imePadding()
.padding(24.dp)
.padding(headerFooterPadding),
contentAlignment = Alignment.Center,
) {
// this is basically a Card with some other stuff of my design
FormSurface(
modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned { mainLayoutCoord = it },
content = content,
)
}
}
}
Daniele Segato
12/07/2022, 10:23 AMprivate fun LayoutCoordinates.isCoveredBy(
cover: LayoutCoordinates?,
tolerance: Float = 0f
): Boolean? {
if (cover == null) return null
val selfBox = boundsInRoot()
val coverBox = cover.boundsInRoot()
val intersect = selfBox.intersect(coverBox)
if (intersect.isEmpty) return false
if (tolerance <= 0f) return true
val intersectArea = intersect.width * intersect.height
val selfArea = selfBox.width * selfBox.height
val ratio = intersectArea / selfArea
return ratio > tolerance
}
Daniele Segato
12/07/2022, 10:24 AMDaniele Segato
12/07/2022, 10:25 AMPaddingValues()
by the wayAlbert Chang
12/07/2022, 1:40 PMDaniele Segato
12/07/2022, 3:05 PMDaniele Segato
12/07/2022, 3:05 PMAlbert Chang
12/08/2022, 10:22 AMDaniele Segato
12/09/2022, 9:48 AMDaniele Segato
12/09/2022, 11:56 AMSnapshot.withoutReadObservation
?
I kept it (a bit different and moved in layout) but I'm not sure why it is needed or why you didn't directly set the offset instead of going through this Snapshot
, thanks!
I also have another small issue (see below the code).
Here's my code, I computed min / max offset instead of headerHeight / footerHeight because I wanted the scrolling to be minimal to reveal those.
I also let the main content in the center without necessarely taking all the space (this allowed me to compute the minimal offset).
This however come with an issue (see screnshot / below the code)
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AuthScreen(
header: @Composable (BoxScope.() -> Unit)? = null,
footer: @Composable (BoxScope.() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit
) {
val scrollState = rememberScrollState()
var minOffset by remember { mutableStateOf(0f) }
var maxOffset by remember { mutableStateOf(0f) }
var offset by rememberSaveable { mutableStateOf(0f) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset =
when {
available.y < 0 && offset > 0f -> {
// if I'm scrolling up and I've offset to consume consume it
val toConsume = available.y.coerceAtLeast(-offset)
offset += toConsume
Offset(0f, toConsume)
}
available.y > 0 && offset < 0f -> {
// if I'm scrolling down and I've offset to consume consume it
val toConsume = available.y.coerceAtMost(-offset)
offset += toConsume
Offset(0f, toConsume)
}
else -> Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
val newOffset = (offset + available.y).coerceIn(minOffset, maxOffset)
val toConsume = newOffset - offset
offset += toConsume
return Offset(0f, toConsume)
}
}
}
Layout(
content = {
if (header != null) {
Box(
content = header,
modifier = Modifier
.layoutId("header")
.fillMaxWidth()
.padding(top = 24.dp)
.padding(horizontal = 24.dp),
)
}
if (footer != null) {
Box(
content = footer,
modifier = Modifier
.layoutId("footer")
.fillMaxWidth()
.padding(bottom = 24.dp)
.padding(horizontal = 24.dp),
)
}
Spacer(
modifier = Modifier
.layoutId("imePadding")
.consumedWindowInsets(LocalContainerPadding.current)
.imePadding()
)
Box(
modifier = Modifier
.layoutId("main")
.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
FormSurface(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState)
.consumedWindowInsets(LocalContainerPadding.current)
.imePadding()
.padding(24.dp),
content = content,
)
}
},
modifier = Modifier
.fillMaxSize()
.padding(LocalContainerPadding.current)
.nestedScroll(nestedScrollConnection)
) { measurables, constraints ->
val relaxedConstraints = constraints.copy(minWidth = 0, minHeight = 0)
val headerPlaceable = measurables.firstOrNull { it.layoutId == "header" }
?.measure(relaxedConstraints)
val footerPlaceable = measurables.firstOrNull { it.layoutId == "footer" }
?.measure(relaxedConstraints)
val imeSpacePlaceable = measurables.first { it.layoutId == "imePadding" }
.measure(relaxedConstraints)
val headerH = headerPlaceable?.height ?: 0
val footerH = footerPlaceable?.height ?: 0
val contentPlaceable = measurables.first { it.layoutId == "main" }
.measure(relaxedConstraints)
layout(constraints.maxWidth, constraints.maxHeight) {
val footerY = constraints.maxHeight - footerH - imeSpacePlaceable.height
val contentY = (constraints.maxHeight - contentPlaceable.height) / 2
val offsetMin = (footerY - contentY - contentPlaceable.height)
.coerceAtMost(0).toFloat()
val offsetMax = headerH - contentY
.coerceAtLeast(0).toFloat()
minOffset = offsetMin
maxOffset = offsetMax
// this is an ugly safeguard, I don't even know if it is needed
Snapshot.withoutReadObservation {
if (offset < offsetMin) {
offset = offsetMin
} else if (offset > offsetMax) {
offset = offsetMax
}
}
headerPlaceable?.place(0, 0)
footerPlaceable?.place(0, footerY)
contentPlaceable.placeWithLayer(0, contentY) {
translationY = offset
}
}
}
}
The only issue I see with all of this is that if I want the indication to start from the edge of the screen I will have to make the main content full screen.
Do you have some idea on how I can move that indication on the edge of the screen while still being able to know how much I need to span my offset?
ThanksDaniele Segato
12/09/2022, 12:17 PMval nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset =
when {
available.y < 0 && offset > minOffset -> {
// if I can consume with offset do it
val toConsume = available.y.coerceAtLeast(minOffset - offset)
offset += toConsume
Offset(0f, toConsume)
}
available.y > 0 && offset < maxOffset -> {
// if I can consume with offset do it
val toConsume = available.y.coerceAtMost(maxOffset - offset)
offset += toConsume
Offset(0f, toConsume)
}
else -> Offset.Zero
}
}
}
still has the issue with indication showing thereAlbert Chang
12/09/2022, 12:57 PMSnapshot.withoutReadObservation
is an optimization. Since we are reading offset
in the layout scope, without it, the change of offset
will cause a re-layout, which is completely unnecessary.Daniele Segato
12/09/2022, 12:58 PMAlbert Chang
12/09/2022, 12:59 PMDaniele Segato
12/09/2022, 1:39 PM