Przeciąganie elementów kompozycyjnych

Umożliwienie użytkownikom fizycznego przeciągania komponentów po ekranie jest podstawą nowoczesnych aplikacji na Androida. Niezależnie od tego, czy tworzysz niestandardowy suwak, komponent do przesuwania w celu odrzucenia czy interaktywny obszar roboczy, Jetpack Compose udostępnia kompleksowy zestaw interfejsów API, które ułatwiają obsługę zdarzeń przeciągania.

Interfejsy API przeciągania w Compose upraszczają złożoność nieprzetworzonych zdarzeń dotknięcia. Zamiast ręcznie obliczać współrzędne dotknięcia i zarządzać cyklami życia gestów za pomocą pointerInput, możesz używać wbudowanych modyfikatorów, które są bezpośrednio powiązane z systemami zarządzania stanem i animacji w Compose.

Na tej stronie opisujemy, jak i kiedy używać poszczególnych interfejsów API do przeciągania.

Wybieranie odpowiedniego interfejsu API do przeciągania

Do implementacji treści, które można przeciągać, możesz użyć kilku interfejsów API.

Przeciągnij wzdłuż jednej osi za pomocą klawisza draggable

Modyfikator draggable to punkt wejścia wysokiego poziomu do gestów przeciągania w jednej orientacji, który raportuje odległość przeciągania w pikselach.

Warto zauważyć, że ten modyfikator jest podobny do scrollable, ponieważ wykrywa tylko gest. Musisz zachować stan i wyświetlić go na ekranie, na przykład przenosząc element za pomocą modyfikatora offset:

@Composable
private fun DraggableText() {
    var offsetX by remember { mutableFloatStateOf(0f) }
    Text(
        modifier = Modifier
            .offset { IntOffset(offsetX.roundToInt(), 0) }
            .draggable(
                orientation = Orientation.Horizontal,
                state = rememberDraggableState { delta ->
                    offsetX += delta
                }
            ),
        text = "Drag me!"
    )
}

Jeśli chcesz kontrolować cały gest przeciągania, rozważ użycie detektora gestu przeciągania za pomocą modyfikatora pointerInput.

@Composable
private fun DraggableTextLowLevel() {
    Box(modifier = Modifier.fillMaxSize()) {
        var offsetX by remember { mutableFloatStateOf(0f) }
        var offsetY by remember { mutableFloatStateOf(0f) }

        Box(
            Modifier
                .offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
                .background(Color.Blue)
                .size(50.dp)
                .pointerInput(Unit) {
                    detectDragGestures { change, dragAmount ->
                        change.consume()
                        offsetX += dragAmount.x
                        offsetY += dragAmount.y
                    }
                }
        )
    }
}

INSERT ALT TEXT HERE
Rysunek 1. Element interfejsu przeciągany palcem

Przeciągaj komponenty między punktami zakotwiczenia za pomocą klawisza anchoredDraggable

Modyfikator anchoredDraggable umożliwia przeciąganie komponentu między zestawem wstępnie zdefiniowanych pozycji nazywanych punktami zakotwiczenia. Gdy użytkownik puści komponent, zatrzyma się on na najbliższej kotwicy. anchoredDraggable jest przydatny do tworzenia komponentów takich jak niestandardowe plansze dolne, złożone suwaki czy panele wysuwane.

Interfejs API

Interfejs AnchoredDraggable API składa się z tych głównych części:

  • Anchors (DraggableAnchors): kreator, który definiuje mapowanie między stanami niestandardowymi (np. klasą wyliczeniową dla Start, Center, End) a odpowiadającymi im wartościami przesunięcia pikseli.
  • State (AnchoredDraggableState<T>): zmienna stanu, która zarządza bieżącą pozycją komponentu (przesunięciem), kotwicą, do której jest przypisany, i postępem przeciągania. Należy go utworzyć za pomocą rememberSaveable i AnchoredDraggableState.Saver<T>, aby zachować jego stan w przypadku zmian konfiguracji.
  • Modyfikator gestów (Modifier.anchoredDraggable): modyfikator, który wykrywa gesty przeciągania wzdłuż Orientation, aktualizuje stan za pomocą przesunięcia i obsługuje gest szybkiego przesunięcia, aby po zwolnieniu animować komponent do najbliższego punktu zakotwiczenia.

Przykład: tworzenie niestandardowego komponentu SwipeToDismiss

Ten przykład pokazuje, jak utworzyć niestandardowy komponent SwipeToDismiss (biały baner), który użytkownik może przesunąć w lewo, aby go zamknąć, lub przeciągnąć w prawo, aby zobaczyć efekt rozciągnięcia przy przewijaniu poza zakres.

  1. Zdefiniowanie wartości kotwic. Zdefiniuj typ wyliczeniowy reprezentujący dyskretne pozycje, w których można umieścić przeciągany element:

    private enum class AnchoredDraggableSwipeDismissValue { DismissedStart, Resting }

  2. Utwórz instancję i zapisz AnchoredDraggableState. Użyj rememberSaveable z AnchoredDraggableState.Saver(), aby zachować bieżącą pozycję komponentu podczas odtwarzania aktywności i zmian konfiguracji (np. obracania ekranu lub zmiany motywu):

    val state =
        rememberSaveable(saver = AnchoredDraggableState.Saver()) {
            AnchoredDraggableState(initialValue = AnchoredDraggableSwipeDismissValue.Resting)
        }

  3. Zastosuj modyfikatory layout, offset i draggable. Aby komponent można było przeciągać i aby jego punkty zakotwiczenia dostosowywały się do jego rozmiaru, zastosuj do treści, które mają się wizualnie przesuwać, te modyfikatory:

    • Modifier.layout: pozycje kotwic często zależą od rozmiaru komponentu, który nie jest znany podczas początkowego tworzenia kompozycji. Użyj modyfikatora layout, aby zmierzyć komponent, dynamicznie obliczyć mapę DraggableAnchors na podstawie jego szerokości i zaktualizować stan za pomocą state.updateAnchors().

    • Modifier.anchoredDraggable: łączy się ze stanem, wysyła zmiany przeciągania w określonej orientacji i obsługuje gest szybkiego przesunięcia, aby animować komponent do najbliższej kotwicy po zwolnieniu. Dodaj ten modyfikator do komponentu, który powinien reagować na gesty przeciągania, i przekaż mu state i przeciągnij orientation (oraz opcjonalnie overscrollEffect).
    • Modifier.offset: odczytuje przesunięcie z state.requireOffset() wewnątrz funkcji lambda przesunięcia. Odczytanie przesunięcia w tej funkcji lambda gwarantuje, że wartość będzie odczytywana tylko podczas fazy umieszczania, co pozwala uniknąć niepotrzebnych rekompozycji podczas przeciągania. Zastosuj ten modyfikator do treści, które powinny się wizualnie przemieszczać.

    Box(
        modifier = Modifier
            .fillMaxWidth()
            // Read state.requireOffset() inside the offset lambda to defer reading to the placement phase
            .offset { IntOffset(state.requireOffset().roundToInt(), 0) }
            .layout { measurable, constraints ->
                val placeable = measurable.measure(constraints)
                val anchors = DraggableAnchors {
                    AnchoredDraggableSwipeDismissValue.DismissedStart at -placeable.width.toFloat()
                    AnchoredDraggableSwipeDismissValue.Resting at 0f
                }
                state.updateAnchors(anchors)
                layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) }
            }
            // Pass overscrollEffect to anchoredDraggable so deltas beyond the bounds (min and max anchor) are dispatched to overscrollEffect, and render the visual effect with Modifier.overscroll.
            .overscroll(overscrollEffect)
            .anchoredDraggable(
                state,
                Orientation.Horizontal,
                overscrollEffect = overscrollEffect
            )
    ) {
        dismissContent()
    }

  4. [Opcjonalnie] Zintegruj przewijanie poza zakres. Interfejs API overscrollEffect implementuje efekt wizualny, który wyświetla się, gdy krawędzie przewijanego kontenera zostaną osiągnięte przez przewijanie lub szybkie przesunięcie. Możesz przekazać użytkownikowi wizualną informację zwrotną, gdy przeciągnie on element poza zdefiniowane granice zakotwiczenia, za pomocą efektu przewijania poza zakres (zwykle rozciągnięcia lub poświaty).

    1. Dispatch: przekaż instancję overscrollEffect do modyfikatora anchoredDraggable. Spowoduje to wysłanie zmian przeciągania do stanu AnchoredDraggable lub efektu overscroll, w zależności od tego, czy gest znajduje się wewnątrz, czy na zewnątrz granic kotwicy.
    2. Renderowanie: zastosuj Modifier.overscroll z tą samą instancją OverscrollEffect do komponentu, który powinien wizualnie renderować efekt.

Poniższy fragment kodu pokazuje, jak dodać standardowy efekt rozciągania:

val overscrollEffect = rememberOverscrollEffect()
Box(
    modifier.fillMaxWidth()
) {
    Box(Modifier.fillMaxWidth()) {
        background()
    }
    Box(
        modifier = Modifier
            .fillMaxWidth()
            // Read state.requireOffset() inside the offset lambda to defer reading to the placement phase
            .offset { IntOffset(state.requireOffset().roundToInt(), 0) }
            .layout { measurable, constraints ->
                val placeable = measurable.measure(constraints)
                val anchors = DraggableAnchors {
                    AnchoredDraggableSwipeDismissValue.DismissedStart at -placeable.width.toFloat()
                    AnchoredDraggableSwipeDismissValue.Resting at 0f
                }
                state.updateAnchors(anchors)
                layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) }
            }
            // Pass overscrollEffect to anchoredDraggable so deltas beyond the bounds (min and max anchor) are dispatched to overscrollEffect, and render the visual effect with Modifier.overscroll.
            .overscroll(overscrollEffect)
            .anchoredDraggable(
                state,
                Orientation.Horizontal,
                overscrollEffect = overscrollEffect
            )
    ) {
        dismissContent()
    }
}

Wynik

Powyższy fragment kodu powoduje wyświetlenie białego banera z tekstem „Przeciągnij mnie, aby zamknąć!”, który użytkownik może przesunąć w lewo, aby go zamknąć (lub przeciągnąć w prawo, aby zobaczyć efekt rozciągania przy przewijaniu):

Film 2. A custom swipe-to-dismiss component implementation.

Przykład: tworzenie niestandardowego panelu nawigacji sterowanego gestami

Ten przykład pokazuje, jak utworzyć niestandardowy panel nawigacji sterowany gestami. Gdy użytkownik przeciąga palcem w poziomie, ekran na pierwszym planie (ScreenContents) przesuwa się w prawo, zmniejsza się z 1.0f do 0.8f i zaokrągla rogi, aby odsłonić panel nawigacji pod spodem.

  1. Zdefiniuj stany i pozycje kotwicy. W tym przykładzie kotwicami są stany otwarcia i zamknięcia panelu. Zdefiniuj typ wyliczeniowy dla stanów Open i Closed, a następnie zmapuj je na przesunięcia pikseli za pomocą DraggableAnchors:

    enum class DrawerState {
        Open,
        Closed
    }
    
        val anchors = DraggableAnchors {
            DrawerState.Open at drawerWidth
            DrawerState.Closed at 0f
        }
        val state = remember {
            AnchoredDraggableState(
                initialValue = DrawerState.Closed,
                anchors = anchors,
            )
        }

  2. Animowanie elementu anchoredDraggableState po kliknięciu Umieść HomeScreenDrawer na dole Box, a ScreenContents na górze. Panel boczny pozostaje nieruchomy, a górna warstwa przesuwa się i skaluje nad nim.

    Gdy użytkownik wybierze element panelu, zaktualizuj stan aktywnego ekranu i automatycznie zamknij panel za pomocą state.animateTo(DrawerState.Closed):

    @Composable
    private fun AnchoredDraggableDrawerOnClickSample(
        modifier: Modifier = Modifier,
        state: AnchoredDraggableState<DrawerState>,
        coroutineScope: CoroutineScope
    ) {
        var screenState by remember { mutableStateOf(ScreenState.Home) }
    
        Box(modifier = modifier.fillMaxSize()) {
            HomeScreenDrawer(
                selectedScreen = screenState,
                onScreenSelected = { screen ->
                    screenState = screen
                    // Dismiss drawer when a destination is selected
                    coroutineScope.launch { state.animateTo(DrawerState.Closed) }
                }
            )
    
            // ScreenContents(...)
        }
    }

  3. Użyj state.requireOffset(), aby zmienić wiele właściwości wizualnych. Zamiast tylko przesuwać treść, możesz zmienić wiele właściwości za pomocą Modifier.graphicsLayer, aby jednocześnie animować przesunięcie, skalę i promień narożnika na podstawie postępu przeciągania:

    • Użyj state.requireOffset(), aby uzyskać bieżące przesunięcie przeciągania.
    • Użyj lerp() (interpolacji liniowej), aby obliczyć proporcjonalną skalę (od 1f do 0.8f) i promień narożnika (od 0.dp do 32.dp) względem przesunięcia i szerokości panelu.
    • Modifier.graphicsLayer wprowadza zmiany w fazie rysowania, co zapewnia wysoką wydajność animacji bez wywoływania rekompozycji podczas przeciągania.

    @Composable
    private fun AnchoredDraggableDrawerGraphicsLayerSample(
        state: AnchoredDraggableState<DrawerState>,
        drawerWidth: Float,
        selectedScreen: ScreenState,
        onDrawerClicked: () -> Unit
    ) {
        ScreenContents(
            selectedScreen = selectedScreen,
            onDrawerClicked = onDrawerClicked,
            modifier = Modifier
                .graphicsLayer {
                    this.translationX = state.requireOffset()
                    val scale = lerp(1f, 0.8f, state.requireOffset() / drawerWidth)
                    this.scaleX = scale
                    this.scaleY = scale
                    val cornerRadius = lerp(0f, 32f, state.requireOffset() / drawerWidth)
                    this.shape = RoundedCornerShape(cornerRadius.dp)
                    this.clip = true
                }
                .anchoredDraggable(state, Orientation.Horizontal)
        )
    }

  4. Uruchamianie animacji automatycznych za pomocą animateTo. Steruj panelem boczny programowo (np. po kliknięciu ikony menu lub wybraniu elementu panelu bocznego), uruchamiając state.animateTo() w zakresie współprogramu:

    fun toggleDrawerState(
        state: AnchoredDraggableState<DrawerState>,
        coroutineScope: CoroutineScope
    ) {
        coroutineScope.launch {
            if (state.currentValue == DrawerState.Open) {
                state.animateTo(DrawerState.Closed)
            } else {
                state.animateTo(DrawerState.Open)
            }
        }
    }

Wynik

Film 3. Ekran na pierwszym planie (Sen) jest przeciągany w prawo, co powoduje wyświetlenie panelu nawigacji.

Przeciągnij w 2 wymiarach za pomocą Modifier.draggable2D

Użyj modyfikatora draggable2D, aby jednocześnie przenosić poszczególne elementy interfejsu wzdłuż osi X i Y.

Przeciąganie elementu kompozycyjnego

Ten przykład pokazuje najczęstszy przypadek użycia draggable2D – umożliwienie użytkownikowi wybrania elementu interfejsu i umieszczenia go w dowolnym miejscu 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)
        }
    }
}

Film 4. Małe fioletowe pole, które jest przesuwane na szarym tle, co ilustruje bezpośrednie przeciąganie w 2D, gdy element przestaje się poruszać w momencie podniesienia palca przez użytkownika.

Poprzedni fragment kodu zawiera te elementy:

  • Śledzi pozycję pola za pomocą stanu offset.
  • Używa modyfikatora offset, aby przesunąć pozycję komponentu na podstawie zmian przeciągania.
  • Ponieważ nie ma obsługi rzutu, pole przestaje się poruszać w momencie, gdy użytkownik podnosi palec.

Przeciąganie elementu kompozycyjnego podrzędnego na podstawie obszaru przeciągania elementu nadrzędnego

Ten przykład pokazuje, jak za pomocą draggable2D utworzyć dwuwymiarowy obszar wejściowy, w którym pokrętło wyboru jest ograniczone do określonej powierzchni. W przeciwieństwie do przykładu z elementem przeciąganym, który przesuwa sam komponent, ta implementacja używa przesunięć 2D do przesuwania podrzędnego „selektora” funkcji kompozycyjnej po 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)
                    }
                )
        )
    }
}

Film 5. Gradient kolorów z białym okrągłym pokrętłem, które można przeciągać w dowolnym kierunku, pokazujący, jak wartości delta 2D są ograniczane do granic kontenera, aby aktualizować wybrane wartości kolorów.

Powyższy fragment kodu zawiera te elementy:

  • Używa modyfikatora onSizeChanged do przechwytywania rzeczywistych wymiarów kontenera gradientu. Selektor dokładnie wie, gdzie są krawędzie.
  • Wewnątrz elementu graphicsLayer dostosowuje właściwości translationX i translationY, aby selektor pozostawał wyśrodkowany podczas przeciągania.