Jetpack ComposeMiddleCoding

Как обрабатывать пользовательский ввод и жесты в Compose?

Для кликов используют Modifier.clickable/combinedClickable, для перетаскивания — draggable или AnchoredDraggableState, для пинча/ротации — transformable, для полного контроля — pointerInput с awaitEachGesture и detectDragGestures.

Обработка ввода и жестов в Jetpack Compose

Compose предоставляет несколько уровней API для работы с вводом: от высокоуровневых компонентов (TextField, Button) до низкоуровневых детекторов жестов через Modifier.pointerInput.

Базовые кликабельные модификаторы

// clickable — добавляет ripple и семантику
Box(
    modifier = Modifier
        .clickable(onClick = { /* обработка клика */ })
        .padding(16.dp)
) {
    Text("Нажми меня")
}

// combinedClickable — клик + долгое нажатие + двойной клик
Box(
    modifier = Modifier.combinedClickable(
        onClick = { println("Одиночный клик") },
        onLongClick = { println("Долгое нажатие") },
        onDoubleClick = { println("Двойной клик") }
    )
)

TextField: текстовый ввод

var text by remember { mutableStateOf("") }

OutlinedTextField(
    value = text,
    onValueChange = { text = it },
    label = { Text("Введите текст") },
    keyboardOptions = KeyboardOptions(
        keyboardType = KeyboardType.Email,
        imeAction = ImeAction.Done
    ),
    keyboardActions = KeyboardActions(
        onDone = { /* скрыть клавиатуру */ }
    ),
    singleLine = true
)

Высокоуровневые детекторы жестов

// draggable — только горизонталь или вертикаль
var offsetX by remember { mutableFloatStateOf(0f) }
Box(
    modifier = Modifier
        .offset { IntOffset(offsetX.roundToInt(), 0) }
        .draggable(
            orientation = Orientation.Horizontal,
            state = rememberDraggableState { delta ->
                offsetX += delta
            }
        )
)

// swipeable (устарел в Compose 1.6, используйте AnchoredDraggable)
val anchors = DraggableAnchors {
    SwipeState.Left at -200f
    SwipeState.Center at 0f
    SwipeState.Right at 200f
}
val anchoredState = remember {
    AnchoredDraggableState(
        initialValue = SwipeState.Center,
        anchors = anchors,
        positionalThreshold = { totalDistance -> totalDistance * 0.5f },
        velocityThreshold = { 100f },
        animationSpec = tween()
    )
}

transformable: масштаб, поворот, перемещение

var scale by remember { mutableFloatStateOf(1f) }
var rotation by remember { mutableFloatStateOf(0f) }
var offset by remember { mutableStateOf(Offset.Zero) }

val transformState = rememberTransformableState { zoomChange, panChange, rotationChange ->
    scale = (scale * zoomChange).coerceIn(0.5f, 3f)
    rotation += rotationChange
    offset += panChange
}

Box(
    modifier = Modifier
        .graphicsLayer(
            scaleX = scale,
            scaleY = scale,
            rotationZ = rotation,
            translationX = offset.x,
            translationY = offset.y
        )
        .transformable(state = transformState)
)

pointerInput: низкоуровневый контроль

Box(
    modifier = Modifier.pointerInput(Unit) {
        awaitEachGesture {
            // Ждём первого касания
            val down = awaitFirstDown()
            down.consume()

            // Ждём движения или отпускания
            var pointer = down
            while (true) {
                val event = awaitPointerEvent()
                val change = event.changes.firstOrNull() ?: break

                if (change.pressed) {
                    val dragAmount = change.position - change.previousPosition
                    println("Движение: $dragAmount")
                    change.consume()
                } else {
                    println("Отпущено")
                    break
                }
            }
        }
    }
)

detectTapGestures и detectDragGestures

// Готовые детекторы внутри pointerInput
Modifier.pointerInput(Unit) {
    detectTapGestures(
        onTap = { offset -> println("Tap at $offset") },
        onLongPress = { offset -> println("Long press at $offset") },
        onDoubleTap = { offset -> println("Double tap at $offset") }
    )
}

Modifier.pointerInput(Unit) {
    detectDragGestures(
        onDragStart = { offset -> println("Start: $offset") },
        onDrag = { change, dragAmount ->
            change.consume()
            println("Drag: $dragAmount")
        },
        onDragEnd = { println("End") }
    )
}

Подводные камни

  • forEachGesture устарел с Compose 1.5 — используйте awaitEachGesture, который корректно обрабатывает отмену жеста (gesture cancellation).
  • Modifier.pointerInput с ключом Unit пересоздаёт обработчик только один раз; если передать переменную как ключ, блок пересоздаётся при каждом изменении этой переменной — захватывайте нужные значения через closures.
  • Конфликт жестов: pointerInput на дочернем элементе внутри LazyColumn перехватит вертикальный свайп — нужно пробрасывать событие через PointerEventPass.Initial или использовать nestedScroll.
  • clickable добавляет Indication (ripple) по умолчанию; для пользовательских UI-элементов без ripple используйте indication = null, но тогда добавьте семантику вручную через Modifier.semantics.
  • AnchoredDraggableState требует явного указания anchors — если anchors пусты, состояние не переходит и жест игнорируется без ошибок.
  • transformable не управляет layout автоматически: изменения scale и offset нужно применять через graphicsLayer или offset modifier вручную.
  • На устройствах с gesture navigation система перехватывает свайп от краёв экрана раньше приложения — поэтому горизонтальные swipe-to-go-back жесты нужно регистрировать через WindowCompat.setDecorFitsSystemWindows.

Common mistakes

  • Объяснять «пользовательский ввод и жесты» только как синтаксис и не описывать поведение runtime/compiler.
  • Игнорировать важный риск: Слишком ранний переход на raw pointerInput часто ломает keyboard, TalkBack и hit testing.
  • Давать пример без edge case: отмены, null, recomposition, platform boundary или ошибки.

What the interviewer is testing

  • Формулирует суть темы «пользовательский ввод и жесты» своими словами и связывает ее с кодом.
  • Называет механизм: Высокоуровневые modifiers дают semantics и accessibility, pointerInput нужен для custom gesture logic и coroutine gesture scopes.
  • Видит production-последствие: Слишком ранний переход на raw pointerInput часто ломает keyboard, TalkBack и hit testing.

Sources

Related topics