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#
코드 보기
class SortableDefaultExample extends StatefulComponent {
const SortableDefaultExample({super.key});
@override
State<SortableDefaultExample> createState() => _SortableDefaultExampleState();
}
class _SortableDefaultExampleState extends State<SortableDefaultExample> {
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.space10}',
),
);
}
}
class SortableDefaultExample extends StatefulWidget {
const SortableDefaultExample({super.key});
@override
State<SortableDefaultExample> createState() => _SortableDefaultExampleState();
}
class _SortableDefaultExampleState extends State<SortableDefaultExample> {
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.space10),
children: [
dragHandle,
Text(_items[index]),
],
);
},
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 사용자가 항목의 우선순위나 순서를 직접 지정해야 하는 경우 (할 일 목록, 메뉴 순서 등)
- 대시보드 위젯, 사이드바 메뉴 순서를 사용자가 커스터마이즈할 수 있어야 하는 경우
- 카테고리나 태그의 노출 순서를 관리자가 직접 설정해야 하는 경우
대신 다른 컴포넌트를 사용하세요:
Table: 정렬이 필요 없는 단순 데이터 표시 테이블Tree: 항목 간 계층 구조(부모-자식)가 있는 경우
Props / Parameters#
W는 Flutter의 Widget, Web의 Component를 뜻합니다. itemBuilder의 BuildContext 인자는 Flutter 빌더에만 있으며 드래그 핸들과 제거 버튼은 해당 플랫폼의 합성 자식입니다. 인덱스는 0-based입니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
itemCount | int | 필수 | 정렬할 항목 수 |
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. |
itemBorderColor | CoreColor? | Item border colour. |
disabledItemBackgroundColor |
CoreColor? |
Item background colour while the list is disabled. |
disabledItemBorderColor |
CoreColor? |
Item border colour while the list is disabled. |
handleColor | CoreColor? | 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-variant1 테두리이며, 안쪽 간격은 읽기 방향 기준 앞 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)#
| 항목 | Flutter | Web |
|---|---|---|
| 재정렬 계산 | coui_core 엔진 (CoreSortable.resolve) |
coui_core 엔진 (CoreSortable.resolve) |
| 드래그 핸들 아이콘 | Icon(LucideIcons.gripVertical) |
Icon(LucideIcons.gripVertical) |
| 제거 버튼 아이콘 | Icon(LucideIcons.x) | 텍스트 글리프 ✕ |
| 드래그 프록시 | proxyScale / proxyElevation 적용 |
프록시 없음 (두 필드 미소비) |
| 항목 래핑 | itemBuilder 가 BuildContext 를 함께 받음 |
itemBuilder 는 index 부터 시작 |