Tree | CoUI
LogoCoUI

Tree

계층적 데이터를 트리 구조로 표시하고 탐색하는 컴포넌트

Tree#

파일 탐색기나 조직도처럼 계층적 데이터를 트리 구조로 표시하는 컴포넌트입니다.

Live Preview#

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • 파일 탐색기처럼 폴더/파일 계층 구조를 표시할 때
  • 조직도, 카테고리 분류처럼 부모-자식 관계의 데이터를 탐색할 때

대신 다른 컴포넌트를 사용하세요:

  • Accordion: 고정된 수의 섹션을 접기/펼치기로 표시할 때
  • Menu: 계층 없이 평면적인 메뉴 목록에
  • Collapsible: 단일 섹션의 접기/펼치기에

기본 사용법 (Basic Usage)#

Tree(
  nodes: [
    CoreTreeNode(
      label: 'Documents',
      expanded: true,
      children: [
        CoreTreeNode(label: 'Resume.pdf'),
        CoreTreeNode(label: 'Cover Letter.pdf'),
      ],
    ),
    CoreTreeNode(
      label: 'Images',
      children: [
        CoreTreeNode(label: 'photo.png'),
      ],
    ),
    CoreTreeNode(label: 'README.md'),
  ],
  onNodeSelect: (node) => print('selected: ${node.label}'),
  onNodeToggle: (node) => print('toggled: ${node.label}'),
)

Props / Parameters#

속성타입기본값설명
nodes List<CoreTreeNode> 필수 트리 노드 목록
onNodeSelect ValueChanged<CoreTreeNode>? null 자식이 없는 노드(leaf) 클릭 시 호출
onNodeToggle ValueChanged<CoreTreeNode>? null 자식이 있는 노드가 펼침/접힘될 때 호출
treeStyle CoreTreeStyle? / CoreTreeStyle? null 인스턴스 스타일 (Style 시스템 참조)

스타일 시스템 (Style System)#

Tree 의 모든 chrome / dimensional / nested-slot 오버라이드는 CoreTreeStyle 단일 슬롯으로 흐릅니다. 시맨틱 (onNodeSelect, onNodeToggle, nodes) 은 위젯 파라미터로 직접 전달합니다.

시맨틱 vs 스타일#

  • 시맨틱 / behaviour: 위젯 파라미터로 직접 (nodes, onNodeSelect, onNodeToggle)
  • chrome / dimensional / 슬롯 스타일: CoreTreeStyle 한 곳으로 (nodeBackgroundColor / nodeSelectedColor / nodeHoverColor / nodePadding / indent / indentLineColor / indentLineThickness / nodeTextStyle / chevronIconStyle)

Resolve chain#

design system default for tree
  → CoreTreeTheme.style                        // 프로젝트 공통
  → parent component slot override
  → widget.treeStyle                           // 인스턴스별

각 nested 슬롯 스타일 (nodeTextStyle / chevronIconStyle) 은 자기 컴포넌트 (CoreTextStyle / CoreIconStyle) 자체 resolve chain 으로 다시 한 번 머지됩니다.

CoreTreeStyle 필드#

필드타입설명
nodeBackgroundColor CoreColor? Node row background fill colour. Carries no defaultNodeBackgroundColor : an unselected, unhovered tree row paints no fill — the file-explorer idiom this component follows, and the reason [nodeSelectedColor] (which does have a default) is the only row fill the design system states. Both platforms express that as absence rather than as a transparent value: Flutter passes it to BoxDecoration.color , where null paints nothing, and Web skips emitColor entirely ( == null ? null : … ) so no bg- class and no inline background-color is emitted. A constant here would put a fill behind every row of every tree.
nodeSelectedColor CoreColor? Node row background colour when selected.
nodeHoverColor CoreColor? Node row background colour on hover. Carries no defaultNodeHoverColor : absence means the row has no hover fill, and on Flutter it means the hover listener is never installed — the row composes Clickable(onHover: nodeHover != null ? … : null) , so a constant would both tint every row on hover and start a setState per row per pointer-enter on trees that never asked for it. Web emits hover:bg-{token} (or the scoped raw-colour rule) only when the slot is set, so the same constant would add a hover state to every tree there too. Hover feedback on a tree row is opt-in; the cursor and focus ring the composed Clickable owns are what a row gets for free.
nodePaddingCoreEdgeInsets?Node row padding.
indentdouble?Per-depth indent (logical px).
borderRadius CoreBorderRadius? Node row border radius. null defers to [defaultBorderRadius].
iconStyle CoreIconStyle? Disclosure chevron / leading icon style override. size defers to [defaultIconStyle].
transitionDuration Duration? Chevron rotate transition duration. null defers to [defaultTransitionDuration].
indentLineColor CoreColor? Indent line colour. Carries no defaultIndentLineColor because this field is the switch for the whole indent guide, not just its colour. Web computes hasIndentLine = indentLineColor != null && depth > 0 and that one boolean gates border-left-style , border-left-width and border-left-color — which is also why [indentLineThickness] can safely have a default while this cannot: the thickness default is only ever reached once this slot is set. A constant here would draw an indent guide down every nested row of every tree. Flutter resolves it ( ResolvedTree.indentLineColor ) but its widget does not paint an indent guide at all, so the guide is Web-only today. That asymmetry is a cross-platform/api.md gap to close by painting the guide on Flutter, not by giving this field a value.
indentLineThickness double? Indent line thickness (logical px).
nodeTextStyle CoreTextStyle? Node label text style override — unselected state. Colour defers to [defaultNodeTextStyle] ( onSurface ).
nodeSelectedTextStyle CoreTextStyle? Node label text style override — selected state. Colour defers to [defaultNodeSelectedTextStyle] ( primary ).
chevronIconStyle CoreIconStyle? Disclosure chevron icon style. Carries no defaultChevronIconStyle because neither of its two sub-values is a constant — each is derived , and one of them is per-state. size falls back to the sibling [iconStyle]'s effective size ( merged.chevronIconStyle?.size ?? iconStyle.size ) so the chevron matches the folder / file glyph beside it, and the leaf-row [chevronPlaceholderGapStyle] reserves that same width so leaf rows stay aligned with their siblings — freezing a size would break that alignment whenever a caller sets iconStyle.size . color tracks the node label colour of the current row state ([defaultNodeTextStyle] unselected, [defaultNodeSelectedTextStyle] selected), and both platforms build the selected chevron from the selected label colour; a single constant cannot hold both, so it would stop the selected chevron following its label.
chevronPlaceholderGapStyle CoreGapStyle? Leaf-row chevron-placeholder spacer style. Forwarded straight to Gap(gapStyle: …) by the platform resolvers (raw forward — Gap applies its own scaling). Per-instance override is merged on top of [defaultChevronPlaceholderGapStyle].
iconGapStyle CoreGapStyle? Chevron-slot ↔ folder/file-icon spacer style. Forwarded straight to Gap(gapStyle: …) by the platform resolvers (raw forward — Gap applies its own scaling). Per-instance override is merged on top of [defaultIconGapStyle].
labelGapStyle CoreGapStyle? Folder/file-icon ↔ label spacer style. Forwarded straight to Gap(gapStyle: …) by the platform resolvers (raw forward — Gap applies its own scaling). Per-instance override is merged on top of [defaultLabelGapStyle].
clickableStyle CoreClickableStyle? Nested [CoreClickableStyle] slot for the composed per-row Clickable (press scale / durations / focus ring / disabled opacity). Merged on top of [defaultClickableStyle] and raw-forwarded — the Clickable's own resolver fills the rest.

동작 스펙 (Behavior)#

인터랙션#

  • 자식이 있는 노드 클릭: 펼침/접힘 토글. onNodeToggle 호출
  • 자식이 없는 노드(leaf) 클릭: onNodeSelect가 제공된 경우 호출
  • 아이콘: 자식 있음 → folder + chevron, 없음 → file

애니메이션#

  • 자식이 있는 노드의 chevron 회전: Duration(milliseconds: CoreDuration.fast) (150ms)
  • Flutter AnimatedRotation, Web transition-transform 동일 duration

사용 가이드라인 (Usage Guidelines)#

✅ Do#

expanded 는 최초 마운트 시점의 초기값 — 이후 변경은 onNodeToggle 로 추적

Tree(
  nodes: nodes, // CoreTreeNode.expanded 는 최초 펼침 상태만 지정
  onNodeToggle: (node) {
    // 펼침/접힘 결과를 앱 상태에도 반영하고 싶다면 여기서 받는다
    trackExpanded(node);
  },
)

TreeinitState 에서 각 노드의 expanded 값을 한 번만 읽어 내부 Set으로 관리합니다. 이후 펼침 상태를 제어하고 싶다면 onNodeToggle 로 변경을 받아 처리하세요.


❌ Don't#

nodes 를 다시 만들어 expanded 값만 바꾸면 트리가 반응할 거라 기대하지 않기

// ❌ 이미 마운트된 Tree는 새 expanded 값을 다시 읽지 않는다
setState(() {
  nodes = [
    CoreTreeNode(label: 'Documents', expanded: false, children: [...]),
  ];
});

화면은 이전 펼침 상태 그대로 남습니다 — expanded 는 첫 빌드에서만 소비되는 초기값이지, 이후 리렌더마다 다시 읽히는 제어값이 아닙니다.

✅ Do#

leaf 노드를 클릭 가능하게 하려면 onNodeSelect 를 명시적으로 전달

Tree(
  nodes: [CoreTreeNode(label: 'README.md')], // leaf
  onNodeSelect: (node) => openFile(node.label),
)

onNodeSelect 를 주면 자식이 없는 leaf 행이 Clickable 로 합성되어 호버 배경 · 클릭 커서 · 포커스 링을 전부 갖춥니다.


❌ Don't#

onNodeSelect 없이 leaf 행에 호버/클릭 피드백을 기대하지 않기

// ❌ onNodeSelect 를 안 주면 leaf 행은 애초에 Clickable로 감싸지지 않는다
Tree(nodes: nodes)

onNodeSelect 가 없는 leaf 행은 비활성화된 버튼이 아니라 처음부터 인터랙션이 없는 콘텐츠로 렌더링됩니다 — 호버 색이나 커서 변화가 나타나지 않습니다.

접근성 (Accessibility)#

역할#

  • Webrole="tree" / role="treeitem"aria-expanded·aria-selected.
  • Flutter — tree role 없음. Flutter 에는 대응 role 이 없어 계층은 각 노드의 확장 상태로 전달됩니다(설계상 의도된 비대칭).

키보드#

FlutterWeb
Enter / Space 자식 있으면 확장/축소, leaf 면 선택 자식 있으면 확장/축소, leaf 면 선택
Tab노드마다 개별 tab stop노드마다 개별 tab stop
ArrowUp/ArrowDown 행 간 포커스 이동 (아래 주의) 없음
ArrowRight/ArrowLeft 로 확장/축소 없음 없음
Home/End없음없음

WAI-ARIA 트리 탐색이 구현돼 있지 않습니다. 확장·축소는 Enter/Space 로만 가능하고 화살표로는 되지 않습니다.

Flutter 의 화살표 이동은 이 컴포넌트가 아니라 프레임워크의 방향성 포커스 순회입니다 — 트리에 스코프되지 않아 트리 밖 요소로도 넘어가고, 확장·축소도 하지 않습니다.

스크린 리더#

Web 은 라벨·treeitem·확장 상태·선택 상태를 읽습니다. 단 aria-selected 가 인터랙티브 행 전부에 무조건 붙습니다 — 선택이 하나도 없는 트리에서도 리더가 모든 행마다 "선택 안 됨"을 읽습니다.

노드를 펼치거나 접은 결과를 알리는 live region 은 없습니다.

포커스 관리#

  • 진입 / 트랩 — 없음. Tab 은 트리 밖으로 그대로 빠져나갑니다.
  • 노드를 접으면 포커스를 잃습니다 — 사라진 행에 포커스가 있었다면 Flutter 는 상위 스코프로, Web 은 <body> 로 떨어집니다. 부모 노드로 옮겨주는 코드가 없습니다.

알려진 제약#

  • 노드 수만큼 tab stop 이 생깁니다. 깊고 넓은 트리는 키보드로 지나가는 비용이 노드 수에 비례합니다.
  • 단일 선택 트리에서도 모든 행에 aria-selected="false" 가 붙습니다.

크로스 플랫폼 차이점 (Platform Differences)#

항목FlutterWeb
클래스명TreeTree
렌더링 Column + 재귀 Padding <div> + 재귀 padding-left
아이콘 Icon(LucideIcons.folder/file) Icon(LucideIcons.folder/file)
회전 애니메이션AnimatedRotationtransition-transform
테마CoreTreeThemeCoreTreeTheme
  • Accordion: 고정된 수의 섹션을 그룹으로 접기/펼치기
  • Menu: 계층 없는 평면 메뉴 목록
  • Collapsible: 단일 섹션의 접기/펼치기

빠른 오버라이드 (Chain)#

이미 만든 Tree 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다. .radius4처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius4 == CoreRadius.radius4) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.

class TreeChainExample extends StatelessWidget {
  const TreeChainExample({super.key});

  @override
  Widget build(BuildContext context) {
    return const Tree(
          nodes: [
            CoreTreeNode(
              label: 'Documents',
              expanded: true,
              children: [
                CoreTreeNode(label: 'Resume.pdf'),
                CoreTreeNode(label: 'Cover Letter.pdf'),
              ],
            ),
            CoreTreeNode(
              label: 'Images',
              children: [
                CoreTreeNode(label: 'photo.png'),
                CoreTreeNode(label: 'banner.jpg'),
              ],
            ),
            CoreTreeNode(label: 'README.md'),
          ],
        )
        .withStyle(
          const CoreTreeStyle(
            nodeBackgroundColor: CoreColor.token(CoreColors.surfaceContainer),
            nodePadding: CoreEdgeInsets.symmetric(
              horizontal: CoreSpace.space12,
              vertical: CoreSpace.space8,
            ),
            indent: CoreSpace.space24,
          ),
        )
        .radius16;
  }
}
class TreeChainExample extends StatelessComponent {
  const TreeChainExample({super.key});

  @override
  Component build(BuildContext context) {
    return const Tree(
          nodes: [
            CoreTreeNode(
              label: 'Documents',
              expanded: true,
              children: [
                CoreTreeNode(label: 'Resume.pdf'),
                CoreTreeNode(label: 'Cover Letter.pdf'),
              ],
            ),
            CoreTreeNode(
              label: 'Images',
              children: [
                CoreTreeNode(label: 'photo.png'),
                CoreTreeNode(label: 'banner.jpg'),
              ],
            ),
            CoreTreeNode(label: 'README.md'),
          ],
        )
        .withStyle(
          const CoreTreeStyle(
            nodeBackgroundColor: CoreColor.token(CoreColors.surfaceContainer),
            nodePadding: CoreEdgeInsets.symmetric(
              horizontal: CoreSpace.space12,
              vertical: CoreSpace.space8,
            ),
            indent: CoreSpace.space24,
          ),
        )
        .radius16;
  }
}