Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Compose] Multiple transitions onSwipe #834

Open
jeckso opened this issue Jul 18, 2023 · 4 comments
Open

[Compose] Multiple transitions onSwipe #834

jeckso opened this issue Jul 18, 2023 · 4 comments
Labels
bug Something isn't working

Comments

@jeckso
Copy link

jeckso commented Jul 18, 2023

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
    }
  }
}
@jeckso jeckso added the bug Something isn't working label Jul 18, 2023
@oscar-ad
Copy link
Collaborator

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
Copy link
Collaborator

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
Copy link
Author

jeckso commented Jul 20, 2023

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
Copy link
Collaborator

oscar-ad commented Jul 20, 2023

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

2 participants