我正在尝试使用 Jetpack Compose 中的 androidx.constraintlayout.compose 版本 1.1.0-alpha09 库来实现带有滑动手势的 MotionLayout 动画。但是,我面临一个问题,即在 LayoutMation 内使用 LazyColumn 并在 LazyColumn 上滑动时,动画无法按预期工作。
这是我的作文
@OptIn(ExperimentalMotionApi::class)
@Composable
fun HomeView(
navController: NavController,
viewModel: AuthViewModel = hiltViewModel()
) {
val context = LocalContext.current
val motionScene = remember { context.resources.openRawResource(R.raw.home_main_scene).readBytes().decodeToString() }
val tabIndex = remember { mutableStateOf(0) }
val motionState = rememberMotionLayoutState()
val systemUiController = rememberSystemUiController()
SideEffect {
systemUiController.setNavigationBarColor(
color = LightColors.lightDebianColor,
darkIcons = true
)
systemUiController.setStatusBarColor(
color = PrimaryDebianColors.primary,
)
}
MotionLayout(
motionScene = MotionScene(content = motionScene),
motionLayoutState = motionState,
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier = Modifier.layoutId("hero"),
contentAlignment = Alignment.Center
) {
ShopHighLights()
}
Box(
modifier = Modifier
.layoutId("tabs")
.padding(top = 16.dp, bottom = 8.dp)
) {
UziaDefaultScrollableTabs(
selectedTabIndex = tabIndex.value,
onClick = { tabIndex.value = it })
}
Surface(
modifier = Modifier
.layoutId("back")
.clip(RoundedCornerShape(topStart = 42.dp, topEnd = 42.dp)),
color = LightColors.lightTealColor
) {
Box(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp),
contentAlignment = Alignment.TopCenter
) {
ActionStatusComponent()
}
}
Surface(
modifier = Modifier
.layoutId("front")
.fillMaxSize()
.clip(RoundedCornerShape(topStart = 42.dp, topEnd = 42.dp)),
color = LightColors.lightDebianColor
) {
FoodGridList(columns = 2)
}
}
}
这是运动场景
{
ConstraintSets: {
start: {
hero: {
width: "spread",
top: ["parent", "top"],
start: ["parent", "start"],
end: ["parent", "end"],
translationY: 0,
alpha: 1,
scaleX: 1,
scaleY: 1,
},
content: {
width: "spread",
height: "spread",
start: ["parent", "start"],
end: ["parent", "end"],
top: ["hero","bottom"],
bottom: ["parent","bottom"],
},
},
end: {
hero: {
width: "spread",
top: ["parent", "top"],
start: ["parent", "start"],
end: ["parent", "end"],
translationY: -50,
alpha: 0,
scaleX: 0.8,
scaleY: 0.8,
},
content: {
width: "spread",
height: "spread",
start: ["parent", "start"],
end: ["parent", "end"],
top: ["parent","top"],
bottom: ["parent","bottom"],
},
},
},
Transitions: {
default: {
from: "start",
to: "end",
onSwipe: {
anchor: "content",
direction: "bottom",
side: "top",
},
}
}
}
该问题似乎与 Jetpack Compose 如何优先考虑滚动事件和滑动事件有关。我尝试禁用和启用 LazyColumn 的 userScrollEnabled 属性,但它没有提供所需的行为。我想要实现的是一个动画,其中 LazyColumn 在动画完成之前不会滚动。动画完成后,如果用户继续滑动,LazyColumn 应恢复滚动。
我怀疑 LazyColumn 和 MotionLayout 之间的触摸事件被拦截或冲突,导致动画无法在 LazyColumn 中按预期工作。
我正在寻找有关如何正确处理 MotionLayout 和 LazyColumn 之间的交互以实现所需的动画行为的建议或见解。我希望 LazyColumn 在动画过程中暂停滚动,如果用户在动画完成后继续滑动,则恢复滚动。
任何帮助或指导将不胜感激。谢谢!
不幸的是,这些解决方法意味着我们无法使用 onSwipe 选项(例如自动完成)。如果您想在折叠状态下优先考虑向下滚动(这是 xml 视图世界中的自然行为),那么您需要共享内部可滚动的滚动状态,并在nestedScrollConnection 中包含一个片段来检查它是否可以滚动后退。 请注意,标题完全折叠,即我的
minPx
= 0 但我认为如果您简单地正确定义 minPx
(即最大高度 - 最小高度),您可以避免使用 headerScrollMaxPx
。
// Can put in utils file
fun collapsingHeaderNestedScrollConnection(
headerExpandedPx: MutableState<Float>,
headerScrollMaxPx: Float,
nestedCanScrollBack: State<Boolean>
): NestedScrollConnection =
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val expandedPx = headerExpandedPx.value
val allowNestedScrollBack = available.y > 0 && nestedCanScrollBack.value
if (allowNestedScrollBack) {
return Offset(0f, 0f)
}
val scrollBeyondMax = expandedPx + available.y > headerScrollMaxPx
if (scrollBeyondMax) {
headerExpandedPx.value = headerScrollMaxPx
return Offset(0f, headerScrollMaxPx - expandedPx)
}
val scrollBeyondCollapsed = expandedPx + available.y < 0
if (scrollBeyondCollapsed) {
headerExpandedPx.value = 0f
return Offset(0f, -expandedPx)
}
// Else use all scroll to collapse/expand header (block nested scroll by consuming all y delta)
headerExpandedPx.value += available.y
return Offset(0f, available.y)
}
}
@Composable
fun MotionLayoutComposeView(
items: List<NotificationSettingItem>
) {
val scrollMaxPx = with(LocalDensity.current) {
dimensionResource(id = R.dimen.collapsing_sweep_view_header_scroll_span_default)
.roundToPx()
.toFloat()
}
val headerExpandedPx = remember { mutableFloatStateOf(scrollMaxPx) }
ManageNotificationsMotionLayout(
headerExpandedPx = headerExpandedPx,
headerScrollMaxPx = scrollMaxPx
) ...
@Composable
fun ManageNotificationsMotionLayout(
headerExpandedPx: State<Float>,
headerScrollMaxPx: Float,
content: @Composable (MotionLayoutScope.() -> Unit)
) {
val progress = 1 - headerExpandedPx.value / headerScrollMaxPx
MotionLayout(
progress = progress...
// Inner scroll content composable scope...
val settingsScrollState = rememberLazyListState()
val canScrollBackState = remember {
derivedStateOf { settingsScrollState.canScrollBackward }
}
val nestedScrollConnection = remember {
collapsingHeaderNestedScrollConnection(
headerExpandedPx = headerExpandedPx,
headerScrollMaxPx = headerScrollMaxPx,
nestedCanScrollBack = canScrollBackState
)
}
CompositionLocalProvider(LocalOverscrollConfiguration provides null) {
LazyColumn(
state = settingsScrollState,
modifier = modifier.nestedScroll(nestedScrollConnection)...
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val height = toolbarHeight.value;
if (height + available.y > maxPx) {
toolbarHeight.value = maxPx
return Offset(0f, maxPx - height)
}
if (height + available.y < minPx) {
toolbarHeight.value = minPx
return Offset(0f, minPx - height)
}
toolbarHeight.value += available.y
return Offset(0f, available.y)
}
}
}
val progress = = 1 - (toolbarHeight.value - minPx) / (maxPx - minPx);
MotionLayout(
...
progress = progress
)
Box( Modifier .nestedScroll(nestedScrollConnection)) {
LazyColumn() {
}
}
}
工作代码可以在这里找到:
https://github.com/androidx/constraintlayout/tree/main/demoProjects/ExamplesComposeMotionLayout#motion-layout-as-collapsing-toolbar-for-lazy-column
progress
参数来驱动 MotionLayout 的动画。要使用 LazyColumn 在 Compose 中重新创建开箱即用的 RecyclerView 行为,我们需要做两件事:
onPreScroll
和
onPostScroll
方法,使我们能够使用所谓的滚动增量。通过访问这些增量,我们只需要弄清楚消耗多少和忽略多少。
LazyListState
即可轻松完成此操作。
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
val newProgress = calculateProgressGivenToolbarHeight(
toolbarHeight = newToolbarHeight,
minHeight = minHeightInPx,
maxHeight = maxHeightInPx,
)
coroutineScope.launch {
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 {
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()
}
我在上面的代码中添加了一些注释来帮助理解。如果您还有任何疑问,请随时在评论中提问。