Assigned
Status Update
Comments
ho...@google.com <ho...@google.com> #2
Can you attach / share a project that reproduces the issue?
fr...@gmail.com <fr...@gmail.com> #3
Now i sure this is viewpager2's bug.
because just occur recyclerview inside viewpager2 with constraintlayout, if paging3 use Independently with viewpage2 it's not problem.
because just occur recyclerview inside viewpager2 with constraintlayout, if paging3 use Independently with viewpage2 it's not problem.
ha...@gmail.com <ha...@gmail.com> #5
deleted
fr...@gmail.com <fr...@gmail.com> #6
deleted
ha...@gmail.com <ha...@gmail.com> #7
ha...@gmail.com <ha...@gmail.com> #8
Comment has been deleted.
ha...@gmail.com <ha...@gmail.com> #9
Just to add, we're also detecting a slight jitter with this nestedScrollConnection updating motionlayout progress
approach.
pa...@gmail.com <pa...@gmail.com> #10
In case anyone is wondering, this solution I've come up with in Compose works just like what we have with the RecyclerView by default.
To recreate the out-of-box RecyclerView's behavior in Compose with LazyColumn, we need to do two things:
- Implement a
NestedScrollConnection
interface. This interface providesonPreScroll
andonPostScroll
methods, which enable us to consume a so-called scrolled delta. By having access to these deltas, we only need to figure out how much to consume & how much to ignore. The LazyColumn will act as a publisher of these events and the toolbar will consume/intercept these events to drive theprogress
value of the MotionLayout. - React to events when scrolling stops and the toolbar is not at a resting state (collapsed or expanded). This is pretty easy to do with the
LazyListState
.
Here's a slightly modified code of the
package com.example.examplescomposemotionlayout
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.EaseInOut
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.constraintlayout.compose.Dimension
import androidx.constraintlayout.compose.ExperimentalMotionApi
import androidx.constraintlayout.compose.MotionLayout
import androidx.constraintlayout.compose.MotionScene
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.launch
@OptIn(ExperimentalMotionApi::class)
@Preview(group = "scroll", device = "spec:shape=Normal,width=480,height=800,unit=dp,dpi=440")
@Composable
fun ToolBarLazyExampleDsl() {
val big = 250.dp
val small = 50.dp
val scene = MotionScene {
val title = createRefFor("title")
val image = createRefFor("image")
val icon = createRefFor("icon")
val start1 = constraintSet {
constrain(title) {
bottom.linkTo(image.bottom)
start.linkTo(image.start)
}
constrain(image) {
width = Dimension.matchParent
height = Dimension.value(big)
top.linkTo(parent.top)
customColor("cover", Color(0x000000FF))
}
constrain(icon) {
top.linkTo(image.top, 16.dp)
start.linkTo(image.start, 16.dp)
alpha = 0f
}
}
val end1 = constraintSet {
constrain(title) {
bottom.linkTo(image.bottom)
start.linkTo(icon.end)
centerVerticallyTo(image)
scaleX = 0.7f
scaleY = 0.7f
}
constrain(image) {
width = Dimension.matchParent
height = Dimension.value(small)
top.linkTo(parent.top)
customColor("cover", Color(0xFF0000FF))
}
constrain(icon) {
top.linkTo(image.top, 16.dp)
start.linkTo(image.start, 16.dp)
}
}
transition(start1, end1, "default") {}
}
val maxHeightInPx = with(LocalDensity.current) { big.roundToPx().toFloat() }
val minHeightInPx = with(LocalDensity.current) { small.roundToPx().toFloat() }
var toolbarHeight by remember { mutableStateOf(maxHeightInPx) }
val progress = remember { Animatable(initialValue = 0f) }
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
val nestedConnection = remember(listState) {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// If the list can scroll backward, then we need to allow it to consume the delta.
// Otherwise, we consume it to update the toolbar height & progress.
return if (listState.canScrollBackward) {
Offset.Zero
} else {
consume(available)
}
}
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
// We need to handle the onPostScroll in order to consume the leftover delta after a user
// flings a list from the bottom to the top so that the toolbar could expand automatically.
return if (!listState.canScrollBackward && available.y > 0) {
consume(available)
} else {
Offset.Zero
}
}
private fun consume(available: Offset): Offset {
val currentHeight = toolbarHeight
return when {
currentHeight + available.y > maxHeightInPx -> {
onUpdateValues(maxHeightInPx)
Offset(0f, maxHeightInPx - currentHeight)
}
currentHeight + available.y < minHeightInPx -> {
onUpdateValues(minHeightInPx)
Offset(0f, minHeightInPx - currentHeight)
}
else -> {
onUpdateValues(toolbarHeight + available.y)
Offset(0f, available.y)
}
}
}
private fun onUpdateValues(newToolbarHeight: Float) {
toolbarHeight = newToolbarHeight
// After we consumed the delta, we now need to recalculate
// new progress value
val newProgress = calculateProgressGivenToolbarHeight(
toolbarHeight = newToolbarHeight,
minHeight = minHeightInPx,
maxHeight = maxHeightInPx,
)
coroutineScope.launch {
// We snap to the new progress value to drive the
// MotionLayout animation
progress.snapTo(newProgress)
}
}
}
}
// Effect that takes care of listening when scrolling stops and if toolbar is not
// at resting state, then it animates it to the closest state
LaunchedEffect(Unit) {
snapshotFlow { listState.isScrollInProgress }
.distinctUntilChanged()
.filterNot { isScrolling -> isScrolling }
.collect {
val currentProgress = progress.value
if (currentProgress != 0f && currentProgress != 1f) {
val newProgress = if (currentProgress < 0.5f) 0f else 1f
// We calculate the duration here in a specific way to recreate 1 to 1
// RecyclerView's OnSwipe behavior
val duration = calculateAutoTransitionDuration(currentProgress)
launch {
// All left to do now is animate the toolbar to the resting state,
// which is specified by the newProgress parameter
progress.animateTo(
targetValue = newProgress,
animationSpec = tween(durationMillis = duration, easing = EaseInOut),
block = {
// Also, when we are animating, we need to recalculate the
// toolbarHeight as well to keep it in sync with the progress
toolbarHeight = calculateToolbarHeightGivenProgress(
progress = value,
minHeight = minHeightInPx,
maxHeight = maxHeightInPx,
)
},
)
}
}
}
}
Column {
MotionLayout(
motionScene = scene,
progress = progress.value,
) {
Image(
modifier = Modifier
.layoutId("image")
.background(customColor("image", "cover")),
painter = painterResource(R.drawable.bridge),
contentDescription = null,
contentScale = ContentScale.Crop
)
Image(
modifier = Modifier.layoutId("icon"),
painter = painterResource(R.drawable.menu),
contentDescription = null
)
Text(
modifier = Modifier.layoutId("title"),
text = "San Francisco",
fontSize = 30.sp,
color = Color.White
)
}
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.nestedScroll(nestedConnection),
state = listState,
) {
items(100) {
Text(text = "item $it", modifier = Modifier.padding(4.dp))
}
}
}
}
private fun calculateToolbarHeightGivenProgress(
progress: Float,
minHeight: Float,
maxHeight: Float,
): Float {
return minHeight + (1 - progress) * (maxHeight - minHeight)
}
private fun calculateProgressGivenToolbarHeight(
toolbarHeight: Float,
minHeight: Float,
maxHeight: Float,
): Float {
return 1 - (toolbarHeight - minHeight) / (maxHeight - minHeight)
}
/**
* Calculates a duration for the auto transition in the following way:
* - for progress that is zero, the duration is minimal (0f -> min)
* - for progress that is half way, the duration is maximal (0.5f -> max)
* - for progress that is one, the duration is minimal (1f -> min)
**/
private fun calculateAutoTransitionDuration(progress: Float): Int {
val minDuration = 300
val maxDuration = 1200
return (minDuration + (maxDuration - minDuration) * 4 * progress * (1 - progress)).toInt()
}
ho...@google.com <ho...@google.com>
fr...@rappi.com <fr...@rappi.com> #11
2024 And there is still no solution :-(
Description
When a child of MotionLayout is a scrollable component, i.e. LazyColum, Scrollable Column, etc, the MotionLayout animation doesn't respond to the swipe at all. The issue seems to be related to how Jetpack Compose prioritizes the scroll event and swipe event. For example, when "userScrollEnabled " is set to false in LazyColumn, the animation works perfectly.
The expectation is, LazyColumn should not scroll until the animation completes. After the animation finishes, the LazyColumn should resume scrolling if the user continues swiping. This is the same behavior when using motion layout and recycler view on View Layout.
I suspect that the touch events are being intercepted or conflicting between LazyColumn and MotionLayout, causing the animation to not work as expected within the LazyColumn.
These look like a bug to me.
Here is a StackOverflow i created