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

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#

수직 분할#

Web
Top
Bottom
Flutter
Loading Flutter...
코드 보기
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 영역을 가집니다.

Web
Panel A
Panel B
Flutter
Loading Flutter...
코드 보기
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>#

속성타입기본값설명
contentW필수패널 내부 콘텐츠
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 1px outline, 드래그 중 2px primary(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 에는 ARIA separator 에 대응하는 role 이 없어 slider: 가 가장 가까운 관용구이고, 양쪽 모두 같은 CoreSemanticRole.separator seat 을 지납니다.
  • 루트와 패널에는 역할이 없습니다. 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, Web preventDefault).
  • 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 (Web col-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)#

항목FlutterWeb
클래스명ResizableResizable
패널 타입 CoreResizablePaneData<Widget> CoreResizablePaneData<Component>
드래그 감지 GestureDetector mousedown + document listeners
레이아웃 LayoutBuilder + Row/Column + Expanded CSS flex + ratio 기반 flex-grow
  • Divider: 콘텐츠 영역 내 단순 구분선에 사용