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 zamykany przez przesunięcie palcem 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 ukrywają złożoność surowych zdarzeń dotknięcia. Zamiast ręcznie obliczać współrzędne dotyku i zarządzać cyklem ż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 przeciągania.

Wybieranie odpowiedniego interfejsu API przeciągania

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

Przeciąganie wzdłuż jednej osi za pomocą modyfikatora draggable

Modyfikator draggable to punkt wejścia wysokiego poziomu do gestów przeciągania w jednej orientacji. Zgłasza on odległość przeciągania w pikselach.

Warto pamiętać, że ten modyfikator jest podobny do modyfikatora scrollable, ponieważ wykrywa tylko gest. Musisz utrzymywać stan i reprezentować go na ekranie np. przesuwają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 gestów 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ąganie komponentów między punktami zaczepienia za pomocą modyfikatora anchoredDraggable

Modyfikator anchoredDraggable umożliwia przeciąganie komponentu między zestawem predefiniowanych pozycji nazywanych punktami zaczepienia. Gdy użytkownik zwolni gest przeciągania, komponent zatrzyma się w najbliższym punkcie zaczepienia. Modyfikator anchoredDraggable jest przydatny do tworzenia komponentów takich jak niestandardowe plansze dolne, złożone suwaki czy panele szuflad.

Powierzchnia interfejsu API

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

  • Punkty zaczepienia (DraggableAnchors): narzędzie do tworzenia, które definiuje mapowanie między stanami niestandardowymi (np. klasą wyliczeniową Start, Center, End) a odpowiadającymi im wartościami przesunięcia w pikselach.
  • Stan (AnchoredDraggableState<T>): zmienna stanu, która zarządza bieżącą pozycją komponentu (przesunięciem), punktem zaczepienia, w którym się zatrzymał, oraz jego postępem przeciągania. Aby zachować stan podczas zmian konfiguracji należy go utworzyć za pomocą rememberSaveable i AnchoredDraggableState.Saver<T>.
  • Modyfikator gestu (Modifier.anchoredDraggable): modyfikator, który wykrywa gesty przeciągania wzdłuż Orientation, aktualizuje stan za pomocą delty przeciągania i obsługuje gest szybkiego przesunięcia, aby animować komponent do najbliższego punktu zaczepienia po zwolnieniu.

Przykład: tworzenie niestandardowego komponentu SwipeToDismiss

Ten przykład pokazuje, jak utworzyć niestandardowy komponent SwipeToDismiss (biały baner), który użytkownik może zamknąć, przesuwając palcem w lewo, lub przeciągnąć w prawo, aby zobaczyć efekt rozciągania overscroll.

  1. Zdefiniuj wartości punktów zaczepienia. Zdefiniuj wyliczenie reprezentujące dyskretne pozycje, w których może się zatrzymać przeciągana treść:

    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 ponownego tworzenia 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 zaczepienia dostosowywały się do jego rozmiaru, zastosuj te modyfikatory do treści, która ma się wizualnie przesuwać:

    • Modifier.layout: pozycje punktów zaczepienia często zależą od rozmiaru komponentu, który nie jest znany podczas początkowej 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 delty przeciągania w określonej orientacji i obsługuje gest szybkiego przesunięcia, aby animować komponent do najbliższego punktu zaczepienia po zwolnieniu. Dodaj ten modyfikator do komponentu, który ma reagować na gesty przeciągania, i przekaż mu state oraz orientation przeciągania (opcjonalnie overscrollEffect).
    • Modifier.offset: odczytuje przesunięcie z state.requireOffset() w lambdzie przesunięcia. Odczytywanie przesunięcia w tej lambdzie zapewnia, że wartość jest odczytywana tylko podczas fazy umieszczania, co pozwala uniknąć niepotrzebnych rekompozycji podczas przeciągania. Zastosuj ten modyfikator do treści, która ma się wizualnie przesuwać.

    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 efekt rozciągania. Interfejs API overscrollEffect implementuje efekt wizualny, który jest wyświetlany, gdy podczas przewijania lub szybkiego przesunięcia osiągnięto krawędzie kontenera przewijania. Możesz zapewnić wizualne informacje zwrotne, gdy użytkownik przeciągnie poza zdefiniowane granice punktu zaczepienia, za pomocą efektu overscroll (zwykle rozciągania lub poświaty).

    1. Wysyłanie: przekaż instancję overscrollEffect do anchoredDraggable modyfikatora. Wysyła to delty przeciągania do stanu AnchoredDraggable lub do efektu overscroll, w zależności od tego, czy gest znajduje się w granicach punktu zaczepienia, czy poza nimi.
    2. Renderowanie: zastosuj Modifier.overscroll z tą samą OverscrollEffect instancją do komponentu, który ma wizualnie renderować efekt.

Ten 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

Poprzedni fragment kodu powoduje wyświetlenie białego banera z tekstem „Przeciągnij, aby zamknąć!”, który użytkownik może zamknąć, przesuwając palcem w lewo (lub przeciągnąć w prawo, aby zobaczyć efekt rozciągania overscroll):

Film 2. Implementacja niestandardowego komponentu zamykanego przez przesunięcie palcem.

Przykład: tworzenie niestandardowego panelu nawigacji sterowanego gestami

Ten przykład pokazuje, jak utworzyć niestandardową szufladę nawigacyjną sterowaną gestami. Gdy użytkownik przeciąga w poziomie, ekran pierwszego planu (ScreenContents) przesuwa się w prawo, skaluje w dół z 1.0f do 0.8f i zaokrągla rogi, aby odsłonić panel nawigacji.

  1. Zdefiniuj stany i pozycje punktów zaczepienia. W tym przykładzie punkty zaczepienia to stany otwarcia i zamknięcia szuflady. Zdefiniuj wyliczenie dla stanów Open i Closed i zmapuj je na przesunięcia w pikselach 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. Animuj anchoredDraggableState po kliknięciu elementu. Umieść HomeScreenDrawer u dołu Box, a ScreenContents na górze. Szuflada pozostaje nieruchoma, a górna warstwa przesuwa się i skaluje nad nią.

    Gdy użytkownik wybierze element szuflady, zaktualizuj stan aktywnego ekranu i automatycznie zamknij szufladę 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() do zmiany wielu właściwości wizualnych. Zamiast tylko przesuwać treść, możesz zmieniać wiele właściwości za pomocą Modifier.graphicsLayer, aby jednocześnie animować przesunięcie, skalę i promień zaokrąglenia 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ę (1f do 0.8f) i promień zaokrąglenia (0.dp do 32.dp) względem przesunięcia i szerokości szuflady.
    • Modyfikator Modifier.graphicsLayer wykonuje zmiany w fazie rysowania, co zapewnia animacje o wysokiej wydajności 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. Wywołuj animacje programowe za pomocą animateTo. Programowo steruj szufladą (np. po kliknięciu ikony menu lub wybraniu elementu szuflady), uruchamiając state.animateTo() w zakresie korutyny:

    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 pierwszego planu (Sleep) jest przeciągany w prawo, co powoduje wyświetlenie panelu nawigacji.

Przeciąganie w 2 wymiarach za pomocą modyfikatora Modifier.draggable2D

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

Przeciąganie elementu kompozycyjnego

Ten przykład pokazuje najczęstszy przypadek użycia modyfikatora draggable2D – umożliwienie użytkownikowi wybrania elementu interfejsu i zmiany jego położenia 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 jest zmieniane na szarym tle, co pokazuje bezpośrednie przeciąganie w 2 wymiarach, w którym element przestaje się poruszać, gdy tylko użytkownik podniesie palec.

Poprzedni fragment kodu zawiera te elementy:

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

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

Ten przykład pokazuje, jak użyć modyfikatora draggable2D do utworzenia obszaru wprowadzania 2D, w którym pokrętło selektora jest ograniczone do określonej powierzchni. W przeciwieństwie do przykładu z elementem, który można przeciągać, gdzie przesuwa się sam komponent, ta implementacja używa delty 2D do przesuwania elementu kompozycyjnego podrzędnego „selektora” 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 selektora, które można przeciągać w dowolnym kierunku. Pokazuje to, jak delty 2D są przycinane do granic kontenera, aby aktualizować wybrane wartości kolorów.

Poprzedni fragment kodu zawiera te elementy:

  • Używa modyfikatora onSizeChanged, aby przechwycić rzeczywiste wymiary kontenera gradientu. Selektor dokładnie wie, gdzie są krawędzie.
  • Wewnątrz graphicsLayer dostosowuje translationX i translationY, aby selektor pozostawał wyśrodkowany podczas przeciągania.