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

Возможность физически перетаскивать компоненты по экрану является фундаментальной для современных приложений 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) – построитель, который определяет сопоставление между вашими пользовательскими состояниями (например, класс перечисления для Start, Center, End) и соответствующими им значениями смещения пикселей.
  • Состояние (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. Градиент цвета с белым круглым селектором, который можно перетаскивать в любом направлении. Это показывает, как двумерные дельты привязаны к границам контейнера, чтобы обновлять выбранные значения цвета.

В приведенном выше фрагменте кода есть следующие элементы:

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