Drag | CoUI
LogoCoUI

Drag

드래그·드롭·재정렬을 조립하는 프리미티브 (DragScope / DragItem / DropZone / DragOverlay)

Drag#

SortableDraggablePanel 이 쓰는 드래그 엔진을 그대로 노출한 프리미티브입니다. 준비된 컴포넌트로 표현되지 않는 상호작용(칸반 보드, 휴지통에 버리기, 캔버스 자유 배치)을 직접 조립할 때 씁니다.

무엇이 드래그인지 — 활성화 · 충돌 판정 · 세션 — 는 coui_core 의 순수 Dart 엔진이 소유하고, 각 플랫폼은 입력과 측정만 공급합니다. 그래서 Flutter 와 Web 이 같은 결정을 읽습니다.

Live Preview#

사용 시기 (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 를 노출합니다 (childWidgetComponent).

DragScope#

속성타입기본값설명
child Widget / Component 필수 이 드래그 런타임을 공유할 subtree
controller DragScopeController? null 직접 만든 런타임. null 이면 scope 가 자체 컨트롤러를 소유

DragScopeController

속성타입기본값설명
modifiers Iterable<CoreDragModifier> const [] 모든 이동에 적용되는 제약 (축 잠금 · 경계 · 격자). 순서대로 적용
autoScroll CoreDragAutoScrollConfig CoreDragAutoScrollConfig.standard 가장자리 자동 스크롤 세기

DragItem#

속성타입기본값설명
idCoreDragId필수scope 안에서의 안정적인 정체성
child Widget / Component 필수 잡을 수 있는 콘텐츠
enabled bool? null (= true) 드래그 가능 여부
data Object? null 드롭 시 앱이 해석할 임의 payload
activationConstraint CoreDragActivationConstraint CoreDragActivationConstraint.immediate 드래그가 시작되는 조건 (즉시 / 이동 거리 / 지연)
keyboardDragStep double 25 화살표 키 한 번당 이동 거리 (논리 픽셀)
labelString?null스크린 리더 라벨
hintString?null스크린 리더 힌트
onDragStart CoreDragStartCallback? null 드래그 시작
onDragMove CoreDragMoveCallback? null 이동 중 (프레임마다)
onDragEnd CoreDragEndCallback? null 놓임 — event.overId 로 착지 zone 확인
onDragCancel CoreDragCancelCallback? null Escape / 취소

DragHandle#

속성타입기본값설명
child Widget / Component 필수 잡는 면. 이 핸들이 있으면 항목의 나머지 영역은 잡히지 않음

DropZone#

속성타입기본값설명
idCoreDragId필수착지 영역의 정체성
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#

스크롤 표면과 공유하는 항목은 activationConstraintdelay를 줘서 스와이프-스크롤과 구분

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 — 떠다니는 단일 패널