Ми додаємо до плавучої клавіатури SharkTTY мікрофон «натисни й говори»: утримуйте, щоб диктувати, дивіться живий попередній перегляд розпізнавання, відпустіть, щоб зупинити, торкніться результату, щоб вставити його в термінал. Сама взаємодія — ровесниця рацій. Але в її реалізації на SwiftUI ховається пастка, що пройшла крізь увесь наш набір тестів і мало не відвантажила функцію, яка не могла працювати в принципі — запис, що сам себе зупиняє через третину секунди після старту, щоразу. Це стаття, яку ми самі хотіли б знайти першою.
Очевидний API — неправильний API
SwiftUI нібито пропонує рівно те, що потрібно кнопці «утримуй, щоб записувати»:
.onLongPressGesture(minimumDuration: 0.35, maximumDistance: 30) { pressing in
if !pressing { stopRecording() } // ← спрацьовує на 0,35 с, а НЕ коли палець піднято
} perform: {
startRecording()
}
Прочитайте це так, як читає більшість — «pressing
каже, чи лежить палець, perform
спрацьовує, коли починається довге натискання» — і все виглядає ідеально:
стартуємо запис у perform,
зупиняємо, коли pressing
стане false.
Ось чого сигнатура не каже: LongPressGesture
досягає успіху тієї миті, коли сплине
minimumDuration — а жест,
що досяг успіху, це жест, що завершився. Замикання
pressing спирається на
gesture state, а gesture state скидається, щойно жест закінчується. Тож
pressing(false)
доставляється на ~0,35 секунди — палець ще міцно лежить на склі. Ваш обробник
зупинки виконується одразу після обробника старту. А підняття пальця, коли б воно
насправді не сталося, — подія, якої вже ніхто не слухає.
У нашому випадку шкода була ще й особливо тихою: перерваний запис тривав ~0,35 с, захист «відкинути випадкові дотики» викидав усе аудіо коротше за 300 мс, і стрічка мовчки поверталася до звичайного стану. Ні помилки, ні аварії, ні часткового результату. Функція, що ніколи не працювала, вбрана в інтерфейс тієї, що працює.
Чому тести цього не побачили
Усе, що можна протестувати, проходило. Скінченний автомат сесії запису мав юніт-тести;
аудіоконвеєр працював; збірка була зеленою. Дефект цілком живе в семантиці
розпізнавача жестів під час виконання — який колбек спрацьовує в яку фізичну
мить — а це саме той шар, до якого юніт-тести не дотягуються і який у симуляторі
нудно ганяти. Впіймало його рев'ю коду — одним запитанням до кожного колбека:
«у яку фізичну мить це насправді виконується?» Для
pressing чесна
відповідь була: «не тоді, коли ви думаєте».
Виправлення: приєднати drag після довгого натискання
Канонічний шаблон — і, наскільки нам відомо, єдиний надійний — це
приєднати послідовно (sequence) DragGesture
з нульовою відстанню після LongPressGesture.
Успіх довгого натискання передає комбінований жест фазі перетягування, а
перетягування тримає його живим, аж доки палець справді не підніметься:
.onTapGesture { switchToVoiceBoard() } // швидкий дотик = інша дія
.gesture(
LongPressGesture(minimumDuration: 0.35, maximumDistance: 30)
.sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .local))
.onChanged { value in
// У фазі drag спрацьовує багаторазово; стартувати рівно один раз.
if case .second(true, _) = value, !isRecording { startRecording() }
}
.onEnded { value in
// Досяжно лише коли довге натискання вдалося — СПРАВЖНЄ підняття пальця.
if case .second = value { stopRecording() }
}
)
Це працює завдяки трьом властивостям. Фаза утримання недвозначна:
.second(true, _) означає
«довге натискання вдалося, палець досі внизу». Відпускання справжнє:
onEnded послідовного
жесту спрацьовує при піднятті пальця, бо на той момент виконується саме drag. І
дотики залишаються чистими: швидкий дотик провалює довге натискання, послідовність
ніколи не дістається .second,
і окремий onTapGesture
бере його на себе.
Дві практичні примітки. По-перше,
onChanged спрацьовує на
кожен рух пальця під час утримання, тож захистіть старт власним прапорцем — він має
виконатися рівно один раз. По-друге, система може скасувати жест так, що
onEnded не спрацює
ніколи (вхідний дзвінок, згортання застосунку посеред утримання) — тому підстрахуйте
жест спостерігачем scenePhase,
який скасовує запис, коли застосунок полишає передній план. Пасок безпеки, а до
нього ще й шлейки.
Залиште попередження наступному
Зламана версія коротша й читається краще, ніж правильна. Це небезпечний
різновид вади: майбутній рефакторинг гляне на послідовний жест, подумає «та це ж
один рядок onLongPressGesture»
— і сумлінно відновить дефект. Тому коментар над нашим кодом не описує, що код
робить, — він одним реченням пояснює, чому простіший варіант хибний:
LongPressGesture досягає
успіху й завершується на minimumDuration,
коли палець ще внизу, тож його колбек pressing не здатен виявити відпускання. Якщо
форму коду продиктувала пастка — запишіть пастку поряд із кодом.
Клавіатура з «натисни й говори» вийде з наступним оновленням SharkTTY для iPhone та iPad. Знайшли власну пастку жестів або дірку в цій? Дошка відгуків відкрита.