Flutter 튜토리얼 13편: 제스처와 터치 처리
요약
핵심 요지
- 문제 정의: 사용자와 상호작용하는 앱을 만들려면 터치, 탭, 드래그 등의 제스처를 감지해야 한다.
- 핵심 주장: Flutter는
GestureDetector1로 다양한 제스처를 감지하고,InkWell2로 Material 리플 효과를 제공한다. - 주요 근거: 포인터 이벤트를 추상화한 제스처 시스템이 있고, 제스처 아레나가 충돌을 자동 해결한다.
- 실무 기준: 커스텀 위젯에는 GestureDetector를, Material 디자인에는 InkWell을 사용한다.
- 한계: 복잡한 제스처 조합은 제스처 아레나의 동작을 이해해야 제대로 구현할 수 있다.
문서가 설명하는 범위
- Flutter의 제스처 시스템 개요
- GestureDetector로 다양한 제스처 감지
- InkWell로 Material 리플 효과 추가
- 제스처 충돌 해결 방식
읽는 시간: 14분 | 난이도: 초급
참고 자료
- Taps, drags, and other gestures - Flutter 제스처 가이드
- Handle taps - 탭 처리 Cookbook
- Add Material touch ripples - 리플 효과 Cookbook
문제 상황
사용자와 상호작용하는 앱을 만들려면 다양한 입력을 처리해야 합니다.
버튼 클릭, 리스트 스와이프, 이미지 확대/축소 등 모든 것이 제스처입니다.
제스처 처리의 복잡성
사용자 입력 처리 과제├── 탭 (한 번, 두 번, 길게)├── 드래그 (가로, 세로, 전방향)├── 스케일 (확대, 축소, 회전)├── 제스처 충돌 (탭 vs 드래그?)└── 시각적 피드백 (리플 효과)Flutter는 이 복잡성을 두 가지 레이어로 추상화합니다.
저수준 포인터 이벤트와 고수준 제스처입니다.
해결 방법
Flutter의 제스처 시스템을 이해하고 적절한 위젯을 사용하면 다양한 사용자 상호작용을 쉽게 구현할 수 있습니다.
챕터 1: 제스처 시스템 이해하기
Why
NOTEFlutter의 제스처 시스템은 두 가지 레이어로 구성됩니다.
이 구조를 이해해야 적절한 도구를 선택할 수 있습니다.사용자 터치↓포인터 이벤트 (저수준)↓제스처 인식 (고수준)↓콜백 실행
What
NOTEFlutter는 포인터 이벤트를 제스처로 변환합니다.
레이어 설명 예시 포인터 이벤트 원시 입력 데이터 화면 접촉, 이동, 해제 제스처 의미 있는 동작 탭, 드래그, 스케일
How
TIP포인터 이벤트 종류
이벤트 설명 PointerDownEvent화면에 접촉 시작 PointerMoveEvent접촉한 상태로 이동 PointerUpEvent화면에서 떼어짐 PointerCancelEvent입력이 취소됨 제스처 종류
// 탭 제스처onTap // 탭 완료onTapDown // 탭 시작onTapUp // 탭 해제onTapCancel // 탭 취소// 더블 탭onDoubleTap // 빠르게 두 번 탭// 롱 프레스onLongPress // 길게 누르기onLongPressStartonLongPressEnd// 드래그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});@overrideWidget 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});@overrideState<MultiTapExample> createState() => _MultiTapExampleState();}class _MultiTapExampleState extends State<MultiTapExample> {String _lastGesture = '아직 없음';@overrideWidget 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
WARNINGGestureDetector에 설정한 콜백만 감지됩니다.
필요한 콜백만 설정하면 불필요한 처리를 줄일 수 있습니다.// 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});@overrideState<VerticalDragExample> createState() => _VerticalDragExampleState();}class _VerticalDragExampleState extends State<VerticalDragExample> {double _yOffset = 0;@overrideWidget 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});@overrideState<PanExample> createState() => _PanExampleState();}class _PanExampleState extends State<PanExample> {Offset _offset = Offset.zero;@overrideWidget 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
NOTEMaterial Design에서는 터치 시 리플(물결) 효과로 피드백을 제공합니다.
InkWell은 이 리플 효과를 쉽게 추가해주는 위젯입니다.// GestureDetector: 리플 효과 없음// InkWell: Material 리플 효과 포함
What
NOTE
InkWell은GestureDetector와 비슷하지만 Material 리플 애니메이션이 포함됩니다.
Material 위젯 트리 안에서 사용해야 합니다.
How
TIP기본 InkWell 사용
class InkWellExample extends StatelessWidget {const InkWellExample({super.key});@overrideWidget 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
WARNINGInkWell의 리플 효과는 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
NOTEMaterial 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
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!