Flutter 튜토리얼 14편: 드래그와 스와이프 구현
요약
핵심 요지
문서가 설명하는 범위
Dismissible로 스와이프 삭제 구현Draggable과DragTarget으로 드래그 앤 드롭 구현- 시각적 피드백과 드래그 앵커 전략
읽는 시간: 15분 | 난이도: 중급
참고 자료
- Implement swipe to dismiss - 스와이프 삭제 구현
- Drag a UI element - 드래그 앤 드롭 구현
- Drag outside - 앱 외부 드래그
문제 상황
리스트 앱에서 항목을 삭제하거나 다른 영역으로 이동하는 기능이 필요합니다.
버튼을 탭하는 것보다 스와이프나 드래그가 더 직관적입니다.
일반적인 인터랙션 패턴
스와이프 삭제: 이메일 앱에서 메일 삭제드래그 앤 드롭: 장바구니에 상품 추가재정렬: 리스트 항목 순서 변경문제는 다음과 같습니다.
- 스와이프 방향에 따른 다른 액션 처리가 필요하다.
- 드래그 중인 항목의 시각적 피드백이 필요하다.
- 드롭 영역 진입 시 하이라이트 표시가 필요하다.
- 실수로 삭제한 경우 복구 방법이 필요하다.
해결 방법
Flutter는 Dismissible로 스와이프 동작을, Draggable과 DragTarget으로 드래그 앤 드롭을 처리합니다.
챕터 1: 스와이프 삭제 기본 구현
Why
NOTE리스트 항목을 삭제할 때 별도 버튼을 찾아 누르는 것보다 스와이프가 더 빠릅니다.
Dismissible위젯은 이 패턴을 쉽게 구현하게 해줍니다.사용자 스와이프 → Dismissible 감지 → onDismissed 콜백 → 상태 업데이트
What
NOTE
Dismissible은 자식 위젯을 스와이프하여 화면에서 제거할 수 있게 해주는 위젯입니다.
스와이프 방향, 배경, 콜백 등을 설정할 수 있습니다.
How
TIP기본 스와이프 삭제 구현
import 'package:flutter/material.dart';class SwipeToDismissDemo extends StatefulWidget {const SwipeToDismissDemo({super.key});@overrideState<SwipeToDismissDemo> createState() => _SwipeToDismissDemoState();}class _SwipeToDismissDemoState extends State<SwipeToDismissDemo> {final items = List<String>.generate(20, (i) => '항목 ${i + 1}');@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: const Text('스와이프 삭제')),body: ListView.builder(itemCount: items.length,itemBuilder: (context, index) {final item = items[index];return Dismissible(key: Key(item),onDismissed: (direction) {setState(() {items.removeAt(index);});ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$item 삭제됨')),);},background: Container(color: Colors.red),child: ListTile(title: Text(item)),);},),);}}핵심 속성
속성 설명 key각 항목을 구분하는 고유 키 (필수) onDismissed스와이프 완료 시 호출되는 콜백 background스와이프 시 보이는 배경 위젯 direction스와이프 허용 방향
Watch out
WARNING
key는 반드시 고유해야 합니다.
인덱스를 키로 사용하면 삭제 후 다른 항목이 잘못 제거될 수 있습니다.// ❌ 잘못된 방법: 인덱스를 키로 사용Dismissible(key: Key(index.toString()),// ...)// ✅ 올바른 방법: 항목의 고유 식별자 사용Dismissible(key: Key(item.id),// ...)
결론: Dismissible로 스와이프 삭제를 구현하고, 고유한 key를 사용해야 합니다.
챕터 2: 양방향 스와이프와 확인 대화상자
Why
NOTE왼쪽과 오른쪽 스와이프에 다른 액션을 할당하면 더 많은 기능을 제공할 수 있습니다.
또한 중요한 작업 전에 확인 대화상자를 표시하면 실수를 방지할 수 있습니다.← 스와이프: 삭제→ 스와이프: 보관함으로 이동
What
NOTE
background와secondaryBackground로 양방향에 다른 배경을 표시합니다.
confirmDismiss콜백으로 스와이프 완료 전에 확인을 받을 수 있습니다.
How
TIP양방향 스와이프 구현
Dismissible(key: Key(item.id),// 왼쪽에서 오른쪽 스와이프 시 배경background: Container(color: Colors.green,alignment: Alignment.centerLeft,padding: const EdgeInsets.only(left: 20),child: const Icon(Icons.archive, color: Colors.white),),// 오른쪽에서 왼쪽 스와이프 시 배경secondaryBackground: Container(color: Colors.red,alignment: Alignment.centerRight,padding: const EdgeInsets.only(right: 20),child: const Icon(Icons.delete, color: Colors.white),),confirmDismiss: (direction) async {if (direction == DismissDirection.endToStart) {// 삭제 확인 대화상자return await showDialog<bool>(context: context,builder: (context) => AlertDialog(title: const Text('삭제 확인'),content: Text('$item을(를) 삭제하시겠습니까?'),actions: [TextButton(onPressed: () => Navigator.pop(context, false),child: const Text('취소'),),TextButton(onPressed: () => Navigator.pop(context, true),child: const Text('삭제'),),],),);}// 보관함 이동은 확인 없이 허용return true;},onDismissed: (direction) {if (direction == DismissDirection.endToStart) {// 삭제 처리deleteItem(item);} else {// 보관함 이동 처리archiveItem(item);}},child: ListTile(title: Text(item.name)),)DismissDirection 옵션
값 설명 horizontal양방향 스와이프 허용 startToEnd왼쪽 → 오른쪽만 허용 endToStart오른쪽 → 왼쪽만 허용 vertical위아래 스와이프 허용 up위로만 허용 down아래로만 허용
Watch out
WARNING
confirmDismiss에서false를 반환하면 항목이 원래 위치로 돌아갑니다.
onDismissed는confirmDismiss가true를 반환한 후에만 호출됩니다.confirmDismiss: (direction) async {// false 반환 시 항목 복원// true 반환 시 onDismissed 호출return shouldDismiss;},
결론: background와 secondaryBackground로 양방향 피드백을, confirmDismiss로 확인 절차를 추가합니다.
챕터 3: Draggable 기본 구현
Why
NOTE드래그 앤 드롭은 항목을 한 위치에서 다른 위치로 이동할 때 사용합니다.
장바구니에 상품을 추가하거나 파일을 폴더로 이동하는 데 적합합니다.롱프레스 → 드래그 시작 → 드래그 중 → 드롭 존에 놓기
What
NOTE
Draggable은 드래그할 수 있는 위젯을 만들고,DragTarget은 드롭을 받는 영역을 정의합니다.
LongPressDraggable은 롱프레스 후 드래그를 시작합니다.
How
TIP기본 드래그 앤 드롭 구현
import 'package:flutter/material.dart';class DragDropDemo extends StatefulWidget {const DragDropDemo({super.key});@overrideState<DragDropDemo> createState() => _DragDropDemoState();}class _DragDropDemoState extends State<DragDropDemo> {String _droppedItem = '';@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: const Text('드래그 앤 드롭')),body: Column(children: [// 드래그 가능한 항목Draggable<String>(data: '사과',feedback: Material(elevation: 4,child: Container(padding: const EdgeInsets.all(16),color: Colors.red.withOpacity(0.8),child: const Text('🍎 사과', style: TextStyle(fontSize: 18)),),),childWhenDragging: Container(padding: const EdgeInsets.all(16),color: Colors.grey[300],child: const Text('🍎 사과', style: TextStyle(color: Colors.grey)),),child: Container(padding: const EdgeInsets.all(16),color: Colors.red[100],child: const Text('🍎 사과', style: TextStyle(fontSize: 18)),),),const SizedBox(height: 100),// 드롭 타겟DragTarget<String>(builder: (context, candidateData, rejectedData) {final isHovering = candidateData.isNotEmpty;return Container(width: 200,height: 200,decoration: BoxDecoration(color: isHovering ? Colors.green[100] : Colors.grey[200],border: Border.all(color: isHovering ? Colors.green : Colors.grey,width: 2,),),child: Center(child: Text(_droppedItem.isEmpty ? '여기에 드롭' : '$_droppedItem 추가됨!',style: const TextStyle(fontSize: 18),),),);},onAcceptWithDetails: (details) {setState(() {_droppedItem = details.data;});},),],),);}}Draggable 핵심 속성
속성 설명 data드롭 시 전달될 데이터 feedback드래그 중 표시될 위젯 child기본 상태의 위젯 childWhenDragging드래그 중 원래 위치에 표시될 위젯
Watch out
WARNING
Draggable<T>의 타입T와DragTarget<T>의 타입이 일치해야 합니다.
타입이 다르면 드롭이 작동하지 않습니다.// ❌ 타입 불일치로 드롭 실패Draggable<int>(data: 1, ...)DragTarget<String>(...) // String을 기대하지만 int가 전달됨// ✅ 타입 일치Draggable<Item>(data: item, ...)DragTarget<Item>(...)
결론: Draggable과 DragTarget의 타입을 일치시키고, feedback으로 드래그 중 시각적 피드백을 제공합니다.
챕터 4: LongPressDraggable과 드래그 앵커
Why
NOTE리스트 항목에서
Draggable을 사용하면 스크롤과 충돌할 수 있습니다.
LongPressDraggable은 롱프레스 후 드래그를 시작하여 스크롤과 충돌을 방지합니다.리스트 스크롤: 짧은 터치로 스크롤항목 드래그: 롱프레스 후 드래그
What
NOTE
LongPressDraggable은 롱프레스를 인식한 후에만 드래그를 시작합니다.
dragAnchorStrategy는 드래그 시 위젯이 손가락에 상대적으로 어디에 위치할지 결정합니다.
How
TIPLongPressDraggable 구현
class DraggableMenuItem extends StatelessWidget {const DraggableMenuItem({super.key,required this.item,});final MenuItem item;@overrideWidget build(BuildContext context) {return LongPressDraggable<MenuItem>(data: item,// 손가락 위치에 피드백 위젯 배치dragAnchorStrategy: pointerDragAnchorStrategy,// 드래그 중 표시될 위젯feedback: Material(elevation: 8,borderRadius: BorderRadius.circular(12),child: SizedBox(width: 100,height: 100,child: ClipRRect(borderRadius: BorderRadius.circular(12),child: Opacity(opacity: 0.85,child: Image(image: item.imageProvider,fit: BoxFit.cover,),),),),),// 기본 상태child: MenuItemCard(item: item),);}}드래그 앵커 전략
// 손가락 위치에 피드백 중앙 배치dragAnchorStrategy: pointerDragAnchorStrategy,// 자식 위젯 중앙에 피드백 배치 (기본값)dragAnchorStrategy: childDragAnchorStrategy,// 커스텀 앵커 전략dragAnchorStrategy: (draggable, context, position) {return Offset(50, 50); // 피드백 왼쪽 상단에서 50, 50 위치},드래그 이벤트 콜백
LongPressDraggable<MenuItem>(data: item,onDragStarted: () {// 드래그 시작 시HapticFeedback.mediumImpact();},onDragEnd: (details) {// 드래그 종료 시print('드래그 종료: ${details.wasAccepted}');},onDragCompleted: () {// 드롭이 수락됨print('드롭 완료!');},onDraggableCanceled: (velocity, offset) {// 드롭이 취소됨print('드롭 취소');},// ...)
Watch out
WARNING
feedback위젯은 원래 컨텍스트와 분리되어 렌더링됩니다.
Theme,MediaQuery등의 상속 위젯에 접근하려면Material위젯으로 감싸야 합니다.// ❌ Theme에 접근할 수 없음feedback: Container(color: Theme.of(context).primaryColor, // 에러 가능child: Text('드래그 중'),)// ✅ Material로 감싸서 테마 적용feedback: Material(child: Container(color: Theme.of(context).primaryColor,child: const Text('드래그 중'),),)
결론: 리스트에서는 LongPressDraggable을 사용하고, dragAnchorStrategy로 드래그 위치를 조정합니다.
챕터 5: DragTarget 고급 활용
Why
NOTE드롭 영역은 드래그 중인 항목이 위에 있을 때 시각적 피드백을 제공해야 합니다.
또한 특정 조건에서만 드롭을 허용해야 할 수 있습니다.드래그 진입 → 하이라이트 → 조건 검사 → 수락 또는 거부
What
NOTE
DragTarget의builder는 드래그 상태에 따라 다른 UI를 반환합니다.
onWillAcceptWithDetails로 드롭 허용 여부를 결정할 수 있습니다.
How
TIP조건부 드롭 허용
DragTarget<CartItem>(builder: (context, candidateData, rejectedData) {final hasCandidate = candidateData.isNotEmpty;final hasRejected = rejectedData.isNotEmpty;return AnimatedContainer(duration: const Duration(milliseconds: 200),decoration: BoxDecoration(color: hasCandidate? Colors.green[100]: hasRejected? Colors.red[100]: Colors.grey[200],border: Border.all(color: hasCandidate? Colors.green: hasRejected? Colors.red: Colors.grey,width: 2,),borderRadius: BorderRadius.circular(16),),child: const Center(child: Text('장바구니'),),);},onWillAcceptWithDetails: (details) {// 재고가 있는 항목만 수락return details.data.inStock;},onAcceptWithDetails: (details) {addToCart(details.data);},onLeave: (data) {// 드래그가 영역을 벗어남print('항목이 영역을 벗어남');},)builder 콜백 파라미터
파라미터 설명 candidateData드롭 가능한 항목 목록 rejectedDataonWillAcceptWithDetails가 false를 반환한 항목 목록장바구니 드래그 앤 드롭 예제
class ShoppingCart extends StatefulWidget {const ShoppingCart({super.key});@overrideState<ShoppingCart> createState() => _ShoppingCartState();}class _ShoppingCartState extends State<ShoppingCart> {final List<Product> _cartItems = [];@overrideWidget build(BuildContext context) {return Row(children: [// 상품 목록Expanded(child: ListView.builder(itemCount: products.length,itemBuilder: (context, index) {final product = products[index];return LongPressDraggable<Product>(data: product,feedback: ProductDragFeedback(product: product),child: ProductCard(product: product),);},),),// 장바구니 드롭 존DragTarget<Product>(builder: (context, candidates, rejected) {return CartDropZone(items: _cartItems,isHighlighted: candidates.isNotEmpty,);},onAcceptWithDetails: (details) {setState(() {_cartItems.add(details.data);});},),],);}}
Watch out
WARNING
candidateData는 현재 영역 위에 있는 모든 드래그 가능한 항목을 포함합니다.
여러 항목을 동시에 드래그하는 경우를 처리해야 할 수 있습니다.builder: (context, candidateData, rejectedData) {// 여러 항목이 동시에 드래그될 수 있음if (candidateData.length > 1) {return MultiDropIndicator();}return SingleDropIndicator();}
결론: onWillAcceptWithDetails로 조건부 드롭을 구현하고, candidateData와 rejectedData로 상태별 UI를 표시합니다.
챕터 6: 앱 외부로 드래그
Why
NOTEFlutter 앱과 다른 앱 간에 드래그 앤 드롭이 필요한 경우가 있습니다.
예를 들어 이미지를 파일 탐색기로 드래그하거나, 외부 파일을 앱으로 드래그하는 기능입니다.앱 내 드래그: Draggable/DragTarget 사용앱 간 드래그: super_drag_and_drop 패키지 사용
What
NOTE
super_drag_and_drop패키지는 Flutter 앱과 다른 앱 간의 드래그 앤 드롭을 지원합니다.
데스크톱, 모바일, 웹 플랫폼에서 모두 작동합니다.
How
TIPsuper_drag_and_drop 패키지 설치
dependencies:super_drag_and_drop: ^0.8.0기본 사용법
import 'package:super_drag_and_drop/super_drag_and_drop.dart';class ExternalDragDemo extends StatelessWidget {const ExternalDragDemo({super.key});@overrideWidget build(BuildContext context) {return DropRegion(formats: Formats.standardFormats,onDropOver: (event) {// 드래그 중인 데이터 형식 확인if (event.session.allowedOperations.contains(DropOperation.copy)) {return DropOperation.copy;}return DropOperation.none;},onPerformDrop: (event) async {// 드롭된 항목 처리final item = event.session.items.first;final reader = item.dataReader!;// 이미지 데이터 읽기if (reader.canProvide(Formats.png)) {reader.getFile(Formats.png, (file) async {final data = await file.readAll();// 이미지 데이터 처리});}},child: Container(width: 300,height: 300,color: Colors.grey[200],child: const Center(child: Text('파일을 여기에 드롭하세요'),),),);}}앱 간 드래그 비교
기능 Draggable super_drag_and_drop 앱 내 드래그 ✅ ✅ 앱 간 드래그 ❌ ✅ 플랫폼 지원 모든 플랫폼 데스크톱, 모바일, 웹 복잡도 낮음 중간 데이터 타입 선언 비동기 동기 (사전 선언)
Watch out
WARNING
super_drag_and_drop은 플랫폼 API와 동기적으로 통신해야 합니다.
따라서 허용할 데이터 형식을 미리 선언해야 합니다.// 허용할 형식을 미리 선언DropRegion(formats: [Formats.png,Formats.jpeg,Formats.plainText,],// ...)앱 내 드래그만 필요하다면
Draggable과DragTarget이 더 간단합니다.
결론: 앱 내 드래그는 기본 위젯을, 앱 간 드래그는 super_drag_and_drop 패키지를 사용합니다.
한계
드래그와 스와이프 구현에는 몇 가지 한계가 있습니다.
- 접근성: 스와이프 제스처는 접근성 사용자에게 어려울 수 있습니다. 대안적인 삭제 방법을 제공해야 합니다.
- 플랫폼 차이: 앱 간 드래그 앤 드롭은 플랫폼마다 지원 범위가 다릅니다.
- 성능: 복잡한 피드백 위젯은 드래그 중 프레임 드롭을 유발할 수 있습니다.
- 제스처 충돌: 스크롤, 탭 등 다른 제스처와 충돌할 수 있습니다.
Footnotes
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!