ReorderableListView class
사용자가 드래그하여 항목을 대화형으로 재정렬할 수 있는 목록입니다.
공식 문서 코드
ReorderableListView 아이템들을 드래그하여 순서를 바꿀 수 있는 스크롤 리스트를 만들 수 있는 위젯이다. 즉 우리가 평소에 사용하는 앱 중에 예를 들어 음악 플레이어가 있다. 음악 플레이어 앱 내에 있는 자신만의 플레이리스트의 서순을 드래그하여 바꿀 수 있을 것이다. 이 위젯을 그것을 지원하는 아주 좋은 위젯이다. 한번 알아보자.
하위 속성
| 속성명 | 타입 | 기본값 | 설명 |
| children | List<Widget> | – | 리스트 항목으로 사용할 자식 위젯 목록 |
| onReorder | ReorderCallback | – | 아이템의 순서가 변경될 때 호출되는 콜백 함수 |
| onReorderStart | void Function(int)? | null | 드래그가 시작될 때 호출되는 콜백 |
| onReorderEnd | void Function(int)? | null | 드래그가 종료될 때 호출되는 콜백 |
| itemExtent | double? | null | 모든 항목의 고정된 크기 |
| itemExtentBuilder | ItemExtentBuilder? | null | 각 항목마다 개별적으로 크기를 지정할 때 사용 |
| prototypeItem | Widget? | null | 항목의 크기를 추정하기 위한 샘플 위젯 |
| proxyDecorator | ReorderItemProxyDecorator? | null | 드래그 중 나타나는 위젯을 커스터마이징 |
| buildDefaultDragHandles | bool | true | 기본 드래그 핸들을 사용할지 여부 |
| padding | EdgeInsets? | null | 리스트의 패딩 영역 설정 |
| header | Widget? | null | 리스트 위에 고정되는 비정렬 요소 |
| footer | Widget? | null | 리스트 아래에 고정되는 비정렬 요소 |
| scrollDirection | Axis | Axis.vertical | 스크롤 방향 설정 |
| reverse | bool | false | 아이템 순서를 반대로 표시할지 여부 |
| scrollController | ScrollController? | null | 스크롤 제어를 위한 컨트롤러 |
| primary | bool? | null | 기본(primary) 스크롤 뷰인지 여부 |
| physics | ScrollPhysics? | null | 스크롤 동작의 물리적 특성 |
| shrinkWrap | bool | false | 리스트의 높이를 콘텐츠에 맞게 줄일지 여부 |
| anchor | double | 0.0 | 뷰포트에서의 리스트 기준 위치 |
| cacheExtent | double? | null | 렌더링 캐시 영역 크기 |
| dragStartBehavior | DragStartBehavior | DragStartBehavior.start | 드래그 시작 위치에 대한 동작 설정 |
| keyboardDismissBehavior | ScrollViewKeyboardDismissBehavior | manual | 스크롤 시 키보드 숨김 방식 |
| restorationId | String? | null | 상태 복원을 위한 식별자 |
| clipBehavior | Clip | Clip.hardEdge | 리스트의 자식 위젯 클리핑 방식 |
| autoScrollerVelocityScalar | double? | null | 자동 스크롤 속도 조정 계수 |
| dragBoundaryProvider | ReorderDragBoundaryProvider? | null | 드래그 가능한 경계 설정 함수 |
| mouseCursor | MouseCursor? | null | 마우스 커서 설정 |
어우;; 하위 속성 진짜 겁나게 많다. 하지만 그만큼 다양한 기능을 알아서 지원해 준다는 소리이니 불평은 하지 말아야 한다.
암튼 필수 하위 속성인 children 와 onReorder 를 먼저 알아보겠다.
children
리스트에 표시할 각 항목들을 리스트에 전달시켜 주는 속성이다. 즉 ListView 의 children 과 완전히 똑같은 목적을 가지고 있는 것이다. 하지만 다른 점이 있다면 ReorderableListView 의 children 에는 반드시 고유한 key 가 들어 있어야 한다. 예제를 한번 봐보자.
children: [
ListTile(key: ValueKey('item1'), title: Text('Item 1')),
ListTile(key: ValueKey('item2'), title: Text('Item 2')),
],
이렇게 ListTile 을 보통 사용하고 그 안에서 key 를 넣어주고 있는 모습이다. 그렇다면 왜 key 를 넣어야 하는지 궁금할 수 있다.
그 이유로는 자체적으로 위젯 트리를 효율적으로 재구성하기 위해 Key 를 사용하여 위젯을 식별하게 되어있다.
특히 리스트처럼 반복되는 항목이 많고 그 순서가 변경될 수 있는 경우에는 각 항목을 고유하게 식별할 수 있어야 정확히 어떤 위젯이 이동했는지를 인식할 수 있다. 즉 키 있어야 위젯이 재사용 가능한지, 생성돼야 하는지, 삭제돼야 하는지 알 수 있는 것이다.
자 이렇게 UI 구성은 벌써 끝났다. 그다음 onReorder 를 알아보자.
onReorder
이건 사용자가 아이템을 드래그해서 순서를 바꿀 때 호출되는 콜백 함수다. 내에서 리스트의 순서를 실제로 바꿔야 하는 것이다.
근데 여기서 주의할 점은 반드시 리스트 순서 바꾸는 로직을 넣어줘야 한다는 점이다. 사실 이렇게 바로 사용해도 된다.
onReorder: (oldIndex, newIndex) {},
이렇게 해도 실행해 보면 오류도 안날것이다. 심지어 드래그해서 변경도 된다. 하지만 변경해 보면 뭔가 이상한 점을 느낄 수 있을 것이다. 아무런 동작도 넣지 않았기에 변경을 시켜도 변경이 안 되는 대참사가 일어나는 것이다.
그래서 무조건 순서를 바꾸는 로직을 기입해줘야 한다.
간단한 예제를 봐보자.
List<String> items = ['A', 'B', 'C', 'D'];
일단 이러한 리스트가 items 라는 이름으로 선언되어 있다.
이제 onReorder 안에서 이 리스트에 간섭하여 순서를 바꾸게 할 것이다.
onReorder: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex -= 1;
final item = items.removeAt(oldIndex);
items.insert(newIndex, item);
});
},
위의 코드를 파해쳐보자.
먼저 이 줄,
if (newIndex > oldIndex) newIndex -= 1;
이것은 에서 항목을 재배열할 때 리스트의 인덱스 조정이 필요한 이유를 해결하기 위한 코드이다.
onReorder는 사용자가 항목을 옮긴 후의 인덱스를 newIndex로 제공한다. 그런데 문제는 리스트에서 removeAt(oldIndex)를 먼저 수행하면, 리스트의 길이가 줄어들고 기존 인덱스가 하나씩 앞으로 밀린다. 이게 무슨 말이냐면 여기에서
List<String> items = ['A', 'B', 'C', 'D'];
이제 B(index 1)를 D(index 3) 다음으로 옮긴다고 하자. 그럼 oldIndex 는 1이 될 것이고 newIndex 는 4가 될 것이다.
근데 여기서 items.removeAt(1)을 실행하면 리스트는
['A', 'C', 'D']
이렇게 되어 이제 newIndex = 4는 리스트 길이를 초과하게 되고, 실제로 items.insert(4, item)은 잘못된 동작을 하거나 에러를 유발할 수 있는 것이다. 또한 B를 D 앞이 아닌 뒤에 두고 싶었던 건데, insert(4, item)은 오히려 리스트 끝에 붙이는 문제가 발생할 수 있다. 그.래.서 이 보정 코드가 필요한 것이다. removeAt(oldIndex)로 아이템 하나 제거되면 그 뒤의 인덱스들이 하나씩 앞당겨지므로 newIndex도 그만큼 조정해 줘야 정확한 위치에 삽입되는 것이다.
그다음은 쉽다.
final item = items.removeAt(oldIndex);
items.insert(newIndex, item);
이 코드는 리스트 항목을 새 위치로 옮기기 위한 핵심 로직인데 리스트 항목을 드래그로 이동시켰을 때, 실제 리스트 데이터의 순서를 바꾸는 작업을 하게 된다.
단계별로 알아보자면..
final item = items.removeAt(oldIndex);
이 줄은 기존 리스트에서 옮기려는 항목을 제거한다. 그렇다 옮긴다는 것은 원래의 것을 제거하고 새로 생성하는 것인 것이다.
여기서 oldIndex는 이동 전의 인덱스이다. 이 시점에서 리스트의 길이는 1 줄어들게 된다.
허나!
items.insert(newIndex, item);
이 줄에서 제거한 항목을 새로운 위치에 삽입하게 된다. newIndex는 onReorder() 콜백으로 전달받은 이동 대상 인덱스인데 아까 언급한 것처럼 newIndex > oldIndex일 때는 newIndex-- 보정을 해줘야 정확한 위치로 들어가게 된다.
위 예시에서 newIndex = 3이면, 리스트는 ['A', 'C', 'D', 'B'] 이렇게 되는 것이다.
자 그럼 이렇게 생각할 수 있다. 왜 remove와 insert를 따로 두는가?
이것은 앞서 말했다시피 '이동'하는 메서드, 즉 옮긴다는 개념이 없다는 것이다.
Dart에서는 리스트 항목을 "이동"하는 메서드는 없고, "제거 후 삽입" 방식으로 구현하는데 removeAt()과 insert()는 List<T> 클래스에서 제공하는 기본 메서드다. 여기서 removeAt()은 반환값으로 제거된 항목을 주기 때문에 이 값을 바로 insert()에 사용할 수 있어 사용이 더 용이하게 되기 때문이다. 암튼 이렇게 동작 설명은 끝났다. 설명한 예시 코드 전체이다.
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: ReorderableListExample(),
);
}
}
class ReorderableListExample extends StatefulWidget {
@override
_ReorderableListExampleState createState() => _ReorderableListExampleState();
}
class _ReorderableListExampleState extends State<ReorderableListExample> {
List<String> items = ['Apple', 'Banana', 'Cherry', 'Date'];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('ReorderableListView Example')),
body: ReorderableListView(
children: List.generate(items.length, (index) {
return ListTile(
key: ValueKey(items[index]),
title: Text(items[index]),
trailing: Icon(Icons.drag_handle),
);
}),
onReorder: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex -= 1;
final item = items.removeAt(oldIndex);
items.insert(newIndex, item);
});
},
),
);
}
}
리스트 만들고 children 에 넣어주고 onReorder 에서 변경하는 로직 넣어주고. 이러면 끝이다. 이제 남은 건 ListView의 단골 생성자인 builder 일 텐데 이제 그건 알아서 해보길 바란다. 도움이 되었길 바라며 마치겠다.