Tree#
파일 탐색기나 조직도처럼 계층적 데이터를 트리 구조로 표시하는 컴포넌트입니다.
Live Preview#
class TreeDefaultExample extends StatelessComponent {
const TreeDefaultExample({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'),
],
);
}
}
class TreeDefaultExample extends StatelessWidget {
const TreeDefaultExample({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'),
],
);
}
}
class TreeSelectedExample extends StatelessComponent {
const TreeSelectedExample({super.key});
@override
Component build(BuildContext context) {
return const Tree(
nodes: [
CoreTreeNode(
label: 'Documents',
expanded: true,
children: [
CoreTreeNode(label: 'Resume.pdf', selected: true),
CoreTreeNode(label: 'Cover Letter.pdf'),
],
),
CoreTreeNode(
label: 'Images',
children: [
CoreTreeNode(label: 'photo.png'),
],
),
],
);
}
}
class TreeSelectedExample extends StatelessWidget {
const TreeSelectedExample({super.key});
@override
Widget build(BuildContext context) {
return const Tree(
nodes: [
CoreTreeNode(
label: 'Documents',
expanded: true,
children: [
CoreTreeNode(label: 'Resume.pdf', selected: true),
CoreTreeNode(label: 'Cover Letter.pdf'),
],
),
CoreTreeNode(
label: 'Images',
children: [
CoreTreeNode(label: 'photo.png'),
],
),
],
);
}
}
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;
}
}
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;
}
}
사용 시기 (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.
|
nodePadding | CoreEdgeInsets? | Node row padding. |
indent | double? | 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, Webtransition-transform동일 duration
사용 가이드라인 (Usage Guidelines)#
✅ 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-expanded·aria-selected. - Flutter — tree role 없음. Flutter 에는 대응 role 이 없어 계층은 각 노드의 확장 상태로 전달됩니다(설계상 의도된 비대칭).
키보드#
| 키 | Flutter | Web |
|---|---|---|
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)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | Tree | Tree |
| 렌더링 | Column + 재귀 Padding |
<div> + 재귀 padding-left |
| 아이콘 | Icon(LucideIcons.folder/file) |
Icon(LucideIcons.folder/file) |
| 회전 애니메이션 | AnimatedRotation | transition-transform |
| 테마 | CoreTreeTheme | CoreTreeTheme |
관련 컴포넌트 (Related Components)#
- 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;
}
}