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.