Resizable | CoUI
LogoCoUI

Resizable

드래그로 패널 크기를 조절할 수 있는 분할 패널 컴포넌트

Resizable#

드래그 핸들을 사용하여 패널 간 크기를 조절할 수 있는 분할 레이아웃 컴포넌트입니다.

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: 고정된 비율의 레이아웃에는 일반 레이아웃 위젯 사용

기본 사용법 (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 영역이 드래그를 담당

상태 전환#

  • idledragging: 디바이더 드래그 시작
  • draggingidle: 드래그 종료 (마우스 업)
  • 드래그 중 문서 전역에서 마우스 이동을 추적 (요소 밖으로 벗어나도 유지)

애니메이션#

  • 드래그 중 즉시 반응 (애니메이션 없음)
  • 디바이더 색상은 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 으로 디바이더에 포커스 이동 (Web tabindex="0", Flutter FocusNode)
  • 크기 조절: 수평 분할(좌우 패널)은 /, 수직 분할(상하 패널)은 /. 한 번 누를 때마다 컨테이너 크기의 1% (CoreResizableContract.defaultKeyboardStepFractionSlider/Range 의 ARIA APG "Slider" 화살표 스텝 기본값과 동일한 컨벤션, 패널 크기가 이미 그 화살표 스텝과 같은 [0, 1] 비율 도메인이라 그대로 재사용) 만큼 이동. minSize/maxSize 제한은 드래그와 동일하게 적용됩니다.
  • 이름: 디바이더는 자기 접근성 이름을 가집니다 — resizableDividerLabel 로케일 멤버 (Web aria-label, Flutter Semantics(label:)). 기본값은 Resize panels / 패널 크기 조절.
  • 상태 announce: 포커스된 디바이더는 좌측/상단 패널의 현재 비율을 백분율로 announce (Web role="separator" + aria-orientation + aria-valuenow/aria-valuemin/aria-valuemax, Flutter Semantics(slider: true, value:, minValue:, maxValue:)). 백분율은 두 갈래로 나갑니다:
    • 읽히는 문장resizableDividerPositionLabel(percent) 로케일 멤버 (분할선 30%). 맨 숫자 30% 는 무엇의 30% 인지 말하지 않기 때문입니다. Web aria-valuetext, Flutter value/increasedValue/decreasedValue.
    • 숫자 경계 — 번역하지 않습니다. Web aria-valuenow/aria-valuemin/aria-valuemax, Flutter minValue/maxValue (String? 이지만 Flutter 웹 엔진이 그대로 aria-valuemin/aria-valuemax 로 내보내고 숫자로 파싱합니다). Diff 도 같은 방식으로 나눕니다.
  • 포커스 표시: 키보드로 포커스된 디바이더는 hover 와 동일한 강조색으로 표시

포커스 & 커서#

  • 디바이더 hover 시 리사이즈 커서 표시
  • 터치 타겟을 위해 디바이더 hit-test 영역은 CoreSpace.space8 (8px)

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

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