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.