Przewijanie dwuwymiarowe: scrollable2D, draggable2D

W Jetpack Compose scrollable2D i draggable2D to modyfikatory niskiego poziomu przeznaczone do obsługi danych wejściowych ze wskaźnika w 2 wymiarach. Standardowe modyfikatory 1D scrollable i draggable są ograniczone do jednej orientacji, natomiast warianty 2D śledzą ruch jednocześnie na osiach X i Y.

Na przykład istniejący modyfikator scrollable jest używany do przewijania i przesuwania w jednej orientacji, a scrollable2d – do przewijania i przesuwania w 2D. Dzięki temu możesz tworzyć bardziej złożone układy, które poruszają się we wszystkich kierunkach, np. arkusze kalkulacyjne lub przeglądarki obrazów. Modyfikator scrollable2d obsługuje też zagnieżdżone przewijanie w scenariuszach 2D.

Rysunek 1. Dwukierunkowe przesuwanie na mapie.

Wybierz scrollable2D lub draggable2D.

Wybór odpowiedniego interfejsu API zależy od elementów interfejsu, które chcesz przenieść, oraz preferowanego zachowania fizycznego tych elementów.

Modifier.scrollable2D: użyj tego modyfikatora w kontenerze, aby przenieść zawartość do jego wnętrza. Możesz go używać na przykład w przypadku Map, arkuszy kalkulacyjnych lub widoków Zdjęcia, gdzie zawartość kontenera musi być przewijana zarówno w poziomie, jak i w pionie. Ma wbudowaną obsługę rzutu, dzięki czemu treść nadal się porusza po przesunięciu palcem, i jest skoordynowany z innymi komponentami przewijania na stronie.

Modifier.draggable2D: użyj tego modyfikatora, aby przenieść sam komponent. Jest to lekki modyfikator, więc ruch zatrzymuje się dokładnie wtedy, gdy użytkownik przestaje przesuwać palcem. Nie obejmuje obsługi rzucania.

Jeśli chcesz, aby komponent można było przeciągać, ale nie potrzebujesz obsługi rzutu ani przewijania zagnieżdżonego, użyj draggable2D.

Wdrażanie modyfikatorów 2D

W sekcjach poniżej znajdziesz przykłady użycia modyfikatorów 2D.

Wdrożenie: Modifier.scrollable2D

Używaj tego modyfikatora w przypadku kontenerów, w których użytkownik musi przesuwać zawartość we wszystkich kierunkach.

Przechwytywanie danych o ruchu 2D

Ten przykład pokazuje, jak przechwytywać nieprzetworzone dane o ruchu 2D i wyświetlać przesunięcie 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()}",
                    // ...
                )
            }
        }
    }
}

Rysunek 2. Fioletowe pole, które śledzi i wyświetla bieżące przesunięcia współrzędnych X i Y, gdy użytkownik przeciąga wskaźnik po jego powierzchni.

Poprzedni fragment kodu wykonuje te czynności:

  • Używa offset jako stanu, który przechowuje łączną odległość przewiniętą przez użytkownika.
  • Wewnątrz rememberScrollable2DState zdefiniowana jest funkcja lambda, która obsługuje każdą zmianę wygenerowaną przez palec użytkownika. Kod offset.value += delta aktualizuje stan ręczny, podając nową pozycję.
  • Komponenty Text wyświetlają bieżące wartości X i Y stanu offset, które są aktualizowane w czasie rzeczywistym, gdy użytkownik przeciąga element.

Przesuwanie dużego widocznego obszaru

Ten przykład pokazuje, jak używać przechwyconych danych przewijanych w 2D i stosować translationX i translationY do treści, które są większe niż ich kontener nadrzędny:

@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
        )
    }
}

Rysunek 3. Widoczny obszar obrazu z dwukierunkowym przesuwaniem, utworzony za pomocą Modifier.scrollable2D.
Rysunek 4. Dwukierunkowy widoczny obszar tekstu utworzony za pomocą Modifier.scrollable2D.

Powyższy fragment kodu zawiera te elementy:

  • Kontener ma stały rozmiar (600x400dp), a treść ma znacznie większy rozmiar (1200x800dp), aby uniknąć zmiany rozmiaru na rozmiar kontenera nadrzędnego.
  • Modyfikator clipToBounds() w kontenerze sprawia, że każda część dużej zawartości, która znajduje się poza polem 600x400, jest ukryta.
  • W przeciwieństwie do komponentów wysokiego poziomu, takich jak LazyColumn, scrollable2D nie przenosi treści automatycznie. Zamiast tego musisz zastosować śledzony element offset do treści, używając przekształceń graphicsLayer lub przesunięć układu.
  • Wewnątrz bloku graphicsLayer klocki translationX = offset.value.x i translationY = offset.value.y przesuwają pozycję rysowania obrazu lub tekstu na podstawie ruchu palca, tworząc efekt wizualny przewijania.

Implementowanie przewijania zagnieżdżonego za pomocą scrollable2D

Ten przykład pokazuje, jak komponent dwukierunkowy można zintegrować ze standardowym jednowymiarowym elementem nadrzędnym, takim jak pionowy kanał wiadomości.

Podczas wdrażania zagnieżdżonego przewijania pamiętaj o tych kwestiach:

  • Funkcja lambda dla rememberScrollable2DState powinna zwracać tylko wykorzystaną deltę, aby lista nadrzędna mogła przejąć kontrolę w naturalny sposób, gdy lista podrzędna osiągnie limit.
  • Gdy użytkownik wykonuje ruch po przekątnej, udostępniana jest prędkość 2D. Jeśli podczas animacji element podrzędny osiągnie granicę, pozostały pęd jest przekazywany do elementu nadrzędnego, aby kontynuować przewijanie w naturalny sposób.

@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)
        )
    }
}

Rysunek 5. Fioletowe pole na liście przewijanej pionowo, które umożliwia wewnętrzny ruch 2D, ale przekazuje kontrolę nad przewijaniem pionowym do listy nadrzędnej, gdy wewnętrzne przesunięcie pola w osi Y osiągnie limit 300 pikseli.

W powyższym fragmencie kodu:

  • Komponent 2D może wykorzystywać ruch w osi X do przesuwania wewnętrznego, a jednocześnie wysyłać ruch w osi Y do listy nadrzędnej po osiągnięciu własnych granic pionowych.
  • Zamiast zatrzymywać użytkownika na powierzchni 2D, system oblicza wykorzystaną deltę i przekazuje pozostałą część w górę hierarchii. Dzięki temu użytkownik może kontynuować przewijanie pozostałej części strony bez odrywania palca.

Wdrożenie: Modifier.draggable2D

Informacje o tym, jak używać Modifier.draggable2D, znajdziesz w artykule Przeciąganie w 2 wymiarach za pomocą Modifier.draggable2D.