Sortable#
드래그 앤 드롭으로 항목의 순서를 변경할 수 있는 정렬 가능한 리스트 컴포넌트입니다.
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.space8}',
),
);
}
}
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: CoreSpace.space8,
children: [
dragHandle,
Text(_items[index]),
],
);
},
);
}
}
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),
),
);
}
}
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),
),
);
}
}
사용 시기 (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#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
itemCount | int | 필수 | 정렬할 항목 수 |
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 |
제거 버튼 표시 여부 (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. |
handleColor | CoreColor? | 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 은 내부 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)#
역할 (Semantics)#
두 플랫폼이 비대칭입니다.
-
Web: 루트
<div>에role="list", 각 행에role="listitem"이 붙습니다. 재정렬이 가능한 경우(enabled && onReorder != null) 각 행은 합성된DragItem이 한 겹 더 감싸며, 그 래퍼가role="application"·aria-grabbed·aria-disabled를 내보냅니다. Sortable 은DragItem에label/hint를 넘기지 않으므로aria-label·aria-description은 생략됩니다. 제거 버튼은 native<button type="button">입니다. -
Flutter: 역할이 하나도 없습니다.
DragItem은Semantics(enabled:)만 내보내고 (label / hint 미전달), 제거 버튼은Clickable이 주는Semantics(button: true)를 받습니다. list / listitem 에 해당하는 시맨틱은 존재하지 않습니다.
키보드#
재정렬 키보드 조작은 양 플랫폼에 있으며, enabled 가 true 이고 onReorder 가 있을 때만
그리고 행 래퍼에 포커스가 있을 때만 동작합니다.
| 키 | 동작 |
|---|---|
Space / Enter | 항목 집어들기 — 드래그 중이면 그 자리에 놓기 |
Escape | 진행 중인 드래그 취소 |
↑ ↓ ← → |
드래그 중인 항목을 keyboardDragStep 만큼 이동 |
Enter / Space (제거 버튼 포커스 시) | 항목 제거 |
Tab / Shift+Tab 은 행과 제거 버튼 사이를 오갑니다. 드래그 핸들 자체는 어떤 키도
처리하지 않습니다.
포커스#
-
Flutter:
DragItem이FocusNode를 소유하고canRequestFocus를enabled에 묶으므로 재정렬 가능한 행이 탭으로 도달됩니다. 제거 버튼은Clickable의FocusableActionDetector로 포커스되며 디자인 시스템 포커스 링(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 은
DragItem에label/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)#
| 항목 | 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 부터 시작 |