Przewijanie dwuwymiarowe: scrollable2D, draggable2D

W Jetpack Compose modyfikatory scrollable2D i draggable2D to modyfikatory niskiego poziomu, które służą do obsługi danych wejściowych wskaźnika w 2 wymiarach. Standardowe modyfikatory 1D scrollabledraggable są ograniczone do jednej orientacji, a warianty 2D śledzą ruch jednocześnie wzdłuż osi X i Y.

Na przykład istniejący modyfikator scrollable jest używany do przewijania i przesuwania w jednym kierunku, a scrollable2d – do przewijania i przesuwania w 2D. Umożliwia to tworzenie bardziej złożonych układów, które można przesuwać we wszystkich kierunkach, np. arkuszy kalkulacyjnych lub przeglądarek obrazów. Modyfikator scrollable2d obsługuje też zagnieżdżone przewijanie w scenariuszach 2D.

Rysunek 1. Dwukierunkowe panoramowanie mapy.

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 przeglądarek zdjęć, w których zawartość kontenera musi być przewijana w pionie i w poziomie. Zawiera wbudowaną obsługę przewijania przez przeciągnięcie, dzięki czemu treść jest przewijana dalej po przesunięciu, i współpracuje 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 przesyłania.

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

Wdrażanie modyfikatorów 2D

W sekcjach poniżej znajdziesz przykłady pokazujące, jak używać modyfikatorów 2D.

Wdróż Modifier.scrollable2D

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

Przechwytywanie danych ruchu 2D

Ten przykład pokazuje,jak rejestrować surowe dane ruchu 2D i wyświetlać przesunięcie w osiach X i 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.

Powyższy fragment kodu wykonuje te czynności:

  • Używa offset jako stanu, który zawiera całkowitą odległość przewiniętą przez użytkownika.
  • rememberScrollable2DState zdefiniowana jest funkcja lambda, która obsługuje każdy przyrost generowany przez palec użytkownika. Kod offset.value += delta aktualizuje stan ręczny o nową pozycję.
  • Komponenty Text wyświetlają bieżące wartości X i Y tego stanu offset, które są aktualizowane w czasie rzeczywistym podczas przeciągania przez użytkownika.

Przesuwanie dużego widocznego obszaru

Ten przykład pokazuje, jak używać przechwyconych danych 2D z możliwością przewijania i stosować atrybuty translationX i translationY do treści, które są większe niż 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. Dwukierunkowy widoczny obszar obrazu z panoramowaniem utworzony za pomocą Modifier.scrollable2D.
Rysunek 4. Dwukierunkowy widoczny obszar tekstu z panoramowaniem, utworzony za pomocą Modifier.scrollable2D.

Powyższy fragment kodu zawiera te elementy:

  • Kontener ma stały rozmiar (600x400dp), a treści mają znacznie większy rozmiar (1200x800dp), aby uniknąć zmiany rozmiaru do rozmiaru elementu nadrzędnego.
  • Modyfikator clipToBounds() w kontenerze sprawia, że każda część dużych treś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.
  • W bloku graphicsLayer funkcje translationX = offset.value.x i translationY = offset.value.y przesuwają pozycję rysowania obrazu lub tekstu w zależności od ruchu palca, tworząc efekt wizualny przewijania.

Implementowanie zagnieżdżonego przewijania za pomocą komponentu scrollable2D

W tym przykładzie pokazano, 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 zużyty przyrost, aby lista nadrzędna mogła przejąć kontrolę, gdy konto podrzędne osiągnie limit.
  • Gdy użytkownik wykona ukośne przesunięcie, udostępniana jest prędkość 2D. Jeśli element podrzędny osiągnie granicę podczas animacji, pozostały impet zostanie przekazany 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 pionowej liście przewijanej, które umożliwia wewnętrzny ruch 2D, ale przekazuje kontrolę nad przewijaniem pionowym do listy nadrzędnej, gdy wewnętrzne przesunięcie w osi Y osiągnie limit 300 pikseli.

W powyższym fragmencie kodu:

  • Komponent 2D może wykorzystywać ruch w osi X do panoramowania 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 zużyty przyrost i przekazuje pozostałą część w górę hierarchii. Dzięki temu użytkownik może dalej przewijać stronę bez odrywania palca.

Wdróż Modifier.draggable2D

Użyj modyfikatora draggable2D, aby przenieść poszczególne elementy interfejsu.

Przeciąganie elementu kompozycyjnego

Ten przykład pokazuje najczęstszy przypadek użycia draggable2D – umożliwienie użytkownikowi wybrania elementu interfejsu i przeniesienia go w dowolne miejsce w kontenerze nadrzędnym.

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

Rysunek 6. Mały fioletowy kwadrat jest przenoszony na szarym tle. Pokazuje to bezpośrednie przeciąganie w 2D, w którym element przestaje się poruszać w momencie, gdy użytkownik podniesie palec.

Powyższy fragment kodu zawiera te elementy:

  • Śledzi pozycję pola za pomocą stanu offset.
  • Używa modyfikatora offset, aby przesuwać komponent na podstawie delty przeciągania.
  • Ponieważ nie ma obsługi szybkiego przesunięcia, pole przestaje się poruszać, gdy tylko użytkownik uniesie palec.

Przeciąganie funkcji kompozycyjnej podrzędnej na podstawie obszaru przeciągania elementu nadrzędnego

W tym przykładzie pokazujemy, jak za pomocą funkcji draggable2D utworzyć dwuwymiarowy obszar danych wejściowych, w którym selektor pokrętła jest ograniczony do określonej powierzchni. W przeciwieństwie do przykładu z elementem, który można przeciągać, i który przesuwa sam komponent, ta implementacja używa różnic 2D do przesuwania podrzędnego komponentu „selektora” w selektorze kolorów:

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

Rysunek 7. Gradient kolorów z białym okrągłym pokrętłem, które można przeciągać w dowolnym kierunku. Pokazuje, jak dwuwymiarowe delty są przycinane do granic kontenera, aby aktualizować wybrane wartości kolorów.

Powyższy fragment kodu zawiera te elementy:

  • Używa modyfikatora onSizeChanged, aby rejestrować rzeczywiste wymiary kontenera gradientu. Selektor dokładnie wie, gdzie znajdują się krawędzie.
  • graphicsLayer dostosowuje translationXtranslationY, aby selektor pozostawał wyśrodkowany podczas przeciągania.