Sortable | CoUI
LogoCoUI

Sortable

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

Sortable#

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

Live Preview#

사용 시기 (When to Use)#

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

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

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

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

기본 사용법 (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: 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),
      ),
    );
  }
}

Props / Parameters#

속성타입기본값설명
itemCountint필수정렬할 항목 수
itemBuilder Flutter: Widget Function(BuildContext, int index, Widget dragHandle, Widget? removeButton)
Web: Component Function(int index, Component dragHandle, Component? 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 제거 버튼 표시 여부 (trueonRemove 필요)
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.
handleColorCoreColor?Drag handle colour.
removeButtonColor CoreColor? Remove button colour.
itemBorderRadius CoreBorderRadius? Item border radius.
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] ( bodyMedium 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,
      ],
    );
  },
)

테마 커스터마이징 (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)#

인터랙션#

  • 드래그 핸들을 잡아 끌면 순서가 바뀝니다. 충돌 판정과 재정렬 계산은 양 플랫폼이 같은 coui_core 엔진(CoreSortable.resolve)을 쓰므로 결과가 동일합니다.
  • 드래그 중 나머지 항목은 animationDuration 동안 실시간으로 밀려납니다.
  • axis: CoreSortableAxis.grid 면 주축 하나로 환원할 수 없으므로 2D 근접도로 착지 위치를 정합니다.

커서#

  • 드래그 핸들은 Web 에서 cursor: grab / cursor: grabbing 을 표시합니다. 역할 · 키보드 · 포커스는 아래 접근성 절에 정리되어 있습니다 (양 플랫폼이 비대칭입니다).

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

✅ 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 은 내부 DragItemlabel/hint 를 전달하지 않고, Flutter 쪽은 list/listitem 시맨틱 자체가 없습니다 — 항목을 구분할 유일한 정보는 itemBuilder 가 직접 넣은 텍스트뿐입니다.


❌ Don't#

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

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

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

접근성 (Accessibility)#

역할 (Semantics)#

두 플랫폼이 비대칭입니다.

  • Web: 루트 <div>role="list", 각 행에 role="listitem" 이 붙습니다. 재정렬이 가능한 경우(enabled && onReorder != null) 각 행은 합성된 DragItem 이 한 겹 더 감싸며, 그 래퍼가 role="application" · aria-grabbed · aria-disabled 를 내보냅니다. Sortable 은 DragItemlabel / hint 를 넘기지 않으므로 aria-label · aria-description 은 생략됩니다. 제거 버튼은 native <button type="button"> 입니다.
  • Flutter: 역할이 하나도 없습니다. DragItemSemantics(enabled:) 만 내보내고 (label / hint 미전달), 제거 버튼은 Clickable 이 주는 Semantics(button: true) 를 받습니다. list / listitem 에 해당하는 시맨틱은 존재하지 않습니다.

키보드#

재정렬 키보드 조작은 양 플랫폼에 있으며, enabledtrue 이고 onReorder 가 있을 때만 그리고 행 래퍼에 포커스가 있을 때만 동작합니다.

동작
Space / Enter항목 집어들기 — 드래그 중이면 그 자리에 놓기
Escape진행 중인 드래그 취소
드래그 중인 항목을 keyboardDragStep 만큼 이동
Enter / Space (제거 버튼 포커스 시)항목 제거

Tab / Shift+Tab 은 행과 제거 버튼 사이를 오갑니다. 드래그 핸들 자체는 어떤 키도 처리하지 않습니다.

포커스#

  • Flutter: DragItemFocusNode 를 소유하고 canRequestFocusenabled 에 묶으므로 재정렬 가능한 행이 탭으로 도달됩니다. 제거 버튼은 ClickableFocusableActionDetector 로 포커스되며 디자인 시스템 포커스 링(FocusOutline)을 그립니다.
  • Web: DragItem 이 활성 시 tabindex="0", 비활성 시 "-1" 을 붙입니다 — 이 컴포넌트에서 tabindex 를 만드는 곳은 여기뿐입니다. 제거 버튼은 native <button> 으로 포커스됩니다. Web 은 자체 포커스 링 CSS 를 내보내지 않아 브라우저 기본 outline 에 의존합니다.
  • 드래그 핸들은 양 플랫폼 모두 포커스를 받지 않습니다cursor: grab 만 있는 표시용 요소입니다.
  • 재정렬이나 제거 이후 포커스를 복원하지 않으며, 포커스를 가두지도 않습니다.

스크린 리더#

  • Web: 컨테이너는 리스트로, 행은 리스트 항목으로 안내되지만, 재정렬 경로에서는 role="application" 래퍼가 사이에 끼어 list > application > listitem 구조가 되므로 리스트–항목 관계가 끊깁니다. 포커스된 행은 이름 없는 application 영역과 aria-grabbed 상태로 읽힙니다. 제거 버튼의 이름은 본문 글리프 가 전부입니다.
  • Flutter: 행에는 이름도 힌트도 역할도 없고 enabled 만 실립니다. 제거 버튼은 이름 없는 버튼으로 읽힙니다(아이콘에 semanticLabel 이 없음).
  • 양 플랫폼 모두 위치(전체 몇 개 중 몇 번째) · 현재 드롭 대상 · 재정렬 결과를 안내하지 않습니다. live region 이나 안내 훅이 코드에 존재하지 않습니다.

알려진 제약#

  • 이름이 없습니다. Sortable 은 DragItemlabel / hint 를 전달하지 않고, 제거 버튼도 라벨이 없습니다(Web 글리프, Flutter 무명). 항목을 소리로 구분해야 한다면 소비자가 itemBuilder 안에서 직접 라벨 있는 콘텐츠를 넣어야 합니다.
  • 키보드로 재정렬해도 결과가 들리지 않습니다aria-posinset · aria-setsize · live region 이 없어 항목이 어디에 놓였는지 알 수 없습니다. 순서가 중요한 데이터라면 소비자가 별도의 상태 안내를 붙여야 합니다.
  • 눈에 보이는 어포던스(드래그 핸들)가 포커스를 받지 못합니다. 키보드 드래그는 행 래퍼에서 시작해야 하는데 그 사실을 알리는 힌트가 없어, 기능이 있어도 발견되지 않습니다.
  • Web 의 행별 role="application" 은 그 안에서 브라우저 읽기 모드를 억제하고 listitem 을 한 단계 깊게 중첩시킵니다.
  • Flutter 에는 list / listitem 시맨틱이 아예 없어 같은 코드가 두 플랫폼에서 다르게 읽힙니다.
  • enabled: false 이거나 onReorder 가 없으면 Flutter 는 포커스도 키 처리도 없는 껍데기를 렌더하고, Web 은 role="list" / "listitem" 은 유지하되 tabindex 와 모든 키 조작을 잃습니다.

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

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

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