Listener отдаёт сырые события указателя и ни с кем за них не конкурирует; GestureDetector превращает события указателя в жесты (тап, драг, масштабирование) через распознаватели, которые соревнуются в арене жестов; InkWell обрабатывает тапы и рисует Material-рябь, подсветку наведения и фокуса.
**InkWell**: выбор по умолчанию для нажимаемой поверхности на Material-экране; ему нужен предок Material, на котором рисуется рябь, а фокус с клавиатуры и реакция на наведение достаются бесплатно
**GestureDetector: любой жест сложнее тапа (двойной тап, долгое нажатие, драг, масштабирование) или экран без Material-ряби; сам он ничего не рисует
Listener: когда нужно каждое событие указателя сразу и нельзя отнимать жесты у других: холст для рисования, эффект нажатия, аналитика касаний
Типичная ошибка**: тап на Listener.onPointerUp срабатывает, даже если палец уехал в сторону или список прокрутился: никто не проверяет ни порог смещения (slop), ни арену
FlutterMiddle
Жесты и ввод указателя
Арена жестов, hit-test, drag и scale, свои распознаватели, клавиатура, hover, drag-and-drop
Закрыто вопросов
0 / 20
- 0Повторить
Вопросы
Оба не дают поддереву реагировать на касания. IgnorePointer исключает поддерево из hit testing, поэтому тапы проходят насквозь к тому, что лежит позади; AbsorbPointer попадает под касание как сплошной блок и поглощает тапы, так что виджеты позади тоже ничего не получают.
**IgnorePointer: для слоёв, которые не должны перекрывать содержимое под собой: затухающий скрим, водяной знак, оверлей с конфетти
AbsorbPointer: чтобы заморозить форму на время сохранения, когда тап не должен провалиться к тому, что лежит за ней
Предки тап всё равно получают**: AbsorbPointer отрезает только своё поддерево; GestureDetector над ним указатель по-прежнему получит
Внешний вид не меняется: в отличие от onPressed: null, кнопки внутри не становятся серыми, так что состояние «недоступно» нужно показать самому
Переключайте флаг: меняйте ignoring или absorbing, а не добавляйте и убирайте обёртку: это меняет дерево и сбрасывает состояние поддерева
Оберните каждую строку в Dismissible, удалите элемент из данных в onDismissed и задайте ключ по идентичности элемента, например ValueKey(item.id). Ключ по индексу после сокращения списка переезжает на другой элемент, и Flutter сопоставляет состояние старой строки не с тем элементом.
**Удаляйте элемент в onDismissed: если после анимации он всё ещё в списке, Flutter бросает «A dismissed Dismissible widget is still part of the tree»
Почему не ValueKey(index): после удаления все индексы сдвигаются, и строка ниже наследует элемент и состояние удалённой: раскрытую плитку, введённое значение
confirmDismiss**: возвращает Future<bool?>; true удаляет, false или null возвращает строку на место, поэтому диалог подтверждения ставят сюда
**direction и background**: ограничьте свайп одной стороной и покажите под строкой, что произойдёт; secondaryBackground отвечает за другое направление
Отмена (undo): храните удалённый элемент и его индекс в родителе: State строки уничтожается вместе с ней
Каждый распознаватель под пальцем входит в арену этого указателя; любой может принять или отклонить жест, побеждает последний оставшийся. Тап не забирает жест, пока ещё возможен драг, поэтому обычно побеждает при отпускании, когда арену «подметают» (sweep) и выигрывает первый вошедший участник.
Кто входит: hit test строит путь от самого глубокого виджета вверх, и событие down, разосланное по этому пути, добавляет распознаватели каждого детектора в этом порядке
Ранняя победа: распознаватель принимает жест, как только уверен: вертикальный драг принимается, когда палец сместился на kTouchSlop (по умолчанию 18 логических px) по вертикали, долгое нажатие принимается через kLongPressTimeout (500 мс); все остальные отклоняются
Выбывание: тап отклоняет себя, когда палец уходит слишком далеко; если у указателя единственный распознаватель, арена разрешается сразу после закрытия на down
Sweep: если до отпускания никто не принял жест, побеждает первый участник; поэтому из двух вложенных onTap срабатывает внутренний, а внешний нет
Hold: распознаватель двойного тапа может удержать арену после отпускания и отложить sweep до 300 мс
HitTestBehavior решает, считается ли детектор попавшим под касание там, где его потомок ничего не рисует, и проверяются ли виджеты позади него. При deferToChild по умолчанию пустые промежутки в Row или внутри отступов касания не получают, и тапы по ним теряются.
**deferToChild**: детектор попадает под касание только там, где оно попадает у потомка; значение по умолчанию, когда у GestureDetector есть child
**opaque**: под касание попадает весь прямоугольник, а соседи позади него в Stack не проверяются; обычный способ убрать мёртвые зоны
**translucent**: под касание попадает весь прямоугольник, и проверка продолжается к соседям позади, так что они тоже получают указатель; значение по умолчанию, когда child равен null. Два onTap всё равно встречаются в одной арене, и обычно срабатывает только один
Что получает касание: Text, Icon, Image и Container с color получают его; пустой SizedBox, Padding и место, которое оставляет spaceBetween, его не получают. Container(color: Colors.transparent) создаёт невидимый, но принимающий касания ColoredBox
Лэйаут всё равно ограничивает: никакое поведение не заставит виджет принимать касания за пределами прямоугольника родителя
Тап по звезде печатает star, но примерно через 300 мс после отпускания пальца; тап по отступу не печатает ничего. Задержка возникает из-за внешнего onDoubleTap, а тишина из-за HitTestBehavior.deferToChild по умолчанию.
Звезда: в арену в порядке hit test входят три распознавателя: внутренний тап, внешний тап, внешний двойной тап. До отпускания никто не принимает жест, так что решает sweep
Удержание: распознаватель двойного тапа удерживает арену после первого отпускания и ждёт второй тап kDoubleTapTimeout (300 мс); когда он сдаётся, выполняется sweep и побеждает первый участник, то есть внутренний тап
Отступ: Padding ничего не рисует, а внешний детектор полагается на потомка, поэтому hit test в этом месте до внешнего детектора не доходит и в арену никто не входит
Исправления: behavior: HitTestBehavior.opaque у внешнего детектора делает отступ мишенью; если убрать двойной тап с предка, все тапы внутри карточки снова сработают мгновенно
Только overlay. translucent позволяет hit test идти дальше, поэтому указатель получают оба детектора, но оба являются распознавателями тапа в одной арене, а у арены один победитель: на отпускании sweep выбирает первого участника, то есть оверлей, потому что Stack проверил его первым.
Hit testing: Stack проверяет детей сверху вниз; оверлей без потомка добавляет себя в результат, но сообщает «не задет» (именно это и делает translucent), и Stack переходит к карточке
Арена: входят оба TapGestureRecognizer; распознаватель оверлея идёт первым в пути и выигрывает sweep, распознаватель карточки отклоняется
**С opaque вывод тот же: разница лишь в том, что карточка даже не увидела бы указатель
Чтобы сработали оба**: наблюдайте, а не соревнуйтесь: оверлей Listener(behavior: HitTestBehavior.translucent, onPointerUp: ...) видит каждое касание, не входя в арену; или просто вызовите оба обработчика из одного onTap
Не используйте хак: распознаватель, который переопределяет rejectGesture и вызывает в нём acceptGesture, заставляет сработать оба, но ломает гарантию «у одного жеста один владелец»
Каждый build создаёт новый TapGestureRecognizer, и никто его не уничтожает, потому что TextSpan не владеет своим распознавателем. После каждой перестройки остаётся один неосвобождённый распознаватель; нужно держать один в State, обновлять его onTap в build и освобождать в dispose().
Почему его никто не освобождает: TextSpan представляет собой неизменяемое описание, и в его документации сказано, что dispose() должен вызвать код, который создал распознаватель; RenderParagraph лишь передаёт ему события нажатия
Во что это обходится: родитель, который перестраивается каждый кадр, например анимирует отступ под клавиатуру, создаёт распознаватель на каждый кадр; в виджет-тесте 20 перестроек создали 20 распознавателей и не освободили ни одного. Вред наносят сами неосвобождённые объекты, которые ловит leak_tracker, плюс одна аллокация на перестройку; именно dispose() освобождает указатели, которые распознаватель ещё отслеживает
Как работает исправление: State живёт, пока виджет смонтирован, поэтому один распознаватель обслуживает все перестройки; присваивание onTap в build подхватывает самый свежий колбэк родителя
Как поймать: leak_tracker: добавьте dev-зависимость leak_tracker_flutter_testing, вызовите LeakTesting.enable() в test/flutter_test_config.dart, затем передайте testWidgets параметр experimentalLeakTesting: LeakTesting.settings.withTrackedAll(); распознаватели появятся как notDisposed при завершении тестового файла. В качестве альтернативы без распознавателя вообще подойдёт WidgetSpan с TextButton внутри
Каждый одиночный тап по этой строке теперь срабатывает примерно на 300 мс позже: арена больше не может разрешиться на отпускании пальца, потому что распознаватель двойного тапа удерживает её kDoubleTapTimeout в ожидании второго тапа.
Только тап: разрешается sweep на отпускании, без дополнительной задержки
Тап плюс двойной тап: распознаватель двойного тапа удерживает арену после первого отпускания, и одиночный тап ждёт все 300 мс
Тап плюс долгое нажатие ничего не стоят: быстрый тап отклоняет долгое нажатие на отпускании; долгое нажатие забирает жест только через 500 мс, и тогда onTap уже не вызывается
Спрячьте ожидание: onTapDown по-прежнему срабатывает в пределах 100 мс, так что рисуйте нажатое состояние там, а в onTap оставьте только само действие
Решение на уровне дизайна: не заставляйте каждый тап платить 300 мс за редкий жест; дайте второстепенному действию свою кнопку или долгое нажатие
Оба драга находятся в одной арене, и каждый принимает жест, как только палец пройдёт kTouchSlop (по умолчанию 18 логических px) вдоль своей оси, поэтому на диагонали побеждает ось, порог по которой пересечён первым: крутой свайп прокручивает список, пологий двигает строку. Это гонка, а не иерархия.
Вложенность не помогает: правило «внутренний первым» действует только на sweep при отпускании, а драг решается задолго до этого
**onHorizontalDragUpdate, а не onPanUpdate**: pan нужно kPanSlop (36 px) движения в любом направлении, поэтому список выигрывает больше диагональных свайпов, строка трогается с опозданием, а вы получаете вертикальные смещения, которые вам не нужны
С одной осью внутри той же оси всё иначе: горизонтальная карусель внутри горизонтального PageView видит те же движения, внутренняя идёт первой в пути и забирает жест первой, так что внешняя не сдвинется, пока вы явно этого не решите
Не пишите это вручную: Dismissible уже берёт на себя драг, порог, confirmDismiss и анимацию схлопывания и участвует в той же гонке осей
Две ошибки. GestureDetector срабатывает assert, когда заданы и pan-, и scale-колбэки, потому что scale является надмножеством pan; а ScaleUpdateDetails.scale отсчитывается от начала жеста, а не от предыдущего кадра, поэтому умножение на него при каждом обновлении накапливается.
Assert: «Having both a pan gesture recognizer and a scale gesture recognizer is redundant; scale is a superset of pan.» Оба забирали бы одни и те же указатели. Оставьте scale-колбэки, а смещение берите из focalPointDelta
Накопление: пока пальцы двигаются при увеличении в 2 раза, d.scale остаётся 2.0 в каждом обновлении, и _scale *= d.scale удваивается при каждом из них. Сохраните масштаб в onScaleStart и присваивайте base * d.scale
Один палец тоже работает: scale-распознаватель отслеживает и одиночный палец, с pointerCount == 1 и scale == 1.0, так что те же колбэки двигают карту
Сначала проверьте готовое: InteractiveViewer уже умеет pan, pinch, инерцию и границы
Draggable<T> несёт типизированный data и показывает feedback под пальцем; DragTarget<T> принимает его в onAcceptWithDetails. Feedback строится в Overlay, поэтому ничего не наследует от экрана вокруг карточки: ни Material, ни провайдеров уровня экрана.
Оберните feedback: в Material с явной шириной; без него текст получает жёлтое отладочное подчёркивание. Всё, что нужно карточке, передавайте по значению
**Внутри прокручиваемого списка берите LongPressDraggable**: обычный Draggable забирает указатель после небольшого смещения в любом направлении и перебивает прокрутку списка; вариант с долгим нажатием ждёт, а другой вариант даёт affinity: Axis.horizontal
**childWhenDragging: что остаётся на месте оригинала во время перетаскивания
Типизированные данные**: Draggable<Task> подсвечивает только DragTarget<Task>; onWillAcceptWithDetails фильтрует, а список кандидатов в builder управляет подсветкой при наведении. Старые onWillAccept и onAccept объявлены deprecated
Меняйте состояние в одном месте: в onAcceptWithDetails; onDragEnd у источника срабатывает при любом отпускании, принятом или нет
Перестановка элементов списка: для этого есть ReorderableListView, не собирайте её из этих двух виджетов
d.globalPosition даёт положение пальца на экране, а Positioned ждёт левый верхний угол карточки в координатах самого Stack, поэтому карточка прыгает на смещение Stack (app bar, отступы) плюс точку, за которую её взяли. Двигать её нужно на d.delta.
Сразу два несоответствия: globalPosition отсчитывается от угла экрана, а left и top отсчитываются от угла Stack; к тому же это точка под пальцем, а не угол карточки. Под AppBar высотой 56 px карточка прыгает вниз на высоту AppBar плюс смещение захвата (56 + 30 px для карточки 60 px, взятой за центр)
**Почему работает delta**: это шаг с прошлого обновления в собственных координатах детектора, поэтому, прибавляя его, вы держите взятую точку под пальцем, где бы ни находилась доска, даже внутри отмасштабированного InteractiveViewer
Когда нужна абсолютная точка: например, для привязки к сетке, переводите координаты через RenderBox.globalToLocal(d.globalPosition) доски (или повесьте детектор на саму доску и берите d.localPosition) и вычитайте смещение захвата, сохранённое в onPanStart. Если детектор на карточке, d.localPosition отсчитывается от карточки и остаётся около точки захвата, так что позицию на доске он не даёт
Почему баг проскакивает: на полноэкранной доске без app bar ошибается только смещение захвата, и в быстром демо всё выглядит почти правильно
Как поймать: виджет-тест: начать жест на карточке, сдвинуть его на Offset(100, 0) и проверить, что tester.getTopLeft изменился ровно на столько же
Velocity.zero. Распознаватель драга сообщает скорость, только если отпускание было взмахом (fling); у пальца, который остановился перед отрывом, нет недавнего движения, поэтому скорость обнуляется, и код, который решает только по скорости, не срабатывает.
Откуда берётся число: VelocityTracker аппроксимирует последние ~100 мс движений и считает палец остановившимся, если за последние 40 мс движений не было
Пороги: если меньше kMinFlingVelocity (50 px/с) или меньше kTouchSlop (по умолчанию 18 px) пути внутри этого окна, получаете ноль; если выше, значение ограничено kMaxFlingVelocity (8000 px/с)
Решайте по скорости или по расстоянию: взмах решает своим направлением, иначе решает то, как далеко уехала строка. Dismissible использует то же правило
Единицы: делите пиксели в секунду на длину пути, прежде чем отдавать их AnimationController с диапазоном 0..1; с сырыми пикселями анимация завершится за один кадр
**primaryVelocity**: задан для горизонтального и вертикального драга; в onPanEnd он null, потому что у pan нет основной оси
В InteractiveViewer нет зума по двойному тапу, поэтому его TransformationController анимируют вручную; прыжок возникает из-за двух источников записи в одну Matrix4 (вашей анимации и жеста пользователя). Останавливайте анимацию в onInteractionStart, чтобы палец всегда побеждал.
**TransformationController**: это ValueNotifier<Matrix4>: подпишитесь на него для индикатора масштаба, присвойте значение для сброса, а toScene(offset) переводит точку вьюпорта в координаты содержимого
Зум вокруг точки тапа: запомните точку в onDoubleTapDown (onDoubleTap не получает деталей) и постройте матрицу, которая оставляет эту точку содержимого под пальцем
Во время жестов читайте, а не пишите: пока пользователь взаимодействует, матрицу пишет сам InteractiveViewer; текущий масштаб читайте через value.getMaxScaleOnAxis()
**Внутри PageView**: держите panEnabled: false, пока изображение не увеличено, иначе pan и свайп страницы борются за один палец
Цена: распознаватель двойного тапа теперь задерживает на 300 мс каждый одиночный тап по изображению, например тап для скрытия тулбара
Shortcuts сопоставляет сочетание клавиш с Intent, Actions сопоставляет тип интента с кодом, который нужно выполнить, а поиск начинается от виджета в фокусе, поэтому без фокуса внутри поддерева горячая клавиша не срабатывает. Для пары локальных клавиш хватит CallbackShortcuts с Focus.
Сначала фокус: обычная причина: добавьте внутрь Focus(autofocus: true) или FocusableActionDetector и помните, что текстовое поле в фокусе оставляет себе пробел, стрелки и другие клавиши редактирования
Зачем интенты: один Ctrl+S может сохранять разное в разных панелях, потому что выполняется ближайший включённый Action над виджетом в фокусе; кнопка на тулбаре переиспользует его через Actions.maybeInvoke(context, const SaveIntent()) из контекста ниже Actions
**CallbackShortcuts: привязывает сочетание прямо к колбэку: без интентов и экшенов, подходит для одного экрана
Cmd или Ctrl**: привяжите SingleActivator(LogicalKeyboardKey.keyS, meta: true) для macOS и control: true для остальных платформ
Модификатор во время клика: выделение диапазона через Shift-клик не горячая клавиша; читайте HardwareKeyboard.instance.isShiftPressed прямо в обработчике тапа
Сырые события клавиш: Focus(onKeyEvent:) с KeyEvent, возвращая KeyEventResult.handled; RawKeyboard и RawKeyEvent объявлены deprecated
Пройдите путь указателя шаг за шагом и заставьте фреймворк печатать каждый шаг: дошёл ли hit test до кнопки, перехватил ли событие кто-то выше, проиграл ли её распознаватель в арене. Если кнопки нет в пути hit test, искать проблему в жестах бессмысленно.
**Включите флаги в main()**: debugPaintPointersEnabled подсвечивает каждый прямоугольник, до которого дошёл hit test, debugPrintHitTestResults печатает путь, debugPrintGestureArenaDiagnostics показывает, кто вошёл в арену и кто победил
За пределами прямоугольника родителя: отрицательный Positioned или вылезающий потомок рисуются нормально, но hit testing следует лэйауту, и выступающая часть указателей не получает
Что-то невидимое сверху: Container(color: Colors.transparent) превращается в получающий касания ColoredBox, а Opacity или AnimatedOpacity с нулём всё равно участвуют в hit test; используйте IgnorePointer, Visibility или Offstage
Нечему принять касание: детектор, потомок которого ничего не рисует, при deferToChild по умолчанию касаний не получает
Перехвачено выше: предок AbsorbPointer или IgnorePointer, который остался включённым из-за забытого флага
Проиграно в арене: драг предка забрал указатель первым; onDoubleTap у предка делает тап опоздавшим на 300 мс, а не мёртвым
Кнопка выключена: при onPressed: null ряби нет вовсе, и это выглядит точь-в-точь как мёртвая кнопка
RawGestureDetector принимает фабрики распознавателей, поэтому стандартные можно настроить шире, чем позволяет GestureDetector (supportedDevices, allowedButtonsFilter, своя duration долгого нажатия), или подключить свой; сам GestureDetector служит тонкой обёрткой над ним.
Свой распознаватель: наследуйтесь от OneSequenceGestureRecognizer: addAllowedPointer начинает отслеживать каждый указатель, handleEvent видит каждое событие, а resolve(GestureDisposition.accepted) выигрывает арену для всех отслеживаемых указателей
Проигрывайте правильно: прекращайте отслеживание в rejectGesture и вызывайте resolve(GestureDisposition.rejected), как только ваш жест стал невозможен; распознаватель, который никогда не разрешается, оставляет решение sweep и может выиграть тапы, которые ему не нужны
Фабрики, а не экземпляры: GestureRecognizerFactoryWithHandlers сохраняет по одному распознавателю на тип между перестроениями и заново применяет только обработчики, поэтому ключом в map должен быть тип распознавателя
Обычно это признак костыля (code smell): сначала проверьте HitTestBehavior, драг по одной оси или готовый виджет; переопределение rejectGesture с вызовом acceptGesture является хаком, ломающим принцип «один владелец на жест»
Не всё это проходит через арену жестов: щелчок колеса приходит как PointerScrollEvent в канале pointer signal, pinch на тачпаде приходит как PointerPanZoom*, а перо приходит как указатель вида stylus с давлением; холст только для касаний теряет зум колесом, pinch на тачпаде, наведение и давление пера.
Колесо: распознаватели его не видят; читайте его в Listener(onPointerSignal:) и забирайте через GestureBinding.instance.pointerSignalResolver.register(event, handler), чтобы холст и прокручиваемый виджет позади не отреагировали на один и тот же щелчок оба
Тачпад: там, где ОС сообщает о жестах тачпада (например, macOS), события pan-zoom потребляют ScaleGestureRecognizer и прокручиваемые виджеты, так что onScaleUpdate уже получает pinch; коду на сыром Listener нужен onPointerPanZoomUpdate
**InteractiveViewer умеет и то и другое**: колесо масштабирует, прокрутка на тачпаде сдвигает, а trackpadScrollCausesScale: true заставляет прокрутку на тачпаде масштабировать
Стилус и наведение: проверяйте в event.kind значение stylus (или invertedStylus, то есть ластик на обратном конце) и нормализуйте pressure через pressureMin и pressureMax; сенсорные экраны обычно сообщают фиксированное или ненадёжное значение. Движения мыши без нажатой кнопки приходят как PointerHoverEvent; вход, выход и курсор даёт MouseRegion, а драг мышью начинается примерно после 1 px
**Ветвитесь по PointerDeviceKind, а не по платформе**: к iPad подключают мыши и перья, а у многих ноутбуков сенсорный экран
Скринридер забирает сенсорный экран себе: ваши распознаватели больше не видят свайпов пользователя, а платформа вместо этого отправляет SemanticsAction узлу семантики в фокусе. Элемент, который понимает только события указателя, остаётся недоступным.
Почему тапы всё ещё работают: GestureDetector сам публикует семантические действия тапа и долгого нажатия для onTap и onLongPress; колбэки драга превращаются в лучшем случае в действия прокрутки, а Listener не публикует ничего
Описывайте контрол, а не жест: значение, которое тянут пальцем, описывается как слайдер: Semantics(slider: true, value: ..., onIncrease: ..., onDecrease: ...) позволяет обоим скринридерам менять его своими жестами
Действия, доступные только жестом: у свайпа в архив нет стандартного действия, поэтому выставьте его в customSemanticsActions; VoiceOver показывает такие действия в роторе (пункт «Действия»), а TalkBack показывает их в меню действий
Сообщайте результат: если после действия фокус не переходит ни на что видимое, выведите статус в текст, помеченный Semantics(liveRegion: true), чтобы его зачитали
Правило в основе: у каждой возможности, доступной только жестом, должна быть фокусируемая альтернатива; проверяйте через showSemanticsDebugger: true, а затем на реальном устройстве