Retour au journal de dev Rapport de piège

Le LongPressGesture de SwiftUI se termine avant le lever du doigt

Nous ajoutons un micro « appuyez pour parler » au clavier flottant de SharkTTY : maintenez pour dicter, suivez la transcription en direct, relâchez pour arrêter, touchez le résultat pour l'insérer dans le terminal. L'interaction est aussi vieille que les talkies-walkies. Son implémentation SwiftUI cache un piège qui a traversé toute notre suite de tests et aurait expédié une fonctionnalité incapable de fonctionner — un enregistrement qui s'arrête tout seul un tiers de seconde après avoir commencé, à chaque fois. Voici l'article que nous aurions aimé trouver en premier.

L'API évidente est la mauvaise API

SwiftUI semble offrir exactement ce qu'il faut à un bouton maintenir-pour-enregistrer :

.onLongPressGesture(minimumDuration: 0.35, maximumDistance: 30) { pressing in
    if !pressing { stopRecording() }   // ← se déclenche à 0,35 s, PAS au lever du doigt
} perform: {
    startRecording()
}

Lisez-le comme la plupart d'entre nous — « pressing me dit si le doigt est posé, perform se déclenche quand l'appui long commence » — et tout paraît parfait : démarrer l'enregistreur dans perform, l'arrêter quand pressing passe à false.

Voici ce que la signature ne dit pas : un LongPressGesture réussit à l'instant où minimumDuration s'écoule — et un geste qui a réussi est un geste qui a pris fin. La closure pressing repose sur du gesture state, et le gesture state se réinitialise quand le geste se termine. pressing(false) est donc livré vers 0,35 seconde, le doigt encore fermement posé sur la vitre. Votre gestion d'arrêt s'exécute juste après celle de démarrage. Le doigt qui se lève, quel que soit le moment réel, est un événement que plus personne n'écoute.

Chez nous, les dégâts étaient d'autant plus discrets : la session interrompue durait ~0,35 s, notre garde « éliminer les touchers accidentels » jetait tout audio sous 300 ms, et la bande revenait à la normale sans un mot. Pas d'erreur, pas de crash, pas de résultat partiel. Une fonctionnalité qui n'a jamais fonctionné, habillée de l'interface d'une qui marche.

Pourquoi les tests ne l'ont pas vu

Tout ce qui était testable passait. La machine à états de la session avait ses tests unitaires ; la chaîne audio fonctionnait ; la build était verte. Le défaut habite entièrement la sémantique d'un reconnaisseur de gestes à l'exécution — quel callback part à quel instant physique — précisément la couche que les tests unitaires n'atteignent pas et que le simulateur rend pénible à exercer. C'est la revue de code qui l'a attrapé, en posant une seule question à chaque callback : « à quel instant physique ce code s'exécute-t-il vraiment ? » Pour pressing, la réponse honnête était : « pas quand vous croyez ».

Le correctif : séquencer un drag après l'appui long

Le motif canonique — et à notre connaissance le seul fiable — consiste à séquencer un DragGesture de distance nulle après le LongPressGesture. La réussite de l'appui long passe le geste combiné à la phase de glissement, et le glissement le maintient vivant jusqu'au lever réel du doigt :

.onTapGesture { switchToVoiceBoard() }   // tap rapide = une autre action
.gesture(
    LongPressGesture(minimumDuration: 0.35, maximumDistance: 30)
        .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .local))
        .onChanged { value in
            // Se déclenche en boucle pendant la phase de drag ; démarrer exactement une fois.
            if case .second(true, _) = value, !isRecording { startRecording() }
        }
        .onEnded { value in
            // Atteint seulement si l'appui long a réussi — le VRAI lever de doigt.
            if case .second = value { stopRecording() }
        }
)

Trois propriétés font tenir l'ensemble. La phase de maintien est sans ambiguïté : .second(true, _) signifie « l'appui long a réussi et le doigt est encore posé ». Le relâchement est réel : le onEnded du geste séquencé part au lever du doigt, puisque c'est le drag qui tourne à ce moment-là. Et les taps restent propres : un tap rapide fait échouer l'appui long, la séquence n'atteint jamais .second, et le onTapGesture séparé s'en charge.

Deux notes pratiques. D'abord, onChanged part à chaque mouvement du doigt pendant le maintien : protégez le démarrage avec votre propre drapeau — il doit s'exécuter exactement une fois. Ensuite, le système peut annuler un geste sans que onEnded ne parte jamais (appel entrant, passage en arrière-plan en plein maintien) : doublez le geste d'un observateur de scenePhase qui annule l'enregistrement quand l'app quitte le premier plan. Ceinture, puis bretelles.

Laissez un avertissement au suivant

La version cassée est plus courte et plus lisible que la bonne. C'est la catégorie dangereuse de bug : un refactoring futur regardera le geste séquencé, pensera « ça tiendrait en une ligne d'onLongPressGesture », et restaurera fidèlement le défaut. Le commentaire au-dessus du nôtre ne décrit donc pas ce que fait le code — il décrit pourquoi la version plus simple est fausse, en une phrase : un LongPressGesture réussit et se termine à minimumDuration, doigt encore posé, si bien que son callback pressing ne peut pas détecter le relâchement. Quand la forme d'un code est dictée par un piège, écrivez le piège à côté du code.

Le clavier avec appui-pour-parler arrive dans la prochaine mise à jour de SharkTTY pour iPhone et iPad. Vous avez déniché votre propre piège de geste, ou une faille dans celui-ci ? Le tableau de retours est ouvert.