DraggablePanel | CoUI

DraggablePanel

화면 위에 떠 있는, 드래그로 옮기고 가장자리에 도킹되는 플로팅 패널 (Apple PencilKit 팔레트 스타일)

DraggablePanel#

화면 전체를 덮는 child 위에 드래그 가능한 핸들을 띄우고, 핸들을 놓으면 가장 가까운 화면 가장자리에 스냅 도킹되는 플로팅 패널입니다. 핸들을 탭하면 호출자가 넘긴 panel 콘텐츠가 펼쳐집니다 — Apple PencilKit 의 떠 있는 도구 팔레트와 같은 동작이며, 하나의 팔레트로 여러 캔버스(예: 도서 뷰어 + 노트)를 제어하는 용도에 적합합니다.

CoUI 는 도메인 무관 generic chrome 만 제공합니다 — 펜/색/두께 같은 도메인 콘텐츠는 panel 슬롯에 호출자가 채웁니다.

Live Preview#

Web
Canvas
Pen Eraser Color
Flutter
Loading Flutter...
class DraggablePanelDefaultExample extends StatefulComponent {
  const DraggablePanelDefaultExample({super.key});

  @override
  State<DraggablePanelDefaultExample> createState() =>
      _DraggablePanelDefaultExampleState();
}

class _DraggablePanelDefaultExampleState
    extends State<DraggablePanelDefaultExample> {
  final CoreDraggablePanelController _controller =
      CoreDraggablePanelController(initiallyOpen: true);

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Component build(BuildContext context) {
    return div(
      [
        DraggablePanel(
          controller: _controller,
          panel: div(
            [
              const Text('Pen').bodyMedium,
              const Text('Eraser').bodyMedium,
              const Text('Color').bodyMedium,
            ],
            classes: 'flex flex-col gap-${CoreSpace.scale.space8}',
          ),
          child: div(
            [const Text('Canvas').bodyMedium],
            classes:
                'flex items-center justify-center w-full h-full bg-surface-container-low',
          ),
        ),
      ],
      styles: Styles(
        raw: const {'width': '${360 / 16}rem', 'height': '${360 / 16}rem'},
      ),
    );
  }
}
class DraggablePanelDefaultExample extends StatefulWidget {
  const DraggablePanelDefaultExample({super.key});

  @override
  State<DraggablePanelDefaultExample> createState() =>
      _DraggablePanelDefaultExampleState();
}

class _DraggablePanelDefaultExampleState
    extends State<DraggablePanelDefaultExample> {
  final CoreDraggablePanelController _controller =
      CoreDraggablePanelController(initiallyOpen: true);

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return SizedBox(
      width: 360,
      height: 360,
      child: DraggablePanel(
        controller: _controller,
        panel: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          spacing: CoreSpace.space8,
          children: [
            const Text('Pen').bodyMedium,
            const Text('Eraser').bodyMedium,
            const Text('Color').bodyMedium,
          ],
        ),
        child: ColoredBox(
          color: theme.colorScheme.surfaceContainerLow!.toValue(),
          child: Center(child: const Text('Canvas').bodyMedium),
        ),
      ),
    );
  }
}

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • 화면 어디서나 접근 가능해야 하는 떠 있는 도구 팔레트 / 액션 패널
  • 사용자가 위치를 옮기고 가장자리에 붙여 두고 싶은 보조 컨트롤
  • 본문(스크롤/줌되는 캔버스 등) 위에 항상 떠 있어야 하는 패널

대신 다른 컴포넌트를 사용하세요:

  • 트리거에 고정 앵커된 팝업: Popover / DropdownMenu
  • 모달: Dialog / Drawer
  • 고정 위치 액션 버튼: Fab
  • 창 외형(타이틀 바 + 컨트롤): WindowPanel

기본 사용법#

final controller = CoreDraggablePanelController(initiallyOpen: true);

DraggablePanel(
  controller: controller,
  panel: const Column(
    mainAxisSize: MainAxisSize.min,
    children: [Text('Pen'), Text('Eraser'), Text('Color')],
  ),
  child: const Center(child: Text('Canvas')),
)
final controller = CoreDraggablePanelController(initiallyOpen: true);

DraggablePanel(
  controller: controller,
  panel: div([
    const Text('Pen').bodyMedium,
    const Text('Eraser').bodyMedium,
    const Text('Color').bodyMedium,
  ], classes: 'flex flex-col gap-\${CoreSpace.scale.space8}'),
  child: div([const Text('Canvas').bodyMedium],
      classes: 'flex items-center justify-center w-full h-full'),
)

Props / Parameters#

ParamTypeDefault설명
child Widget? / Component? null 패널이 떠 있는 본문 (보통 화면 전체)
panel Widget? / Component? null 핸들을 열었을 때 펼쳐지는 패널 콘텐츠
handle Widget? / Component? null 커스텀 핸들 콘텐츠 (null 이면 기본 드래그 글리프)
dockType CoreDraggablePanelDockType .inside 가장자리 도킹 방식
enabledbooltrue드래그 가능 여부
closeOnTapOutside bool true 열린 패널 바깥 탭 시 닫힘
dockOffset double CoreSpace.space8 도킹 시 가장자리와의 간격 (logical px)
controller CoreDraggablePanelController? null 위치/열림 상태 프로그래매틱 제어
onPositionChanged void Function(double x, double y)? null 핸들 위치 확정 시 콜백 (영속화용)
onOpenChanged CoreValueChanged<bool>? null 패널 열림 상태 변경 콜백
draggablePanelStyle CoreDraggablePanelStyle? null 인스턴스별 chrome 스타일 (단일 진입점)

스타일 시스템#

모든 시각 chrome 은 단일 draggablePanelStyle (CoreDraggablePanelStyle) 슬롯으로 흐릅니다. 평면 chrome prop 은 노출하지 않습니다.

Resolve chain#

CoreDraggablePanelStyle.defaultX
  → CoreDraggablePanelTheme.style          // 프로젝트 공통
  → widget.draggablePanelStyle             // 인스턴스별

Style 필드#

필드기본값
panelBackgroundColorsurface
panelBorderColor / panelBorderWidth outlineVariant / stroke1
panelBorderRadiusradius16
panelPaddingspace8
panelWidthsize240
panelShadowCoreShadow.lg
handleSizesize48
handleBackgroundColor / handleForegroundColor surface / onSurfaceVariant
handleBorderRadiusradius16
handleShadowCoreShadow.md
animationDuration200ms

동작 스펙#

  • 드래그: 핸들을 끌면 본문 위를 따라 이동합니다 (Flutter GestureDetector, Web document 레벨 mousemove 리스너 — 핸들 밖으로 나가도 드래그 유지).
  • 스냅 도킹: 놓으면 가장 가까운 좌/우 가장자리에 dockOffset 간격으로 스냅됩니다 (animationDuration 으로 애니메이션).
  • 탭으로 열기/닫기: 드래그 없이 탭하면 패널이 토글됩니다.
  • 방향 자동 선택: 패널은 핸들이 도킹된 반대쪽(공간이 있는 쪽)으로 펼쳐집니다.

크로스 플랫폼 차이점#

동작·API 는 양 플랫폼 동일합니다. 위치 좌표는 logical px 단위이며 Web 은 rem 으로 emit 됩니다. 핸들/패널의 런타임 위치(left/top)는 위젯이 드래그 상태에서 계산하고, chrome(색·radius·shadow·크기)은 resolver 가 제공합니다.

관련 컴포넌트#

  • WindowPanel — 타이틀 바 + 컨트롤이 있는 창 외형 패널
  • Fab — 고정 위치 플로팅 액션 버튼 (드래그 없음)
  • Popover — 트리거에 앵커된 팝업