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

Vì sao cú chạm của bạn không ăn: hit testing và đấu trường cử chỉ

Có một loại bug Flutter rất đặc trưng, đủ để ngốn cả buổi chiều. Widget đang hiển thị trên màn hình. onTap đã được nối. Bạn thêm một lệnh print và nó không bao giờ chạy. Không có gì trong console, không lỗi, không cảnh báo — cú chạm đơn giản là không tồn tại.

Mọi trường hợp như vậy đều thuộc một trong hai nguyên nhân. Hoặc con trỏ chưa từng tới được detector của bạn trong lúc hit testing, hoặc nó đã tới nơi và detector thua đấu trường cử chỉ trước một đối thủ khác. Đó là hai vấn đề khác nhau với hai cách sửa khác nhau, và chúng phân biệt được.

Con trỏ tìm đến một widget như thế nào

Khi ngón tay chạm xuống, framework duyệt cây render từ gốc, hỏi từng render object xem điểm đó có nằm trong nó không. Kết quả là một đường hit test: một danh sách có thứ tự từ đối tượng sâu nhất trúng đích lên tới gốc. Sự kiện con trỏ sau đó được phát dọc theo đường này.

Ba quy tắc giải thích phần lớn những bất ngờ:

  1. Hit testing là hình học, và nó dùng cái hộp của render object. Không phải dùng hình dạng nhìn thấy được. Một Container không màu và không con thì có kích thước bằng không, nên không gì trúng nó được.
  2. Cái sâu nhất trúng trước, và sự kiện đi ngược lên từ đó.
  3. Một con nằm ngoài khung của cha thì không bị trúng, kể cả khi nó vẫn được vẽ ra. Đây là điều bắt hụt tất cả mọi người.

Quy tắc thứ ba xứng đáng có một ví dụ, vì nó tạo ra một widget bạn nhìn thấy mà không chạm được:

SizedBox(
  height: 40,
  child: Stack(
    clipBehavior: Clip.none,   // huy hiệu được vẽ ra ngoài khung 40px
    children: [
      const Icon(Icons.notifications),
      Positioned(
        top: -12,
        child: GestureDetector(
          onTap: _dismiss,       // không bao giờ chạy: nằm ngoài khung của cha
          child: const _Badge(),
        ),
      ),
    ],
  ),
)

Clip.none cho phép huy hiệu được vẽ ra ngoài khung cha, nhưng hit testing vẫn dừng ở khung của cha. Cách sửa là làm cho cha đủ lớn để chứa phần bạn muốn chạm được — vẽ và hit testing là hai hệ thống riêng biệt, và chỉ một trong hai tôn trọng Clip.none.

HitTestBehavior: công tắc ba nấc

GestureDetector nhận một behavior, và giá trị mặc định của nó phụ thuộc vào việc có con hay không. Đây là cách sửa phổ biến nhất cho tình huống “cú chạm của tôi không làm gì cả”.

Giá trịÝ nghĩa
deferToChildChỉ trúng ở nơi có con bị trúng. Mặc định khi có con.
opaqueTrúng ở bất kỳ đâu trong khung của detector; chặn phép thử đi tiếp xuống các widget phía sau. Mặc định khi không có con.
translucentTrúng ở bất kỳ đâu trong khung, và đồng thời cho các widget phía sau cũng trúng.

Kiểu hỏng kinh điển:

// Chạm vào khoảng trống quanh chữ thì không có gì xảy ra.
GestureDetector(
  onTap: _select,
  child: Container(
    height: 80,
    alignment: Alignment.centerLeft,
    child: const Text('Chạm bất cứ đâu trên dòng này'),
  ),
)

Container không có màu, nên bản thân nó không tham gia hit testing; deferToChild nghĩa là chỉ đúng cái hộp chữ của Text mới chạm được. Có hai cách sửa, và cách đầu tốt hơn:

GestureDetector(
  behavior: HitTestBehavior.opaque,   // cả dòng 80px đều chạm được
  onTap: _select,
  child: /* ... */,
)

hoặc cấp cho Container một màu — kể cả Colors.transparent cũng tham gia, và đó là lý do color: Colors.transparent “sửa được một cách thần kỳ” các vùng chạm, cũng là lý do mẹo đó làm bối rối những ai chưa đọc phần này.

translucent dành cho trường hợp hai thứ chồng nhau đều muốn nhận sự kiện: một nền dùng để đóng panel trong khi chính panel đó vẫn nhận cú chạm.

Đấu trường cử chỉ

Giờ tới nguyên nhân thứ hai. Giả sử con trỏ đã tới được detector của bạn. Nhiều recognizer dọc theo đường hit test có thể cùng quan tâm tới một con trỏ — chạm, kéo ngang, kéo dọc, nhấn giữ. Không thể tất cả cùng thắng.

Flutter giải quyết bằng một đấu trường. Mọi recognizer quan tâm đều bước vào, rồi:

  • Một recognizer tuyên bố thắng khi nó đã chắc chắn (một cú kéo đã vượt kTouchSlop).
  • Một recognizer bỏ cuộc khi nó chắc chắn đây không phải cử chỉ của mình (một cú chạm mà con trỏ đã di chuyển quá xa).
  • Nếu tới lúc nhấc tay mà mọi bên vẫn chưa ngã ngũ, người vào đấu trường đầu tiên thắng theo mặc định.

Hệ quả thực dụng:

Chạm thua kéo một khi có chuyển động. Đó là lý do một dòng chạm được nằm trong danh sách cuộn vẫn cuộn: recognizer kéo dọc của scroll view thắng ngay khi ngón tay dịch chuyển, và cú chạm bỏ cuộc. Đó là hành vi đúng, và là lý do bạn không nên chống lại nó.

Một GestureDetector nằm trong một cái khác thì cái bên trong thường thắng với cùng loại cử chỉ, vì nó sâu hơn. Nếu bạn muốn cả hai cùng phản ứng, chúng phải là hai cử chỉ khác nhau — hoặc bạn cần RawGestureDetector.

Hai cú kéo cạnh tranh trên cùng một trục là vấn đề thiết kế, không phải vấn đề code. Một thẻ vuốt ngang được nằm trong một danh sách cuộn ngang sẽ luôn mơ hồ với cả người dùng lẫn framework.

Gỡ lỗi: bật sự kiện lên

Flutter có một cờ toàn cục in ra mọi sự kiện con trỏ và mọi quyết định của đấu trường:

import 'package:flutter/gestures.dart';

void main() {
  debugPrintGestureArenaDiagnostics = true;
  debugPrintHitTestResults = true;   // cũng có sẵn
  runApp(const MyApp());
}

debugPrintGestureArenaDiagnostics cho thấy từng recognizer bước vào, và cái nào được “chấp nhận”. Nếu recognizer của bạn không bao giờ xuất hiện, vấn đề nằm ở hit testing — hãy quay lại HitTestBehavior và khung bao. Nếu nó xuất hiện rồi bị từ chối, vấn đề là cạnh tranh trong đấu trường — hãy đi tìm xem cái gì đã đánh bại nó.

Chỉ một cờ này thôi đã biến phiên bản “cả buổi chiều” của con bug này thành phiên bản hai phút.

Listener: nằm dưới hệ thống cử chỉ

GestureDetector ngồi trên một tầng thấp hơn. Listener cho bạn sự kiện con trỏ thô, không đấu trường, không phân định, không semantics:

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

Hãy dùng nó để quan sát — một bản đồ nhiệt, một lớp phủ gỡ lỗi, một lớp bọc “chạm bất kỳ đâu thì reset bộ đếm nhàn rỗi”. Đừng dùng nó để cài đặt hành vi chạm: bạn sẽ phải tự viết lại ngưỡng dung sai, việc tham gia đấu trường, semantics trợ năng và phản hồi theo nền tảng — những thứ GestureDetector đã có sẵn.

RawGestureDetector và recognizer tự viết

Nằm giữa hai thứ trên là RawGestureDetector, cho phép bạn cấp recognizer trực tiếp — kể cả các lớp con thay đổi hành vi trong đấu trường.

Cách dùng kinh điển là “hãy để con này thắng cú kéo dọc dù phía trên có một scroll view”:

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

class _EagerVerticalDrag extends VerticalDragGestureRecognizer {
  @override
  void rejectGesture(int pointer) {
    // Giành lấy con trỏ thay vì nhường cho scrollable ở trên.
    acceptGesture(pointer);
  }
}

Đây là một công cụ sắc. Ghi đè rejectGesture nghĩa là recognizer của bạn từ chối thua, điều đó hoàn toàn đúng cho một sheet kéo được nằm trong scroll view và hoàn toàn sai ở gần như mọi chỗ khác. Chỉ dùng tới nó sau khi debugPrintGestureArenaDiagnostics đã cho bạn thấy bạn đang cạnh tranh với recognizer nào.

IgnorePointerAbsorbPointer

Hai widget cố ý phá vỡ hit testing, và liên tục bị nhầm lẫn:

  • IgnorePointer — cả nhánh trở nên vô hình với hit testing. Sự kiện xuyên qua tới bất cứ thứ gì phía sau.
  • AbsorbPointer — cả nhánh vẫn bị trúng, nhưng sự kiện dừng lại ở đó. Không gì phía sau nhận được, và không gì bên trong phản ứng.

Dùng IgnorePointer cho một lớp phủ trang trí mà bạn muốn chạm xuyên qua được. Dùng AbsorbPointer cho một lớp che kiểu “form đang gửi, chặn hết”. Chọn nhầm cái sẽ tạo ra hoặc một giao diện chết, hoặc một giao diện mà màn hình bị vô hiệu hoá vẫn tương tác được ở dưới.

Trình tự chẩn đoán

  1. Widget có kích thước khác không chứ? Hãy kiểm tra bằng widget inspector, đừng nhìn bằng mắt.
  2. Nó có nằm trong khung của cha không? Clip.none và các giá trị Positioned âm là những nghi phạm quen thuộc.
  3. IgnorePointer, AbsorbPointer, hay một tổ tiên trong suốt nhưng opaque chắn đường không?
  4. Đặt behavior: HitTestBehavior.opaque. Nếu giờ nó chạy, thì đó chính là nguyên nhân.
  5. Bật debugPrintGestureArenaDiagnostics. Nếu recognizer của bạn không bao giờ vào đấu trường, vấn đề vẫn là hit testing. Nếu nó vào rồi thua, hãy tìm kẻ thắng.

Câu hỏi thường gặp

Vì sao cú chạm của tôi ăn ở giữa dòng nhưng không ăn ở mép?

deferToChild với một con nhỏ hơn cả dòng. Hãy đặt behavior: HitTestBehavior.opaque cho detector.

Vì sao một nút trong ListView lại cuộn thay vì được nhấn?

Đấu trường đang chạy đúng như thiết kế: bất kỳ chuyển động nào vượt ngưỡng dung sai đều khiến cú kéo thắng. Một cú chạm không di chuyển vẫn kích hoạt bình thường. Đây là hành vi đúng chuẩn nền tảng trên cả Android lẫn iOS.

Tôi nên dùng InkWell hay GestureDetector?

InkWell cho bất cứ thứ gì cần trông như một control Material — nó thêm hiệu ứng gợn sóng, trạng thái focus và hover, cùng semantics đúng. GestureDetector cho những cử chỉ không cần phản hồi thị giác. InkWell cần một tổ tiên Material để vẽ được vệt mực.

Hai widget cùng xử lý một cú chạm được không?

Không, với cùng một cử chỉ trong đấu trường; kẻ thắng lấy tất. Hãy chồng thêm một Listener để quan sát, hoặc dùng behavior translucent để cả hai cùng nằm trên đường hit test cho các loại cử chỉ khác nhau.

Vì sao onTapDown chạy mà onTap thì không?

onTapDown chạy theo kiểu lạc quan; onTap chỉ chạy nếu recognizer thắng đấu trường. Thấy cái này mà không thấy cái kia là dấu hiệu rõ ràng nhất rằng có thứ khác đã thắng.


Các quy tắc hit testing, ngữ nghĩa HitTestBehavior, cách phân định trong đấu trường và các cờ gỡ lỗi mô tả ở đây được ghi trong hướng dẫn cử chỉ cùng các trang API của Flutter đã dẫn. Trình tự chẩn đoán, cách quy về hai nguyên nhân gốc, và lời cảnh báo quanh việc ghi đè rejectGesture là đánh giá riêng của tôi từ việc gỡ lỗi cử chỉ theo cách này. Hành vi của recognizer có thể thay đổi giữa các bản phát hành — hãy đối chiếu với SDK bạn dùng.


Bài viết gốc đăng tại FlutterCook. Bản trên đó là bản được cập nhật mới nhất.

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...

2026 👏 Google miễn phí một năm gói AI Plus cho sinh viên Việt Nam 🇻🇳🇻🇳🇻🇳

Chúc mừng anh em 👏 Google miễn phí một năm gói AI Plus cho sinh viên Việt Nam 🇻🇳🇻🇳🇻🇳 Đối tượng là sinh viên đại học từ 18–24 tuổi (bao gồm sinh viên mới và sinh viên từng dùng gói AI Pro 2025). Lưu ý là cần xác minh tư cách sinh viên hàng năm. Khi nhận gói, anh em có được các ưu đãi sau: 🔖 Nâng cấp bộ nhớ đám mây lên 400GB. 🔖 Nhân đôi hạn mức truy cập mô hình AI. 🔖 Mở quyền trải nghiệm tính năng tạo video bằng Gemini Omni. Lưu ý chương trình chỉ dành cho sinh viên đủ điều kiện. Hạn nhận ưu đãi: 31 tháng 12, 2026. Các bạn cần có phương thức thanh toán hợp lệ khi đăng ký. Google AI Plus sẽ tự động tính phí 132.000 ₫/tháng sau khi thời gian dùng thử kết thúc, trừ phi bạn đã huỷ trước đó. Huỷ bất cứ lúc nào. CÁC BƯỚC THỰC HIỆN CHI TIẾT 🔹Bước 1: Truy cập cổng đăng ký chương trình 🔖 Mở trình duyệt và truy cập vào trang chính thức: gemini.google/students/ 🔖 Đăng nhập vào Tài khoản Google cá nhân của bạn. 🔖 Nhấn vào nút "Claim your student plan at no cost" (hoặc Nhận gó...