Défilement bidimensionnel : scrollable2D, draggable2D

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

Par exemple, le modificateur scrollable existant est utilisé pour le défilement et le balayage à 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. Un déplacement bidirectionnel sur une carte.

Sélectionnez scrollable2D ou draggable2D.

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

Modifier.scrollable2D : utilisez ce modificateur sur un conteneur pour déplacer du contenu à l'intérieur. Par exemple, utilisez-le avec des cartes, des feuilles de calcul ou des visionneuses de photos, où le contenu du conteneur doit défiler dans les deux sens (horizontal et vertical). Il inclut une prise en charge intégrée du défilement rapide, 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 de la diffusion.

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 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 2D

Cet exemple montre comment capturer des données brutes de mouvement 2D 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. Boîte 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 précédent effectue les opérations suivantes :

  • Utilise offset comme état qui contient 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 dans une grande fenêtre d'affichage

Cet exemple montre comment utiliser les données de défilement 2D capturées 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. Viewport d'image panoramique bidirectionnelle, créée avec Modifier.scrollable2D.
Figure 4. Fenêtre d'affichage de texte avec panoramique bidirectionnel, 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 est défini sur 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 grand contenu qui se trouve en dehors de la boîte 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éplacent 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.

Voici quelques points à garder à l'esprit 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 que le défilement se poursuive 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. Boîte 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 boîte atteint sa limite de 300 pixels.

Dans l'extrait précédent :

  • Le composant 2D peut consommer le mouvement de l'axe X pour effectuer un panoramique en interne tout en distribuant simultanément le mouvement de l'axe Y à la liste parente une fois que les propres 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. Cela permet à l'utilisateur de continuer à faire défiler le reste de la page sans lever le doigt.

Implémenter Modifier.draggable2D

Utilisez le modificateur draggable2D pour déplacer des éléments d'interface utilisateur individuels.

Faire glisser un élément composable

Cet exemple illustre le cas d'utilisation le plus courant pour draggable2D : permettre à un utilisateur de sélectionner un élément d'interface utilisateur et de le repositionner n'importe où dans un conteneur parent.

@Composable
private fun DraggableComposableElement() {
    // 1. Track the position of the floating window
    var offset by remember { mutableStateOf(Offset.Zero) }

    Box(modifier = Modifier.fillMaxSize().background(Color(0xFFF5F5F5))) {
        Box(
            modifier = Modifier
                // 2. Apply the offset to the box's position
                .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
                // ...
                // 3. Attach the 2D drag logic
                .draggable2D(
                    state = rememberDraggable2DState { delta ->
                        // 4. Update the position based on the movement delta
                        offset += delta
                    }
                ),
            contentAlignment = Alignment.Center
        ) {
            Text("Video Preview", color = Color.White, fontSize = 12.sp)
        }
    }
}

Figure 6. Petite boîte violette repositionnée sur un fond gris, illustrant le déplacement 2D direct où l'élément cesse de bouger dès que l'utilisateur lève le doigt.

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

  • Suit la position de la boîte à l'aide d'un état offset.
  • Utilise le modificateur offset pour déplacer la position du composant en fonction des deltas de déplacement.
  • Comme il n'y a pas de prise en charge du geste de balayage, la boîte cesse de bouger dès que l'utilisateur lève le doigt.

Faire glisser un composable enfant en fonction de la zone de déplacement du parent

Cet exemple montre comment utiliser draggable2D pour créer une zone de saisie 2D dans laquelle un sélecteur est limité à une surface spécifique. Contrairement à l'exemple d'élément déplaçable, qui déplace le composant lui-même, cette implémentation utilise les deltas 2D pour déplacer un composable enfant "sélecteur" dans un sélecteur de couleur :

@Composable
private fun ExampleColorSelector(
    // ...
)  {
    // 1. Maintain the 2D position of the selector in state.
    var selectorOffset by remember { mutableStateOf(Offset.Zero) }

    // 2. Track the size of the background container.
    var containerSize by remember { mutableStateOf(IntSize.Zero) }

    Box(
        modifier = Modifier
            .size(300.dp, 200.dp)
            // Capture the actual pixel dimensions of the container when it's laid out.
            .onSizeChanged { containerSize = it }
            .clip(RoundedCornerShape(12.dp))
            .background(
                brush = remember(hue) {
                    // Create a simple gradient representing Saturation and Value for the given Hue.
                    Brush.linearGradient(listOf(Color.White, Color.hsv(hue, 1f, 1f)))
                }
            )
    ) {
        Box(
            modifier = Modifier
                .size(24.dp)
                .graphicsLayer {
                    // Center the selector on the finger by subtracting half its size.
                    translationX = selectorOffset.x - (24.dp.toPx() / 2)
                    translationY = selectorOffset.y - (24.dp.toPx() / 2)
                }
                // ...
                // 3. Configure 2D touch dragging.
                .draggable2D(
                    state = rememberDraggable2DState { delta ->
                        // 4. Calculate the new position and clamp it to the container bounds
                        val newX = (selectorOffset.x + delta.x)
                            .coerceIn(0f, containerSize.width.toFloat())
                        val newY = (selectorOffset.y + delta.y)
                            .coerceIn(0f, containerSize.height.toFloat())

                        selectorOffset = Offset(newX, newY)
                    }
                )
        )
    }
}

Figure 7. Dégradé de couleurs avec un sélecteur circulaire blanc qui peut être déplacé dans n'importe quelle direction, montrant comment les deltas 2D sont ancrés aux limites du conteneur pour mettre à jour les valeurs de couleur sélectionnées.

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

  • Il utilise le modificateur onSizeChanged pour capturer les dimensions réelles du conteneur de dégradé. Le sélecteur sait exactement où se trouvent les bords.
  • Dans graphicsLayer, il ajuste translationX et translationY pour s'assurer que le sélecteur reste centré pendant le déplacement.