Перетаскивание функций

Возможность физически перетаскивать компоненты по экрану является фундаментальной для современных приложений Android. Создаете ли вы собственный ползунок, компонент, который можно смахнуть, или интерактивный холст, Jetpack Compose предоставляет полный набор API для простой обработки событий перетаскивания.

API перетаскивания Compose абстрагируют сложность необработанных событий касания. Вместо того чтобы вручную вычислять координаты касаний и управлять жизненными циклами жестов с помощью pointerInput, вы можете использовать встроенные модификаторы, которые напрямую связаны с системами управления состоянием и анимацией Compose.

На этой странице рассказывается, как и когда использовать каждый из API перетаскивания.

Как выбрать правильный API перетаскивания

Для реализации перетаскиваемого контента можно использовать несколько API.

  • Используйте стандартные компоненты Material (например, SwipeToDismissBox или выдвижные панели навигации), если вам нужно стандартное поведение Material 3 при проведении пальцем по экрану или перетаскивании (например, удаление письма, архивирование задачи или открытие бокового меню или нижних листов). Они предоставляют встроенное поведение, не требуя пользовательского состояния или управления макетом.
  • Когда выбирать. Modifier.draggable Вам нужны только необработанные дельты перетаскивания (например, при перетаскивании пользовательского ползунка). Подробнее о том, как перетаскивать объекты по одной оси с помощью draggable…
  • Выбирайте Modifier.anchoredDraggable, когда вы создаете интерактивные компоненты, которые должны переключаться между двумя или более дискретными положениями (например, нижний экран, панель навигации или действие "Проведите, чтобы показать"). Подробнее о том, как перетаскивать компоненты между опорными точками с помощью инструмента anchoredDraggable…

Перетаскивание по одной оси с помощью клавиши draggable

Модификатор draggable – это высокоуровневая точка входа для жестов перетаскивания в одном направлении. Он сообщает расстояние перетаскивания в пикселях.

Обратите внимание, что этот модификатор похож на scrollable, поскольку он также только распознает жест. Вам нужно сохранить состояние и отобразить его на экране, например переместив элемент с помощью модификатора 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!"
    )
}

Если вам нужно контролировать весь процесс перетаскивания, используйте детектор жестов перетаскивания с помощью модификатора 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
Рисунок 1. Элемент интерфейса, который перетаскивают пальцем.

Перетаскивайте компоненты между опорными точками с помощью клавиши anchoredDraggable.

Модификатор anchoredDraggable позволяет перетаскивать компонент между заданными позициями, которые называются точками привязки. Когда пользователь отпускает жест перетаскивания, компонент останавливается у ближайшей точки привязки. anchoredDraggable полезен для создания таких компонентов, как настраиваемые нижние экраны, сложные ползунки или панели ящиков.

API surface

AnchoredDraggable API состоит из следующих основных частей:

  • Якоря (DraggableAnchors) – построитель, который определяет сопоставление между вашими пользовательскими состояниями (например, класс enum для Start, Center, End) и соответствующими им значениями смещения пикселей.
  • State (AnchoredDraggableState<T>) – хранилище состояния, которое управляет текущей позицией компонента (смещением), привязкой, к которой он прикреплен, и ходом перетаскивания. Его следует создать с помощью rememberSaveable и AnchoredDraggableState.Saver<T>, чтобы сохранить его состояние при изменении конфигурации.
  • Модификатор жестов (Modifier.anchoredDraggable) – модификатор, который обнаруживает жесты перетаскивания вдоль Orientation, обновляет состояние с учетом дельты перетаскивания и обрабатывает жест смахивания, чтобы анимировать компонент до ближайшего якоря при отпускании.

Пример: создание специального компонента SwipeToDismiss

В этом примере показано, как создать собственный компонент SwipeToDismiss (белый баннер), который можно закрыть, проведя пальцем влево, или перетащить вправо, чтобы увидеть эффект растяжения при достижении конца прокрутки.

  1. Определите опорные значения. Определите перечисление, представляющее дискретные позиции, в которых может находиться перетаскиваемый контент:

    private enum class AnchoredDraggableSwipeDismissValue { DismissedStart, Resting }

  2. Создайте экземпляр AnchoredDraggableState и сохраните его. Используйте rememberSaveable с AnchoredDraggableState.Saver(), чтобы сохранить текущую позицию компонента при повторном создании действия и изменении конфигурации (например, повороте экрана или изменении темы):

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

  3. Примените модификаторы layout, offset и draggable. Чтобы сделать компонент перетаскиваемым и обеспечить адаптацию его опорных точек к его размеру, примените следующие модификаторы к контенту, который должен визуально перемещаться:

    • Modifier.layout. Позиции привязки часто зависят от размера компонента, который неизвестен во время начальной композиции. Используйте модификатор layout, чтобы измерить компонент, динамически рассчитать карту DraggableAnchors на основе ее ширины и обновить состояние с помощью state.updateAnchors().

    • Modifier.anchoredDraggable: подключается к состоянию, отправляет дельты перетаскивания в указанной ориентации и обрабатывает жест пролистывания, чтобы анимировать компонент до ближайшей точки привязки после отпускания. Добавьте этот модификатор к компоненту, который должен реагировать на жесты перетаскивания, и передайте ему state и перетащите orientation (и, при необходимости, overscrollEffect).
    • Modifier.offset: считывает смещение из state.requireOffset() внутри лямбда-выражения смещения. Чтение смещения в этой лямбде гарантирует, что значение будет считываться только на этапе размещения, что позволит избежать ненужных рекомпозиций во время перетаскивания. Примените этот модификатор к контенту, который должен визуально перемещаться.

    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. [Необязательно] Интегрируйте эффект достижения конца прокрутки. API overscrollEffect реализует визуальный эффект, который появляется, когда при прокрутке или быстром перемещении достигаются края контейнера. Вы можете добавить визуальный эффект, который будет появляться, когда пользователь прокручивает страницу дальше заданных границ (обычно это растяжение или свечение).

    1. Отправка. Передайте экземпляр overscrollEffect в модификатор anchoredDraggable. Это отправляет дельты перетаскивания либо в состояние AnchoredDraggable, либо в эффект достижения конца прокрутки, в зависимости от того, находится ли жест внутри или за пределами границ привязки.
    2. Отрисовка. Примените Modifier.overscroll с тем же экземпляром OverscrollEffect к компоненту, который должен визуально отрисовать эффект.

Во фрагменте кода ниже показано, как добавить стандартный эффект растяжения:

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

Результат

Приведенный выше фрагмент кода создает белый баннер с текстом "Drag me to dismiss!" (Перетащите, чтобы закрыть). Пользователь может провести по нему влево, чтобы закрыть его, или вправо, чтобы увидеть эффект растяжения при достижении конца прокрутки.

Видео 2 Специальная реализация компонента для закрытия смахиванием.

Пример. Создание собственной панели навигации, управляемой жестами

В этом примере показано, как создать собственную панель навигации, управляемую жестами. Когда пользователь проводит пальцем по горизонтали, передний экран (ScreenContents) сдвигается вправо, уменьшается с 1.0f до 0.8f, а его углы закругляются, чтобы открыть панель навигации.

  1. Задайте состояния и позиции фиксированных объявлений. В этом примере якорями являются открытое и закрытое состояния панели. Определите перечисление для состояний Open и Closed и сопоставьте их со смещениями пикселей с помощью 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. Анимация anchoredDraggableState при нажатии на объект. Поместите HomeScreenDrawer внизу Box, а ScreenContents – вверху. Панель остается неподвижной, а верхний слой перемещается и масштабируется над ней.

    Когда пользователь выбирает элемент меню, обновите состояние активного экрана и автоматически закройте меню с помощью 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. Используйте state.requireOffset(), чтобы изменить несколько визуальных свойств. Вместо того чтобы просто смещать контент, вы можете изменить несколько свойств с помощью Modifier.graphicsLayer, чтобы одновременно анимировать перевод, масштабирование и радиус скругления углов в зависимости от прогресса перетаскивания:

    • а state.requireOffset() – чтобы получить текущее смещение перетаскивания.
    • Используйте lerp() (линейную интерполяцию), чтобы рассчитать пропорциональный масштаб (1f–0.8f) и радиус скругления углов (0.dp–32.dp) относительно смещения и ширины панели.
    • Modifier.graphicsLayer выполняет изменения на этапе отрисовки, обеспечивая высокую производительность анимации без повторной композиции во время перетаскивания.

    @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. Запускайте алгоритмические анимации с помощью animateTo. Управляйте боковым меню программно (например, при нажатии на значок меню или выборе элемента в меню), запустив state.animateTo() в области сопрограммы:

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

Результат

Видео 3. Экран на переднем плане (Sleep) сдвинут вправо, чтобы показать панель навигации под ним.

Перетаскивание в двух измерениях с помощью Modifier.draggable2D

Используйте модификатор draggable2D, чтобы перемещать отдельные элементы интерфейса одновременно по осям X и Y.

Перетащите составной элемент

В этом примере показан самый распространенный вариант использования draggable2D – возможность перетаскивать элемент интерфейса в любое место родительского контейнера.

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

Видео 4. Небольшое фиолетовое поле перемещается на сером фоне, демонстрируя прямое 2D-перетаскивание, при котором элемент перестает двигаться в тот момент, когда пользователь поднимает палец.

Приведенный выше фрагмент кода включает следующие элементы:

  • Отслеживает положение поля с помощью состояния offset.
  • Использует модификатор offset для сдвига положения компонента на основе дельт перетаскивания.
  • Поскольку поддержка прокрутки отсутствует, поле перестает двигаться в тот момент, когда пользователь поднимает палец.

Перетаскивание дочерней composable-функции на основе области перетаскивания родительской функции

В этом примере показано, как использовать draggable2D для создания двумерной области ввода, в которой селектор ограничен определенной поверхностью. В отличие от примера с перетаскиваемым элементом, в котором перемещается сам компонент, в этой реализации используются двумерные дельты для перемещения дочернего селектора по палитре:

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

Видео 5. Градиент цвета с белым круглым селектором, который можно перетаскивать в любом направлении. Это демонстрирует, как 2D-дельты ограничиваются по границам контейнера, чтобы обновить выбранные значения цвета.

Приведенный выше фрагмент кода содержит следующие элементы:

  • Он использует модификатор onSizeChanged, чтобы получить фактические размеры контейнера градиента. Селектор точно определяет границы.
  • Внутри graphicsLayer он настраивает translationX и translationY, чтобы селектор оставался по центру во время перетаскивания.