Estamos añadiendo un micrófono pulsar-para-hablar al teclado flotante de SharkTTY: mantén pulsado para dictar, mira la transcripción en vivo, suelta para parar, toca el resultado para insertarlo en el terminal. La interacción es tan vieja como los walkie-talkies. Su implementación en SwiftUI esconde una trampa que sobrevivió a toda nuestra batería de tests y habría publicado una función incapaz de funcionar: una grabación que se detiene sola un tercio de segundo después de empezar, siempre. Este es el artículo que nos habría gustado encontrar primero.
La API obvia es la API equivocada
SwiftUI parece ofrecer exactamente lo que necesita un botón de mantener-para-grabar:
.onLongPressGesture(minimumDuration: 0.35, maximumDistance: 30) { pressing in
if !pressing { stopRecording() } // ← se dispara a los 0.35 s, NO al levantar el dedo
} perform: {
startRecording()
}
Léelo como lo leemos casi todos — «pressing
me dice si el dedo está apoyado, perform
se dispara cuando empieza la pulsación larga» — y parece perfecto: arranca la
grabadora en perform,
párala cuando pressing
pase a false.
Esto es lo que la firma no te cuenta: un LongPressGesture
triunfa en el instante en que transcurre
minimumDuration — y un
gesto que ha triunfado es un gesto que ha terminado. El closure de
pressing se apoya en
gesture state, y el gesture state se reinicia cuando el gesto termina. Así que
pressing(false) llega a
los ~0.35 segundos, con el dedo aún firmemente sobre el cristal. Tu manejador de
parada corre justo después del de arranque. El dedo levantándose, cuando de verdad
ocurra, es un evento que ya nadie escucha.
En nuestro caso el daño fue especialmente silencioso: la sesión detenida duraba ~0.35 s, nuestra protección de «descartar toques accidentales» tiraba cualquier audio por debajo de 300 ms, y la tira volvía a la normalidad sin decir nada. Sin error, sin crash, sin resultado parcial. Una función que jamás funcionó, vestida con la interfaz de una que sí.
Por qué los tests no lo vieron
Todo lo testeable pasaba. La máquina de estados de la sesión tenía tests unitarios;
la tubería de audio funcionaba; la build estaba en verde. El defecto vive por entero
en la semántica de un reconocedor de gestos en tiempo de ejecución — qué
callback se dispara en qué instante físico — y esa es exactamente la capa a la que
los tests unitarios no llegan y que el simulador vuelve tedioso ejercitar. Lo cazó
la revisión de código, haciéndole una sola pregunta a cada callback: «¿en qué
instante físico corre esto realmente?» Para
pressing, la respuesta
honesta era «no cuando tú crees».
El arreglo: secuenciar un drag tras la pulsación larga
El patrón canónico — y hasta donde sabemos el único fiable — es secuenciar un
DragGesture de distancia
cero después del LongPressGesture.
El triunfo de la pulsación larga cede el gesto combinado a la fase de arrastre, y el
arrastre lo mantiene vivo hasta que el dedo se levanta de verdad:
.onTapGesture { switchToVoiceBoard() } // toque rápido = otra acción
.gesture(
LongPressGesture(minimumDuration: 0.35, maximumDistance: 30)
.sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .local))
.onChanged { value in
// Se dispara repetidamente durante el arrastre; arranca exactamente una vez.
if case .second(true, _) = value, !isRecording { startRecording() }
}
.onEnded { value in
// Solo se alcanza si la pulsación larga triunfó: el levantamiento REAL.
if case .second = value { stopRecording() }
}
)
Tres propiedades lo sostienen. La fase de mantener es inconfundible:
.second(true, _)
significa «la pulsación larga triunfó y el dedo sigue abajo». La suelta es real: el
onEnded del gesto
secuenciado se dispara al levantar el dedo, porque para entonces lo que corre es el
drag. Y los toques quedan limpios: un toque rápido hace fracasar la pulsación larga,
la secuencia nunca llega a .second,
y el onTapGesture
separado se hace cargo.
Dos notas prácticas. Primera:
onChanged se dispara con
cada movimiento del dedo durante el mantenimiento, así que protege el arranque con tu
propia bandera — debe ejecutarse exactamente una vez. Segunda: el sistema puede
cancelar un gesto sin que onEnded
llegue a dispararse (una llamada entrante, la app pasando a segundo plano a mitad del
mantenimiento), así que respalda el gesto con un observador de
scenePhase que cancele
la grabación cuando la app deje el primer plano. Cinturón, y también tirantes.
Deja un aviso para el siguiente
La versión rota es más corta y más legible que la correcta. Ese es el tipo
peligroso de bug: una refactorización futura mirará el gesto secuenciado, pensará
«esto podría ser un one-liner de
onLongPressGesture» y
restaurará el defecto con toda fidelidad. Por eso el comentario sobre el nuestro no
describe qué hace el código: describe por qué lo más simple está mal, en una frase —
un LongPressGesture
triunfa y termina en minimumDuration
con el dedo aún abajo, así que su callback de pressing no puede detectar la suelta.
Si una pieza de código le debe su forma a una trampa, escribe la trampa donde está el
código.
El teclado con pulsar-para-hablar llega con la próxima actualización de SharkTTY para iPhone y iPad. ¿Encontraste tu propia trampa de gestos, o un agujero en esta? El tablón de comentarios está abierto.