Flutter 튜토리얼 14편: 드래그와 스와이프 구현

요약#

핵심 요지#

  • 문제 정의: 사용자가 직관적으로 항목을 삭제하거나 이동하는 인터랙션이 필요하다.
  • 핵심 주장: Flutter의 Dismissible1Draggable2 위젯으로 스와이프 삭제와 드래그 앤 드롭을 구현한다.
  • 주요 근거: Dismissible은 스와이프 동작을 처리하고, DraggableDragTarget3은 드래그 앤 드롭을 처리한다.
  • 실무 기준: 스와이프 방향별 다른 액션, 드롭 존 하이라이트, 실행 취소 기능을 제공해야 한다.
  • 한계: 앱 간 드래그 앤 드롭은 super_drag_and_drop 패키지가 필요하다.

문서가 설명하는 범위#

  • Dismissible로 스와이프 삭제 구현
  • DraggableDragTarget으로 드래그 앤 드롭 구현
  • 시각적 피드백과 드래그 앵커 전략

읽는 시간: 15분 | 난이도: 중급


참고 자료#


문제 상황#

리스트 앱에서 항목을 삭제하거나 다른 영역으로 이동하는 기능이 필요합니다.
버튼을 탭하는 것보다 스와이프나 드래그가 더 직관적입니다.

일반적인 인터랙션 패턴#

스와이프 삭제: 이메일 앱에서 메일 삭제
드래그 앤 드롭: 장바구니에 상품 추가
재정렬: 리스트 항목 순서 변경

문제는 다음과 같습니다.

  • 스와이프 방향에 따른 다른 액션 처리가 필요하다.
  • 드래그 중인 항목의 시각적 피드백이 필요하다.
  • 드롭 영역 진입 시 하이라이트 표시가 필요하다.
  • 실수로 삭제한 경우 복구 방법이 필요하다.

해결 방법#

Flutter는 Dismissible로 스와이프 동작을, DraggableDragTarget으로 드래그 앤 드롭을 처리합니다.

챕터 1: 스와이프 삭제 기본 구현#

Why#

NOTE

리스트 항목을 삭제할 때 별도 버튼을 찾아 누르는 것보다 스와이프가 더 빠릅니다.
Dismissible 위젯은 이 패턴을 쉽게 구현하게 해줍니다.

사용자 스와이프 → Dismissible 감지 → onDismissed 콜백 → 상태 업데이트

What#

NOTE

Dismissible은 자식 위젯을 스와이프하여 화면에서 제거할 수 있게 해주는 위젯입니다.
스와이프 방향, 배경, 콜백 등을 설정할 수 있습니다.

How#

TIP

기본 스와이프 삭제 구현

import 'package:flutter/material.dart';
class SwipeToDismissDemo extends StatefulWidget {
const SwipeToDismissDemo({super.key});
@override
State<SwipeToDismissDemo> createState() => _SwipeToDismissDemoState();
}
class _SwipeToDismissDemoState extends State<SwipeToDismissDemo> {
final items = List<String>.generate(20, (i) => '항목 ${i + 1}');
@override
Widget 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

backgroundsecondaryBackground로 양방향에 다른 배경을 표시합니다.
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를 반환하면 항목이 원래 위치로 돌아갑니다.
onDismissedconfirmDismisstrue를 반환한 후에만 호출됩니다.

confirmDismiss: (direction) async {
// false 반환 시 항목 복원
// true 반환 시 onDismissed 호출
return shouldDismiss;
},

결론: backgroundsecondaryBackground로 양방향 피드백을, 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});
@override
State<DragDropDemo> createState() => _DragDropDemoState();
}
class _DragDropDemoState extends State<DragDropDemo> {
String _droppedItem = '';
@override
Widget 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>의 타입 TDragTarget<T>의 타입이 일치해야 합니다.
타입이 다르면 드롭이 작동하지 않습니다.

// ❌ 타입 불일치로 드롭 실패
Draggable<int>(data: 1, ...)
DragTarget<String>(...) // String을 기대하지만 int가 전달됨
// ✅ 타입 일치
Draggable<Item>(data: item, ...)
DragTarget<Item>(...)

결론: DraggableDragTarget의 타입을 일치시키고, feedback으로 드래그 중 시각적 피드백을 제공합니다.


챕터 4: LongPressDraggable과 드래그 앵커#

Why#

NOTE

리스트 항목에서 Draggable을 사용하면 스크롤과 충돌할 수 있습니다.
LongPressDraggable은 롱프레스 후 드래그를 시작하여 스크롤과 충돌을 방지합니다.

리스트 스크롤: 짧은 터치로 스크롤
항목 드래그: 롱프레스 후 드래그

What#

NOTE

LongPressDraggable은 롱프레스를 인식한 후에만 드래그를 시작합니다.
dragAnchorStrategy는 드래그 시 위젯이 손가락에 상대적으로 어디에 위치할지 결정합니다.

How#

TIP

LongPressDraggable 구현

class DraggableMenuItem extends StatelessWidget {
const DraggableMenuItem({
super.key,
required this.item,
});
final MenuItem item;
@override
Widget 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

DragTargetbuilder는 드래그 상태에 따라 다른 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});
@override
State<ShoppingCart> createState() => _ShoppingCartState();
}
class _ShoppingCartState extends State<ShoppingCart> {
final List<Product> _cartItems = [];
@override
Widget 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로 조건부 드롭을 구현하고, candidateDatarejectedData로 상태별 UI를 표시합니다.


챕터 6: 앱 외부로 드래그#

Why#

NOTE

Flutter 앱과 다른 앱 간에 드래그 앤 드롭이 필요한 경우가 있습니다.
예를 들어 이미지를 파일 탐색기로 드래그하거나, 외부 파일을 앱으로 드래그하는 기능입니다.

앱 내 드래그: Draggable/DragTarget 사용
앱 간 드래그: super_drag_and_drop 패키지 사용

What#

NOTE

super_drag_and_drop 패키지는 Flutter 앱과 다른 앱 간의 드래그 앤 드롭을 지원합니다.
데스크톱, 모바일, 웹 플랫폼에서 모두 작동합니다.

How#

TIP

super_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});
@override
Widget 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('파일을 여기에 드롭하세요'),
),
),
);
}
}

앱 간 드래그 비교

기능Draggablesuper_drag_and_drop
앱 내 드래그
앱 간 드래그
플랫폼 지원모든 플랫폼데스크톱, 모바일, 웹
복잡도낮음중간
데이터 타입 선언비동기동기 (사전 선언)

Watch out#

WARNING

super_drag_and_drop은 플랫폼 API와 동기적으로 통신해야 합니다.
따라서 허용할 데이터 형식을 미리 선언해야 합니다.

// 허용할 형식을 미리 선언
DropRegion(
formats: [
Formats.png,
Formats.jpeg,
Formats.plainText,
],
// ...
)

앱 내 드래그만 필요하다면 DraggableDragTarget이 더 간단합니다.

결론: 앱 내 드래그는 기본 위젯을, 앱 간 드래그는 super_drag_and_drop 패키지를 사용합니다.


한계#

드래그와 스와이프 구현에는 몇 가지 한계가 있습니다.

  • 접근성: 스와이프 제스처는 접근성 사용자에게 어려울 수 있습니다. 대안적인 삭제 방법을 제공해야 합니다.
  • 플랫폼 차이: 앱 간 드래그 앤 드롭은 플랫폼마다 지원 범위가 다릅니다.
  • 성능: 복잡한 피드백 위젯은 드래그 중 프레임 드롭을 유발할 수 있습니다.
  • 제스처 충돌: 스크롤, 탭 등 다른 제스처와 충돌할 수 있습니다.

Footnotes#

  1. Dismissible(디스미서블): 스와이프 제스처로 자식 위젯을 화면에서 제거할 수 있게 해주는 위젯이다.

  2. Draggable(드래그어블): 드래그할 수 있는 위젯을 만드는 클래스다. 타입 파라미터로 전달할 데이터 타입을 지정한다.

  3. DragTarget(드래그 타겟): Draggable 위젯이 드롭될 수 있는 영역을 정의하는 위젯이다.

공유

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

Flutter 튜토리얼 14편: 드래그와 스와이프 구현
https://moodturnpost.net/posts/flutter/flutter-drag-swipe/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차