DraggablePanel#
화면 전체를 덮는 child 위에 드래그 가능한 핸들을 띄우고, 핸들을 놓으면
가장 가까운 화면 가장자리에 스냅 도킹되는 플로팅 패널입니다. 핸들을 탭하면
호출자가 넘긴 panel 콘텐츠가 펼쳐집니다 — Apple PencilKit 의 떠 있는 도구
팔레트와 같은 동작이며, 하나의 팔레트로 여러 캔버스(예: 도서 뷰어 + 노트)를
제어하는 용도에 적합합니다.
CoUI 는 도메인 무관 generic chrome 만 제공합니다 — 펜/색/두께 같은 도메인
콘텐츠는 panel 슬롯에 호출자가 채웁니다.
Live Preview#
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),
),
),
);
}
}
class DraggablePanelChainExample extends StatefulComponent {
const DraggablePanelChainExample({super.key});
@override
State<DraggablePanelChainExample> createState() => _DraggablePanelChainExampleState();
}
class _DraggablePanelChainExampleState extends State<DraggablePanelChainExample> {
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',
),
).withStyle(
const CoreDraggablePanelStyle(
panelBackgroundColor: CoreColor.token(
CoreColors.surfaceContainerHighest,
),
panelBorderColor: CoreColor.token(CoreColors.primary),
panelBorderWidth: CoreStrokeWidth.stroke2,
panelBorderRadius: CoreBorderRadius.all(CoreRadius.radius24),
handleBorderRadius: CoreBorderRadius.all(CoreRadius.radius4),
handleBackgroundColor: CoreColor.token(
CoreColors.primaryContainer,
),
handleIconStyle: CoreIconStyle(
size: CoreIconSize.size24,
color: CoreColor.token(CoreColors.onPrimaryContainer),
),
),
),
],
styles: Styles(
raw: const {'width': '${360 / 16}rem', 'height': '${360 / 16}rem'},
),
);
}
}
class DraggablePanelChainExample extends StatefulWidget {
const DraggablePanelChainExample({super.key});
@override
State<DraggablePanelChainExample> createState() => _DraggablePanelChainExampleState();
}
class _DraggablePanelChainExampleState extends State<DraggablePanelChainExample> {
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),
),
).withStyle(
const CoreDraggablePanelStyle(
panelBackgroundColor: CoreColor.token(
CoreColors.surfaceContainerHighest,
),
panelBorderColor: CoreColor.token(CoreColors.primary),
panelBorderWidth: CoreStrokeWidth.stroke2,
panelBorderRadius: CoreBorderRadius.all(CoreRadius.radius24),
handleBorderRadius: CoreBorderRadius.all(CoreRadius.radius4),
handleBackgroundColor: CoreColor.token(
CoreColors.primaryContainer,
),
handleIconStyle: CoreIconStyle(
size: CoreIconSize.size24,
color: CoreColor.token(CoreColors.onPrimaryContainer),
),
),
),
);
}
}
사용 시기 (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'),
)
빠른 오버라이드 (Chain)#
이미 만든 DraggablePanel 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class DraggablePanelChainExample extends StatefulWidget {
const DraggablePanelChainExample({super.key});
@override
State<DraggablePanelChainExample> createState() => _DraggablePanelChainExampleState();
}
class _DraggablePanelChainExampleState extends State<DraggablePanelChainExample> {
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),
),
).withStyle(
const CoreDraggablePanelStyle(
panelBackgroundColor: CoreColor.token(
CoreColors.surfaceContainerHighest,
),
panelBorderColor: CoreColor.token(CoreColors.primary),
panelBorderWidth: CoreStrokeWidth.stroke2,
panelBorderRadius: CoreBorderRadius.all(CoreRadius.radius24),
handleBorderRadius: CoreBorderRadius.all(CoreRadius.radius4),
handleBackgroundColor: CoreColor.token(
CoreColors.primaryContainer,
),
handleIconStyle: CoreIconStyle(
size: CoreIconSize.size24,
color: CoreColor.token(CoreColors.onPrimaryContainer),
),
),
),
);
}
}
class DraggablePanelChainExample extends StatefulComponent {
const DraggablePanelChainExample({super.key});
@override
State<DraggablePanelChainExample> createState() => _DraggablePanelChainExampleState();
}
class _DraggablePanelChainExampleState extends State<DraggablePanelChainExample> {
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',
),
).withStyle(
const CoreDraggablePanelStyle(
panelBackgroundColor: CoreColor.token(
CoreColors.surfaceContainerHighest,
),
panelBorderColor: CoreColor.token(CoreColors.primary),
panelBorderWidth: CoreStrokeWidth.stroke2,
panelBorderRadius: CoreBorderRadius.all(CoreRadius.radius24),
handleBorderRadius: CoreBorderRadius.all(CoreRadius.radius4),
handleBackgroundColor: CoreColor.token(
CoreColors.primaryContainer,
),
handleIconStyle: CoreIconStyle(
size: CoreIconSize.size24,
color: CoreColor.token(CoreColors.onPrimaryContainer),
),
),
),
],
styles: Styles(
raw: const {'width': '${360 / 16}rem', 'height': '${360 / 16}rem'},
),
);
}
}
Props / Parameters#
| Param | Type | Default | 설명 |
|---|---|---|---|
child |
Widget? / Component? |
null |
패널이 떠 있는 본문 (보통 화면 전체) |
panel |
Widget? / Component? |
null |
핸들을 열었을 때 펼쳐지는 패널 콘텐츠 |
handle |
Widget? / Component? |
null |
커스텀 핸들 콘텐츠 (null 이면 기본 드래그 글리프) |
dockType |
CoreDraggablePanelDockType |
.inside |
가장자리 도킹 방식 |
enabled | bool | true | 드래그 가능 여부 |
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 // 인스턴스별
CoreDraggablePanelStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
panelBackgroundColor |
CoreColor? |
Background fill of the expanded panel surface. |
panelBorderColor |
CoreColor? |
Border colour of the expanded panel surface. |
panelBorderWidth |
double? |
Border width (logical px) of the expanded panel surface. |
panelBorderRadius |
CoreBorderRadius? |
Corner radius of the expanded panel surface. |
panelPadding |
CoreEdgeInsets? |
Inner padding around the panel content. |
panelWidth |
double? |
Fixed width (logical px) of the expanded panel surface. |
panelShadow |
List<CoreShadowLayer>? |
Drop shadow of the expanded panel surface. |
handleSize |
double? |
Edge length (logical px) of the square collapsed handle button. |
handleBackgroundColor |
CoreColor? |
Background fill of the collapsed handle button. |
handleForegroundColor |
CoreColor? |
Foreground (icon/glyph) colour of the default handle. |
handleBorderRadius |
CoreBorderRadius? |
Corner radius of the collapsed handle button. |
handleIconStyle |
CoreIconStyle? |
Nested icon style for the default handle icon (the hamburger affordance drawn when the caller supplies no custom
handle
). Raw-forwarded to the unified
Icon
so both platforms render the same vector glyph — a text glyph would rasterise differently per platform font stack.
|
handleShadow |
List<CoreShadowLayer>? |
Drop shadow of the collapsed handle button. |
animationDuration |
Duration? |
Duration of the open/close and dock-snap animations. |
shadowBaseColor |
CoreColor? |
Tint colour override for the panel/handle drop shadows.
null
→ [defaultShadowBaseColor]. [panelShadow] / [handleShadow] only carry geometry (offset/blur/spread/opacity — no colour), so this is the single override path for shadow tint.
|
동작 스펙#
-
드래그: 핸들을 끌면 본문 위를 따라 이동합니다 (Flutter
GestureDetector, Web document 레벨mousemove리스너 — 핸들 밖으로 나가도 드래그 유지). -
스냅 도킹: 놓으면 가장 가까운 좌/우 가장자리에
dockOffset간격으로 스냅됩니다 (animationDuration으로 애니메이션). - 탭으로 열기/닫기: 드래그 없이 탭하면 패널이 토글됩니다.
- 방향 자동 선택: 패널은 핸들이 도킹된 반대쪽(공간이 있는 쪽)으로 펼쳐집니다.
사용 가이드라인 (Usage Guidelines)#
✅ Do#
키보드 사용자를 위한 별도 토글 컨트롤을 함께 제공
Button(
onPressed: () => controller.toggle(),
child: const Text('도구 패널 열기/닫기'),
)
핸들은 포인터로만 토글되고 Escape도 열린 패널을 닫지 않습니다. 키보드·스위치 사용자에게 패널이 필요하다면 여닫는 별도 컨트롤을 함께 제공해야 합니다.
❌ Don't#
확인이 꼭 필요한 흐름을 패널에 넣지 않기
// ❌ 포커스 트랩·복원·배경 inert 가 전혀 없는 패널에
// 삭제 확인 같은 모달 흐름을 넣음
DraggablePanel(
panel: ConfirmDeleteForm(onConfirm: deleteAccount),
child: canvas,
)
열린 패널 뒤의 child 서브트리가 그대로 탭으로 도달되고, 닫을 때 포커스 복원도 없습니다. 모달이 필요하면 Dialog 또는 Drawer를 사용하세요.
접근성 (Accessibility)#
현재 이 컴포넌트는 키보드만으로 열거나 닫을 수 없습니다. 아래 제약을 읽고, 필요한 부분은 소비자 코드에서 직접 보완해야 합니다.
역할 / Semantics#
DraggablePanel 자신은 아무 역할도 내보내지 않습니다. 루트·콘텐츠·스크림·패널은 모두 호출자 attributes
만 실린 평범한 <div>(Web) / Position + DecoratedBox(Flutter) 입니다. 패널에는
dialog 도 region 도 붙지 않고 이름도 없습니다.
역할이 붙는 곳은 핸들에 합성된 DragItem 하나뿐입니다.
-
Web:
role="application"+aria-grabbed+aria-disabled - Flutter:
Semantics(enabled:)노드
DragItem 은 label / hint 파라미터를 받지만 DraggablePanel 이 이를 넘기지 않고, 대신 지정할 파라미터도 노출하지 않습니다.
그래서 핸들은 양 플랫폼 모두 이름이 없습니다.
패널의 열림/닫힘 상태는 어디에도 노출되지 않습니다 — aria-expanded 도, Flutter 의 expanded 플래그도 없습니다. 핸들과 패널을 잇는
aria-controls / aria-haspopup 도 없습니다.
키보드#
처리되는 키는 DragItem 의 드래그 키뿐이며, 모두 핸들의 이동을 제어할 뿐 패널을 열고 닫지 않습니다.
| 키 | 동작 |
|---|---|
Space / Enter | 키보드 드래그 시작. 드래그 중이면 그 자리에 놓기(드롭) |
Escape | 진행 중인 드래그만 취소 — 열린 패널은 닫히지 않습니다 |
← → ↑ ↓ |
드래그 중 DragItem 의 keyboardDragStep 만큼 핸들 이동 |
패널 토글은 포인터 전용입니다 — Flutter 는 GestureDetector(onTap:), Web 은 핸들의 click 핸들러입니다. Web 핸들은
<button> 이 아니라 role="application" 인 <div> 이고 DragItem
이 Enter/Space 에 preventDefault() 를 호출하므로 합성 클릭도 발생하지 않습니다. 결과적으로
양 플랫폼 모두 키보드로 패널을 토글할 수 없습니다.
포커스#
포커스를 받는 것은 핸들뿐입니다 — Web 은 활성화 시 tabindex="0"(비활성 시 -1), Flutter 는 DragItem
의 Focus 노드입니다. 핸들에는 포커스 링이 없습니다 — draggable_panel_style 에 포커스 chrome 자체가 정의되어 있지 않아, 키보드 사용자는 핸들에 포커스가 있는지 시각적으로 알 수 없습니다.
패널 콘텐츠의 포커스는 전혀 관리되지 않습니다.
- 패널이 열려도 autofocus 없음
- 포커스 트랩 없음
- 닫을 때 포커스 복원 없음
-
배경이
inert/ExcludeFocus/BlockSemantics로 가려지지 않아, 열린 패널 뒤의child서브트리가 그대로 탭으로 도달됩니다
스크린 리더#
핸들은 Web 에서 이름 없는 "application" 영역(grabbed / disabled 상태 포함)으로, Flutter 에서는 이름 없는 활성 노드로 안내됩니다. 패널이 열려도 아무 안내가 발생하지 않으며, 열림/닫힘 상태도, 핸들과 패널의 연결도 읽히지 않습니다 — 패널 콘텐츠가 읽기 순서에 그냥 나타날 뿐입니다.
닫기용 스크림은 포인터 핸들러만 달린 빈 div 라 보조 기술에는 보이지 않습니다. 드래그나 화살표 키로 핸들 위치가 바뀌어도 안내되지 않습니다.
알려진 제약#
소비자가 직접 보완해야 하는 항목입니다.
-
키보드 토글 경로가 없습니다. 키보드·스위치 사용자에게 패널이 필요하다면 열고 닫는 별도 컨트롤(예:
Button)을 함께 제공하고, 그 컨트롤에aria-expanded상당 정보를 직접 부여하세요. Escape로 패널이 닫히지 않습니다. 필요하면 소비자 코드에서 키 핸들러를 직접 붙여야 합니다.-
핸들에 접근 가능한 이름이 없고, 이름을 넘길 파라미터도 없습니다. Web 은 루트 passthrough
attributes로 우회할 수 있지만 핸들 자체를 직접 라벨링할 수는 없습니다. - 핸들에 포커스 표시가 없습니다.
- 열림/닫힘 상태와 핸들↔패널 연결이 노출되지 않습니다.
-
포커스 트랩·복원·배경 inert 가 없습니다. 패널을 모달로 다뤄야 한다면 이 컴포넌트가 아니라
Dialog/Drawer를 검토하세요. onOpenChanged는 포인터 토글에서만 발생합니다.
전역으로 적용되는 항목(감소된 모션·고대비·강제 색상·최소 터치 타겟 등)은 전역 접근성 축을 참고하세요.
크로스 플랫폼 차이점 (Platform Differences)#
동작·API 는 양 플랫폼 동일합니다. 위치 좌표는 logical px 단위이며 Web 은
rem 으로 emit 됩니다. 핸들/패널의 런타임 위치(left/top)는 위젯이 드래그
상태에서 계산하고, chrome(색·radius·shadow·크기)은 resolver 가 제공합니다.
관련 컴포넌트#
WindowPanel— 타이틀 바 + 컨트롤이 있는 창 외형 패널Fab— 고정 위치 플로팅 액션 버튼 (드래그 없음)Popover— 트리거에 앵커된 팝업