DraggablePanel
화면 위에 떠 있는, 드래그로 옮기고 가장자리에 도킹되는 플로팅 패널 (Apple PencilKit 팔레트 스타일)
목차
화면 전체를 덮는 child 위에 드래그 가능한 핸들을 띄우고, 핸들을 놓으면
가장 가까운 화면 가장자리에 스냅 도킹되는 플로팅 패널입니다. 핸들을 탭하면
호출자가 넘긴 panel 콘텐츠가 펼쳐집니다 — Apple PencilKit 의 떠 있는 도구
팔레트와 같은 동작이며, 하나의 팔레트로 여러 캔버스(예: 도서 뷰어 + 노트)를
제어하는 용도에 적합합니다.
CoUI 는 도메인 무관 generic chrome 만 제공합니다 — 펜/색/두께 같은 도메인
콘텐츠는 panel 슬롯에 호출자가 채웁니다.
기본 사용법#
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: Theme.of(context).geometry.space(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'},
),
);
}
}
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: Theme.of(context).geometry.space(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
- 모달 또는 가장자리 sheet가 필요하면 Dialog / Drawer
- 패널 없이 떠 있는 액션 버튼이면 Fab
- 타이틀 바·창 컨트롤이 필요하면 WindowPanel
Props / Parameters#
W 는 Flutter의 Widget, Web의 Jaspr Component 입니다. W?
는 선택 슬롯입니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
child |
W? |
null |
패널이 떠 있는 본문 (보통 화면 전체) |
panel |
W? |
null |
핸들을 열었을 때 펼쳐지는 패널 콘텐츠 |
handle |
W? |
null |
커스텀 핸들 콘텐츠 (null 이면 기본 드래그 글리프) |
dockType |
CoreDraggablePanelDockType |
.inside |
가장자리 도킹 방식 —
.inside
는
dockOffset
만큼 안쪽,
.outside
는 닫혀 있는 동안 핸들 절반이 가장자리 밖에 걸쳐 있다가 탭하면 안으로 돌아옴
|
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 스타일 (단일 진입점) |
스타일 시스템 (Style System)#
모든 시각 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.
|
동작 스펙 (Behavior)#
-
드래그: 양쪽 모두
DragScope/DragItem의 공유 Core 런타임으로 핸들을 옮깁니다. 활성화 거리 기본값은CoreDraggablePanelContract.defaultDragActivationDistance(18 논리 px)입니다. 실제 드래그가 된 누름은 패널 토글로 다시 처리하지 않습니다. -
스냅 도킹: 놓으면 가장 가까운 좌/우 가장자리에
dockOffset간격으로 스냅됩니다 (animationDuration으로 애니메이션). - 탭으로 열기/닫기: 드래그 없이 탭하면 패널이 토글됩니다.
-
도킹 방식 (
dockType):.inside(기본) — 핸들이 가장자리에서dockOffset만큼 안쪽에 머뭅니다..outside— 패널이 닫혀 있는 동안 핸들이 가장자리 밖으로 걸쳐CoreDraggablePanelDockType.outsideVisibleFraction(절반)만 보입니다. 탭하면(또는controller.open = true) 핸들이dockOffset안쪽 자리로 돌아오며 패널이 열리고, 닫히면 다시 밖으로 미끄러져 나갑니다. 가장자리 밖으로 나간 부분은 잘려 페이지를 넓히지 않습니다.- 두 방식 모두 컨트롤러와
onPositionChanged가 보고하는 x 는 안쪽 도킹 자리(탭하면 돌아오는 자리)입니다 — 영속화한 값을 그대로 되돌려 넣으면 같은 방식으로 다시 그려집니다.
- 방향 자동 선택: 패널은 핸들이 도킹된 반대쪽(공간이 있는 쪽)으로 펼쳐집니다.
-
좌표는 화면 전체가 아닌 컴포넌트 부모 박스 기준이며, 드래그 종료·취소 후 스냅한 위치로
onPositionChanged(x, y)를 호출합니다. 드래그 중에는 위치 트랜지션을 끕니다. enabled: false는 패널·핸들을 렌더하지 않고child본문만 남깁니다. 패널을 보이되 드래그만 막는 플래그가 아닙니다.
사용 가이드라인 (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)#
현재 이 컴포넌트는 키보드만으로 열거나 닫을 수 없습니다. 아래 제약을 읽고, 필요한 부분은 소비자 코드에서 직접 보완해야 합니다.
역할#
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)#
위치 값은 부모 박스의 논리 px 기준이고 Web 출력은 rem으로 변환합니다. 공유 drag 런타임과 렌더·측정 메커니즘은 별개입니다.
| 항목 | Flutter | Web |
|---|---|---|
| 가용 박스 | LayoutBuilder constraints | 루트 DOM ResizeObserver |
| 드래그 입력 | DragItem gesture | DragItem document pointer 스트림 |
| 위치 전환 | AnimatedPositioned | CSS 위치 transition |
| SSR 초기 위치 | 해당 없음 | 첫 클라이언트 측정 전 CSS 오른쪽 도킹 |
관련 컴포넌트#
WindowPanel— 타이틀 바 + 컨트롤이 있는 창 외형 패널Fab— 고정 위치 플로팅 액션 버튼 (드래그 없음)Popover— 트리거에 앵커된 팝업