Hover con mouse — feedback visual
Cuando el usuario usa un mouse o trackpad, espera feedback visual al pasar el cursor sobre elementos interactivos. Los componentes de Material3 ya incluyen hover states. Para componentes custom:
@Composable
fun ItemConHover(onClick: () -> Unit, content: @Composable () -> Unit) {
val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState()
Box(
modifier = Modifier
.hoverable(interactionSource = interactionSource)
.clickable(
interactionSource = interactionSource,
indication = ripple(),
onClick = onClick
)
.background(
color = if (isHovered)
MaterialTheme.colorScheme.surfaceVariant
else
Color.Transparent,
shape = RoundedCornerShape(8.dp)
)
.padding(8.dp)
) {
content()
}
}
// Los componentes estándar de Material3 (Button, Card, ListItem) ya tienen hover
// Solo necesitás custom hover para componentes propios
Click derecho y ContextMenu
// En Compose — ContextMenuArea para click derecho con mouse
@Composable
fun ArchivoConMenu(archivo: Archivo, onRenombrar: () -> Unit, onEliminar: () -> Unit) {
ContextMenuArea(
items = {
listOf(
ContextMenuItem("Renombrar") { onRenombrar() },
ContextMenuItem("Eliminar") { onEliminar() },
ContextMenuItem("Propiedades") { /* abrir diálogo */ }
)
}
) {
// El contenido sobre el que se puede hacer click derecho
Row(modifier = Modifier.padding(8.dp)) {
Icon(Icons.Default.InsertDriveFile, contentDescription = null)
Text(archivo.nombre)
}
}
}
// Para Views (XML) — el sistema maneja showContextMenu automáticamente:
// view.setOnLongClickListener { view.showContextMenu(); true }
// view.setOnCreateContextMenuListener { menu, _, _ ->
// menu.add("Renombrar").setOnMenuItemClickListener { true }
// menu.add("Eliminar").setOnMenuItemClickListener { true }
// }
Drag & drop
// Drag & drop entre elementos — API de Compose:
@Composable
fun ItemArrastrable(item: Item) {
var isDragging by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.dragAndDropSource {
detectTapGestures(
onLongPress = {
startTransfer(
DragAndDropTransferData(
ClipData.newPlainText("item_id", item.id.toString())
)
)
}
)
}
.alpha(if (isDragging) 0.5f else 1f)
) {
Text(item.nombre)
}
}
@Composable
fun ZonaDeposito(onDeposito: (String) -> Unit) {
var isDragOver by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.background(
if (isDragOver) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant
)
.dragAndDropTarget(
shouldStartDragAndDrop = { true },
target = object : DragAndDropTarget {
override fun onEntered(event: DragAndDropEvent) { isDragOver = true }
override fun onExited(event: DragAndDropEvent) { isDragOver = false }
override fun onDrop(event: DragAndDropEvent): Boolean {
val itemId = event.toAndroidDragEvent()
.clipData.getItemAt(0).text.toString()
onDeposito(itemId)
isDragOver = false
return true
}
}
)
) {
Text("Soltar aquí", modifier = Modifier.align(Alignment.Center))
}
}
Navegación con teclado — Tab y flechas
// La navegación con Tab entre elementos interactivos funciona automáticamente
// en componentes estándar de Compose (Button, TextField, Checkbox, etc.)
// Para controlar el orden de focus manualmente:
@Composable
fun Formulario() {
val focusManager = LocalFocusManager.current
val (nombreFocus, emailFocus, passwordFocus) = FocusRequester.createRefs()
TextField(
value = nombre, onValueChange = { nombre = it },
label = { Text("Nombre") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { emailFocus.requestFocus() }),
modifier = Modifier.focusRequester(nombreFocus).focusOrder { next = emailFocus }
)
TextField(
value = email, onValueChange = { email = it },
label = { Text("Email") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
modifier = Modifier.focusRequester(emailFocus)
.focusOrder { previous = nombreFocus; next = passwordFocus }
)
TextField(
value = password, onValueChange = { password = it },
label = { Text("Contraseña") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
modifier = Modifier.focusRequester(passwordFocus)
.focusOrder { previous = emailFocus }
)
}
// Para capturar teclas específicas:
Box(
modifier = Modifier.onKeyEvent { event ->
if (event.key == Key.Escape && event.type == KeyEventType.KeyDown) {
onCerrar()
true // consumido
} else false // no consumido
}
) { /* contenido */ }
Atajos de teclado
// Registrar atajos de teclado globales en la Activity:
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
return when {
// Ctrl+S → guardar
event.isCtrlPressed && keyCode == KeyEvent.KEYCODE_S -> {
viewModel.guardar()
true
}
// Ctrl+Z → deshacer
event.isCtrlPressed && keyCode == KeyEvent.KEYCODE_Z -> {
viewModel.deshacer()
true
}
// Ctrl+Shift+Z → rehacer
event.isCtrlPressed && event.isShiftPressed && keyCode == KeyEvent.KEYCODE_Z -> {
viewModel.rehacer()
true
}
else -> super.onKeyDown(keyCode, event)
}
}
// En Compose — usando KeyboardShortcut para documentarlos:
// (API de Android 14+)
// Los atajos registrados aparecen en el panel de atajos del sistema
// cuando el usuario mantiene presionado Meta/Command
// Atajos que siempre deben funcionar en ChromeOS y tablets con teclado:
// Ctrl+C / Ctrl+X / Ctrl+V → copiar/cortar/pegar (el sistema los maneja automáticamente)
// Ctrl+A → seleccionar todo (implementar en campos de texto)
// Ctrl+Z / Ctrl+Y → deshacer/rehacer (implementar si la app tiene historial)
// Escape → cerrar diálogo / cancelar acción
Stylus en tablets
// Compose — capturar eventos de stylus con presión y posición:
@Composable
fun LienzoDibujo() {
val trazos = remember { mutableStateListOf<Trazo>() }
var trazoActual by remember { mutableStateOf<Trazo?>(null) }
Canvas(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val evento = awaitPointerEvent()
evento.changes.forEach { cambio ->
// Detectar si viene de stylus:
val esStylusODedo = cambio.type == PointerType.Stylus
|| cambio.type == PointerType.Touch
if (esStylusODedo) {
val posicion = cambio.position
// pressure: 0f (sin presión) a 1f (presión máxima)
val presion = cambio.pressure
when {
cambio.pressed && trazoActual == null -> {
// Inicio del trazo
trazoActual = Trazo(
puntos = mutableListOf(posicion),
grosor = presion * 20f
)
}
cambio.pressed -> {
// Continuación del trazo
trazoActual?.puntos?.add(posicion)
}
!cambio.pressed && trazoActual != null -> {
// Fin del trazo
trazos.add(trazoActual!!)
trazoActual = null
}
}
cambio.consume()
}
}
}
}
}
) {
// Dibujar todos los trazos completados
trazos.forEach { trazo -> dibujarTrazo(trazo) }
// Dibujar el trazo en progreso
trazoActual?.let { dibujarTrazo(it) }
}
}