ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Flutter 터치는 콜백이 아니라 Gesture Arena에서 결정됩니다
    AI Agent/flutter 2026. 7. 3. 15:00
    728x90
    반응형

    카페 테이블 위 폰 화면을 두드리는 검지의 매크로 컷
    카드 안 버튼이 탭을 못 받던 오후

    카드 안에 넣은 작은 하트 버튼이 탭을 안 먹었습니다.
    정확히는 열 번 누르면 두세 번만 먹었습니다.
    onTap에 로그를 박아놨는데, 손가락으로 화면을 톡톡 두드리는 동안 콘솔에 로그가 뜨다 말다 했습니다.
    저는 그 앞에서 한참을 같은 자리만 두드리고 있었습니다.

    처음엔 히트 영역이 작아서 손가락이 빗나가는 줄 알았습니다.
    버튼 패딩을 키워봤지만 똑같았습니다.
    이상한 건, 카드를 살짝 쓸면서 놓으면 거의 항상 안 먹고, 딱 찍듯이 누르면 그나마 먹는다는 점이었습니다.
    손 모양에 따라 결과가 갈렸습니다.

    이 대목에서 저는 잠깐 멈췄습니다.
    히트 영역 문제라면 손가락을 어떻게 대든 결과가 같아야 합니다.
    넓히면 다 먹거나, 좁으면 다 안 먹거나.
    그런데 "어떻게 누르느냐"가 결과를 가른다는 건, 좌표의 문제가 아니라 동작의 문제라는 뜻이었습니다.
    저는 그때까지 탭이 그냥 "닿았다 떼었다"의 단순한 사건인 줄 알았습니다.

    왜 같은 버튼이 눌릴 때마다 다를까요

    Flutter에서 터치는 콜백에 바로 꽂히지 않습니다.
    포인터가 닿으면, 그 지점을 덮고 있는 여러 제스처 인식기(recognizer)가 전부 후보로 등판해서 경쟁합니다.
    이 경쟁판을 Gesture Arena라고 부릅니다.
    탭 인식기, 드래그 인식기, 스크롤 인식기가 같은 손가락을 두고 "이건 내 제스처다" 하고 붙는 겁니다.

    승자는 하나뿐입니다.
    손가락이 조금이라도 움직이면 드래그나 스크롤 인식기가 유리해지고, 그 순간 탭은 패자가 되어 취소됩니다.
    제 하트 버튼이 열 번 중 몇 번만 먹은 이유가 이거였습니다.
    카드 자체가 스크롤 가능한 리스트 안에 있었고, 손가락이 미세하게 밀리면 리스트의 드래그가 아레나에서 이겨버렸습니다.

    딱 찍듯이 누르면 그나마 먹었던 것도 이제 설명이 됩니다.
    손가락이 안 움직이면 드래그 인식기가 나설 명분이 없으니, 탭이 조용히 승자가 됩니다.
    반대로 살짝 쓸면 그 순간 드래그가 "이건 내 거"라며 아레나를 가져가고, 탭은 판정 직전에 취소됩니다.
    사용자 손의 미세한 떨림이 매번 다른 승자를 만들고 있었던 겁니다.
    그러니 열 번에 두세 번이라는 확률이 나왔습니다.

    부모와 자식이 같은 터치를 두고 싸우면요

    카드 전체에도 GestureDetector가 걸려 있었습니다.
    카드를 누르면 상세로 들어가는 동작이었죠.
    그러니까 한 번의 터치를 두고 세 명이 싸운 셈입니다.
    리스트의 스크롤, 카드의 탭, 하트의 탭.
    이 셋이 같은 아레나에 들어가면, 손가락이 어떻게 움직였느냐에 따라 매번 다른 놈이 이깁니다.

    여기서 깨달은 건, 제가 콜백을 "이 위젯을 누르면 이게 실행된다"는 직선으로 생각하고 있었다는 겁니다.
    실제로는 콜백 이전에 판정이 먼저 있고, 그 판정은 위젯 트리 전체의 인식기들이 참여하는 경쟁이었습니다.
    버튼이 안 먹은 게 아니라, 버튼이 경쟁에서 진 거였습니다.

    그래서 눌린 순간만은 빼앗기지 않게 했습니다

    하트처럼 손이 닿는 순간 바로 반응해야 하는 UI는, 아레나 판정을 기다리는 GestureDetector가 오히려 불리합니다.
    판정 전에 눌림 상태를 켜고 싶으면 raw 포인터 이벤트를 직접 듣는 Listener가 낫습니다.
    ListeneronPointerDown은 아레나 승패와 무관하게 포인터가 닿는 즉시 들어옵니다.

    Listener(
      onPointerDown: (_) => _pressed.value = true,
      onPointerUp: (_) => _pressed.value = false,
      onPointerCancel: (_) => _pressed.value = false,
      child: GestureDetector(
        onTap: _toggleLike,
        child: HeartIcon(pressed: _pressed),
      ),
    )
    

    닿는 순간의 눌림 피드백은 Listener가 즉시 처리하고, 실제 "좋아요"를 확정하는 판정만 GestureDetector에 맡겼습니다.
    손가락이 밀려서 탭이 취소되더라도, 최소한 눌렸다는 시각 반응은 안 사라졌습니다.
    사용자 입장에서 "눌리긴 눌렸는데 반응이 없네"가 "눌린 건 보이는데 가끔 안 되네"로 바뀐 셈입니다.
    완전히 나은 건 아니지만, 적어도 먹통처럼 느껴지진 않았습니다.

    그날 배운 건 도구 이름 하나가 아니었습니다.
    ListenerGestureDetector 중 뭘 쓰느냐는 결국 "이 반응이 아레나 판정을 기다려도 되는가"라는 질문이었습니다.
    확정이 필요한 동작은 기다려도 되지만, 손이 닿는 즉시 피드백을 줘야 하는 동작은 기다리면 늦습니다.
    이 둘을 나눠서 보기 시작하니, 터치가 씹히는 다른 화면들도 조금씩 다르게 보였습니다.

    완전히 해결됐다고는 못 하겠습니다.
    카드 탭과 하트 탭이 겹치는 지점은 여전히 경계가 미묘하고, 리스트를 아주 빠르게 튕기면 하트가 씹히는 경우가 남아 있습니다.
    다만 이제는 그게 왜 씹히는지는 압니다.
    다음에 이런 버그를 만나면, 히트 영역부터 키우는 대신 이 터치가 지금 누구랑 싸우고 있는지부터 그려볼 겁니다.

    원문 기준

    원문 기준: https://docs.flutter.dev/ui/interactivity/gestures

    확인일: 2026-07-01.
    제스처 API는 예제 코드가 종종 바뀌어서, 게시 직전에 원문 예제를 다시 대조합니다.

    728x90
    반응형
Designed by Tistory.