Volver al blog de desarrollo Informe de trampa

El LongPressGesture de SwiftUI termina antes de que levantes el dedo

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.