Chuyển đến nội dung chính

Why your tap doesn't register: hit testing and the gesture arena

There is a specific kind of Flutter bug that eats an afternoon. The widget is on screen. The onTap is wired. You add a print statement and it never fires. Nothing in the console, no error, no warning — the tap simply does not exist.

Every instance of this is one of two causes. Either the pointer never reached your detector during hit testing, or it reached it and the detector lost the gesture arena to something else. They are different problems with different fixes, and they are distinguishable.

How a pointer finds a widget

When a finger goes down, the framework walks the render tree from the root, asking each render object whether the point is inside it. The result is a hit test path: an ordered list from the deepest hit object up to the root. Pointer events are then dispatched along that path.

Three rules explain most surprises:

  1. Hit testing is geometric, and it uses the render object’s box. Not its visual appearance. A Container with no colour and no child has zero size, so nothing can hit it.
  2. The deepest hit wins first, and events travel up from there.
  3. A child outside its parent’s bounds is not hit, even if it is painted. This is the one that catches everybody.

That third rule deserves an example, because it produces a widget you can see and cannot tap:

SizedBox(
  height: 40,
  child: Stack(
    clipBehavior: Clip.none,   // the badge is painted outside the 40px box
    children: [
      const Icon(Icons.notifications),
      Positioned(
        top: -12,
        child: GestureDetector(
          onTap: _dismiss,       // never fires: outside the parent's box
          child: const _Badge(),
        ),
      ),
    ],
  ),
)

Clip.none lets the badge paint outside the parent, but hit testing still stops at the parent’s box. The fix is to make the parent big enough to contain what you want tappable — painting and hit testing are separate systems, and only one of them respects Clip.none.

HitTestBehavior: the three-way switch

GestureDetector takes a behavior, and its default depends on whether it has a child. This is the single most common fix for “my tap does nothing”.

ValueMeaning
deferToChildHit only where a child is hit. Default when there is a child.
opaqueHit anywhere in the detector’s box; stops the test from continuing to widgets behind. Default when there is no child.
translucentHit anywhere in the box, and also let widgets behind be hit.

The classic failure:

// Taps in the empty space around the text do nothing.
GestureDetector(
  onTap: _select,
  child: Container(
    height: 80,
    alignment: Alignment.centerLeft,
    child: const Text('Tap anywhere on this row'),
  ),
)

The Container has no colour, so it does not participate in hit testing itself; deferToChild means only the Text’s actual glyph box is tappable. Two fixes, and the first is better:

GestureDetector(
  behavior: HitTestBehavior.opaque,   // the whole 80px row is tappable
  onTap: _select,
  child: /* ... */,
)

or give the Container a colour — even Colors.transparent participates, which is why color: Colors.transparent “magically fixes” tap targets and why that trick confuses people who have not read this.

translucent is for the case where two stacked things both want the event: a background that dismisses a panel while the panel itself still receives taps.

The gesture arena

Now the second cause. Suppose the pointer did reach your detector. Multiple recognizers along the hit test path may all be interested in the same pointer — a tap, a horizontal drag, a vertical drag, a long press. They cannot all win.

Flutter resolves this with an arena. Every interested recognizer enters, and then:

  • A recognizer declares victory when it is certain (a drag that has moved past kTouchSlop).
  • A recognizer gives up when it is certain it is not the gesture (a tap whose pointer moved too far).
  • If everyone is still undecided when the pointer lifts, the first entrant in the arena wins by default.

The practical consequences:

Tap loses to drag once movement starts. This is why a tappable row inside a scrollable list still scrolls: the vertical drag recognizer of the scroll view wins as soon as the finger moves, and the tap gives up. That is correct behaviour, and it is why you should not fight it.

A GestureDetector inside another usually means the inner one wins for the same gesture type, because it is deeper. If you want both to react, they must be different gestures — or you need RawGestureDetector.

Two competing drags in the same axis is a design problem, not a code problem. A horizontally-swipeable card inside a horizontally-scrolling list will always be ambiguous to the user as well as to the framework.

Debugging: turn the events on

Flutter has a global flag that prints every pointer event and every arena decision:

import 'package:flutter/gestures.dart';

void main() {
  debugPrintGestureArenaDiagnostics = true;
  debugPrintHitTestResults = true;   // also available
  runApp(const MyApp());
}

debugPrintGestureArenaDiagnostics shows each recognizer entering, and which one is “accepted”. If your recognizer never appears, the problem is hit testing — go back to HitTestBehavior and bounds. If it appears and is rejected, the problem is arena competition — go and look at what beat it.

This single flag converts the afternoon-long version of this bug into a two-minute one.

Listener: below the gesture system

GestureDetector sits on top of a lower layer. Listener gives you raw pointer events with no arena, no disambiguation, and no semantics:

Listener(
  onPointerDown: (e) => _trace('down at ${e.position}'),
  onPointerMove: (e) => _trace('move ${e.delta}'),
  onPointerUp: (e) => _trace('up'),
  behavior: HitTestBehavior.translucent,
  child: child,
)

Use it to observe — a heat map, a debug overlay, a “reset the idle timer on any touch” wrapper. Do not use it to implement tapping: you would be re-implementing slop tolerance, arena participation, accessibility semantics and platform feedback, all of which GestureDetector already has.

RawGestureDetector and custom recognizers

Between the two sits RawGestureDetector, which lets you supply recognizers directly — including subclasses that change arena behaviour.

The canonical use is “let this child win a vertical drag even though a scroll view is above it”:

RawGestureDetector(
  gestures: {
    _EagerVerticalDrag: GestureRecognizerFactoryWithHandlers<_EagerVerticalDrag>(
      () => _EagerVerticalDrag(),
      (instance) => instance
        ..onUpdate = _onDragUpdate
        ..onEnd = _onDragEnd,
    ),
  },
  child: child,
);

class _EagerVerticalDrag extends VerticalDragGestureRecognizer {
  @override
  void rejectGesture(int pointer) {
    // Claim the pointer instead of yielding to the ancestor scrollable.
    acceptGesture(pointer);
  }
}

This is a sharp tool. Overriding rejectGesture means your recognizer refuses to lose, which is exactly right for a draggable sheet inside a scroll view and exactly wrong almost everywhere else. Reach for it only after debugPrintGestureArenaDiagnostics has shown you which recognizer you are actually competing with.

IgnorePointer and AbsorbPointer

Two widgets that intentionally break hit testing, and are constantly confused:

  • IgnorePointer — the subtree is invisible to hit testing. Events pass through to whatever is behind.
  • AbsorbPointer — the subtree is hit, but events stop there. Nothing behind gets them, and nothing inside reacts.

Use IgnorePointer for a decorative overlay you want to be able to tap through. Use AbsorbPointer for a “form is submitting, block everything” scrim. Choosing the wrong one produces either a dead UI or a UI where a disabled screen is still interactive underneath.

A diagnostic order

  1. Does the widget have non-zero size? Check with the widget inspector, not by looking.
  2. Is it inside its parent’s bounds? Clip.none and negative Positioned offsets are the usual suspects.
  3. Is there an IgnorePointer, AbsorbPointer, or a transparent-but-opaque ancestor in the way?
  4. Set behavior: HitTestBehavior.opaque. If it now works, that was it.
  5. Turn on debugPrintGestureArenaDiagnostics. If your recognizer never enters, it is still hit testing. If it enters and loses, find the winner.

FAQ

Why does my tap work in the middle of a row but not at the edges?

deferToChild with a child smaller than the row. Set behavior: HitTestBehavior.opaque on the detector.

Why does a button inside a ListView scroll instead of pressing?

The arena is working as designed: any movement past the touch slop makes the drag win. A tap without movement still fires. This is the platform-correct behaviour on both Android and iOS.

Should I use InkWell or GestureDetector?

InkWell for anything that should look like a Material control — it adds the ripple, focus and hover states, and correct semantics. GestureDetector for gestures without visual feedback. InkWell needs a Material ancestor to paint its ink.

Can two widgets both handle the same tap?

Not for the same gesture in the arena; the winner takes it. Stack a Listener for observation, or use translucent behaviour so both are on the hit test path for different gesture types.

Why does onTapDown fire but not onTap?

onTapDown fires optimistically; onTap only fires if the recognizer wins the arena. Seeing one without the other is the clearest possible sign that something else won.


The hit testing rules, HitTestBehavior semantics, arena resolution and debug flags described here are documented in the Flutter gesture guide and API references linked above. The diagnostic ordering, the framing of the two root causes, and the caution around overriding rejectGesture are my own judgement from debugging gesture problems this way. Recognizer behaviour can change between releases — verify against the SDK you use.


Originally published on FlutterCook. Read the latest version there — that copy is the one kept up to date.

Nhận xét

Bài đăng phổ biến từ blog này

5 concepts every Flutter dev should know

  Phụ lục: State management architecture Testing IDE Shortcuts Platform channel Maintaining a project Tôi đã làm việc với Flagship trong một thời gian dài, và đây là những điều mà tôi phát hiện ra là điều cần phải có đối với bất kỳ nhà phát triển Flagship nào, về tổng thể nó sẽ khiến bạn trở thành một nhà phát triển Flagship giỏi trong thời gian dài. 1. State management architecture Đây là một trong những chủ đề quan trọng nhất trong cộng đồng thiết bị rung, nó khá quan trọng nếu bạn muốn duy trì một dự án rung kích thước trung bình hoặc lớn. Nó sẽ giúp tạo một dự án suôn sẻ và thêm các tính năng mới một cách hoàn hảo.  2. Testing Đây là một chủ đề duy nhất mà tôi không hiểu tại sao nó lại quan trọng trước đó trong sự nghiệp của tôi, nhưng khi tôi tiến lên trong sự nghiệp của mình và có kinh nghiệm với nhiều dự án và vấn đề xảy ra trong môi trường sản xuất. Tôi đã nhận ra một cách khó khăn, tại sao điều này lại quan trọng như vậy. Nếu bạn vẫn muốn có thêm lý do để cân nhắc thử...

Thiết kế giao diện với DotNetBar (Phần 1)

Đây là phiên bản DotNetBar hỗ trợ C# và Visual Basic https://www.dropbox.com/s/wx80jpvgnlrmtux/DotNetBar.rar  , phiên bản này hỗ trợ giao diện Metro cực kỳ “dễ thương” Các bạn load về và cài đặt, khi cài đặt xong sẽ có source code mẫu của tất cả các control. Để sử dụng được các control của DotNetBar các bạn nhớ add item vào controls box. Thiết kế giao diện với DotNetBar, giao diện sẽ rất đẹp. Link các video hướng dẫn chi tiết cách sử dụng và coding: http://www.devcomponents.com/dotnetbar/movies.aspx Hiện tại DotNetBar có rất nhiều công cụ cực mạnh, trong đó có 3 công cụ dưới đây: DotNetBar for Windows Forms Requires with Visual Studio 2003, 2005, 2008, 2010 or 2012.   DotNetBar for WPF Requires with Visual Studio 2010 or 2012 and Windows Presentation Foundation.   DotNetBar for Silverlight Requires with Visual Studio 2010 or 2012 and Silverlight. Dưới đây là một số hình ảnh về các control trong DotnetBar.   Metro User Interface  controls with Metro Tiles, toolba...

Announcing Flutter 2

  Phụ lục: Flutter on the web Flutter 2 on desktops, foldables, and embedded devices The growing Flutter ecosystem Dart: The secret sauce behind Flutter Flutter 2: Available now Hôm nay, chúng tôi sẽ công bố Flutter 2: một bản nâng cấp lớn cho Flutter cho phép các nhà phát triển tạo các ứng dụng đẹp, nhanh chóng và di động cho bất kỳ nền tảng nào. Với Flutter 2, bạn có thể sử dụng cùng một cơ sở mã để gửi các ứng dụng gốc cho năm hệ điều hành: IOS, Android, Windows, macOS và Linux; cũng như trải nghiệm web nhắm mục tiêu các trình duyệt như Chrome, Firefox, Safari hoặc Edge. Flutter thậm chí có thể được nhúng vào ô tô, TV và thiết bị gia dụng thông minh, mang đến trải nghiệm di động và lan tỏa nhất cho thế giới điện toán xung quanh. Mục tiêu của chúng tôi là thay đổi cơ bản cách các nhà phát triển nghĩ về việc xây dựng ứng dụng, bắt đầu không phải với nền tảng bạn đang nhắm mục tiêu mà là với trải nghiệm bạn muốn tạo. Flutter cho phép bạn tạo ra những trải nghiệm tuyệt đẹp trong đó ...