Назад до девблогу Звіт про пастку

LongPressGesture у SwiftUI завершується до підняття пальця

Ми додаємо до плавучої клавіатури 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. Знайшли власну пастку жестів або дірку в цій? Дошка відгуків відкрита.