Drag#
Sortable 과 DraggablePanel 이 쓰는 드래그 엔진을 그대로 노출한 프리미티브입니다. 준비된 컴포넌트로 표현되지 않는 상호작용(칸반 보드, 휴지통에 버리기, 캔버스 자유 배치)을 직접 조립할 때 씁니다.
무엇이 드래그인지 — 활성화 · 충돌 판정 · 세션 — 는 coui_core 의 순수 Dart 엔진이 소유하고, 각 플랫폼은 입력과 측정만 공급합니다. 그래서 Flutter 와 Web 이 같은 결정을 읽습니다.
Live Preview#
/// The smallest complete drag interaction: one item, one zone.
class DragDefaultExample extends StatefulComponent {
const DragDefaultExample({super.key});
@override
State<DragDefaultExample> createState() => _DragDefaultExampleState();
}
const _bin = CoreDragId('bin');
class _DragDefaultExampleState extends State<DragDefaultExample> {
bool _dropped = false;
void handleDragEnd(CoreDragEndEvent event) {
setState(() => _dropped = event.overId == _bin);
}
@override
Component build(BuildContext context) {
final cs = context.colorScheme;
return DragScope(
child: div(
[
DragItem(
id: const CoreDragId('card'),
onDragEnd: handleDragEnd,
child: Card(
cardStyle: CoreCardStyle(
backgroundColor: cs.surfaceContainer,
padding: const CoreEdgeInsets.all(CoreSpace.space12),
),
child: const Text('Drag me').bodyMedium.onSurface,
),
),
DropZone(
id: _bin,
builder: (context, dropDetails, child) => Card(
cardStyle: CoreCardStyle(
backgroundColor: dropDetails.isOver
? cs.primaryContainer
: cs.surfaceContainerLow,
padding: const CoreEdgeInsets.all(CoreSpace.space16),
),
child: child,
),
child: Text(
_dropped ? 'Dropped' : 'Drop here',
).bodyMedium.onSurfaceVariant,
),
],
classes: 'flex flex-col items-start gap-${CoreSpace.scale.space16}',
),
);
}
}
/// The smallest complete drag interaction: one item, one zone.
class DragDefaultExample extends StatefulWidget {
const DragDefaultExample({super.key});
@override
State<DragDefaultExample> createState() => _DragDefaultExampleState();
}
const _bin = CoreDragId('bin');
class _DragDefaultExampleState extends State<DragDefaultExample> {
bool _dropped = false;
void handleDragEnd(CoreDragEndEvent event) {
setState(() => _dropped = event.overId == _bin);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return DragScope(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: CoreSpace.space16,
children: [
DragItem(
id: const CoreDragId('card'),
onDragEnd: handleDragEnd,
child: Card(
cardStyle: CoreCardStyle(
backgroundColor: cs.surfaceContainer,
padding: const CoreEdgeInsets.all(CoreSpace.space12),
),
child: const Text('Drag me').bodyMedium.onSurface,
),
),
DropZone(
id: _bin,
builder: (context, dropDetails, child) => Card(
cardStyle: CoreCardStyle(
backgroundColor: dropDetails.isOver
? cs.primaryContainer
: cs.surfaceContainerLow,
padding: const CoreEdgeInsets.all(CoreSpace.space16),
),
child: child,
),
child: Text(
_dropped ? 'Dropped' : 'Drop here',
).bodyMedium.onSurfaceVariant,
),
],
),
);
}
}
/// A two-column board built from drag primitives alone.
///
/// There is no board component: `DropZone` measures the columns,
/// `DragItem` measures the cards, and `CoreSortableAcross` reads both out
/// of the shared registry to say where a card would land.
class DragBoardExample extends StatefulComponent {
const DragBoardExample({super.key});
@override
State<DragBoardExample> createState() => _DragBoardExampleState();
}
const _todo = CoreDragId('todo');
const _doing = CoreDragId('doing');
class _DragBoardExampleState extends State<DragBoardExample> {
final DragScopeController _controller = DragScopeController();
Map<CoreDragId, List<CoreDragId>> _board = {
_todo: const [CoreDragId('Write spec'), CoreDragId('Review PR')],
_doing: const [CoreDragId('Fix bug')],
};
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void handleDragEnd(CoreDragEndEvent event) {
final result = CoreSortableAcross.resolve(
activeId: event.session.activeId,
containers: _board,
itemRects: _controller.registry.itemRects,
// The columns registered themselves as drop zones, so their measured
// rects are already here — nothing extra to wire up.
containerRects: _controller.registry.dropZoneRects,
pointer: event.session.pointer,
transform: event.session.transform,
);
if (!result.moves) return;
setState(() {
final next = {
for (final entry in _board.entries) entry.key: [...entry.value],
};
next[result.fromContainer]!.removeAt(result.fromIndex);
next[result.toContainer]!.insert(
result.toIndex.clamp(0, next[result.toContainer]!.length),
event.session.activeId,
);
_board = next;
});
}
@override
Component build(BuildContext context) {
return DragScope(
controller: _controller,
child: div(
[_buildColumn(context, _todo, 'To do'), _buildColumn(context, _doing, 'Doing')],
classes: 'flex items-start gap-${CoreSpace.scale.space12}',
),
);
}
Component _buildColumn(BuildContext context, CoreDragId column, String title) {
final cs = context.colorScheme;
return DropZone(
id: column,
builder: (context, dropDetails, child) => Card(
cardStyle: CoreCardStyle(
backgroundColor: dropDetails.isOver
? cs.surfaceContainerHigh
: cs.surfaceContainer,
padding: const CoreEdgeInsets.all(CoreSpace.space8),
),
child: child,
),
child: div(
[
Text(title).labelMedium.onSurfaceVariant,
for (final card in _board[column]!) _buildCard(card),
],
classes:
'flex flex-col w-${CoreSpace.scale.space160} '
'gap-${CoreSpace.scale.space8}',
),
);
}
Component _buildCard(CoreDragId card) => DragItem(
id: card,
onDragEnd: handleDragEnd,
child: Card(
cardStyle: const CoreCardStyle(
padding: CoreEdgeInsets.all(CoreSpace.space8),
sizing: CoreCardSizing.expand,
),
child: Text(card.value).bodySmall.onSurface,
),
);
}
/// A two-column board built from drag primitives alone.
///
/// There is no board component: `DropZone` measures the columns,
/// `DragItem` measures the cards, and `CoreSortableAcross` reads both out
/// of the shared registry to say where a card would land.
class DragBoardExample extends StatefulWidget {
const DragBoardExample({super.key});
@override
State<DragBoardExample> createState() => _DragBoardExampleState();
}
const _todo = CoreDragId('todo');
const _doing = CoreDragId('doing');
class _DragBoardExampleState extends State<DragBoardExample> {
final DragScopeController _controller = DragScopeController();
Map<CoreDragId, List<CoreDragId>> _board = {
_todo: const [CoreDragId('Write spec'), CoreDragId('Review PR')],
_doing: const [CoreDragId('Fix bug')],
};
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void handleDragEnd(CoreDragEndEvent event) {
final result = CoreSortableAcross.resolve(
activeId: event.session.activeId,
containers: _board,
itemRects: _controller.registry.itemRects,
// The columns registered themselves as drop zones, so their measured
// rects are already here — nothing extra to wire up.
containerRects: _controller.registry.dropZoneRects,
pointer: event.session.pointer,
transform: event.session.transform,
);
if (!result.moves) return;
setState(() {
final next = {
for (final entry in _board.entries) entry.key: [...entry.value],
};
next[result.fromContainer]!.removeAt(result.fromIndex);
next[result.toContainer]!.insert(
result.toIndex.clamp(0, next[result.toContainer]!.length),
event.session.activeId,
);
_board = next;
});
}
@override
Widget build(BuildContext context) {
return DragScope(
controller: _controller,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: CoreSpace.space12,
children: [
_buildColumn(_todo, 'To do'),
_buildColumn(_doing, 'Doing'),
],
),
);
}
Widget _buildColumn(CoreDragId column, String title) {
final cs = Theme.of(context).colorScheme;
return DropZone(
id: column,
builder: (context, dropDetails, child) => Card(
cardStyle: CoreCardStyle(
backgroundColor: dropDetails.isOver
? cs.surfaceContainerHigh
: cs.surfaceContainer,
padding: const CoreEdgeInsets.all(CoreSpace.space8),
),
child: child,
),
child: SizedBox(
width: CoreSpace.space160,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
spacing: CoreSpace.space8,
children: [
Text(title).labelMedium.onSurfaceVariant,
for (final card in _board[column]!) _buildCard(card),
],
),
),
);
}
Widget _buildCard(CoreDragId card) => DragItem(
id: card,
onDragEnd: handleDragEnd,
child: Card(
cardStyle: const CoreCardStyle(
padding: CoreEdgeInsets.all(CoreSpace.space8),
sizing: CoreCardSizing.expand,
),
child: Text(card.value).bodySmall.onSurface,
),
);
}
/// A dragged card lifted out of its clipping parent by `DragOverlay`.
///
/// The list clips its children, so the card alone would be cut off at the
/// boundary. The overlay draws the copy above everything instead.
class DragOverlayExample extends StatelessComponent {
const DragOverlayExample({super.key});
@override
Component build(BuildContext context) {
final cs = context.colorScheme;
return DragScope(
child: div(
[
div(
[
DragItem(
id: const CoreDragId('card'),
child: Card(
cardStyle: CoreCardStyle(
backgroundColor: cs.surfaceContainer,
padding: const CoreEdgeInsets.all(CoreSpace.space12),
),
child: const Text('Drag past the edge').bodyMedium.onSurface,
),
),
],
classes: 'overflow-hidden h-${CoreSpace.scale.space64}',
),
DragOverlay(
builder: (context, session) => Card(
cardStyle: CoreCardStyle(
backgroundColor: cs.primaryContainer,
padding: const CoreEdgeInsets.all(CoreSpace.space12),
),
child: Text(session.activeId.value).bodyMedium.onPrimaryContainer,
),
),
],
classes: 'flex flex-col items-start gap-${CoreSpace.scale.space8}',
),
);
}
}
/// A dragged card lifted out of its clipping parent by `DragOverlay`.
///
/// The list clips its children, so the card alone would be cut off at the
/// boundary. The overlay draws the copy above everything instead.
class DragOverlayExample extends StatelessWidget {
const DragOverlayExample({super.key});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return DragScope(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: CoreSpace.space8,
children: [
ClipRect(
child: SizedBox(
height: CoreSize.size64,
child: DragItem(
id: const CoreDragId('card'),
child: Card(
cardStyle: CoreCardStyle(
backgroundColor: cs.surfaceContainer,
padding: const CoreEdgeInsets.all(CoreSpace.space12),
),
child: const Text('Drag past the edge').bodyMedium.onSurface,
),
),
),
),
DragOverlay(
builder: (context, session) => Card(
cardStyle: CoreCardStyle(
backgroundColor: cs.primaryContainer,
padding: const CoreEdgeInsets.all(CoreSpace.space12),
),
child: Text(session.activeId.value).bodyMedium.onPrimaryContainer,
),
),
],
),
);
}
}
사용 시기 (When to Use)#
이 프리미티브를 사용하세요:
- 여러 컨테이너 사이로 항목을 옮겨야 하는 경우 (칸반 보드)
- 드롭 대상이 리스트가 아닌 경우 (휴지통, 즐겨찾기 영역)
- 드래그 결과를 앱이 직접 해석해야 하는 경우
대신 다른 컴포넌트를 사용하세요:
Sortable: 한 목록 안에서 순서만 바꾸면 되는 경우 — 핸들·제거 버튼·정렬 수학이 이미 들어 있습니다DraggablePanel: 화면 위를 떠다니는 단일 패널
구성 요소#
| 컴포넌트 | 역할 |
|---|---|
DragScope | 드래그 런타임 하나를 subtree 에 제공. 좌표계의 원점이자 참여자들의 측정 기준 |
DragItem | 이 subtree 를 잡을 수 있게 만든다. 생명주기 콜백으로 의도를 보고 |
DragHandle | 잡는 면을 자식으로 좁힌다 (없으면 항목 전체가 잡힌다) |
DropZone | 착지 영역으로 등록. builder 로 드래그-오버 상태를 렌더 |
DragOverlay | 끌리는 항목의 사본을 최상위 레이어에 그린다 |
기본 사용법 (Basic Usage)#
가장 작은 완결 상호작용 — 항목 하나를 영역 하나로:
DragScope(
child: Column(
children: [
DragItem(
id: CoreDragId('card'),
onDragEnd: (event) => setState(() => _dropped = event.overId == _bin),
child: Card(child: Text('Drag me').bodyMedium.onSurface),
),
DropZone(
id: _bin,
builder: (context, dropDetails, child) =>
Opacity(opacity: dropDetails.isOver ? 1 : 0.6, child: child),
child: Text('Drop here').bodyMedium.onSurfaceVariant,
),
],
),
)
Props / Parameters#
다섯 프리미티브 모두 Flutter / Web 에서 동일한 named-properties API 를 노출합니다 (child 만 Widget
↔ Component).
DragScope#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
child |
Widget / Component |
필수 | 이 드래그 런타임을 공유할 subtree |
controller |
DragScopeController? |
null |
직접 만든 런타임. null 이면 scope 가 자체 컨트롤러를 소유 |
DragScopeController
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
modifiers |
Iterable<CoreDragModifier> |
const [] |
모든 이동에 적용되는 제약 (축 잠금 · 경계 · 격자). 순서대로 적용 |
autoScroll |
CoreDragAutoScrollConfig |
CoreDragAutoScrollConfig.standard |
가장자리 자동 스크롤 세기 |
DragItem#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
id | CoreDragId | 필수 | scope 안에서의 안정적인 정체성 |
child |
Widget / Component |
필수 | 잡을 수 있는 콘텐츠 |
enabled |
bool? |
null (= true) |
드래그 가능 여부 |
data |
Object? |
null |
드롭 시 앱이 해석할 임의 payload |
activationConstraint |
CoreDragActivationConstraint |
CoreDragActivationConstraint.immediate |
드래그가 시작되는 조건 (즉시 / 이동 거리 / 지연) |
keyboardDragStep |
double |
25 |
화살표 키 한 번당 이동 거리 (논리 픽셀) |
label | String? | null | 스크린 리더 라벨 |
hint | String? | null | 스크린 리더 힌트 |
onDragStart |
CoreDragStartCallback? |
null |
드래그 시작 |
onDragMove |
CoreDragMoveCallback? |
null |
이동 중 (프레임마다) |
onDragEnd |
CoreDragEndCallback? |
null |
놓임 — event.overId 로 착지 zone 확인 |
onDragCancel |
CoreDragCancelCallback? |
null |
Escape / 취소 |
DragHandle#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
child |
Widget / Component |
필수 | 잡는 면. 이 핸들이 있으면 항목의 나머지 영역은 잡히지 않음 |
DropZone#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
id | CoreDragId | 필수 | 착지 영역의 정체성 |
child |
Widget / Component |
필수 | 영역 콘텐츠 |
builder |
DropZoneBuilder? |
null |
드래그-오버 상태(dropDetails.isOver)를 반영해 child 를 감싸는 빌더 |
enabled |
bool? |
null (= true) |
착지 대상으로 등록할지 |
data |
Object? |
null |
앱이 해석할 임의 payload |
DragOverlay#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
builder |
DragOverlayBuilder |
필수 | 활성 세션을 받아 최상위 레이어에 그릴 사본을 만드는 빌더 |
DragItem 은 스스로 움직이지 않는다#
끌린다고 해서 항목이 저절로 이동하지는 않습니다. 위치는 소비처가 세션을 읽어 정합니다.
의도된 설계입니다. DraggablePanel 같은 소비처는 같은 세션으로 이미 자기 위치를 계산하는데, 항목까지 스스로 움직이면 이동이 두 번 적용됩니다. 포인터를 따라가게 하려면:
final transform = DragScope.maybeOf(context)!.transformOf(id);
Transform.translate(offset: Offset(transform.x, transform.y), child: card)
보드 — 컨테이너 사이 이동#
보드 컴포넌트는 없습니다. 컬럼을 DropZone 으로, 카드를 DragItem 으로 두면 CoreSortableAcross
가 두 측정값을 읽어 착지 지점을 알려줍니다.
void handleDragEnd(CoreDragEndEvent event) {
final result = CoreSortableAcross.resolve(
activeId: event.session.activeId,
containers: _board,
itemRects: _controller.registry.itemRects,
// 컬럼이 스스로 drop zone 으로 등록했으므로 측정값이 이미 여기 있다.
containerRects: _controller.registry.dropZoneRects,
pointer: event.session.pointer,
transform: event.session.transform,
);
if (!result.moves) return;
// result.fromContainer/fromIndex → result.toContainer/toIndex
}
resolve 는 착지 인덱스와 형제 변위를 한 번에 냅니다. 따로 계산하면 미리 보여준 자리와 실제로 놓이는 자리가 갈라집니다.
이동 제약 (Modifier)#
축 잠금 · 경계 구속 · 격자 스냅은 엔진이 소유합니다. 렌더 시점에 좌표를 손으로 버리지 마세요.
DragScopeController(
modifiers: [
CoreDragModifiers.snapToGrid(x: CoreSpace.space8, y: CoreSpace.space8),
CoreDragModifiers.restrictToRect,
],
)
순서가 중요합니다 — 마지막 modifier 만 유지가 보장됩니다. 경계로 자른 뒤 격자로 스냅하면 방금 지킨 경계를 도로 넘을 수 있으므로, 반드시 이겨야 하는 제약을 맨 뒤에 둡니다.
자동 스크롤#
스크롤 컨테이너 안의 DragScope 는 가장자리에서 자동으로 스크롤합니다. 밴드 깊이에 비례해 속도가 붙고, 드래그가 끝나면 멈춥니다. 세기는 DragScopeController(autoScroll:)
로 조정합니다.
사용 가이드라인 (Usage Guidelines)#
✅ Do#
스크롤 표면과 공유하는 항목은 activationConstraint에 delay를 줘서 스와이프-스크롤과 구분
DragItem(
id: CoreDragId('row-3'),
activationConstraint: const CoreDragActivationConstraint(
delay: Duration(milliseconds: 200),
),
child: rowContent,
)
기본값 immediate는 포인터가 조금만 움직여도 드래그를 시작시켜, 스크롤 가능한 리스트 안에서는 스와이프-스크롤 의도를 드래그로 가로챕니다.
❌ Don't#
DragItem이 스스로 위치를 옮긴다고 가정하지 않기
// ❌ transform 을 읽지 않음 — 드래그해도 카드가 움직이지 않는다
DragItem(
id: CoreDragId('card'),
child: Card(child: Text('Drag me')),
)
위치는 소비처가 세션(DragScope.maybeOf(context)!.transformOf(id))을 직접 읽어 반영해야 합니다. 항목까지 스스로 움직이면 DraggablePanel 같은 소비처의 이동 계산과 겹쳐 이동이 두 번 적용되기 때문에 의도적으로 비워뒀습니다.
✅ Do#
반드시 지켜야 하는 제약은 modifiers 리스트의 마지막에 두기
DragScopeController(
modifiers: [
CoreDragModifiers.snapToGrid(x: CoreSpace.space8, y: CoreSpace.space8),
CoreDragModifiers.restrictToRect, // 마지막 = 반드시 지켜지는 제약
],
)
modifier는 순서대로 적용되고 마지막 modifier만 유지가 보장됩니다.
❌ Don't#
필수 제약을 마지막이 아닌 자리에 두지 않기
// ❌ restrictToRect 를 먼저 두면 뒤이은 snapToGrid 가 경계를 다시 넘길 수 있음
DragScopeController(
modifiers: [
CoreDragModifiers.restrictToRect,
CoreDragModifiers.snapToGrid(x: CoreSpace.space8, y: CoreSpace.space8),
],
)
경계로 자른 뒤 격자로 스냅하면 방금 지킨 경계를 도로 넘을 수 있습니다.
접근성 (Accessibility)#
-
DragItem은 포커스를 받고 키보드로 조작됩니다 — Space/Enter 로 집고, 화살표로 옮기고, 다시 Space/Enter 로 놓고, Escape 로 취소합니다. - 이동 폭은
keyboardDragStep으로 조정합니다. label/hint는 스크린 리더에 전달됩니다.
관련 컴포넌트#
- Sortable — 한 목록 안의 재정렬이 필요한 전부일 때
- DraggablePanel — 떠다니는 단일 패널