androidx / constraintlayout

ConstraintLayout is an Android layout component which allows you to position and size widgets in a flexible way
Apache License 2.0
1.06k stars 177 forks source link

[Compose] Multiple transitions onSwipe #834

Open jeckso opened 11 months ago

jeckso commented 11 months ago

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

{
  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',
      duration: 300,
      onSwipe: {
        direction: 'start',
        touchUp: 'autocomplete',
        anchor: 'topCard',
        side: 'start'
      }
    },
    pass: {
      from: 'rest',
      to: 'pass',
      duration: 300,
      onSwipe: {
        direction: 'end',
        touchUp: 'autocomplete',
        anchor: 'topCard',
        side: 'end'
      }
    },
    animateToEnd: {
      from: 'like',
      to: 'offScreenLike',
      duration: 150
    },
    animateToEndPass: {
      from: 'pass',
      to: 'offScreenPass',
      duration: 150
    }
  }
}
oscar-ad commented 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.

oscar-ad commented 11 months ago

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
}
jeckso commented 11 months ago

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?

oscar-ad commented 11 months ago

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.