Open jeckso opened 11 months ago
We currently don't have multi-state swipe transitions. It's a planned feature but not ready yet.
Right now the only option is to drive it with a Modifier.anchoredDraggable
and map its state to a transitionName
and progress
.
It's a bit tricky since you have to adjust the progress from the AnchoredDraggableState
to match the expected progress in MotionLayout, but it depends a lot on the current and target position of the AnchoredDraggableState
.
I'll try to upload an example here to give you an idea. Tho there seems to be an issue with the implementation of Modifier.anchoredDraggable
which can lead to a janky experience.
Here's an example. Tho as I said, if you pay attention you might notice some issues from the anchoredDraggable
implementation.
@OptIn(ExperimentalFoundationApi::class)
@Preview
@Composable
private fun MultiTransitionGestureDemo() {
var transition by remember { mutableStateOf(SwipeTransitions.default) }
var progress by remember { mutableFloatStateOf(0f) }
val density = LocalDensity.current
// To adapt to the density, a SideEffect call should be made to draggableState.updateAnchors...
val distance = remember {
with(density) {
300.dp.toPx()
}
}
val anchors = remember {
DraggableAnchors {
DragPosition.Left at -distance
DragPosition.Center at 0f
DragPosition.Right at distance
}
}
val draggableState = remember {
AnchoredDraggableState(
initialValue = DragPosition.Center,
anchors = anchors,
positionalThreshold = {
// We want MotionLayout to change transition precisely, so 0f threshold to avoid
// undesired snapping
0f
},
velocityThreshold = {
// Pixels/second, needs to be adjusted to get the desired "feel"
150f
},
animationSpec = spring()
)
}
Column {
MotionLayout(
motionScene = MotionScene(
"""
{
ConstraintSets: {
rest: {
topCard: {
width: "spread",
height: "spread",
top: ['parent', 'top',50],
start: ['parent', 'start',50],
bottom: ['parent', 'bottom',50],
end: ['parent', 'end',50],
}
},
pass: {
topCard: {
width: '70%',
height: 'spread',
start: ['parent', 'start', 50],
end: ['parent', 'end',200],
top: ['parent', 'top',20],
bottom: ['parent', 'bottom',80]
}
},
like: {
topCard: {
width: '70%',
height: 'spread',
start: ['parent', 'start', 200],
end: ['parent', 'end',50],
top: ['parent', 'top',20],
bottom: ['parent', 'bottom',80]
}
},
offScreenLike: {
topCard: {
width: '70%',
height: 'spread',
start: ['parent', 'start', 0],
end: ['parent', 'end',50],
top: ['parent', 'top',20],
bottom: ['parent', 'bottom',80]
}
},
offScreenPass: {
topCard: {
width: '70%',
height: 'spread',
start: ['parent', 'start', 50],
end: ['parent', 'end',500],
top: ['parent', 'top',20],
bottom: ['parent', 'bottom',80]
}
}
},
Transitions: {
default: {
from: 'rest',
to: 'like',
},
pass: {
from: 'rest',
to: 'pass',
},
animateToEnd: {
from: 'like',
to: 'offScreenLike',
},
animateToEndPass: {
from: 'pass',
to: 'offScreenPass',
}
}
}
""".trimIndent()
),
progress = progress,
transitionName = transition.name,
modifier = Modifier
.fillMaxWidth()
.weight(1f, true)
.background(Color.LightGray)
// We take the entire MotionLayout to track swiping
.anchoredDraggable(
state = draggableState,
orientation = Orientation.Horizontal
)
) {
Text(
text = "This is TopCard",
modifier = Modifier
.layoutId("topCard")
.background(Color.Gray)
)
}
Column {
Row {
// Shows current MotionLayout transition
Button(onClick = {
transition = when (transition) {
SwipeTransitions.default -> SwipeTransitions.pass
SwipeTransitions.pass -> SwipeTransitions.animateToEnd
SwipeTransitions.animateToEnd -> SwipeTransitions.animateToEndPass
SwipeTransitions.animateToEndPass -> SwipeTransitions.default
}
}) {
Text(text = "Current Transition: ${transition.name}")
}
}
// Shows current MotionLayout progress
Slider(
value = progress,
onValueChange = { progress = it }
)
// Show current AnchoredDraggableState progress
Text("Anchored Draggable Progress: ${draggableState.progress}")
}
}
LaunchedEffect(Unit) {
// Handle the changes in offset from AnchoredDraggableState, note that the resulting
// `transitionName` and `progress` value is highly dependent on the current and target
// positions in AnchoredDraggableState
snapshotFlow { draggableState.requireOffset() }.collect {
if (draggableState.currentValue == DragPosition.Center) {
when (draggableState.targetValue) {
// When the target is Left from the Center, the progress is positive in the
// `pass` transitions
DragPosition.Left -> {
transition = SwipeTransitions.pass
progress = draggableState.progress
}
// When the target and current positions are the same, progress is 0 on the
// default transition
DragPosition.Center -> {
transition = SwipeTransitions.default
progress = 0f
}
// When the target is Right from the Center, the progress is positive in the
// default transition
DragPosition.Right -> {
transition = SwipeTransitions.default
progress = draggableState.progress
}
}
} else if (draggableState.currentValue == DragPosition.Left) {
// Apply similar logic to other combination of current/target positions, keeping in
// mind the direction of the progress for each Transition
when (draggableState.targetValue) {
DragPosition.Left -> {
progress = 1f
}
DragPosition.Center -> {
progress = 1f - draggableState.progress
}
DragPosition.Right -> {
// TODO: investigate, is this actually supported by the anchoredDraggable
// implementation?
if (it > 0f) {
transition = SwipeTransitions.default
progress = (draggableState.progress - 0.5f) * 2f
} else {
progress = 1f - ((draggableState.progress - 0.5f) * 2f)
}
}
}
} else {
when (draggableState.targetValue) {
DragPosition.Right -> {
progress = 1f
}
DragPosition.Center -> {
progress = 1f - draggableState.progress
}
DragPosition.Left -> {
// TODO: investigate, is this actually supported by the anchoredDraggable
// implementation?
if (it < 0f) {
transition = SwipeTransitions.pass
progress = (draggableState.progress - 0.5f) * 2f
} else {
progress = 1f - ((draggableState.progress - 0.5f) * 2f)
}
}
}
}
}
}
}
private enum class DragPosition {
Left,
Center,
Right
}
/**
* Custom enum that maps exactly the name of each transition in the MotionScene
*/
private enum class SwipeTransitions {
default,
pass,
animateToEnd,
animateToEndPass
}
Thanks for your help! I've tried an example you have provided, but ran into java.lang.NoClassDefFoundError: Failed resolution of: Landroidx/compose/runtime/PrimitiveSnapshotStateKt;
Could this be anchoredDraggable related issue?
Probably just need to update the compose runtime dependency, try 1.5.0-beta03.
You might have to update the kotlin version and possibly the Android Gradle Plugin too, the one applied to com.android.application
/com.android.library
.
Is it possible to define multiple default transitions? I have a card that requires swipe gestures both to the right and left, each with distinct transitions. However, I currently can only apply a single default transition. I'm using androidx.constraintlayout:constraintlayout-compose:1.1.0-alpha10