본문 바로가기
Flutter

[Flutter] UI(5) - ExpansionTile을 활용하여 TreeView 만들기

by 아마도개발자 2024. 1. 2.

 

flutter에서 기본 위젯으로 TreeView를 지원하지는 않는다. 하지만 pub.dev에 검색해보면 flutter_fancy_tree_view와 같이 잘 만들어진 패키지들을 찾아볼 수 있다. 하지만 학습의 차원에서 직접 TreeView를 만들어 보았다. 처음 만들자고 생각했을 때 부터 쉽지는 않을 것 같다고 생각했지만, 실제로 해보니 훨씬 복잡하다고 생각 되었다.

 

우선 TreeView를 만들기 위해서는

 

1. 재귀로 위젯 호출

2. ExpansionTile 위젯 (optional) 

3. CustomPaint 위젯

 

세 가지 개념을 알고 있어야 한다. 

 

1. 재귀로 위젯 호출

만약 내가 TreeView를 현업에서 만든다면, 무조건 재귀를 통해서 하위 데이터들을 불러올 것이다. 트리구조가 변경 될 때에도 수정이 확연히 줄어들고, 재귀로 구현을 해 놓으면 재사용에도 굉장히 용이해지기 때문이다. 

 

class TreeView extends StatefulWidget {
  final List<TreeModel> data;
  final int level;

  TreeView({Key? key, required this.data, required this.level})
      : super(key: key);

  @override
  State<TreeView> createState() => _TreeViewState();
}

class _TreeViewState extends State<TreeView> {
  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      shrinkWrap: true,
      itemCount: widget.data.length,
      itemBuilder: (context, index) {
        return BuildTiles(
          tree: widget.data[index],
          level: widget.level,
        );
      },
    );
  }
}

 

class BuildTiles extends StatefulWidget {
  final TreeModel tree;
  final int level;
  const BuildTiles({Key? key, required this.tree, required this.level})
      : super(key: key);

  @override
  State<BuildTiles> createState() => _BuildTilesState();
}

class _BuildTilesState extends State<BuildTiles>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;
  late bool hasChildren;
  bool _isExpanded = false;

  @override
  void initState() {
    _controller = AnimationController(duration: kExpand, vsync: this);
    _animation = Tween<double>(begin: 0.0, end: 0.5).animate(_controller);
    widget.tree.children.isNotEmpty ? hasChildren = true : hasChildren = false;
  }

  void _toggleAnimation() {
    if (_isExpanded) {
      _controller.reverse();
    } else {
      _controller.forward();
    }
    _isExpanded = !_isExpanded;

    print(_isExpanded);
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        // for (int i = 0; i < widget.level; i++)
        // SizedBox(
        //   width: 42,
        //
        // ),
        Expanded(
          child: Theme(
            data: ThemeData(
              dividerColor: Colors.transparent,
            ),
            child: ListTileTheme(
              contentPadding: EdgeInsets.all(0),
              dense: true,
              horizontalTitleGap: 0,
              minLeadingWidth: 0,
              minVerticalPadding: 0,
              child: ExpansionTile(
                tilePadding: EdgeInsets.zero,
                onExpansionChanged: (value) => _toggleAnimation(),
                leading: Wrap(
                  children: [
                    for (int i = 0; i < widget.level; i++)
                      Container(
                        alignment: Alignment.center,
                        height: 42,
                        width: 30,
                        child: CustomPaint(
                          size: Size(30, 42),
                          painter: LShapePainter(
                              borderSide:
                                  BorderSide(color: Colors.grey, width: 1),
                              hasChildren:
                                  i == widget.level - 1 ? false : true),
                        ),
                      ),
                  ],
                ),
                title: SizedBox(
                  height: 30,
                  child: Row(
                    children: [
                      widget.tree.children.isNotEmpty
                          ? Container(
                              // color: Colors.red,
                              height: 30,
                              width: 30,
                              child: RotationTransition(
                                turns: _animation,
                                child: const Icon(Icons.expand_more),
                              ),
                            )
                          : SizedBox(
                              width: 30,
                            ),
                      Padding(
                        padding: EdgeInsets.zero,
                        child: Container(
                            alignment: Alignment.center,
                            height: 30,
                            child: Text(
                              widget.tree.node,
                              // textAlign: TextAlign.center,
                            )),
                      ),
                    ],
                  ),
                ),
                trailing: SizedBox(),
                // ExpansionTile의 내부 컨텐츠 패딩 제거
                childrenPadding: EdgeInsets.zero,
                children: widget.tree.children
                    .map((child) => BuildTiles(
                          tree: child,
                          level: widget.level + 1,
                        ))
                    .toList(),
              ),
            ),
          ),
        ),
      ],
    );
  }
}

 

TreeModel의 속성에는 List<TreeModel>이 자식 노드로 들어가 있다. TreeView위젯에서 처음 BuildTiles를 호출한 뒤, BuildTiles안에서 ExpansionTile의 children으로 다시 BuildTiles위젯을 호출하는 것을 확인할 수 있다. 이러한 재귀를 통해 트리뷰의 최하층 데이터까지 접근할 수 있게 된다.

 

2. ExpansionTile 위젯

다행히 Flutter에서는 기본 클래스로 collapse형태의 위젯인 ExpansionTile을 제공한다. 사실 처음에는 ExpansionTile대신 Contianer를 사용하여 직접 접었다 펼쳤다 하는 기능을 구현하려고 했다. 그 이유로는 ExpansionTile의 Vertical padding이 어떤 짓을 하더라도 없어지지 않았기 때문이다.(github issue, stackoverflow 등 참고할 수 있는 모든 방법을 시도해도 실패하였다.) 하지만 직접 구현에는 시간이 너무 오래걸릴 것 같았기 때문에 조금의 어색함을 차치하고, ExpansionTile을 사용하기로 하였다.

 

ExpansionTile(
  tilePadding: EdgeInsets.zero,
  onExpansionChanged: (value) => _toggleAnimation(),
  leading: Wrap(
    children: [
      for (int i = 0; i < widget.level; i++)
        Container(
          alignment: Alignment.center,
          height: 42,
          width: 30,
          child: CustomPaint(
            size: Size(30, 42),
            painter: LShapePainter(
                borderSide:
                    BorderSide(color: Colors.grey, width: 1),
                hasChildren:
                    i == widget.level - 1 ? false : true),
          ),
        ),
    ],
  ),
  title: SizedBox(
    height: 30,
    child: Row(
      children: [
        widget.tree.children.isNotEmpty
            ? Container(
                // color: Colors.red,
                height: 30,
                width: 30,
                child: RotationTransition(
                  turns: _animation,
                  child: const Icon(Icons.expand_more),
                ),
              )
            : SizedBox(
                width: 30,
              ),
        Padding(
          padding: EdgeInsets.zero,
          child: Container(
              alignment: Alignment.center,
              height: 30,
              child: Text(
                widget.tree.node,
                // textAlign: TextAlign.center,
              )),
        ),
      ],
    ),
  ),
  trailing: SizedBox(),
  // ExpansionTile의 내부 컨텐츠 패딩 제거
  childrenPadding: EdgeInsets.zero,
  children: widget.tree.children
      .map((child) => BuildTiles(
            tree: child,
            level: widget.level + 1,
          ))
      .toList(),
)

 

- leading을 Wrap으로 감싼 이유는 leading에서는 Row를 사용할 수 없기 때문이다.

- onExpansionChanged에서 토글 시 함수를 실행할 수 있다.

- ExpansionTile이 펼쳐졌을 때 보여지는 위젯은 children에서 표현된다. tree의 각각의 children을 매개변수로 BuildTiles를 재귀호출 한다.

 

 

3. CustomPaint 위젯

 

CustomPaint위젯은 App View에 직접적으로 그림을 그릴 수 있게 해주는 위젯이다. View에 그림을 그리는 Canvas, 그림의 특성을 설정하는 Paint 클래스를 가지며, paint 메써드에 CustomPainter위젯을 넣어 내가 원하는 UI를 만들 수 있다. 여기서는 트리뷰에서 사용되는 선을 표현하기 위해 사용되었다.

CustomPaint(
  size: Size(30, 42),
  painter: LShapePainter(
      borderSide:
          BorderSide(color: Colors.grey, width: 1),
      hasChildren:
          i == widget.level - 1 ? false : true),
),

class LShapePainter extends CustomPainter {
  final BorderSide borderSide;
  final bool hasChildren;
  // final double height;

  LShapePainter({required this.borderSide, required this.hasChildren
      // required this.height
      });

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()..style = PaintingStyle.stroke;
    final double cornerSize = size.width / 2;

    canvas.drawLine(
      Offset(15, 0),
      Offset(15, 42),
      paint,
    );
    if (!hasChildren)
      canvas.drawLine(
        Offset(15, 21),
        Offset(30, 21),
        paint,
      );
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return false;
  }
}

 

 

위 코드들을 활용하여 TreeView를 만들어 보았다. 실제로 서비스되는 앱을 위한 TreeView를 만들 때는 오픈소스 패키지를 사용하는 것이 정신적으로 좋을 듯하다. 특히, ExpansionTile의 padding이 vertical에서 사라지지 않는 문제 때문에 선이 일부 끊기는 현상이 발생하였는데... 도저히 고칠 수가 없었다. 여하튼 결과물은 아래와 같다.