Flutter 튜토리얼 13편: 제스처와 터치 처리

요약#

핵심 요지#

  • 문제 정의: 사용자와 상호작용하는 앱을 만들려면 터치, 탭, 드래그 등의 제스처를 감지해야 한다.
  • 핵심 주장: Flutter는 GestureDetector1로 다양한 제스처를 감지하고, InkWell2로 Material 리플 효과를 제공한다.
  • 주요 근거: 포인터 이벤트를 추상화한 제스처 시스템이 있고, 제스처 아레나가 충돌을 자동 해결한다.
  • 실무 기준: 커스텀 위젯에는 GestureDetector를, Material 디자인에는 InkWell을 사용한다.
  • 한계: 복잡한 제스처 조합은 제스처 아레나의 동작을 이해해야 제대로 구현할 수 있다.

문서가 설명하는 범위#

  • Flutter의 제스처 시스템 개요
  • GestureDetector로 다양한 제스처 감지
  • InkWell로 Material 리플 효과 추가
  • 제스처 충돌 해결 방식

읽는 시간: 14분 | 난이도: 초급


참고 자료#


문제 상황#

사용자와 상호작용하는 앱을 만들려면 다양한 입력을 처리해야 합니다.
버튼 클릭, 리스트 스와이프, 이미지 확대/축소 등 모든 것이 제스처입니다.

제스처 처리의 복잡성#

사용자 입력 처리 과제
├── 탭 (한 번, 두 번, 길게)
├── 드래그 (가로, 세로, 전방향)
├── 스케일 (확대, 축소, 회전)
├── 제스처 충돌 (탭 vs 드래그?)
└── 시각적 피드백 (리플 효과)

Flutter는 이 복잡성을 두 가지 레이어로 추상화합니다.
저수준 포인터 이벤트와 고수준 제스처입니다.


해결 방법#

Flutter의 제스처 시스템을 이해하고 적절한 위젯을 사용하면 다양한 사용자 상호작용을 쉽게 구현할 수 있습니다.

챕터 1: 제스처 시스템 이해하기#

Why#

NOTE

Flutter의 제스처 시스템은 두 가지 레이어로 구성됩니다.
이 구조를 이해해야 적절한 도구를 선택할 수 있습니다.

사용자 터치
포인터 이벤트 (저수준)
제스처 인식 (고수준)
콜백 실행

What#

NOTE

Flutter는 포인터 이벤트를 제스처로 변환합니다.

레이어설명예시
포인터 이벤트원시 입력 데이터화면 접촉, 이동, 해제
제스처의미 있는 동작탭, 드래그, 스케일

How#

TIP

포인터 이벤트 종류

이벤트설명
PointerDownEvent화면에 접촉 시작
PointerMoveEvent접촉한 상태로 이동
PointerUpEvent화면에서 떼어짐
PointerCancelEvent입력이 취소됨

제스처 종류

// 탭 제스처
onTap // 탭 완료
onTapDown // 탭 시작
onTapUp // 탭 해제
onTapCancel // 탭 취소
// 더블 탭
onDoubleTap // 빠르게 두 번 탭
// 롱 프레스
onLongPress // 길게 누르기
onLongPressStart
onLongPressEnd
// 드래그
onVerticalDragStart // 세로 드래그 시작
onVerticalDragUpdate // 세로 드래그 중
onVerticalDragEnd // 세로 드래그 끝
onHorizontalDragStart // 가로 드래그 시작
onHorizontalDragUpdate // 가로 드래그 중
onHorizontalDragEnd // 가로 드래그 끝
// 팬 (전방향 드래그)
onPanStart // 드래그 시작
onPanUpdate // 드래그 중
onPanEnd // 드래그 끝

히트 테스트(Hit Test)

포인터 이벤트가 발생하면 Flutter는 해당 위치의 위젯을 찾습니다.
가장 안쪽 위젯부터 위로 올라가며 이벤트를 전달합니다.

Watch out#

WARNING

onPan과 방향별 드래그 콜백을 함께 사용하면 충돌이 발생합니다.

// 오류: Pan과 방향 드래그를 함께 사용
GestureDetector(
onPanUpdate: (details) { }, // 전방향
onVerticalDragUpdate: (details) { }, // 세로만
child: Container(),
)
// 런타임 오류 발생!
// 해결: 둘 중 하나만 선택
GestureDetector(
onPanUpdate: (details) {
// 전방향 드래그 처리
},
child: Container(),
)

결론: Flutter는 포인터 이벤트를 제스처로 추상화해 복잡한 입력 처리를 단순화합니다.


챕터 2: GestureDetector로 탭 감지하기#

Why#

NOTE

커스텀 위젯에 탭 기능을 추가하려면 GestureDetector를 사용합니다.
Container, Text 등 기본 위젯은 터치에 반응하지 않기 때문입니다.

// 터치에 반응하지 않음
Container(
color: Colors.blue,
child: Text('탭해도 아무 일 없음'),
)

What#

NOTE

GestureDetector는 자식 위젯을 감싸서 제스처를 감지하는 위젯입니다.
다양한 콜백을 통해 제스처에 반응할 수 있습니다.

How#

TIP

기본 탭 처리

class TapExample extends StatelessWidget {
const TapExample({super.key});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('탭!')),
);
},
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'탭하세요',
style: TextStyle(color: Colors.white),
),
),
);
}
}

다양한 탭 제스처

class MultiTapExample extends StatefulWidget {
const MultiTapExample({super.key});
@override
State<MultiTapExample> createState() => _MultiTapExampleState();
}
class _MultiTapExampleState extends State<MultiTapExample> {
String _lastGesture = '아직 없음';
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
onTap: () {
setState(() => _lastGesture = '탭');
},
onDoubleTap: () {
setState(() => _lastGesture = '더블 탭');
},
onLongPress: () {
setState(() => _lastGesture = '롱 프레스');
},
child: Container(
width: 200,
height: 200,
color: Colors.amber,
alignment: Alignment.center,
child: const Text('여기를 터치하세요'),
),
),
const SizedBox(height: 20),
Text('마지막 제스처: $_lastGesture'),
],
);
}
}

탭 위치 감지

GestureDetector(
onTapDown: (TapDownDetails details) {
print('탭 위치: ${details.localPosition}');
print('전역 위치: ${details.globalPosition}');
},
child: Container(
width: 300,
height: 300,
color: Colors.green,
),
)

Watch out#

WARNING

GestureDetector에 설정한 콜백만 감지됩니다.
필요한 콜백만 설정하면 불필요한 처리를 줄일 수 있습니다.

// onTap만 설정 → 탭만 감지
GestureDetector(
onTap: () { },
child: Container(),
)
// onTap과 onDoubleTap 설정
// → Flutter가 탭인지 더블탭인지 구분하기 위해 약간의 지연 발생
GestureDetector(
onTap: () { },
onDoubleTap: () { },
child: Container(),
)

결론: GestureDetector로 커스텀 위젯에 탭 기능을 쉽게 추가할 수 있습니다.


챕터 3: 드래그 제스처 처리하기#

Why#

NOTE

슬라이더, 드래그 앤 드롭, 스와이프 기능을 구현하려면 드래그 제스처를 처리해야 합니다.
드래그는 시작, 업데이트, 종료의 세 단계로 구성됩니다.

What#

NOTE

드래그 제스처는 방향에 따라 세 가지로 나뉩니다.

타입설명사용 예
Vertical세로 방향만리스트 스크롤
Horizontal가로 방향만캐러셀
Pan전 방향지도 이동

How#

TIP

세로 드래그 예제

class VerticalDragExample extends StatefulWidget {
const VerticalDragExample({super.key});
@override
State<VerticalDragExample> createState() => _VerticalDragExampleState();
}
class _VerticalDragExampleState extends State<VerticalDragExample> {
double _yOffset = 0;
@override
Widget build(BuildContext context) {
return GestureDetector(
onVerticalDragUpdate: (DragUpdateDetails details) {
setState(() {
_yOffset += details.delta.dy;
});
},
child: Container(
color: Colors.grey[200],
child: Center(
child: Transform.translate(
offset: Offset(0, _yOffset),
child: Container(
width: 100,
height: 100,
color: Colors.blue,
child: const Center(
child: Text('드래그', style: TextStyle(color: Colors.white)),
),
),
),
),
),
);
}
}

전방향 드래그 (Pan)

class PanExample extends StatefulWidget {
const PanExample({super.key});
@override
State<PanExample> createState() => _PanExampleState();
}
class _PanExampleState extends State<PanExample> {
Offset _offset = Offset.zero;
@override
Widget build(BuildContext context) {
return GestureDetector(
onPanStart: (details) {
print('드래그 시작: ${details.localPosition}');
},
onPanUpdate: (details) {
setState(() {
_offset += details.delta;
});
},
onPanEnd: (details) {
print('드래그 끝, 속도: ${details.velocity}');
},
child: Container(
color: Colors.grey[300],
child: Center(
child: Transform.translate(
offset: _offset,
child: Container(
width: 80,
height: 80,
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
),
),
),
),
);
}
}

드래그 속도 활용

onPanEnd: (DragEndDetails details) {
// 속도를 사용해 관성 효과 구현 가능
final velocity = details.velocity.pixelsPerSecond;
print('X 속도: ${velocity.dx}');
print('Y 속도: ${velocity.dy}');
}

Watch out#

WARNING

드래그 중에는 빈번한 setState 호출이 발생합니다.
성능이 중요한 경우 최적화를 고려해야 합니다.

// 성능 최적화: RepaintBoundary 사용
RepaintBoundary(
child: GestureDetector(
onPanUpdate: (details) {
setState(() {
_offset += details.delta;
});
},
child: CustomPaint(
painter: MyPainter(_offset),
),
),
)

결론: 드래그 콜백의 delta와 velocity를 활용해 자연스러운 드래그 인터페이스를 구현합니다.


챕터 4: InkWell로 리플 효과 추가하기#

Why#

NOTE

Material Design에서는 터치 시 리플(물결) 효과로 피드백을 제공합니다.
InkWell은 이 리플 효과를 쉽게 추가해주는 위젯입니다.

// GestureDetector: 리플 효과 없음
// InkWell: Material 리플 효과 포함

What#

NOTE

InkWellGestureDetector와 비슷하지만 Material 리플 애니메이션이 포함됩니다.
Material 위젯 트리 안에서 사용해야 합니다.

How#

TIP

기본 InkWell 사용

class InkWellExample extends StatelessWidget {
const InkWellExample({super.key});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('탭!')),
);
},
child: const Padding(
padding: EdgeInsets.all(16),
child: Text('탭하면 리플 효과'),
),
);
}
}

커스텀 리플 색상

InkWell(
onTap: () { },
splashColor: Colors.red.withOpacity(0.3), // 리플 색상
highlightColor: Colors.red.withOpacity(0.1), // 하이라이트 색상
child: Container(
padding: const EdgeInsets.all(16),
child: const Text('커스텀 리플'),
),
)

모서리 둥근 InkWell

Material(
color: Colors.transparent,
child: InkWell(
onTap: () { },
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Colors.blue),
borderRadius: BorderRadius.circular(12),
),
child: const Text('둥근 리플'),
),
),
)

InkWell vs GestureDetector 비교

Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// GestureDetector - 리플 없음
GestureDetector(
onTap: () => print('GestureDetector 탭'),
child: Container(
padding: const EdgeInsets.all(16),
color: Colors.blue,
child: const Text('GestureDetector'),
),
),
// InkWell - 리플 있음
Material(
color: Colors.blue,
child: InkWell(
onTap: () => print('InkWell 탭'),
child: const Padding(
padding: EdgeInsets.all(16),
child: Text('InkWell'),
),
),
),
],
)

Watch out#

WARNING

InkWell의 리플 효과는 Material 위젯 위에서만 제대로 표시됩니다.

// 나쁜 예: 리플이 Container 아래에 숨겨짐
Container(
color: Colors.white, // 불투명 배경이 리플을 가림
child: InkWell(
onTap: () { },
child: Text('리플 안 보임'),
),
)
// 좋은 예: Material로 감싸거나 Ink 사용
Material(
color: Colors.white,
child: InkWell(
onTap: () { },
child: const Padding(
padding: EdgeInsets.all(16),
child: Text('리플 보임'),
),
),
)
// 또는 Ink.image 사용 (이미지 위 리플)
Ink.image(
image: const NetworkImage('...'),
fit: BoxFit.cover,
child: InkWell(
onTap: () { },
),
)

결론: Material 디자인 앱에서는 InkWell을 사용해 표준 리플 피드백을 제공합니다.


챕터 5: 제스처 아레나 이해하기#

Why#

NOTE

여러 제스처 인식기가 같은 영역에서 충돌할 수 있습니다.
예를 들어, 리스트 아이템을 탭할 때 스크롤도 감지됩니다.
Flutter는 “제스처 아레나”로 이 충돌을 해결합니다.

What#

NOTE

제스처 아레나(Gesture Arena)는 여러 제스처 인식기가 경쟁해서 승자를 결정하는 시스템입니다.

flowchart TD A[포인터 다운] --> B[제스처 아레나 생성] B --> C[인식기들 경쟁] C --> D{승자 결정} D -->|탭 승리| E[탭 콜백 실행] D -->|드래그 승리| F[드래그 콜백 실행]

How#

TIP

아레나 동작 방식

// 탭과 수직 드래그가 충돌하는 경우
GestureDetector(
onTap: () => print('탭!'),
onVerticalDragStart: (_) => print('드래그 시작!'),
child: Container(
width: 200,
height: 200,
color: Colors.purple,
),
)
// 동작:
// 1. 터치 시작 → 두 인식기 모두 아레나 진입
// 2. 움직임 없이 떼면 → 탭 승리
// 3. 세로로 움직이면 → 드래그 승리

중첩된 제스처 처리

// 외부: 전체 카드 탭
// 내부: 아이콘 버튼 탭
GestureDetector(
onTap: () => print('카드 탭'),
child: Card(
child: ListTile(
title: const Text('아이템'),
trailing: IconButton(
onPressed: () => print('아이콘 탭'),
icon: const Icon(Icons.more_vert),
),
),
),
)
// 아이콘 탭 → 아이콘 버튼 콜백만 실행
// 다른 곳 탭 → 카드 탭 콜백 실행

HitTestBehavior로 히트 영역 제어

GestureDetector(
// 기본값: deferToChild - 자식이 있는 영역만 감지
behavior: HitTestBehavior.deferToChild,
onTap: () { },
child: Container(
width: 200,
height: 200,
// 투명 영역은 탭 감지 안 됨
),
)
GestureDetector(
// opaque: 전체 영역 감지 (이벤트 전파 차단)
behavior: HitTestBehavior.opaque,
onTap: () { },
child: Container(
width: 200,
height: 200,
),
)
GestureDetector(
// translucent: 전체 영역 감지 (이벤트 전파 허용)
behavior: HitTestBehavior.translucent,
onTap: () { },
child: Container(
width: 200,
height: 200,
),
)

Watch out#

WARNING

제스처 인식에는 약간의 지연이 발생할 수 있습니다.

// onTap만 있으면 → 즉시 인식
GestureDetector(
onTap: () { },
child: Container(),
)
// onTap + onDoubleTap → 탭 후 약간 대기
// (더블 탭인지 확인하기 위해)
GestureDetector(
onTap: () { },
onDoubleTap: () { },
child: Container(),
)

성능이 중요한 경우 필요한 제스처만 등록하세요.

결론: 제스처 아레나가 자동으로 충돌을 해결하지만, 동작 방식을 이해하면 더 정확한 제어가 가능합니다.


챕터 6: 기본 제공 버튼 활용하기#

Why#

NOTE

단순한 탭 처리를 위해 항상 GestureDetector를 사용할 필요는 없습니다.
Flutter는 다양한 기본 버튼 위젯을 제공합니다.

What#

NOTE

Material Design 버튼들은 기본적으로 리플 효과와 접근성을 지원합니다.

버튼용도
ElevatedButton주요 액션, 입체감
FilledButton중요한 최종 액션
OutlinedButton보조 액션
TextButton덜 중요한 액션
IconButton아이콘만 있는 버튼
FloatingActionButton화면의 주요 액션

How#

TIP

버튼 예시

Column(
children: [
ElevatedButton(
onPressed: () => print('Elevated'),
child: const Text('Elevated Button'),
),
const SizedBox(height: 8),
FilledButton(
onPressed: () => print('Filled'),
child: const Text('Filled Button'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => print('Outlined'),
child: const Text('Outlined Button'),
),
const SizedBox(height: 8),
TextButton(
onPressed: () => print('Text'),
child: const Text('Text Button'),
),
const SizedBox(height: 8),
IconButton(
onPressed: () => print('Icon'),
icon: const Icon(Icons.favorite),
),
],
)

비활성화 버튼

ElevatedButton(
onPressed: null, // null이면 비활성화
child: const Text('비활성화'),
)

아이콘 포함 버튼

ElevatedButton.icon(
onPressed: () { },
icon: const Icon(Icons.add),
label: const Text('추가'),
)

Watch out#

WARNING

버튼의 onPressed가 null이면 비활성화되어 탭이 불가능합니다.

// 조건부 활성화
ElevatedButton(
onPressed: isValid ? () => submit() : null,
child: const Text('제출'),
)

결론: 기본 버튼을 활용하면 일관된 디자인과 접근성을 쉽게 확보할 수 있습니다.


한계#

Flutter의 제스처 시스템에도 주의할 점이 있습니다.

  • 복잡한 제스처 조합: 여러 제스처를 조합하면 아레나 동작을 예측하기 어려울 수 있습니다.
  • 플랫폼 차이: 마우스와 터치의 동작이 다를 수 있습니다.
  • 접근성: GestureDetector로 만든 커스텀 위젯은 Semantics를 별도로 설정해야 스크린 리더가 인식합니다.
  • 성능: 빈번한 제스처 업데이트는 리빌드를 유발할 수 있습니다.

Footnotes#

  1. GestureDetector: 자식 위젯을 감싸서 탭, 드래그 등 다양한 제스처를 감지하는 위젯이다.

  2. InkWell: Material Design의 리플(물결) 효과와 함께 탭을 감지하는 위젯이다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 13편: 제스처와 터치 처리
https://moodturnpost.net/posts/flutter/flutter-gestures/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차