Sortable | CoUI
본문으로 건너뛰기
LogoCoUI

Sortable

드래그 앤 드롭으로 항목 순서를 변경하는 정렬 컴포넌트

목차

드래그 앤 드롭으로 항목의 순서를 변경할 수 있는 정렬 가능한 리스트 컴포넌트입니다.

기본 사용법 (Basic Usage)#

Sortable(
  itemCount: items.length,
  onReorder: (oldIndex, newIndex) {
    setState(() {
      final newItems = [...items];
      final item = newItems.removeAt(oldIndex);
      newItems.insert(newIndex, item);
      items = newItems;
    });
  },
  itemBuilder: (context, index, dragHandle, removeButton) {
    return Row(
      children: [
        dragHandle,
        Expanded(child: Text(items[index])),
        if (removeButton != null) removeButton,
      ],
    );
  },
)
Sortable(
  itemCount: items.length,
  onReorder: (oldIndex, newIndex) => reorderItems(oldIndex, newIndex),
  itemBuilder: (index, dragHandle, removeButton) => div(
    [
      dragHandle,
      Text(items[index]).bodyMedium.onSurface,
      if (removeButton != null) removeButton,
    ],
    classes: 'flex items-center flex-1',
  ),
)

빠른 오버라이드 (Chain)#

이미 만든 Sortable 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.

class SortableChainExample extends StatefulWidget {
  const SortableChainExample({super.key});

  @override
  State<SortableChainExample> createState() => _SortableChainExampleState();
}

class _SortableChainExampleState extends State<SortableChainExample> {
  List<String> _items = const ['Item 1', 'Item 2', 'Item 3'];

  void handleReorder(int oldIndex, int newIndex) {
    setState(() {
      final newItems = [..._items];
      final item = newItems.removeAt(oldIndex);
      newItems.insert(newIndex, item);
      _items = newItems;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Sortable(
      itemCount: _items.length,
      onReorder: handleReorder,
      itemBuilder: (context, index, dragHandle, removeButton) {
        return Row(
          mainAxisSize: MainAxisSize.min,
          spacing: Theme.of(context).geometry.space(CoreSpace.space8),
          children: [
            dragHandle,
            Text(_items[index]),
          ],
        );
      },
    ).withStyle(
      const CoreSortableStyle(
        itemBackgroundColor: CoreColor.token(CoreColors.surfaceContainerHigh),
        itemBorderRadius: CoreBorderRadius.all(CoreRadius.radius16),
        itemPadding: CoreEdgeInsets.symmetric(
          horizontal: CoreSpace.space16,
          vertical: CoreSpace.space12,
        ),
        handleColor: CoreColor.token(CoreColors.primary),
      ),
    );
  }
}
class SortableChainExample extends StatefulComponent {
  const SortableChainExample({super.key});

  @override
  State<SortableChainExample> createState() => _SortableChainExampleState();
}

class _SortableChainExampleState extends State<SortableChainExample> {
  List<String> _items = const ['Item 1', 'Item 2', 'Item 3'];

  void handleReorder(int oldIndex, int newIndex) {
    setState(() {
      final newItems = [..._items];
      final item = newItems.removeAt(oldIndex);
      newItems.insert(newIndex, item);
      _items = newItems;
    });
  }

  @override
  Component build(BuildContext context) {
    return Sortable(
      itemCount: _items.length,
      onReorder: handleReorder,
      itemBuilder: (index, dragHandle, removeButton) => div(
        [
          dragHandle,
          span([Text(_items[index])]),
        ],
        classes: 'flex items-center flex-1 gap-${CoreSpace.scale.space8}',
      ),
    ).withStyle(
      const CoreSortableStyle(
        itemBackgroundColor: CoreColor.token(CoreColors.surfaceContainerHigh),
        itemBorderRadius: CoreBorderRadius.all(CoreRadius.radius16),
        itemPadding: CoreEdgeInsets.symmetric(
          horizontal: CoreSpace.space16,
          vertical: CoreSpace.space12,
        ),
        handleColor: CoreColor.token(CoreColors.primary),
      ),
    );
  }
}

Live Preview#

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • 사용자가 항목의 우선순위나 순서를 직접 지정해야 하는 경우 (할 일 목록, 메뉴 순서 등)
  • 대시보드 위젯, 사이드바 메뉴 순서를 사용자가 커스터마이즈할 수 있어야 하는 경우
  • 카테고리나 태그의 노출 순서를 관리자가 직접 설정해야 하는 경우

대신 다른 컴포넌트를 사용하세요:

  • Table: 정렬이 필요 없는 단순 데이터 표시 테이블
  • Tree: 항목 간 계층 구조(부모-자식)가 있는 경우

Props / Parameters#

W는 Flutter의 Widget, Web의 Component를 뜻합니다. itemBuilder의 BuildContext 인자는 Flutter 빌더에만 있으며 드래그 핸들과 제거 버튼은 해당 플랫폼의 합성 자식입니다. 인덱스는 0-based입니다.

속성타입기본값설명
itemCountint필수정렬할 항목 수
itemBuilder Flutter: W Function(BuildContext, int, W, W?) / Web: W Function(int, W, W?) 필수 항목 빌더 (index, dragHandle, removeButton)
axis CoreSortableAxis CoreSortableContract.defaultAxis (vertical) 리스트가 흐르는 축 (vertical / horizontal / grid)
onReorder void Function(int oldIndex, int newIndex)? null 순서 변경 콜백
onRemove void Function(int index)? null 항목 제거 콜백 (removable true 시 필요)
enabled bool true 드래그 앤 드롭 활성화 여부
removable bool false 제거 버튼 표시 여부 (true 시 onRemove 필요)
sortableStyle CoreSortableStyle? null 항목 / 핸들 / 제거 버튼 chrome + nested 슬롯 단일 진입점

Web 은 위 파라미터에 더해 id / classes / css / attributes 와 DOM 이벤트 슬롯(onClick / onKeyDown 등)을 받아 루트 <div> 로 통과시킵니다.

CoreSortableStyle 필드#

필드타입설명
itemBackgroundColor CoreColor? Item background colour.
itemHoverColor CoreColor? Item hover background colour.
itemBorderColorCoreColor?Item border colour.
disabledItemBackgroundColor CoreColor? Item background colour while the list is disabled.
disabledItemBorderColor CoreColor? Item border colour while the list is disabled.
handleColorCoreColor?Drag handle colour.
disabledHandleColor CoreColor? Drag handle colour while the list is disabled.
disabledForegroundColor CoreColor? Item text and remove-button glyph colour while the list is disabled. null → [defaultDisabledForegroundColor].
removeButtonColor CoreColor? Remove button colour.
itemBorderRadius CoreBorderRadius? Item border radius.
itemBorderWidth double? Item border width (logical px).
itemMinHeight double? Minimum item height (logical px).
removeButtonBorderRadius CoreBorderRadius? Remove button corner radius.
itemSpacing double? Spacing between items (logical px).
itemPadding CoreEdgeInsets? Inner padding of each sortable item.
handlePadding CoreEdgeInsets? Padding around the drag handle.
removeButtonSize double? Size of the remove button hit area (logical px, square).
animationDuration Duration? Duration of the reorder shift / colour transition. Both platforms convert this to their native unit — Flutter passes the Duration straight through, Web maps it to a duration-* Tailwind class.
proxyScale double? Drag-proxy peak scale multiplier ( 1.0 = no change). null → [defaultProxyScale]. Flutter feeds Transform.scale ; Web (no proxy yet) leaves it unconsumed.
proxyElevation double? Drag-proxy peak Material elevation. null → [defaultProxyElevation]. Flutter feeds Material.elevation ; Web (no proxy yet) unconsumed.
itemTextStyle CoreTextStyle? Per-item label typography slot (sb-text-style-repackage). Overlays the [defaultItemTextStyle] ( bodyLarge role + onSurface colour); the text colour is carried via [CoreTextStyle.color] inside this slot, so both platforms drive the item label typography / colour from the single nested slot.
iconStyle CoreIconStyle? Shared icon slot style (size + colour) for both the drag-handle and the remove glyphs.
removeButtonStyle CoreButtonStyle? Remove (dismiss) button style override. Mirrors the [removeButtonSize] / [removeButtonBorderRadius] / [removeButtonColor] flat fields as a nested CoreButtonStyle slot. When both flat and nested are set the resolver prefers removeButtonStyle.X , then falls back to the flat field, then to the design-system default.
handleStyle CoreButtonStyle? Drag handle style override. Mirrors the [handlePadding] / [handleColor] flat fields as a nested CoreButtonStyle slot. When both flat and nested are set the resolver prefers handleStyle.X , then falls back to the flat field, then to the design-system default.

제거 가능한 항목 (Removable Items)#

Sortable(
  itemCount: items.length,
  onReorder: handleReorder,
  onRemove: (index) {
    setState(() {
      items = [
        ...items.sublist(0, index),
        ...items.sublist(index + 1),
      ];
    });
  },
  removable: true,
  itemBuilder: (context, index, dragHandle, removeButton) {
    return Row(
      children: [
        dragHandle,
        Expanded(child: Text(items[index])),
        if (removeButton != null) removeButton,
      ],
    );
  },
)

스타일 시스템 (Style System)#

테마 커스터마이징 (Theme Customization)#

CoreComponentTheme.sortable을 통해 프로젝트 레벨에서 스타일을 오버라이드할 수 있습니다.

CoreComponentTheme(
  sortable: CoreSortableTheme(
    style: CoreSortableStyle(
      itemBackgroundColor: CoreColor.token(CoreColors.surfaceContainer),
      itemBorderRadius: CoreBorderRadius.all(CoreRadius.radius8),
      handleColor: CoreColor.token(CoreColors.onSurfaceVariant),
      itemSpacing: CoreSpace.space12,
    ),
  ),
)

동작 스펙 (Behavior)#

모양#

  • 한 행은 lg 컨트롤 행의 최소 높이 48 · 모서리 12 · outline-variant 1 테두리이며, 안쪽 간격은 읽기 방향 기준 앞 12 · 뒤 8 입니다(위아래는 0 — 내용이 세로 가운데에 놓입니다).
  • 드래그 핸들은 on-surface-subtle, 제거 아이콘은 한 단계 진한 on-surface-variant 이고, 핸들은 아이콘 폭(16) 그대로입니다.
  • 제거 버튼은 합성된 Button(variant: .plain, size: .sm, shape: .square) 로 32 정사각이며 글리프는 16 입니다. 호버 · 포커스는 Button 이 그립니다(removeButtonStyle 로 덮어씁니다).
  • enabled: false 는 불투명도가 아니라 disabled-container 면 · disabled-outline 테두리 · disabled-content 핸들 · 글자 · 제거 아이콘으로 그립니다.

인터랙션#

  • 드래그 핸들을 잡아 끌면 순서가 바뀝니다. 충돌 판정과 재정렬 계산은 양 플랫폼이 같은 coui_core 엔진(CoreSortable.resolve)을 쓰므로 결과가 동일합니다.
  • 드래그 중 나머지 항목은 animationDuration 동안 실시간으로 밀려납니다.
  • 키보드로도 같은 재정렬을 합니다 — 드래그 핸들에 포커스를 두고 Space / Enter 로 집고, 화살표로 한 칸씩 옮기고, 다시 Space / Enter 로 놓습니다. 키 표와 안내 문장은 접근성 절에 있습니다.
  • axis: CoreSortableAxis.grid 면 주축 하나로 환원할 수 없으므로 2D 근접도로 착지 위치를 정합니다.

커서#

  • 드래그 핸들은 Web 에서 cursor: grab / cursor: grabbing 을 표시합니다. 역할 · 키보드 · 포커스는 아래 접근성 절에 정리되어 있습니다.

사용 가이드라인 (Usage Guidelines)#

사용자가 항목의 순서를 바꿔야 하면 Sortable을 사용합니다. 순서를 고정한 행·열 목록은 Listing, pane 크기만 바꾸려면 Resizable, 셀의 반응형 배치는 ResponsiveGrid를 선택합니다. onReorder/onRemove 통보 뒤 실제 데이터 목록 갱신은 호출자가 담당합니다.

✅ Do#

removable: true 면 항상 onRemove 를 같이 제공

Sortable(
  itemCount: items.length,
  itemBuilder: itemBuilder,
  removable: true,
  onRemove: (index) => handleRemove(index),
)

removable: true 는 제거 버튼을 렌더할 뿐, 실제 제거 로직은 onRemove 콜백이 담당합니다 — 콜백이 없으면 버튼이 보여도 클릭 시 아무 일도 일어나지 않습니다.


❌ Don't#

removable 만 켜고 onRemove 콜백 누락 금지

// ❌ 제거 버튼은 보이지만 클릭해도 아무 동작 없음
Sortable(
  itemCount: items.length,
  itemBuilder: itemBuilder,
  removable: true,
)

사용자에게는 고장난 버튼으로 보입니다 — 버튼이 그려지는 조건과 실제 제거가 일어나는 조건이 다르다는 것을 눈으로 확인할 방법이 없습니다.

✅ Do#

itemBuilder 안에 읽을 수 있는 텍스트 라벨을 직접 포함

itemBuilder: (context, index, dragHandle, removeButton) => Row(
  children: [
    dragHandle,
    Expanded(child: Text(items[index]).bodyMedium),
    if (removeButton != null) removeButton,
  ],
),

Sortable 은 내부 DragItem 에 label/hint 를 전달하지 않고, Flutter 쪽은 list/listitem 시맨틱 자체가 없습니다 — 항목을 구분할 유일한 정보는 itemBuilder 가 직접 넣은 텍스트뿐입니다.


❌ Don't#

아이콘만으로 항목을 구성하지 않기

// ❌ 텍스트 라벨 없이 아이콘만
itemBuilder: (context, index, dragHandle, removeButton) => Row(
  children: [dragHandle, Icon(LucideIcons.file), if (removeButton != null) removeButton],
),

스크린 리더 사용자에게는 이름 없는 영역과 이름 없는 버튼만 남아 어떤 항목인지 구분할 수 없습니다.

접근성 (Accessibility)#

역할#

  • Web: 루트 <div> 에 role="list", 각 행에 role="listitem" 이 붙습니다. 재정렬이 가능한 경우(enabled && onReorder != null) 각 행은 합성된 DragItem 이 한 겹 더 감싸지만, 그 래퍼는 포인터 드래그만 받는 평범한 <div> 입니다 — 키보드 경로는 핸들이 맡습니다. 드래그 핸들은 role="application" · tabindex="0" 을 갖고, aria-label 로 위치를 (Reorder item 2 of 5 / 5개 중 2번째 항목 순서 바꾸기), aria-description 으로 조작법을 알립니다. 제거 버튼은 합성된 Button(plain · sm · square)이고 aria-label 로 로컬라이즈된 이름을 갖습니다.
  • Flutter: 드래그 핸들이 자기 시맨틱 노드를 가지며 같은 라벨 · 힌트를 싣습니다. 제거 버튼은 로컬라이즈된 라벨의 Semantics(button: true) 입니다. list / listitem 에 해당하는 시맨틱은 없습니다.

핸들이 role="button" 이 아니라 application 인 이유: 브라우저 읽기 모드의 스크린 리더는 버튼 위의 Space 를 클릭으로 바꾸고 화살표를 자기 가상 커서에 씁니다. 그러면 키보드 재정렬 키가 핸들에 도달하지 못합니다.

키보드#

enabled 가 true 이고 onReorder 가 있을 때 드래그 핸들이 포커스를 받고 아래 키를 처리합니다. 한 번 누를 때마다 한 칸씩 움직이며 행 높이와 무관합니다.

키동작
Space / Enter항목 집어들기 — 집은 상태면 그 자리에 놓기
↑ ↓ ( vertical ) · ← → ( horizontal ) 집은 항목을 한 칸 이동
← → 한 칸 · ↑ ↓ 한 줄 (grid) 집은 항목을 이동 — 줄 길이는 측정한 첫 줄에서 셉니다
Home / End맨 앞 / 맨 뒤로 이동
Escape이동을 취소하고 원래 자리로 되돌리기
Enter / Space (제거 버튼 포커스 시)항목 제거
  • 목록의 끝은 벽입니다 — 마지막 항목에서 ↓ 를 눌러도 맨 앞으로 돌아가지 않습니다.
  • 항목을 집은 동안에는 모든 화살표를 핸들이 소비해 페이지가 스크롤되지 않습니다.
  • 놓으면 포인터 드래그와 같은 시그니처로 onReorder(oldIndex, newIndex) 가 호출됩니다. 미리보기 · 형제 항목 밀려남 · 커밋은 포인터와 같은 엔진(CoreSortable.resolve)을 지나므로 두 입력이 다른 자리에 놓을 수 없습니다.
  • 집은 채로 포커스가 핸들을 떠나면 이동이 취소됩니다.

포커스 관리#

  • 행마다 탭 정지는 핸들 하나(와 제거 버튼)입니다. 행 래퍼는 탭 순서에 들어가지 않습니다.
  • 핸들은 키보드 포커스일 때 디자인 시스템 포커스 링(FocusOutline)을 그립니다. 링은 sortableStyle.handleStyle.focusOutlineStyle 로 바꿀 수 있습니다. Web 은 :focus-visible 일 때만 링을 그려 포인터로 잡을 때는 나타나지 않습니다.
  • 놓은 뒤 포커스는 항목이 착지한 자리의 핸들로 옮겨 갑니다 — 행은 위치 기준이라, 그대로 두면 다음 화살표가 다른 항목을 움직입니다. 취소하면 원래 핸들에 남습니다.

스크린 리더#

키보드로 옮길 때 각 단계가 위치와 함께 안내됩니다. Web 은 DragScope 의 polite live region, Flutter 는 플랫폼 안내(SemanticsService)를 씁니다.

순간안내 (en)안내 (ko)
집기 Picked up item 1 of 3. Use the arrow keys to move it. 3개 중 1번째 항목을 집었습니다. 화살표 키로 옮기세요.
이동Moved to position 2 of 3.3개 중 2번째 위치로 옮겼습니다.
놓기Dropped at position 2 of 3.3개 중 2번째 위치에 놓았습니다.
취소Cancelled. Returned to position 1 of 3.취소했습니다. 3개 중 1번째 위치로 돌아갔습니다.

한 단계에 한 문장입니다 — 드래그 엔진의 일반 문장("Picked up." 등)을 대신하며, 그 뒤에 덧붙지 않습니다. 포인터로 드래그할 때는 일반 문장이 그대로 나갑니다.

알려진 제약#

  • 포인터 드래그는 위치를 안내하지 않습니다 — 집기 · 놓기 · 취소의 일반 문장만 나옵니다.
  • Flutter 에는 list / listitem 시맨틱이 없어 같은 코드가 두 플랫폼에서 다르게 읽힙니다.
  • 오른쪽에서 왼쪽으로 쓰는 로케일에서도 ← 가 앞 칸, → 가 뒤 칸입니다 — 화살표 방향은 아직 미러링하지 않습니다.
  • enabled: false 이거나 onReorder 가 없으면 핸들은 표시용 아이콘으로만 그려지고 포커스 · 키 조작이 없습니다.

reduced motion · 고대비 · 최소 터치 타겟 등 컴포넌트를 가로지르는 축은 전역 접근성 축에서 다룹니다.

크로스 플랫폼 차이점 (Platform Differences)#

항목FlutterWeb
재정렬 계산 coui_core 엔진 (CoreSortable.resolve) coui_core 엔진 (CoreSortable.resolve)
드래그 핸들 아이콘 Icon(LucideIcons.gripVertical) Icon(LucideIcons.gripVertical)
제거 버튼 아이콘Icon(LucideIcons.x)텍스트 글리프 ✕
드래그 프록시 proxyScale / proxyElevation 적용 프록시 없음 (두 필드 미소비)
항목 래핑 itemBuilder 가 BuildContext 를 함께 받음 itemBuilder 는 index 부터 시작
  • Table: 데이터를 표 형태로 표시하며 컬럼 정렬이 필요한 경우
  • Tree: 계층 구조로 된 항목의 순서를 변경해야 하는 경우