Tree | CoUI
본문으로 건너뛰기
LogoCoUI

Tree

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

목차

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

기본 사용법 (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}'),
)

Live Preview#

사용 시기 (When to Use)#

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

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

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

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

Props / Parameters#

노드 데이터는 CoreTreeNode로 공유합니다. 커스텀 빌더의 반환 콘텐츠 타입 W는 Flutter의 Widget, Web의 Component입니다. virtualRowHeight/virtualViewportHeight와 들여쓰기 치수는 논리 px 단위이며 노드 id와 화면 인덱스는 구분합니다.

속성타입기본값설명
nodes List<CoreTreeNode> 필수 트리 노드 목록
onNodeSelect ValueChanged<CoreTreeNode>? null 자식이 없는 노드(leaf) 클릭 시 호출
onNodeToggle ValueChanged<CoreTreeNode>? null 자식이 있는 노드가 펼침/접힘될 때 호출
onNodeRename CoreTreeRenameCallback? null 인라인 이름 바꾸기 — 더블클릭·포커스된 행의 F2 로 입력칸을 열고, 확정된 새 이름을 전달
canRenameNode CoreTreeRenamePredicate? null 이름을 바꿀 수 없는 행을 거르는 소비자 정책 (미지정 시 모든 행)
controller CoreTreeController? null 트리 밖에서 이름 바꾸기 시작 — startRename(id) 가 그 행을 더블클릭· F2 와 같이 입력칸으로 바꿈 (행 밖 키보드 모델용)
nodeContextMenu CoreTreeNodeMenuBuilder<W>? null 행 우클릭(터치는 길게 누르기) 메뉴 — 그 노드의 CoreMenuItem 목록을 ContextMenu 로 포인터 위치에 띄움. 빈 목록이면 그 행은 메뉴 없음
selectFolders bool false 폴더 행 선택과 셰브론 펼침을 분리
toggleFoldersOnSelect bool false selectFolders 와 함께 켜면 폴더 행 클릭이 선택과 펼침/접힘을 함께 함 (파일 탐색기 관례)
expansionControlled bool false 매 빌드 CoreTreeNode.expanded 를 따름
loadChildren Future<List<CoreTreeNode>> Function(CoreTreeNode)? null 빈 가지를 펼칠 때 최초 한 번만 로드, 오류 후 재시도 가능
leadingBuilder / trailingBuilder CoreTreeNodeBuilder<W>? null 펼침 여부를 받아 앞 아이콘·상태 배지를 그림. trailingBuilder 가 있으면 이름이 남은 폭을 차지해 trailing 이 줄 끝에 맞춰짐
loadingBuilder / errorBuilder CoreTreeLoadingBuilder<W>? / CoreTreeLoadErrorBuilder<W>? null 지연 적재 상태를 그리는 슬롯
virtualRowHeight / virtualViewportHeight double? null 둘 다 주면 고정 높이 창 안의 보이는 행만 렌더링
nodeKeyPrefix String? null 안정적인 id가 있는 행의 Key/id 접두사
selectedId String? null 지연 적재된 자식을 포함해 ID로 선택 상태 제어 (미지정 시 노드 selected 그대로)
onNodeDrop CoreTreeDropCallback? null 노드를 가지에 놓은 후 호출자에게 이동 의도 전달
canDrop CoreTreeDropPredicate? null 권한·상태에 따른 소비자 추가 거부 조건
treeStyle 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 / nodeMinHeight / indent / indentLineColor / indentLineThickness / nodeTextStyle / iconStyle / leafIconStyle / chevronIconStyle)

Resolve chain#

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

각 nested 슬롯 스타일 (nodeTextStyle / iconStyle / leafIconStyle / 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. null defers to [defaultNodeSelectedColor].
dropTargetColor CoreColor? Tint behind a folder row while a dragged node hovers it as a valid drop target. null defers to [defaultDropTargetColor]. Independent of [nodeSelectedColor]. A leaf row that is a valid target takes the top edge in [dropIndicatorColor] instead of a tint.
dropIndicatorColor CoreColor? Colour of the drop indicator line — the 2px insertion line between rows and the top edge of a leaf row that is a valid target. null defers to [defaultDropIndicatorColor]. Independent of [nodeSelectedColor].
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.
nodeMinHeight double? Node row minimum height (logical px). Defaults to [defaultNodeMinHeight].
indentdouble?Per-depth indent (logical px).
borderRadius CoreBorderRadius? Node row border radius. null defers to [defaultBorderRadius].
iconStyle CoreIconStyle? Leading folder / file icon style override. size defers to [defaultIconStyle]. It does not resize the disclosure chevron — that is [chevronIconStyle]. A color given here colours folder and file glyphs alike; [leafIconStyle] overrides the file glyph alone.
leafIconStyle CoreIconStyle? Leaf-row (file) icon style override, applied on top of [iconStyle] for leaf rows only. Chain for a leaf glyph, lowest to highest: the leading default ([defaultIconStyle]) → [defaultLeafIconStyle] ( onSurfaceSubtle ) → the caller's [iconStyle] → this slot. A branch (folder) row never reads it.
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. Role and colour defer to [defaultNodeTextStyle] ( codeSm , onSurfaceVariant ).
nodeSelectedTextStyle CoreTextStyle? Node label text style override — selected state. Role and colour defer to [defaultNodeSelectedTextStyle] ( codeSm , onSurface ).
chevronIconStyle CoreIconStyle? Disclosure chevron icon style. size defers to [defaultChevronIconStyle] (12) — it is its own default and does not follow [iconStyle]'s size, so resizing the leading folder / file glyph leaves the chevron alone. The leaf-row [chevronPlaceholderGapStyle] reserves the effective chevron width, so a chevron resized here keeps leaf rows aligned with their siblings. color defers to [defaultChevronIconStyle] ( onSurfaceSubtle ) and is the same for every row state — selected or not. Set color here to recolour the chevron for every state.
chevronPlaceholderGapStyle CoreGapStyle? Leaf-row chevron slot. Its size is the width a leaf row reserves where a parent row draws the disclosure chevron, so leaf rows stay aligned with their siblings. The platform resolvers read it as a fixed slot rather than a spacing: it holds a glyph's place, so the density does not move it. 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가 제공된 경우 호출
  • 폴더 선택 (selectFolders): 폴더 행 클릭은 onNodeSelect 만 부르고 펼침/접힘은 셰브론이 맡습니다. toggleFoldersOnSelect: true 를 함께 주면 행 어디를 눌러도 선택과 펼침/접힘이 함께 일어나고, 셰브론은 여전히 펼침/접힘만 합니다 — 폴더를 위임 대상으로 고르면서 행 전체로 여닫는 IDE 파일 트리 관례입니다.
  • 이름 바꾸기 (onNodeRename): 행을 더블클릭하거나 행에 포커스가 있을 때 F2 를 누르면 라벨 자리가 현재 이름이 든 입력칸으로 바뀝니다. leaf 는 마지막 점 앞까지(main.dart → main), 가지는 전체가 선택된 채 열립니다. Enter 를 누르거나 입력칸 밖을 누르면(또는 포커스가 빠지면) 확정, Esc 는 취소입니다. 앞뒤 공백을 다듬은 이름이 비었거나 그대로면 보고하지 않습니다(CoreTreeRenameRules.committedLabel). 디스크·데이터 반영과 새 노드 전달은 드롭과 같이 소비자가 맡습니다.
  • 더블클릭과 단일 클릭: 더블클릭의 첫 클릭은 기다리지 않고 평소대로 동작하고(선택·펼침), 두 번째 클릭만 이름 바꾸기로 갑니다 — 단일 클릭이 느려지지 않습니다. 이름을 바꾸는 동안 그 행은 누름·드래그를 받지 않고, 입력칸의 키 입력은 트리 키보드 처리로 새지 않습니다.
  • 밖에서 시작 (controller): 키보드 모델이 행 밖에 있는 소비자(자기 커서를 움직이고 행에는 포커스를 주지 않는 IDE 탐색기 등)는 행 단위 F2 에 닿지 못합니다. CoreTreeController 를 넘기고 startRename(노드 id) 를 부르면 그 행이 더블클릭·F2 와 똑같이 열립니다. 보이지 않는 행(조상이 접힘·없는 id)과 canRenameNode 가 거부한 행은 무시합니다. 끝나면 포커스가 트리로 돌아옵니다 — Flutter 는 시작 전 포커스(바깥 Focus)로, Web 은 그 행(로빙 탭 스톱)으로.
  • 노드 맥락 메뉴 (nodeContextMenu): 행을 우클릭(터치는 길게 누르기)하면 그 노드의 항목이 포인터 위치 메뉴로 열립니다(ContextMenu). 우클릭은 행을 선택하지도 펼치지도 않습니다. 빈 목록을 돌려준 노드와 이름을 바꾸는 행에는 메뉴가 없습니다. 「이름 바꾸기」 항목은 CoreTreeController.startRename(node.id) 로 이으면 됩니다.
  • 아이콘: 자식 있음 → 펼침 folderOpen / 접힘 folderClosed + chevron, 없음 → file. leadingBuilder 를 주면 그쪽이 우선합니다.
  • 색: chevron 은 행이 선택됐는지와 상관없이 항상 onSurfaceSubtle 입니다(chevronIconStyle.color 로 덮음). 파일(leaf) 아이콘은 onSurfaceSubtle(leafIconStyle.color 로 덮음), 폴더 아이콘은 onSurfaceVariant 로 한 단계 진하게 그려져 폴더와 파일이 구분됩니다. 이름 글자만 선택 상태에 따라 onSurfaceVariant / onSurface 로 바뀝니다.
  • 줄 크기: 행 최소 높이 28px(nodeMinHeight), 세로 패딩 0, 깊이당 들여쓰기 18px(indent)입니다. 기본 행 높이는 패딩이 아니라 nodeMinHeight 가 정하며, 라벨 줄 상자에 세로 패딩을 더한 높이가 이를 넘으면 그 높이로 커집니다.
  • 줄 끝 요소 (trailingBuilder): 이름이 남은 폭을 모두 차지해(길면 말줄임) trailing 이 줄 끝에 맞춰지고, 이름과 trailing 사이에는 labelGapStyle 간격이 놓입니다. 깃 변경 표시(M·A·D)처럼 어떤 색·글자를 쓸지는 제품 내용이라 Core 스타일에 두지 않고, 호출 쪽이 Text(...) 체인으로 직접 그립니다.
  • 미적재 폴더: canHaveChildren: true 로 선언합니다. children: [] 만으로는 파일과 빈 폴더를 구분할 수 없습니다. loadChildren 성공 후 빈 결과도 캐시하며, 오류 후 행의 재시도 동작을 제공합니다.
  • 노드 정체성: id가 있으면 폴더 확장 상태와 행 Key가 형제의 추가·정렬에도 유지됩니다. 없으면 종전 인덱스·라벨 기준입니다. 파일 작업·데이터 갱신은 소비자가 소유합니다.
  • 드롭: onNodeDrop을 주면 안정적인 id가 있는 노드만 드래그할 수 있습니다. 폴더(canHaveChildren 또는 이미 적재된 자식)만 대상이며 자신·적재된 자손 대상은 거부합니다. CoUI의 DragScope·DragItem·DropZone이 포인터/키보드 입력과 표시를 맡고, 파일 이동·권한·충돌 검사와 새 노드 전달은 소비자가 맡습니다. 소비자의 canDrop으로 작업 중인 항목 같은 추가 제약을 설정하세요.
Tree(
  nodes: const [CoreTreeNode(id: 'src', label: 'src', canHaveChildren: true)],
  loadChildren: (node) => repository.children(node.id!),
  selectFolders: true,
  onNodeSelect: (node) => select(node.id!),
  leadingBuilder: (context, node, {required expanded}) =>
      Icon(expanded ? LucideIcons.folderOpen : LucideIcons.folderClosed),
  virtualRowHeight: 36,
  virtualViewportHeight: 300,
)

애니메이션#

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

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

부모·자식 노드의 펼침·선택·지연 적재·이름 바꾸기가 필요하면 Tree를 사용합니다. 고정 콘텐츠 섹션의 접기/펼치기는 Accordion, 단일 영역은 Collapsible, 명령·이동 항목을 담는 메뉴는 Menu를 선택합니다. Tree는 파일 이동·이름 저장을 직접 수행하지 않고 소비자 콜백에 의도를 전달합니다.

✅ Do#

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

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

Tree 는 initState 에서 각 노드의 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)#

역할#

  • Web — role="tree" / role="treeitem" 과 aria-level·aria-posinset· aria-setsize(DOM 이 평평하므로 계층을 행마다 적습니다), 부모 행의 aria-expanded, 선택 가능한 행의 aria-selected.
  • Flutter — tree role 없음. Flutter 에는 대응 role 이 없어 계층은 각 노드의 확장 상태로 전달됩니다(설계상 의도된 비대칭).

키보드#

키FlutterWeb
Enter / Space 자식 있으면 확장/축소, leaf 면 선택 자식 있으면 확장/축소, leaf 면 선택
Tab 노드마다 개별 tab stop 트리 전체가 tab stop 하나(roving tabindex)
ArrowUp/ArrowDown 행 간 포커스 이동 (아래 주의) 보이는 행 사이 이동 — 끝에서 멈춤
ArrowRight없음닫힌 부모는 펼침, 열린 부모는 첫 자식으로
ArrowLeft없음열린 부모는 접음, 그 밖에는 부모 행으로
Home/End없음첫 행 / 마지막 보이는 행
문자 입력없음그 글자로 시작하는 다음 행으로(타이핑 점프)
F2 포커스된 행의 이름 바꾸기 (onNodeRename 있을 때) 활성 행의 이름 바꾸기 (onNodeRename 있을 때)
이름 입력칸의 Enter / Esc 확정 / 취소 확정 / 취소 — 끝나면 그 행으로 포커스가 돌아옴

Web 은 WAI-ARIA 트리 패턴을 따릅니다. 표시 전용 leaf(onNodeSelect 없음)도 treeitem 으로 순회에 포함됩니다 — 키보드가 닿지 못하는 행은 스크린 리더도 읽지 못합니다. 폴더 chevron(selectFolders: true)은 포인터 전용이라 별도 tab stop 이 아닙니다.

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

브라우저 검증 — 이 컴포넌트의 Web 키보드 이동은 야간 파리티 스윕의 키보드 프로브(tool/parity/keyboard_probe.dart)가 하이드레이션된 문서 사이트에서 실제로 키를 눌러, 포커스가 기대한 항목으로 옮겨 갔는지 확인합니다. 핸들러가 달렸는지가 아니라 포커스가 어디에 있는지를 봅니다.

스크린 리더#

Web 은 라벨·treeitem·깊이·형제 중 위치·확장 상태를 읽습니다. aria-selected 는 선택이 의미 있는 행(선택 가능하거나 선택된 행)에만 붙습니다 — 아무도 선택하지 않는 트리에서 모든 행마다 "선택 안 됨"을 읽지 않습니다.

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

포커스 관리#

  • 진입 / 트랩 — 트랩 없음. Tab 은 트리에 한 번 들어와 마지막으로 머문 행에 멈추고, 다음 Tab 은 트리 밖으로 나갑니다(Web).
  • 노드를 접을 때 — Web 은 tab stop 이 접힌 하위에 있었다면 접힌 부모로 옮깁니다. Flutter 는 사라진 행에 포커스가 있었다면 상위 스코프로 떨어집니다.

알려진 제약#

  • Flutter 는 노드 수만큼 tab stop 이 생기고 화살표 확장/축소가 없습니다(Web 과 비대칭).

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

항목FlutterWeb
클래스명TreeTree
렌더링 Column + 재귀 Padding <div> + 재귀 padding-left
아이콘 Icon(LucideIcons.folderOpen/folderClosed/file) Icon(LucideIcons.folderOpen/folderClosed/file)
회전 애니메이션AnimatedRotationtransition-transform
테마CoreTreeThemeCoreTreeTheme
노드 빌더 CoreTreeContract<Widget> — 첫 인자는 Flutter BuildContext CoreTreeContract<Component> — 첫 인자는 Jaspr BuildContext · 이름과 나머지 인자는 계약이 두 플랫폼에 같게 고정
  • 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;
  }
}