Resizable#
드래그 핸들을 사용하여 패널 간 크기를 조절할 수 있는 분할 레이아웃 컴포넌트입니다.
Live Preview#
class ResizableDefaultExample extends StatelessComponent {
const ResizableDefaultExample({super.key});
@override
Component build(BuildContext context) {
final cs = context.colorScheme;
return div(
[
Resizable(
panes: [
CoreResizablePaneData<Component>(
content: div(
[Text('Panel A')],
classes: 'flex items-center justify-center bg-${cs.surfaceContainer} text-${cs.onSurface} h-full',
),
initialSize: 0.4,
),
CoreResizablePaneData<Component>(
content: div(
[Text('Panel B')],
classes: 'flex items-center justify-center bg-${cs.surfaceContainerHigh} text-${cs.onSurface} h-full',
),
initialSize: 0.6,
),
],
),
],
styles: Styles(raw: {'width': '100%', 'height': '200px'}),
);
}
}
class ResizableDefaultExample extends StatelessWidget {
const ResizableDefaultExample({super.key});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final onSurface = scheme.onSurface.toValue();
return SizedBox(
height: 200,
child: Resizable(
panes: [
CoreResizablePaneData(
content: Container(
color: scheme.surfaceContainer.toValue(),
alignment: Alignment.center,
child: Text('Panel A', style: TextStyle(color: onSurface)),
),
initialSize: 0.4,
),
CoreResizablePaneData(
content: Container(
color: scheme.surfaceContainerHigh.toValue(),
alignment: Alignment.center,
child: Text('Panel B', style: TextStyle(color: onSurface)),
),
initialSize: 0.6,
),
],
),
);
}
}
class ResizableChainExample extends StatelessComponent {
const ResizableChainExample({super.key});
@override
Component build(BuildContext context) {
final cs = context.colorScheme;
return div(
[
Resizable(
panes: [
CoreResizablePaneData<Component>(
content: div(
[Text('Panel A')],
classes:
'flex items-center justify-center bg-${cs.surfaceContainer} text-${cs.onSurface} text-${CoreTextStyles.bodyMedium.name} h-full',
),
initialSize: 0.4,
),
CoreResizablePaneData<Component>(
content: div(
[Text('Panel B')],
classes:
'flex items-center justify-center bg-${cs.surfaceContainerHigh} text-${cs.onSurface} text-${CoreTextStyles.bodyMedium.name} h-full',
),
initialSize: 0.6,
),
],
).withStyle(
const CoreResizableStyle(
dividerThickness: CoreStrokeWidth.stroke6,
dividerColor: CoreColor.token(CoreColors.primary),
dividerHoverColor: CoreColor.token(CoreColors.tertiary),
handleLength: CoreSize.size32,
),
),
],
classes: 'coui-resizable-preview',
styles: Styles(raw: {'width': '100%', 'height': '200px'}),
);
}
}
class ResizableChainExample extends StatelessWidget {
const ResizableChainExample({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final textStyle = theme.typography.bodyMedium
.toValue(theme: theme)
.copyWith(
color: scheme.onSurface.toValue(),
);
return SizedBox(
height: 200,
child:
Resizable(
panes: [
CoreResizablePaneData(
content: Container(
color: scheme.surfaceContainer.toValue(),
alignment: Alignment.center,
child: Text('Panel A', style: textStyle),
),
initialSize: 0.4,
),
CoreResizablePaneData(
content: Container(
color: scheme.surfaceContainerHigh.toValue(),
alignment: Alignment.center,
child: Text('Panel B', style: textStyle),
),
initialSize: 0.6,
),
],
).withStyle(
const CoreResizableStyle(
dividerThickness: CoreStrokeWidth.stroke6,
dividerColor: CoreColor.token(CoreColors.primary),
dividerHoverColor: CoreColor.token(CoreColors.tertiary),
handleLength: CoreSize.size32,
),
),
);
}
}
수직 분할#
class ResizableVerticalExample extends StatelessComponent {
const ResizableVerticalExample({super.key});
@override
Component build(BuildContext context) {
final cs = context.colorScheme;
return div(
[
Resizable(
direction: CoreResizableDirection.vertical,
panes: [
CoreResizablePaneData<Component>(
content: div(
[Text('Top')],
classes: 'flex items-center justify-center bg-${cs.surfaceContainer} text-${cs.onSurface} h-full',
),
initialSize: 0.5,
),
CoreResizablePaneData<Component>(
content: div(
[Text('Bottom')],
classes: 'flex items-center justify-center bg-${cs.surfaceContainerHigh} text-${cs.onSurface} h-full',
),
initialSize: 0.5,
),
],
),
],
styles: Styles(raw: {'width': '100%', 'height': '260px'}),
);
}
}
class ResizableVerticalExample extends StatelessWidget {
const ResizableVerticalExample({super.key});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final onSurface = scheme.onSurface.toValue();
return SizedBox(
height: 260,
child: Resizable(
direction: CoreResizableDirection.vertical,
panes: [
CoreResizablePaneData(
content: Container(
color: scheme.surfaceContainer.toValue(),
alignment: Alignment.center,
child: Text('Top', style: TextStyle(color: onSurface)),
),
initialSize: 0.5,
),
CoreResizablePaneData(
content: Container(
color: scheme.surfaceContainerHigh.toValue(),
alignment: Alignment.center,
child: Text('Bottom', style: TextStyle(color: onSurface)),
),
initialSize: 0.5,
),
],
),
);
}
}
드래그 핸들 (터치)#
showDragHandle: true 로 패널 경계 위에 떠 있는 알약형 그랩 핸들을 표시합니다. 얇은 디바이더로 콘텐츠 영역을 최대한 확보하면서, 핸들은 콘텐츠 위로 떠 터치 조작이 쉽도록 넓은 hit 영역을 가집니다.
class ResizableDragHandleExample extends StatelessComponent {
const ResizableDragHandleExample({super.key});
@override
Component build(BuildContext context) {
final cs = context.colorScheme;
return div(
[
Resizable(
showDragHandle: true,
panes: [
CoreResizablePaneData<Component>(
content: div(
[Text('Panel A')],
classes: 'flex items-center justify-center bg-${cs.surfaceContainer} text-${cs.onSurface} h-full',
),
initialSize: 0.4,
),
CoreResizablePaneData<Component>(
content: div(
[Text('Panel B')],
classes: 'flex items-center justify-center bg-${cs.surfaceContainerHigh} text-${cs.onSurface} h-full',
),
initialSize: 0.6,
),
],
),
],
styles: Styles(raw: {'width': '100%', 'height': '200px'}),
);
}
}
class ResizableDragHandleExample extends StatelessWidget {
const ResizableDragHandleExample({super.key});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final onSurface = scheme.onSurface.toValue();
return SizedBox(
height: 200,
child: Resizable(
showDragHandle: true,
panes: [
CoreResizablePaneData(
content: Container(
color: scheme.surfaceContainer.toValue(),
alignment: Alignment.center,
child: Text('Panel A', style: TextStyle(color: onSurface)),
),
initialSize: 0.4,
),
CoreResizablePaneData(
content: Container(
color: scheme.surfaceContainerHigh.toValue(),
alignment: Alignment.center,
child: Text('Panel B', style: TextStyle(color: onSurface)),
),
initialSize: 0.6,
),
],
),
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- IDE, 코드 에디터, 대시보드처럼 사용자가 패널 크기를 직접 조정해야 할 때
- 좌우 또는 상하로 분할된 레이아웃에서 각 패널의 비율을 사용자가 선택하게 할 때
- 세 개 이상의 패널을 유연하게 분할 배치할 때
대신 다른 컴포넌트를 사용하세요:
Divider: 크기 조절 없이 시각적 구분선만 필요할 때Row/Column: 고정된 비율의 레이아웃에는 일반 레이아웃 위젯 사용
기본 사용법 (Basic Usage)#
// 수평 분할 패널
Resizable(
panes: [
CoreResizablePaneData(
content: SidebarPanel(),
initialSize: 0.3,
),
CoreResizablePaneData(
content: MainContentPanel(),
initialSize: 0.7,
),
],
)
// 수직 분할 (min/max 제한 포함)
Resizable(
direction: CoreResizableDirection.vertical,
onResize: (sizes) => saveLayout(sizes),
panes: [
CoreResizablePaneData(
content: EditorPanel(),
initialSize: 0.6,
minSize: 0.2,
),
CoreResizablePaneData(
content: TerminalPanel(),
initialSize: 0.4,
minSize: 0.1,
maxSize: 0.6,
),
],
)
// 수평 분할 패널
Resizable(
panes: [
CoreResizablePaneData<Component>(
content: div(
[Text('사이드바').bodyMedium.onSurface],
classes: 'flex items-center justify-center h-full',
),
initialSize: 0.3,
),
CoreResizablePaneData<Component>(
content: div(
[Text('메인 콘텐츠').bodyMedium.onSurface],
classes: 'flex items-center justify-center h-full',
),
initialSize: 0.7,
),
],
)
// 수직 분할
Resizable(
direction: CoreResizableDirection.vertical,
panes: [
CoreResizablePaneData<Component>(
content: div([Text('에디터').bodyMedium.onSurface]),
initialSize: 0.6,
),
CoreResizablePaneData<Component>(
content: div([Text('터미널').bodyMedium.onSurface]),
initialSize: 0.4,
),
],
)
빠른 오버라이드 (Chain)#
이미 만든 Resizable 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class ResizableChainExample extends StatelessWidget {
const ResizableChainExample({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final textStyle = theme.typography.bodyMedium
.toValue(theme: theme)
.copyWith(
color: scheme.onSurface.toValue(),
);
return SizedBox(
height: 200,
child:
Resizable(
panes: [
CoreResizablePaneData(
content: Container(
color: scheme.surfaceContainer.toValue(),
alignment: Alignment.center,
child: Text('Panel A', style: textStyle),
),
initialSize: 0.4,
),
CoreResizablePaneData(
content: Container(
color: scheme.surfaceContainerHigh.toValue(),
alignment: Alignment.center,
child: Text('Panel B', style: textStyle),
),
initialSize: 0.6,
),
],
).withStyle(
const CoreResizableStyle(
dividerThickness: CoreStrokeWidth.stroke6,
dividerColor: CoreColor.token(CoreColors.primary),
dividerHoverColor: CoreColor.token(CoreColors.tertiary),
handleLength: CoreSize.size32,
),
),
);
}
}
class ResizableChainExample extends StatelessComponent {
const ResizableChainExample({super.key});
@override
Component build(BuildContext context) {
final cs = context.colorScheme;
return div(
[
Resizable(
panes: [
CoreResizablePaneData<Component>(
content: div(
[Text('Panel A')],
classes:
'flex items-center justify-center bg-${cs.surfaceContainer} text-${cs.onSurface} text-${CoreTextStyles.bodyMedium.name} h-full',
),
initialSize: 0.4,
),
CoreResizablePaneData<Component>(
content: div(
[Text('Panel B')],
classes:
'flex items-center justify-center bg-${cs.surfaceContainerHigh} text-${cs.onSurface} text-${CoreTextStyles.bodyMedium.name} h-full',
),
initialSize: 0.6,
),
],
).withStyle(
const CoreResizableStyle(
dividerThickness: CoreStrokeWidth.stroke6,
dividerColor: CoreColor.token(CoreColors.primary),
dividerHoverColor: CoreColor.token(CoreColors.tertiary),
handleLength: CoreSize.size32,
),
),
],
classes: 'coui-resizable-preview',
styles: Styles(raw: {'width': '100%', 'height': '200px'}),
);
}
}
Props / Parameters#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
panes |
List<CoreResizablePaneData<W>> |
필수 | 분할할 패널 목록 |
direction |
CoreResizableDirection |
horizontal |
분할 방향 (horizontal, vertical) |
onResize |
void Function(List<double>)? |
null |
크기 변경 콜백 (ratio 배열) |
showDragHandle |
bool |
false |
true
면 패널 경계 위에 알약형 그랩 핸들을 띄워(터치 친화) 표시. 패널은 거터 없이 맞붙고 핸들은 콘텐츠 위로 떠 레이아웃 폭을 점유하지 않음.
false
면 얇은 디바이더 라인 자체가 드래그 hit 영역을 가짐(포인터 친화)
|
resizableStyle |
CoreResizableStyle? |
null |
디바이더·핸들 chrome 단일 진입점 |
CoreResizablePaneData<W>#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
content |
W (Widget / Component) |
필수 | 패널 내부 콘텐츠 |
initialSize |
double |
0.5 |
초기 크기 비율 (0.0~1.0). 선언 그대로 그려지는 게 아니라 아래 정규화를 거친다 |
minSize |
double? |
null |
최소 크기 비율. 패널들의 최소값 합이 컨테이너를 넘으면 비례 완화된다 |
maxSize |
double? |
null |
최대 크기 비율. 패널들의 최대값 합이 컨테이너를 못 채우면 비례 완화된다 |
크기 정규화 — 선언은 의도이지 결과가 아니다#
panes 는 손으로 쓰는 리터럴이라 그 숫자가 서로 모순될 수 있다 — 비율의 합이 1 이 아니거나, initialSize 가 자기
minSize/maxSize 밖이거나, 최소값들이 컨테이너보다 큰 공간을 요구하거나. 어느 쪽이든 렌더링 전에 정규화되어, 화면에 그려지는 비율과 스크린 리더가 읽는 비율이 항상 같은 하나의 숫자가 된다.
| 선언 | 실제 레이아웃 |
|---|---|
[0.3 | 0.3] (합 0.6) | [0.5 | 0.5] — 합이 컨테이너로 정규화 |
[0.3 minSize .5 | 0.7] | [0.5 | 0.5] — 최소값까지 올리고 그만큼 이웃이 낸다 |
[0.9 maxSize .6 | 0.1] |
[0.6 | 0.4] — 최대값까지 내리고 그만큼 이웃이 받는다 |
[0.5 minSize .5 | 0.5 minSize .6] | 두 최소값이 110% 를 요구하므로 비례 완화 후 배치 |
두 가지가 이 정규화에 딸려 온다:
-
onResize가 넘기는 값도 정규화된 비율이다. 합이 항상 1 이므로 그대로 저장했다가initialSize로 되돌려도 같은 레이아웃이 나온다. -
panes의 비율/제약이 런타임에 바뀌면 다시 정규화된다. 콘텐츠만 바뀐 리빌드는 사용자가 드래그해 둔 크기를 그대로 유지한다.
스타일 시스템 — resizableStyle#
CoreResizableStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
dividerThickness |
double? |
Divider stroke thickness override (logical px). |
dividerHitExtent |
double? |
Divider hit-test region extent override (logical px). |
dividerColor |
CoreColor? |
Divider stroke colour override. |
dividerHoverColor |
CoreColor? |
Divider stroke colour on hover override. |
transitionDuration |
Duration? |
Divider colour crossfade duration override. |
handleHitExtent |
double? |
Enlarged hit-test extent override used when the drag handle is shown (logical px). |
handleLength |
double? |
Grab-handle length along the divider axis override (logical px). |
handleBreadth |
double? |
Grab-handle breadth across the divider axis override (logical px). |
handleColor |
CoreColor? |
Grab-handle background colour override. |
handleIconColor |
CoreColor? |
Drag-dot icon colour inside the grab handle override. |
handleIconSize |
double? |
Drag-dot icon size inside the grab handle override (logical px). |
Resolve chain#
design system default (CoreResizableStyle.defaultX)
→ CoreResizableTheme.style // 프로젝트 공통
→ widget.resizableStyle // 인스턴스별
동작 스펙 (Behavior)#
인터랙션#
- 드래그: 디바이더를 드래그하여 인접 패널 크기 실시간 조정
- 커서 변경: 수평은
col-resize, 수직은row-resize - min/max 제한: 드래그가 제한 범위를 벗어나면 무시됨
-
드래그 핸들(
showDragHandle: true): 패널은 거터 없이 맞붙어 콘텐츠 영역을 최대화하고, 디바이더 라인·그랩 핸들은 패널 경계(seam) 위 오버레이로 렌더됨. 포인터 환경은 라인 hit 영역이, 터치 환경은 부유 핸들의 넓은 hit 영역이 드래그를 담당
상태 전환#
idle→dragging: 디바이더 드래그 시작dragging→idle: 드래그 종료 (마우스 업)- 드래그 중 문서 전역에서 마우스 이동을 추적 (요소 밖으로 벗어나도 유지)
애니메이션#
- 드래그 중 즉시 반응 (애니메이션 없음)
- 디바이더 색상은 hover 시
CoreDuration.fast(150ms) 전환
사용 가이드라인 (Usage Guidelines)#
✅ Do#
minSize로 패널이 너무 좁아지지 않도록 제한
Resizable(
panes: [
CoreResizablePaneData(
content: SidebarPanel(),
initialSize: 0.3,
minSize: 0.2,
),
CoreResizablePaneData(
content: ContentPanel(),
initialSize: 0.7,
),
],
)
최소 크기 제한이 없으면 사용자가 패널을 완전히 닫아 콘텐츠에 접근하지 못할 수 있습니다.
❌ Don't#
너무 많은 패널을 한 번에 분할하지 않기
패널이 4개를 초과하면 각 패널이 너무 좁아져 콘텐츠를 표시하기 어렵습니다.
✅ Do#
onResize 콜백으로 사용자 설정 저장
Resizable(
onResize: (sizes) => preferences.saveSizes(sizes),
panes: panes,
)
사용자가 설정한 패널 크기를 저장하면 다음 방문 시 동일한 레이아웃이 유지됩니다.
접근성 (Accessibility)#
키보드 (WCAG 2.1.1)#
디바이더는 ARIA APG "window splitter" 패턴을 따르는 포커스 가능한 위젯입니다 — 마우스/터치 없이도 키보드만으로 패널 크기를 조절할 수 있습니다.
-
포커스:
Tab으로 디바이더에 포커스 이동 (Webtabindex="0", FlutterFocusNode) -
크기 조절: 수평 분할(좌우 패널)은
←/→, 수직 분할(상하 패널)은↑/↓. 한 번 누를 때마다 컨테이너 크기의 1% (CoreResizableContract.defaultKeyboardStepFraction—Slider/Range의 ARIA APG "Slider" 화살표 스텝 기본값과 동일한 컨벤션, 패널 크기가 이미 그 화살표 스텝과 같은[0, 1]비율 도메인이라 그대로 재사용) 만큼 이동.minSize/maxSize제한은 드래그와 동일하게 적용됩니다. -
이름: 디바이더는 자기 접근성 이름을 가집니다 —
resizableDividerLabel로케일 멤버 (Webaria-label, FlutterSemantics(label:)). 기본값은Resize panels/패널 크기 조절. -
상태 announce: 포커스된 디바이더는 좌측/상단 패널의 현재 비율을 백분율로 announce (Web
role="separator"+aria-orientation+aria-valuenow/aria-valuemin/aria-valuemax, FlutterSemantics(slider: true, value:, minValue:, maxValue:)). 백분율은 두 갈래로 나갑니다:- 읽히는 문장 —
resizableDividerPositionLabel(percent)로케일 멤버 (분할선 30%). 맨 숫자30%는 무엇의 30% 인지 말하지 않기 때문입니다. Webaria-valuetext, Fluttervalue/increasedValue/decreasedValue. - 숫자 경계 — 번역하지 않습니다. Web
aria-valuenow/aria-valuemin/aria-valuemax, FlutterminValue/maxValue(String?이지만 Flutter 웹 엔진이 그대로aria-valuemin/aria-valuemax로 내보내고 숫자로 파싱합니다).Diff도 같은 방식으로 나눕니다.
- 읽히는 문장 —
- 포커스 표시: 키보드로 포커스된 디바이더는 hover 와 동일한 강조색으로 표시
포커스 & 커서#
- 디바이더 hover 시 리사이즈 커서 표시
- 터치 타겟을 위해 디바이더 hit-test 영역은
CoreSpace.space8(8px)
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | Resizable | Resizable |
| 패널 타입 | CoreResizablePaneData<Widget> |
CoreResizablePaneData<Component> |
| 드래그 감지 | GestureDetector |
mousedown + document listeners |
| 레이아웃 | LayoutBuilder + Row/Column + Expanded |
CSS flex + ratio 기반 flex-grow |
관련 컴포넌트 (Related Components)#
- Divider: 콘텐츠 영역 내 단순 구분선에 사용