티스토리 뷰

플러터를 개발하는데 있어서 빠질 수 없는게 바로 Listener 위젯입니다.

(Gesture Recognizer를 구현하여 제스처를 커스텀해서 사용한다던가 자체 RawGestureDetector를 구현하는 경우가 아니라면 일반적인 상황에서 거의 사용되지 않습니다.)

해당 위젯은 사용자의 포인터를 인식하고 전달하는 역할을 하는 위젯입니다.

 

그렇다면 GestureDetector와 다른 점이 무엇일까요?

GestureDetector는 단순히 Gesture Arena 또는 Gesture Disambiguation(제스쳐 명확화)(Tap인지 DoubleTap인지 LongPress인지를 일관성있게 정의하는 것을 말합니다)에 의해 제스쳐를 정의합니다.

 

이는 Gesture Recognizer(Tap, Double, LongPress 등등) 라고 말하는 제스쳐를 인식하는 역할을 하는 클래스를 Gesture Arena을 통해 한가지의 제스쳐로 정의합니다.

 

또한 Touch Slop을 참조하여 스크롤인지 단순히 그냥 실수로 살짝 드레그 한것인지 사용자의 제스쳐를 정의하고 가공합니다.

 

Listener은 단순하게 그냥 미가공 포인터 이벤트를 전달해주는 역할을 하는 위젯입니다.

GestureDetector는 그냥 제스쳐를 인식하고 정의하는 역할을 하는 위젯입니다.

 

Listener는 미가공 포인터 데이터를 전달해주는 역할이라고 설명하였습니다.

만약 이를 중첩으로 두게 된다면 어떠한 동작이 발생할가요?

 

여기서 한가지 문제가 발생하게 됩니다.

 

위젯 계층 구조

 

사용자가 눌렀을때(Tap이 아니라 Pointer Down) 어떠한 동작을 수행한다고 정의했다고 가정합니다. (GestureDetector의 tapDown과 Listener의 pointerDown은 같습니다)

 

먼저 부모 Listener가 트리거된다면 어떠한 동작이 발생할까요?

 

 

부모 위젯을 트리거한 경우

 

반대로 자식 Listener가 트리거된다면 부모 Listener도 트리거가 됩니다.

 

자식 위젯을 트리거한 경우

 

제가 원하던 동작이 아닙니다. 자식 Listener를 트리거 했는데 그 상위 위젯인 부모 Listener도 같이 트리거가 되어버렸습니다.

 

왜 이렇게 될까요?

 

이는 포인터가 아래로 내려가면 적중 테스트를 수행하여 포인터가 화면에 닿은 위치에 어떤 위젯이 존재하는지 확인합니다. 그런 다음 포인터 다운 이벤트(및 해당 포인터에 대한 후속 이벤트)가 히트 테스트에서 찾은 가장 안쪽 위젯으로 보내집니다. 거기서부터 이벤트는 현재 context의 위젯 트리를 따라 위로 올라가며 가장 안쪽 위젯에서 루트 위젯 트리(맨 상위 위젯)까지 경로에 있는 모든 위젯에 수신됩니다. 포인터 이벤트가 더 이상 수신되지 않도록 취소하거나 중지하는 메커니즘은 은 기본적으로 플러터에서 구현되어 있지 않습니다(stopPropagation 같은 함수를 의미합니다)

 

어떻게 해야할까요?

 

답은 매우 간단합니다.

 

자식 Listener이 트리거된 경우 자신의 상위 위젯인 Listener의 이벤트를 취소하면 됩니다.

매우 간단하죠.

 

코드를 작성하여 구현해볼까요?

 

/// [Listener] 위젯을 사용하여 제스쳐를 트리거하면 자식 위젯을 트리거 시 부모 위젯에게도
/// 트리거가 그대로 전이되는 것을 방지하고 처리하기 위해 선언된 위젯입니다.
/// 
class PointerRecognizer extends StatefulWidget {
  const PointerRecognizer({
    super.key,
    required this.child,
    this.onPointerDown,
    this.onPointerMove,
    this.onPointerUp,
    this.onPointerCancel,
  });

  final Widget child;

  final PointerDownEventListener? onPointerDown;
  final PointerMoveEventListener? onPointerMove;
  final PointerUpEventListener? onPointerUp;
  final PointerCancelEventListener? onPointerCancel;

  static State<PointerRecognizer>? of(BuildContext context) {
    return context.findAncestorStateOfType<PointerRecognizerState>();
  }

  @override
  State<PointerRecognizer> createState() => PointerRecognizerState();
}



class PointerRecognizerState extends State<PointerRecognizer> {
  
  /// 모든 이벤트가 취소된 또는
  /// 이벤트 관련 함수를 호출할 수 없는 상태의 여부
  /// 
  /// * 자식 위젯이 트리거되고
  ///   현재 위젯보다 자식 위젯인 [PointerRecognizer]이(가) 존재한다면
  ///   그 상위 위젯의 트리거를 취소하도록 정의하기 위해 선언되었습니다.
  /// 
  bool isCanceled = false;

  /// 포인터 이벤트를 처리하고 여러 동작을 정의합니다.
  /// 
  /// * PointerDown: 상위 위젯의 [cancel]을 호출하고 상위 위젯이 트리거되지 않도록 정의합니다.
  void _handlePointer(PointerEvent event) {
    if(event is PointerDownEvent) {
      final state = PointerRecognizer.of(context) as PointerRecognizerState?;
            state?.cancel();

      if(!isCanceled) widget.onPointerDown?.call(event);
    } else if(event is PointerMoveEvent) {
      if(isCanceled) return;

      widget.onPointerMove?.call(event);
    } else { // PointerUpEvent, PointerCancelEvent
      if(isCanceled) { isCanceled = false; return; }
      
        event is PointerUpEvent
      ? widget.onPointerUp?.call(event)
      : widget.onPointerCancel?.call(event as PointerCancelEvent);
    }
  }

  void cancel() => isCanceled = true;

  @override
  Widget build(BuildContext context) {
    return Listener(
      behavior: HitTestBehavior.translucent,
      onPointerDown: (event) => _handlePointer(event),
      onPointerMove: (event) => _handlePointer(event),
      onPointerUp: (event) => _handlePointer(event),
      child: widget.child,
    );
  }
}

 

 

PointerRecognizer의 자식 위젯을 트리거한 경우

 

코드를 시각적으로 표현

 
PointerRecognizerState.isCanceled를 참조하여 자식 위젯이 트리거되었다는 것을 인지한 상위 위젯은 인자로 등록된 콜백 함수들을 호출하지 않습니다.

 

하지만 제스처를 정의해야 될때 Listener만을 사용하는 것은 매우 비효율적이고 플러터에서 권장되지 않는 방법입니다.

해당 방식은 특정 예외 상황에서만 사용하시기를 바랍니다.