Défilement bidimensionnel : scrollable2D, draggable2D

Dans Jetpack Compose, scrollable2D et draggable2D sont des modificateurs de bas niveau conçus pour gérer les entrées de pointeur en deux dimensions. Alors que les modificateurs 1D standards scrollable et draggable sont limités à une seule orientation, les variantes 2D suivent simultanément les mouvements sur les axes X et Y.

Par exemple, le modificateur scrollable existant est utilisé pour le défilement et le balayage dans une seule orientation, tandis que scrollable2d est utilisé pour le défilement et le balayage en 2D. Cela vous permet de créer des mises en page plus complexes qui se déplacent dans toutes les directions, comme des feuilles de calcul ou des visionneuses d'images. Le modificateur scrollable2d est également compatible avec le défilement imbriqué dans les scénarios 2D.

Figure 1. Panoramique bidirectionnel sur une carte.

Sélectionnez scrollable2D ou draggable2D.

Le choix de l'API dépend des éléments d'interface utilisateur que vous souhaitez déplacer et du comportement physique souhaité pour ces éléments.

Modifier.scrollable2D : utilisez ce modificateur sur un conteneur pour déplacer le contenu qu'il contient. Par exemple, utilisez-le avec des cartes, des feuilles de calcul ou des visionneuses de photos, où le contenu du conteneur doit défiler horizontalement et verticalement. Il inclut une prise en charge intégrée du balayage, de sorte que le contenu continue de défiler après un balayage, et il se coordonne avec les autres composants de défilement de la page.

Modifier.draggable2D : utilisez ce modificateur pour déplacer un composant lui-même. Il s'agit d'un modificateur léger, de sorte que le mouvement s'arrête exactement lorsque le doigt de l'utilisateur s'arrête. Il n'inclut pas la prise en charge du lancer.

Si vous souhaitez rendre un composant déplaçable, mais que vous n'avez pas besoin de la prise en charge du défilement rapide ou du défilement imbriqué, utilisez draggable2D.

Implémenter des modificateurs 2D

Les sections suivantes fournissent des exemples d'utilisation des modificateurs 2D.

Implémenter Modifier.scrollable2D

Utilisez ce modificateur pour les conteneurs dans lesquels l'utilisateur doit déplacer du contenu dans toutes les directions.

Capturer des données de mouvement en 2D

Cet exemple montre comment capturer des données de mouvement 2D brutes et afficher le décalage X,Y :

@Composable
private fun Scrollable2DSample() {
    // 1. Manually track the total distance the user has moved in both X and Y directions
    var offset by remember { mutableStateOf(Offset.Zero) }

    Box(
        modifier = Modifier
            .fillMaxSize()
            // ...
        contentAlignment = Alignment.Center
    ) {
        Box(
            modifier = Modifier
                .size(200.dp)
                // 2. Attach the 2D scroll logic to capture XY movement deltas
                .scrollable2D(
                    state = rememberScrollable2DState { delta ->
                        // 3. Update the cumulative offset state with the new movement delta
                        offset += delta

                        // Return the delta to indicate the entire movement was handled by this box
                        delta
                    }
                )
                // ...
            contentAlignment = Alignment.Center
        ) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                // 4. Display the current X and Y values from the offset state in real-time
                Text(
                    text = "X: ${offset.x.roundToInt()}",
                    // ...
                )
                Spacer(modifier = Modifier.height(8.dp))
                Text(
                    text = "Y: ${offset.y.roundToInt()}",
                    // ...
                )
            }
        }
    }
}

Figure 2. Une zone violette qui suit et affiche les décalages actuels des coordonnées X et Y lorsqu'un utilisateur fait glisser le pointeur sur sa surface.

L'extrait de code précédent effectue les opérations suivantes :

  • Utilise offset comme état contenant la distance totale parcourue par l'utilisateur.
  • Dans rememberScrollable2DState, une fonction lambda est définie pour gérer chaque delta généré par le doigt de l'utilisateur. Le code offset.value += delta met à jour l'état manuel avec la nouvelle position.
  • Les composants Text affichent les valeurs X et Y actuelles de cet état offset, qui sont mises à jour en temps réel lorsque l'utilisateur fait glisser l'élément.

Faire un panoramique sur une grande fenêtre d'affichage

Cet exemple montre comment utiliser des données capturées à défilement 2D et appliquer un translationX et un translationY à un contenu plus grand que son conteneur parent :

@Composable
private fun Panning2DImage() {

    // Manually track the total distance the user has moved in both X and Y directions
    val offset = remember { mutableStateOf(Offset.Zero) }

    // Define how gestures are captured. The lambda is called for every finger movement
    val scrollState = rememberScrollable2DState { delta ->
        offset.value += delta
        delta
    }

    // The Viewport (Container): A fixed-size box that acts as a window into the larger content
    Box(
        modifier = Modifier
            .size(600.dp, 400.dp) // The visible area dimensions
            // ...
            // Hide any parts of the large content that sit outside this container's boundaries
            .clipToBounds()
            // Apply the 2D scroll modifier to intercept touch and fling gestures in all directions
            .scrollable2D(state = scrollState),
        contentAlignment = Alignment.Center,
    ) {
        // The Content: An image given a much larger size than the container viewport
        Image(
            painter = painterResource(R.drawable.cheese_5),
            contentDescription = null,
            modifier = Modifier
                .requiredSize(1200.dp, 800.dp)
                // Manual Scroll Effect: Since scrollable2D doesn't move content automatically,
                // we use graphicsLayer to shift the drawing position based on the tracked offset.
                .graphicsLayer {
                    translationX = offset.value.x
                    translationY = offset.value.y
                },
            contentScale = ContentScale.FillBounds
        )
    }
}

Figure 3. Une fenêtre d'affichage d'image panoramique bidirectionnelle, créée avec Modifier.scrollable2D.
Figure 4. Une fenêtre d'affichage de texte panoramique bidirectionnelle, créée avec Modifier.scrollable2D.

L'extrait de code précédent inclut les éléments suivants :

  • Le conteneur est défini sur une taille fixe (600x400dp), tandis que le contenu reçoit une taille beaucoup plus grande (1200x800dp) pour éviter qu'il ne soit redimensionné à la taille de son parent.
  • Le modificateur clipToBounds() sur le conteneur garantit que toute partie du contenu volumineux qui se trouve en dehors de la zone 600x400 est masquée.
  • Contrairement aux composants de haut niveau tels que LazyColumn, scrollable2D ne déplace pas automatiquement le contenu pour vous. Vous devez plutôt appliquer le offset suivi à votre contenu, soit à l'aide de transformations graphicsLayer, soit à l'aide de décalages de mise en page.
  • Dans le bloc graphicsLayer, translationX = offset.value.x et translationY = offset.value.y décalent la position de dessin de l'image ou du texte en fonction du mouvement de votre doigt, ce qui crée l'effet visuel de défilement.

Implémenter le défilement imbriqué avec scrollable2D

Cet exemple montre comment un composant bidirectionnel peut être intégré à un parent unidimensionnel standard, comme un flux d'actualités vertical.

Tenez compte des points suivants lorsque vous implémentez le défilement imbriqué :

  • Le lambda pour rememberScrollable2DState ne doit renvoyer que le delta consommé, pour permettre à la liste parente de prendre le relais naturellement lorsque l'enfant atteint sa limite.
  • Lorsqu'un utilisateur effectue un balayage diagonal, la vitesse 2D est partagée. Si l'enfant atteint une limite pendant l'animation, l'élan restant est propagé au parent pour poursuivre le défilement naturellement.

@Composable
private fun NestedScrollable2DSample() {
    var offset by remember { mutableStateOf(Offset.Zero) }
    val maxScrollDp = 250.dp
    val maxScrollPx = with(LocalDensity.current) { maxScrollDp.toPx() }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .background(Color(0xFFF5F5F5)),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            "Scroll down to find the 2D Box",
            modifier = Modifier.padding(top = 100.dp, bottom = 500.dp),
            style = TextStyle(fontSize = 18.sp, color = Color.Gray)
        )

        // The Child: A 2D scrollable box with nested scroll coordination
        Box(
            modifier = Modifier
                .size(250.dp)
                .scrollable2D(
                    state = rememberScrollable2DState { delta ->
                        val oldOffset = offset

                        // Calculate new potential offset and clamp it to our boundaries
                        val newX = (oldOffset.x + delta.x).coerceIn(-maxScrollPx, maxScrollPx)
                        val newY = (oldOffset.y + delta.y).coerceIn(-maxScrollPx, maxScrollPx)

                        val newOffset = Offset(newX, newY)

                        // Calculate exactly how much was consumed by the child
                        val consumed = newOffset - oldOffset

                        offset = newOffset

                        // IMPORTANT: Return ONLY the consumed delta.
                        // The remaining (unconsumed) delta propagates to the parent Column.
                        consumed
                    }
                )
                // ...
            contentAlignment = Alignment.Center
        ) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                val density = LocalDensity.current
                Text("2D Panning Zone", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
                Spacer(Modifier.height(8.dp))
                Text("X: ${with(density) { offset.x.toDp().value.roundToInt() }}dp", color = Color.White, fontWeight = FontWeight.Bold)
                Text("Y: ${with(density) { offset.y.toDp().value.roundToInt() }}dp", color = Color.White, fontWeight = FontWeight.Bold)
            }
        }

        Text(
            "Once the Purple Box hits Y: 250 or -250,\nthis parent list will take over the vertical scroll.",
            textAlign = TextAlign.Center,
            modifier = Modifier.padding(top = 40.dp, bottom = 800.dp),
            style = TextStyle(fontSize = 14.sp, color = Color.Gray)
        )
    }
}

Figure 5. Une zone violette dans une liste à défilement vertical qui permet un mouvement 2D interne, mais transmet le contrôle du défilement vertical à la liste parente une fois que le décalage Y interne de la zone atteint sa limite de 300 pixels.

Dans l'extrait précédent :

  • Le composant 2D peut utiliser le mouvement de l'axe X pour effectuer un panoramique en interne, tout en envoyant simultanément le mouvement de l'axe Y à la liste parente une fois que les limites verticales de l'enfant sont atteintes.
  • Au lieu de piéger l'utilisateur dans la surface 2D, le système calcule le delta consommé et transmet le reste à la hiérarchie. L'utilisateur peut ainsi continuer à faire défiler le reste de la page sans lever le doigt.

Implémenter Modifier.draggable2D

Pour en savoir plus sur l'utilisation de Modifier.draggable2D, consultez Faire glisser en deux dimensions avec Modifier.draggable2D.