개발/flutter

[Flutter] DraggableList의 아키텍쳐 분석

iris3455 2025. 2. 19. 16:26

 

Flutter 커스텀 리스트 패키지 개발기

 

저번에 드래그를 통한 아이템 정렬 기능이 핵심인 패키지를 개발하면서 어떤 식으로 구조를 설계했는지 어떻게 동작하는지는 내용이 너무 길어질것 같아서 설명을 하지 못했다. 그래서 이번 포스팅을 통해 설명을 하고자 한다. 사실 엄청난 구조를 설계해서 막 자랑하고 싶고 다른 개발자들이 적용했으면 좋겠고 그런 마음으로 글을 쓰는건 아니다.. (ω o ) 그냥 내가 짠 코드 내가 더 깊이 이해하고자 올리는 것이니 참고바란다!

 

https://heenano.tistory.com/37

 

[Flutter] draggable_list 패키지 개발

대망의 첫 package 개발하기 이번에 설 연휴가 10일이나 된다. 그래서 설 연휴 동안의 일정이 비어버리게 되었다. 이참에 예전부터 하고 싶었던 flutter 패키지 개발이나 해보자 해서 만들고 싶었던

heenano.tistory.com

 

 

1. 구조 설계

 

일단 이 패키지의 핵심 포인트를 Customizing  Intuitive 로 잡았다.

ListStyle 클래스를 만들어서 색이나 폰트 아이콘 패딩 등을 매개변수로 전달하는 방법으로 커스터 마이징이 가능하게 만들었다. 물론 디폴트 값이 있어서 필요한 필수 인자는 아니고 default widget도 만들어서 커스터 마이징이 필요없는 사용자는 디폴트 위젯을 빌드하게 설정했다.

또한 어떻게 하면 사용하기 쉽고 직관적이게 만들수 있을까 하다가 예전에 사이드 프로젝트로 카카오 맵 플러그인을 사용한 적이 있는데 그때 controller를 사용하여 손쉽게 marker들을 추가하고 삭제했던 경험이 떠올랐다. 이 패키지는 드래그 가능한 리스트들의 관리가 중요 포인트인데 하나의 클래스 혹은 여러 클래스에서 같은 리스트를 생성 및 삭제 뿐 만 아니라 사용자가 만든 리스트를 받아와서 수정해야 했다. 때문에 map 플러그인의 구조와 비슷하게 controller를 사용하면 직관적이고 쉽게 패키지를 사용할 수 있을것 같다고 느꼈다.

따라서 리스트 상태관리 컨트롤러 (ListController), 컨트롤러 접근 및 공유(DraggableList), 리스트 위젯(TextBuilder, ListBuilder) 이런식으로 구조를 나눠서 설계를 했다.

 

 

2. 코드 설명

main.dart

개발자가 패키지를 사용하는 예시코드이다.

 

1. DraggableList 정의

  • listValues : 초기 리스트 데이터
  • listController : DraggableList에서 받아올 controller 저장
  •  child : 위젯
  • canWrite :  텍스트 필드가 포함되어있는 리스트 사용시 true
  • enableDrag : 드래그 가능 여부
  • duration : 드래그 시작전 대기 시간을 지정
import 'package:draggable_list/draggable_list.dart';

// listValues 초기화
late List<ListModel> listValues;

listValues = List.generate(
      contents.length,
      (index) => ListModel(
        listOrder: index,
        listContent: contents[index],
      ),
    );
    
// listController 지연 초기화
late ListController listController;

DraggableList(
          listValues: listValues,
          canWrite: canWrite,
          enableDrag: true,
          duration: const Duration(milliseconds: 100),
          initializeController: ((controller) {
          // listController 초기화
            listController = controller;
          }),
          child:

 

 

2. listController 사용

  • listController의 addList 함수 : 가장 마지막에 리스트가 추가됨
  • listController의 removeList 함수 : 가장 마지막 리스트가 삭제됨
  • listController.draggableLists.value : 현재 리스트 순서와 내용을 가져옴
// 리스트 추가

Padding(
      padding: const EdgeInsets.symmetric(horizontal: 6.0),
      child: ElevatedButton(
        onPressed: () {
          listController.addList();
        },
        child: const Text('Add list'),
      ),
    );
    
// 리스트 삭제

Padding(
      padding: const EdgeInsets.symmetric(horizontal: 6.0),
      child: ElevatedButton(
        onPressed: () {
          listController
              .removeList(listController.draggableLists.value.length - 1);
        },
        child: const Text('Delete list'),
      ),
    ); 

// 리스트 정보 가져오기

Padding(
      padding: const EdgeInsets.symmetric(horizontal: 6.0),
      child: ElevatedButton(
        onPressed: () {
          List<ListModel> finalLists = listController.draggableLists.value;
          List<String> contentList =
              finalLists.map((item) => item.listContent ?? "").toList();

          String contentString = contentList.map((item) => item).join(", ");
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(
              content: Text(contentString),
            ),
          );
        },
        child: const Text('Save list'),
      ),
    );

 

3. list 스타일 커스터 마이징

  • ListBuilder : 리스트를 보여주는 위젯으로 DraggableList의 child안에 정의해야 함 (이유는 밑에서 설명)
  • ListStyle :  텍스트 필드가 포함된 리스트 (canwrite = true)를 커스텀할 클래스. hintText, textStyle, animateScale등을 사용자가 정의할 수 있음
  • customListBuilder :  텍스트 필드가 포함되어있지 않은 리스트 아이템 (canwrite = false)의 커스텀이 가능
// ListStyle 정의

final ListStyle listStyle = ListStyle(
    backgroundColor: Colors.white,
    borderRadius: BorderRadius.circular(8.0),
    border: Border.all(
      color: Colors.grey,
      width: 0.5,
      style: BorderStyle.solid,
    ),
    contentTextStyle: const TextStyle(
      decorationThickness: 0,
      color: Colors.black,
      fontSize: 14,
      fontFamily: 'Pretendard',
      fontWeight: FontWeight.w500,
      height: 1.43,
    ),
    hintTextStyle: const TextStyle(
      decorationThickness: 0,
      color: Color(0xffADB5BD),
      fontSize: 14,
      fontFamily: 'Pretendard',
      fontWeight: FontWeight.w500,
      height: 1.43,
    ),
    hintText: '내용',
    listPadding: const EdgeInsets.symmetric(vertical: 2.0),
    textPadding: const EdgeInsets.only(top: 20.0, bottom: 20.0, left: 16.0),
    animateBeginScale: 1.0,
    animateEndScale: 1.2,
    deleteIcon: const Icon(
      Icons.cancel,
      color: Color(0xFF212529),
      size: 18.0,
    ),
  );


// ListBuilder 정의

Padding(
      padding: const EdgeInsets.symmetric(vertical: 20.0),
      child: ListBuilder(
          style: listStyle,
          customListBuilder: (context, index) {
            return Padding(
              key: ValueKey(index),
              padding: const EdgeInsets.all(8.0),
              child: Container(
                height: 100,
                width: 50,
              ),
            );
          }),
);

 

 

DraggableList

리스트 상태 초기화 및 리스트 컨트롤러 접근 및 공유

 

 

1. DraggableList 클래스

  • DraggableList : InheritedWidget을 확장한 전역 상태 관리 위젯 (하위 위젯이 context를 통해 접근할 수 있음)
class DraggableList extends InheritedWidget {
  final ListController controller;
  final bool canWrite;
  final bool enableDrag;
  final Duration duration;

  DraggableList({
    Key? key,
    required List<ListModel> listValues,
    required Widget child,
    required void Function(ListController) initializeController,
    this.canWrite = false,
    this.enableDrag = true,
    this.duration = const Duration(milliseconds: 150),
  })

 

 

 

2. 상태 초기화 및 InheritedWidget 래핑

  • controller = ListController() : controller를 초기화하고 리스트 상태를 관리
  • Listener & GestureDetector : Listener & GestureDetector가 화면 어디든 터치되면 실행됨
  • FocusManager.instance.primaryFocus?.unfocus() : 현재 포커스를 해제함
    • primaryFocus는 현재 활성화된 FocusNode를 가리키는 전역 변수
)  : controller = ListController(),
    super(
        key: key,
        child: Listener(
          onPointerDown: (_) {
            FocusManager.instance.primaryFocus?.unfocus();
          },
          child: GestureDetector(
            behavior: HitTestBehavior.translucent,
            onTap: () {
              FocusManager.instance.primaryFocus?.unfocus();
            },
            child: child,
          ),
        ))

 

 

 

3. 컨트롤러 초기화 및 리스트 데이터 설정

  • initializeController(controller) : 컨트롤러를 외부에서 사용 할 수 있도록 초기화된 컨트롤러 콜백함수로 전달
  • controller.saveWriteState : 리스트 데이터를 controller에 전달하여 초기 상태를 설정
  • controller.initializeListOrder(listValues) : 리스트 초기값 저장 
initializeController(controller);
controller.saveWriteState = canWrite;
controller.initializeListOrder(listValues);

 

 

4. InheritedWidget 오버라이드

  • updateShouldNotify : false로 설정하여, 위젯이 업데이트 되더라도 하위 위젯을 재빌드하지 않도록 최적화
@override
bool updateShouldNotify(covariant InheritedWidget oldWidget) => false;

 

 

5. DraggableList context를 통해 접근 가능하게

  • 하위 위젯에서 DraggableList.of(context)를 호출하면 현재 트리에 있는 DraggableList를 찾아서 접근할 수 있음
  • 위에서 말했던 ListBuilder에서도 controller를 사용하므로 DraggableList의 하위 위젯으로 ListBuilder가 와야함
static DraggableList? of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<DraggableList>();
}

 

 

ListController

리스트의 상태 관리 컨트롤러

 

 

1. 싱글톤 패턴 적용

  • ListController가 싱글톤으로 동작하도록 설계
  • factory 생성자를 통해 항상 동일한 인스턴스를 반환 → 전역적으로 하나의 컨트롤러만 사용됨
static final ListController _instance = ListController._internal();
factory ListController() => _instance;
ListController._internal();

 

 

2. 리스트 상태 관리

  • ValueNotifier :  리스트의 변경 사항을 자동으로 UI에 반영
  • draggableLists: 드래그 가능한 리스트 항목(ListModel)을 저장
  • listTextControllers: 리스트 항목의 텍스트 필드 컨트롤러를 저장
final ValueNotifier<List<ListModel>> draggableLists = ValueNotifier([]);
final ValueNotifier<List<TextEditingController>> listTextControllers = ValueNotifier([]);

 

 

3. 함수

  • saveWriteState : 리스트에 텍스트 필드 추가 여부 설정
  • initializeListOrder : 리스트 데이터를 받아와 draggableLists에 저장
  • initializeListTextControllers : draggableLists에 저장된 리스트의 개수만큼 TextEditingController를 생성
  • addList : 가장 큰 listOrder 값을 찾아 1을 더해 새로운 리스트 추가 
  • addListTextControllers : canWrite가 true라면 새로운 텍스트 컨트롤러도 추가
  • removeList : 인덱스(index)를 받아 리스트 항목을 삭제
  • removeTextController : canWrite가 true라면 텍스트 컨트롤러도 함께 삭제
  • reorderList : oldIndex → newIndex로 리스트 항목 이동
  • reorderListTextController : canWrite가 true라면 텍스트 컨트롤러의 순서도 함께 변경
  set saveWriteState(bool writeState) {
    canWrite = writeState;
  }

  void initializeListOrder(List<ListModel> lists) {
    draggableLists.value = List<ListModel>.from(lists);
    if (canWrite) {
      initializeListTextControllers();
    }
  }

  void initializeListTextControllers() {
    int count = draggableLists.value.length;
    listTextControllers.value = List.generate(count, (index) {
      return TextEditingController(
          text: draggableLists.value[index].listContent);
    });
  }

  void addList() {
    int newOrder = draggableLists.value.isNotEmpty
        ? draggableLists.value
                .reduce((a, b) => a.listOrder > b.listOrder ? a : b)
                .listOrder +
            1
        : 1;
    ListModel newItem = ListModel(listOrder: newOrder);
    draggableLists.value = [...draggableLists.value, newItem];
    if (canWrite) {
      addListTextControllers();
    }
  }

  void addListTextControllers() {
    listTextControllers.value = [
      ...listTextControllers.value,
      TextEditingController()
    ];
  }

  void removeList(int index) {
    if (index >= 0 && index < draggableLists.value.length) {
      final updatedList = List<ListModel>.from(draggableLists.value);
      updatedList.removeAt(index);
      draggableLists.value = updatedList;
    }
    if (canWrite) {
      removeTextController(index);
    }
  }

  void removeTextController(int index) {
    if (index >= 0 && index < listTextControllers.value.length) {
      final updatedTextList =
          List<TextEditingController>.from(listTextControllers.value);
      updatedTextList.removeAt(index);
      listTextControllers.value = updatedTextList;
    }
  }

  void reorderList({required int oldIndex, required int newIndex}) {
    if (canWrite) {
      reorderListTextController(oldIndex: oldIndex, newIndex: newIndex);
    }
    if (oldIndex < newIndex) newIndex -= 1;
    final updatedList = List<ListModel>.from(draggableLists.value);
    final item = updatedList.removeAt(oldIndex);
    updatedList.insert(newIndex, item);
    draggableLists.value = updatedList;
  }

  void reorderListTextController(
      {required int oldIndex, required int newIndex}) {
    if (oldIndex < newIndex) newIndex -= 1;
    final updatedListTextController =
        List<TextEditingController>.from(listTextControllers.value);
    final item = updatedListTextController.removeAt(oldIndex);
    updatedListTextController.insert(newIndex, item);
    listTextControllers.value = updatedListTextController;
  }
}

 

 

ListBuilder

리스트의 위젯 

 

1. DraggableList에서 설정값 가져오기 

  • final draggableList = DraggableList.of(context)
    : DraggableList.of(context)를 호출하여 DraggableList에서 공유된 컨트롤러와 설정값을 가져옴
  • controller = draggableList?.controller ?? ListController();
    : DraggableList가 존재하면 그 안의 ListController를 사용, 없으면 새로운 ListController 생성
@override
void didChangeDependencies() {
  super.didChangeDependencies();
  final draggableList = DraggableList.of(context);
  controller = draggableList?.controller ?? ListController();
  canWrite = draggableList?.canWrite ?? false;
  enableDrag = draggableList?.enableDrag ?? true;
  duration = draggableList?.duration ?? const Duration(milliseconds: 150);
}

 

 

2. 리스트 항목 빌드 

  • canWrite가 true : TextListBuilder 사용 (텍스트 편집 기능 지원)
  • canWrite가 false : customListBuilder가 있으면 커스텀 위젯 그리기. 없으면 _defaultListWidget 호출
Widget _buildListWidget(BuildContext context, int index) {
  return canWrite
      ? TextListBuilder(
          enableDrag: enableDrag,
          key: ValueKey(controller.draggableLists.value[index].listOrder),
          textEditingController: controller.listTextControllers.value[index],
          index: index,
          style: widget.style,
          duration: duration,
        )
      : widget.customListBuilder?.call(
              context, controller.draggableLists.value[index].listOrder) ??
          _defaultListWidget(index);
}

 

 

3. UI 빌드

  • ValueListenableBuilder : draggableLists 값이 변경될 때마다 UI 자동 업데이트
@override
Widget build(BuildContext context) {
  return ValueListenableBuilder<List<ListModel>>(
    valueListenable: controller.draggableLists,
    builder: (context, listOrder, child) {

 

 

4. ReorderableListView.builder 사용하여 리스트 구현

  • ReorderableListView.builder : 드래그하여 순서를 변경할 수 있는 리스트 생성
  • proxyDecorator : 항목을 드래그할 때 애니메이션 효과 적용 (ScaleTransition)
  • buildDefaultDragHandles: enableDrag가true면 리스트 항목을 드래그할 수 있음
  • onReorder : 리스트의 순서가 변경되었을 때 controller.reorderList(oldIndex, newIndex) 함수 호출
return ReorderableListView.builder(
  proxyDecorator: (child, index, animation) {
    return Material(
      color: Colors.transparent,
      child: ScaleTransition(
        scale: animation.drive(
          Tween<double>(
                  begin: widget.style.animateBeginScale,
                  end: widget.style.animateEndScale)
              .chain(
            CurveTween(curve: Curves.linear),
          ),
        ),
        child: ConstrainedBox(
          constraints: BoxConstraints(
            maxWidth: MediaQuery.of(context).size.width,
            maxHeight: MediaQuery.of(context).size.height * 0.9,
          ),
          child: child,
        ),
      ),
    );
  },
  buildDefaultDragHandles: enableDrag,
  physics: const ClampingScrollPhysics(),
  shrinkWrap: true,
  itemCount: controller.draggableLists.value.length,
  itemBuilder: (context, index) {
    return _buildListWidget(context, index);
  },
  onReorder: (oldIndex, newIndex) {
    controller.reorderList(oldIndex: oldIndex, newIndex: newIndex);
  },
);

 

 

TextListBuilder

리스트 항목을 텍스트 입력 필드로 렌더링하는 위젯

 

1. DraggableList에서 설정값 가져오기

  • DraggableList.of(context)를 호출하여 공유된 ListController와 설정값을 가져옴
@override
void didChangeDependencies() {
  super.didChangeDependencies();
  final draggableList = DraggableList.of(context);
  controller = draggableList?.controller ?? ListController();
}

 

 

2. 입력 필드 포커스 감지 및 내용 저장

  • _onFocusChanged : 입력 필드에서 포커스가 해제되면 현재 리스트 항목의 listContent를 textEditingController.text 값으로 업데이트
void _onFocusChanged() {
  if (!_textFocusNode.hasFocus) {
    controller.draggableLists.value[widget.index].listContent =
        widget.textEditingController.text;
  }
}

 

 

3. UI 빌드

  • CustomReorderableDragListener
    • enableDrag: widget.enableDrag로 드래그 가능 여부 설정
    • delay: widget.duration로 드래그 지연 시간 적용
    • child: 리스트 항목의 내용 
@override
Widget build(BuildContext context) {
  return CustomReorderableDragListener(
    enableDrag: widget.enableDrag,
    key: Key('${widget.index}'),
    delay: widget.duration,
    parentContext: context,
    index: widget.index,
    child: Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [

 

 

4. 텍스트 입력 필드 UI

Flexible(
  child: Padding(
    padding: widget.style.listPadding,
    child: Container(
      decoration: BoxDecoration(
        border: widget.style.border,
        color: widget.style.backgroundColor,
        borderRadius: widget.style.borderRadius,
      ),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Expanded(
            child: TextFormField(
              focusNode: _textFocusNode,
              keyboardType: TextInputType.multiline,
              controller: widget.textEditingController,
              textAlignVertical: TextAlignVertical.top,
              textInputAction: TextInputAction.newline,
              maxLines: null,
              style: widget.style.contentTextStyle,
              decoration: InputDecoration(
                hintText: widget.style.hintText,
                hintStyle: widget.style.hintTextStyle,
                isDense: true,
                contentPadding: widget.style.textPadding,
                border: InputBorder.none,
              ),
            ),
          ),

 

 

5. 해당 리스트 삭제 버튼

  • controller.removeList(widget.index) : 리스트에서 해당 항목 제거
InkWell(
  onTap: () {
    controller.removeList(widget.index);
  },
  child: Padding(
    padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 20.0),
    child: widget.style.deleteIcon,
  ),
),

 

 

CustomReorderableDragListener

ReorderableListView에서 항목을 길게 눌러 드래그할 때의 동작을 커스텀

 

1. createRecognizer() 오버라이드

  • enableDrag == false : 즉시 드래그할 수 있도록 ImmediateMultiDragGestureRecognizer 사용.
  • enableDrag == true : 지정된 딜레이 후 드래그 시작하는 DelayedMultiDragGestureRecognizer 사용.
@override
MultiDragGestureRecognizer createRecognizer() {
  if (!enableDrag) {
    return ImmediateMultiDragGestureRecognizer(debugOwner: this);
  }
  return DelayedMultiDragGestureRecognizer(delay: delay, debugOwner: this);
}

 

 

 

 

끝으로

 

사실 이렇게 문서로 정리를 하면서 최적화 할 부분이 참 많다는걸 느꼈다.. 아무렴 역시 처음부터 완벽할 수는 없지. 나름 시간과 정성을 쏟아서 개발한 패키지라 출시하고 끝이 아니라 정말 다른 개발자 분들이 버그 없이 스무스하게 쓸 수 있도록 몇번의 리팩토링 정도는 하고 싶다. 시간상 금방될지는 모르겠지만.. 일단 내가 마음을 먹었으니 이 패키지는 더 개선될 것이다!! 개발 하면서 구조를 다지고 설계하는 부분에서 확실히 흥미가 있는것 같고 그래서인지 패키지나 라이브러리 개발 쪽이 적성에 맞는것 같기도 하다. 아무튼 버킷 리스트 또 하나 해결 완료.