Resizable
드래그로 패널 크기를 조절할 수 있는 분할 패널 컴포넌트
목차
드래그 핸들을 사용하여 패널 간 크기를 조절할 수 있는 분할 레이아웃 컴포넌트입니다.
기본 사용법 (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'}),
);
}
}
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 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: 고정된 비율의 레이아웃에는 일반 레이아웃 위젯 사용
Props / Parameters#
W는 Flutter의 Widget, Web의 Component를 뜻하는 pane 콘텐츠 타입입니다. 크기와 onResize 값은 호스트 전체에 대한 비율이며 논리 px 치수와 구분합니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
panes |
List<CoreResizablePaneData<W>> |
필수 | 분할할 패널 목록 |
direction |
CoreResizableDirection |
horizontal |
분할 방향 (horizontal, vertical) |
onResize |
void Function(List<double>)? |
null |
크기 변경 콜백 (ratio 배열) |
showDragHandle |
bool |
false |
true
면 패널 경계 위에 알약형 그랩 핸들을 띄워(터치 친화) 표시. 패널은 거터 없이 맞붙고 핸들은 콘텐츠 위로 떠 레이아웃 폭을 점유하지 않음.
false
면 얇은 디바이더 라인 자체가 드래그 hit 영역을 가짐(포인터 친화)
|
optionalDivider |
bool |
false |
구분선을 hover·focus·drag 중에만 표시. 숨겨져도 hit 영역과 리사이즈 동작은 유지 |
resizableStyle |
CoreResizableStyle? |
null |
디바이더·핸들 chrome 단일 진입점 |
CoreResizablePaneData<W>#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
content | W | 필수 | 패널 내부 콘텐츠 |
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의 비율/제약이 런타임에 바뀌면 다시 정규화된다. 콘텐츠만 바뀐 리빌드는 사용자가 드래그해 둔 크기를 그대로 유지한다.
스타일 시스템 (Style System)#
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. |
dividerDraggingThickness |
double? |
Divider stroke thickness while dragged override (logical px). |
dividerDraggingColor |
CoreColor? |
Divider stroke colour while dragged override. |
focusOutlineStyle |
CoreFocusOutlineStyle? |
Keyboard focus ring override, forwarded to the composed
FocusOutline
.
null
defers to [defaultFocusOutlineStyle].
|
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) 전환 -
디바이더는 네 가지 자세로 그려집니다 — 평상시 1px
outlineVariant, hover 1pxoutline, 드래그 중 2pxprimary(dividerDraggingThickness·dividerDraggingColor), 키보드 포커스는 평상시 선 + 공통 포커스 링(focusOutlineStyle). 잡는 영역(dividerHitExtent, 8px)은 선 두께와 별개라 자세가 바뀌어도 그대로입니다.
사용 가이드라인 (Usage Guidelines)#
사용자가 인접한 pane의 비율을 조절해야 하면 Resizable을 사용합니다. 화면 폭에 따른 행/열 전환은 ResponsiveRowColumn, 셀 그리드는 ResponsiveGrid, 떠 있는 창의 위치·크기·스냅은 Window를 선택합니다. Table의 행·열 크기 조절은 ResizableTable이 담당합니다.
✅ 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)#
디바이더는 ARIA APG "window splitter" 패턴을 따르는 포커스 가능한 위젯입니다 — 마우스·터치 없이 키보드만으로도 패널 크기를 조절할 수 있습니다.
역할#
-
Web — 디바이더 래퍼
<div>에role="separator"+aria-orientation(수평 분할이면vertical, 수직 분할이면horizontal) +aria-valuenow/aria-valuemin/aria-valuemax/aria-valuetext+tabindex="0". -
Flutter —
Semantics(slider: true, value:, increasedValue:, decreasedValue:, minValue:, maxValue:). Flutter 에는 ARIAseparator에 대응하는 role 이 없어slider:가 가장 가까운 관용구이고, 양쪽 모두 같은CoreSemanticRole.separatorseat 을 지납니다. -
루트와 패널에는 역할이 없습니다. Web 루트
<div>와 각 패널<div>는 role 없이 나가고, Flutter 패널도Expanded로만 감싸여 별도 시맨틱 경계를 만들지 않습니다.
키보드#
| 키 | 수평 분할 (좌우 패널) | 수직 분할 (상하 패널) |
|---|---|---|
Tab | 디바이더마다 정지점 하나 | 디바이더마다 정지점 하나 |
← / → | 1% 씩 이동 | 없음 |
↑ / ↓ | 없음 | 1% 씩 이동 |
Home / End |
없음 | 없음 |
Enter (접기 / 복원 토글) | 없음 | 없음 |
F6 (패널 간 이동) | 없음 | 없음 |
-
한 번 누를 때마다 컨테이너 크기의 1% (
CoreResizableContract.defaultKeyboardStepFraction—Slider/Range의 ARIA APG "Slider" 화살표 스텝 기본값과 같은 컨벤션입니다. 패널 크기가 이미 그 화살표 스텝과 같은[0, 1]비율 도메인이라 그대로 재사용합니다). -
minSize/maxSize제한은 드래그와 완전히 같은 경로로 적용됩니다 — 키보드와 포인터가 같은 delta 적용 함수를 공유합니다. -
처리한 키만 소비합니다 (Flutter
Shortcuts/Actions, WebpreventDefault). -
VoiceOver / TalkBack 의 "조절 가능" 스와이프도 같은 스텝만큼 움직입니다 — Flutter 가
onIncrease/onDecrease를 붙여 둡니다.
스크린 리더#
디바이더 이름은 resizableDividerLabel 로케일 멤버에서 옵니다 — 기본값은
Resize panels / 패널 크기 조절 (Web aria-label, Flutter
Semantics(label:)).
포커스된 디바이더는 왼쪽(또는 위쪽) 패널의 현재 비율을 백분율로 알립니다.
읽히는 문장은 resizableDividerPositionLabel(percent) 로케일 멤버
(분할선 30%)이며 Web aria-valuetext, Flutter value
/ increasedValue /
decreasedValue 에 실립니다. 숫자 경계(Web aria-valuemin
/ aria-valuemax,
Flutter minValue / maxValue)는 번역하지 않습니다.
최솟값·최댓값은 그 패널의 minSize/maxSize 만이 아니라 양쪽 패널 제한의 교집합입니다 — 오른쪽 패널의
minSize 때문에 왼쪽이 더 못 커지는 경우까지 aria-valuemax 에 반영됩니다.
값 변화를 알리는 별도 live region 은 없습니다. 통보는 포커스된 디바이더의 값 속성 갱신에만 의존하므로, 포커스가 없는 상태에서 마우스로 끌어 바뀐 값은 통보되지 않습니다.
포커스 관리#
- 진입 / 이탈 / 트랩 — 없음. 오버레이가 아니므로
Tab은 디바이더들을 지나 그대로 빠져나갑니다. -
디바이더 개수(
panes.length - 1)만큼 탭 정지점이 생깁니다 — Flutter 는 디바이더당FocusNode하나, Web 은 디바이더당tabindex="0". -
포인터 드래그를 어느 오버레이가 소유하든(기본값의 얇은 선,
showDragHandle: true의 pill 핸들) 같은 노드가 키보드 포커스도 소유합니다. - Flutter 는 hover 와 키보드 포커스를 별도 필드로 추적합니다 — 마우스가 떠나도 키보드 포커스가 켠 강조는 그대로 남습니다.
-
호버 시 커서는
resizeColumn/resizeRow(Webcol-resize/row-resize).
알려진 제약#
- 디바이더 이름은 공통 로케일 문구입니다. 패널이 셋 이상이어도 어느 두 패널 사이인지 이름으로 구별하지 않습니다. 패널별 맥락은 호출부에서 제공해야 합니다.
-
디바이더가 어느 패널을 제어하는지 프로그램적으로 연결돼 있지 않습니다 —
aria-controls가 없고, 참조할id나 role 이 패널 쪽에도 없습니다. -
기본 디바이더의 포인터 타깃이 8px 입니다.
CoreResizableStyle.defaultDividerHitExtent가CoreSpace.space8이라 WCAG 2.2 최소 타깃 24px(CoreTouchTarget.minimum)에 미달합니다. 대안:showDragHandle: true로 pill 핸들을 켜면 그 히트 영역이defaultHandleHitExtent(CoreSpace.space24)이고,resizableStyle: CoreResizableStyle(dividerHitExtent: ...)로 직접 넓힐 수도 있습니다.
모션 감소 · 고대비 · 최소 터치 타깃처럼 모든 컴포넌트에 공통으로 적용되는 축은 전역 접근성 축 에 정리되어 있습니다.
크로스 플랫폼 차이점 (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: 콘텐츠 영역 내 단순 구분선에 사용