ScrollableClient#
고정 크기 뷰포트 안에서 2D 스크롤을 제공하고, 매 스크롤 프레임마다 현재
offset / viewportSize를 builder 콜백에 전달하는 컴포넌트입니다.
Table의 가로/세로 고정 셀이나 가상화 레이아웃처럼 스크롤 위치에 따라
렌더링을 조정해야 하는 서브트리에 최적화되어 있습니다.
Live Preview#
class ScrollableClientDefaultExample extends StatelessComponent {
const ScrollableClientDefaultExample({super.key});
@override
Component build(BuildContext context) {
final cs = context.theme.colorScheme;
return div(
[
ScrollableClient(
diagonalDragBehavior: CoreDiagonalDragBehavior.free,
builder: (context, offset, viewportSize, child) {
return div(
[
Text(
'Offset: ${offset.dx.toStringAsFixed(1)}, '
'${offset.dy.toStringAsFixed(1)}',
),
Gap.space8(),
Text(
'Viewport: ${viewportSize.width.toStringAsFixed(0)} × '
'${viewportSize.height.toStringAsFixed(0)}',
),
Gap.space16(),
Text(
'Drag or scroll to move around this 1200 × 560 content.',
),
],
classes:
'flex flex-col bg-${cs.surfaceContainer} text-${CoreTextStyles.bodyMedium.name} text-${cs.onSurface} rounded-${CoreRadius.scale.radius16} p-${CoreSpace.scale.space16}',
styles: Styles(
raw: const {
'width': '${1200 / 16}rem',
'height': '${560 / 16}rem',
},
),
);
},
),
],
styles: Styles(
raw: const {
'height': '${320 / 16}rem',
'width': '100%',
'min-width': '0',
},
),
);
}
}
class ScrollableClientDefaultExample extends StatelessWidget {
const ScrollableClientDefaultExample({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
height: 320,
child: ScrollableClient(
diagonalDragBehavior: CoreDiagonalDragBehavior.free,
builder: (context, offset, viewportSize, child) {
return Container(
width: 1200,
height: 560,
padding: const EdgeInsets.all(CoreSpace.space16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer.toValue(),
borderRadius: BorderRadius.circular(CoreRadius.radius16),
),
child: DefaultTextStyle.merge(
style: theme.typography.bodyMedium.toValue(theme: theme).copyWith(
color: theme.colorScheme.onSurface.toValue(),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Offset: ${offset.dx.toStringAsFixed(1)}, '
'${offset.dy.toStringAsFixed(1)}',
),
const Gap.space8(),
Text(
'Viewport: ${viewportSize.width.toStringAsFixed(0)} × '
'${viewportSize.height.toStringAsFixed(0)}',
),
const Gap.space16(),
const Text(
'Drag or scroll to move around this 1200 × 560 content.',
),
],
),
),
);
},
),
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 가로/세로 동시 스크롤이 필요한 고정 크기 영역을 만들 때
- 현재 스크롤 오프셋을 기준으로 자식을 다르게 렌더링해야 할 때 (예: 테이블의 frozen 헤더/컬럼, 가상화된 리스트)
- Web/Flutter 양쪽에 동일한 builder 인터페이스로 스크롤 영역을 재사용하고 싶을 때
대신 다른 컴포넌트를 사용하세요:
-
SingleChildScrollView/<div overflow:auto>로 충분한 단방향 스크롤: 그냥 네이티브 스크롤 컨테이너를 쓰는 게 더 가볍습니다 - 리사이저블 레이아웃이 필요:
Resizable
기본 사용법 (Basic Usage)#
SizedBox(
height: CoreSpace.space256,
child: ScrollableClient(
diagonalDragBehavior: CoreDiagonalDragBehavior.free,
builder: (context, offset, viewportSize, child) {
return Card(
cardStyle: const CoreCardStyle(
sizing: CoreCardSizing.fixed,
width: 720,
height: 560,
),
child: /* content */,
);
},
),
)
Web에서도 동일한 생성자로 사용합니다 — 단 builder의 offset은
({double dx, double dy}) 레코드, viewportSize는
({double width, double height}) 레코드입니다.
Props / Parameters#
| 이름 | 타입 | 기본값 | 설명 |
|---|---|---|---|
builder |
CoreScrollableBuilder<W, Off, Sz> |
required | 매 스크롤 프레임마다 호출되는 빌더 (context, offset, viewportSize, child) |
child |
W? |
null |
builder에 그대로 전달되는 정적 자식 (리빌드 비용 절감용) |
mainAxis |
CoreAxis |
vertical |
주 스크롤 축 (diagonalDragBehavior 결정에도 사용) |
verticalDetails |
CoreScrollableDetails |
.vertical() |
세로 축의 direction + initialOffset |
horizontalDetails |
CoreScrollableDetails |
.horizontal() |
가로 축의 direction + initialOffset |
diagonalDragBehavior |
CoreDiagonalDragBehavior? |
none |
대각선 드래그 처리 방식 |
overscroll |
bool? |
false |
끝에서 계속 스크롤 허용 여부 (네이티브 rubber-band) |
clipBehavior |
Clip? / CoreClip? |
hardEdge |
오버플로우 클리핑 (Web: CoreClip.none=overflow:visible) |
hitTestBehavior |
CoreHitTestBehavior? |
opaque |
포인터 이벤트 수신 방식 |
keyboardDismissBehavior |
CoreKeyboardDismissBehavior? |
manual |
드래그 스크롤 시 포커스/키보드 해제 여부 |
showScrollbar |
bool? |
true |
디자인 시스템 스크롤바 표시 여부 |
semanticLabel |
String? |
null |
뷰포트의 접근성 이름. null이면 현재 로케일의 scrollableClientLabel로 폴백 |
위 기본값들은 테마 오버라이드 대상이 아닙니다 — CoreScrollableClientTheme 은
chrome style 슬롯만 나르고, 이 behaviour 기본값은
CoreScrollableClientContract 의 static const 로 살아서 Flutter/Web resolver
가 같은 값을 읽습니다 (clipBehavior 만 각 플랫폼의 hardEdge 상수).
Flutter 전용#
| 이름 | 타입 | 설명 |
|---|---|---|
flutterVerticalDetails |
ScrollableDetails? |
controller / physics / decorationClipBehavior 등 프레임워크 전용 파라미터 |
flutterHorizontalDetails |
ScrollableDetails? |
위와 동일 (가로 축) |
primary |
bool? |
PrimaryScrollController 사용 여부 |
dragStartBehavior |
DragStartBehavior? |
Flutter gesture arena 전용 |
CoreScrollableClientStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
scrollbarThickness |
double? |
Scrollbar thumb / track thickness override (logical px).
null
defers to [defaultScrollbarThickness].
|
scrollbarRadius |
double? |
Scrollbar thumb / track corner radius override (logical px).
null
defers to [defaultScrollbarRadius].
|
scrollbarThumbColor |
CoreColor? |
Scrollbar thumb colour override. null defers to [defaultScrollbarThumbColor]. |
scrollbarTrackColor |
CoreColor? |
Scrollbar track colour override. null defers to [defaultScrollbarTrackColor]. |
focusOutlineStyle |
CoreFocusOutlineStyle? |
Focus-ring override for the viewport itself. The viewport takes keyboard focus (it is scrollable by arrow keys), so it draws the shared ring — Flutter through
FocusOutline
, Web through a scoped
:focus-visible
box-shadow
rule.
null
(or any unset field on the slot) defers to [defaultFocusOutlineStyle].
|
사용 가이드라인 (Usage Guidelines)#
✅ Do#
오프셋과 무관한 콘텐츠는 child 로 전달
ScrollableClient(
child: staticHeader,
builder: (context, offset, viewportSize, child) {
return Column(children: [child!, /* offset 기반 콘텐츠 */]);
},
)
builder 는 매 스크롤 프레임마다 다시 호출되지만 child 는 그대로 forward 됩니다 — 오프셋과 무관한 무거운 서브트리를 builder 안에서 직접 만들지 않고 child 로 넘기면 매 프레임 다시 빌드되지 않습니다.
❌ Don't#
정적 콘텐츠를 builder 안에서 직접 생성하지 않기
// ❌ 오프셋과 무관한 콘텐츠를 builder 안에서 매번 생성
ScrollableClient(
builder: (context, offset, viewportSize, child) {
return Column(children: [ExpensiveStaticHeader(), /* ... */]);
},
)
builder 는 스크롤할 때마다 호출되므로, 여기서 직접 만든 정적 콘텐츠는 스크롤이 일어날 때마다 다시 빌드됩니다.
✅ Do#
진짜 2D 드래그가 필요하면 diagonalDragBehavior: .free 명시
ScrollableClient(
mainAxis: CoreAxis.horizontal,
diagonalDragBehavior: CoreDiagonalDragBehavior.free,
builder: (context, offset, viewportSize, child) => canvasContent,
)
기본값 CoreDiagonalDragBehavior.none 은 한 번의 드래그를 dominant 축 하나로만 반영합니다 — 두 축을 동시에 움직여야 하는 캔버스나 테이블이라면 free 를 명시해야 합니다.
❌ Don't#
대각선 드래그가 필요한데 기본값에 의존하지 않기
// ❌ 대각선 드래그가 필요한데 diagonalDragBehavior 를 지정하지 않음
ScrollableClient(
builder: (context, offset, viewportSize, child) => canvasContent,
)
기본값에서는 대각선으로 끌어도 한 축만 움직입니다 — 두 축이 함께 반응해야 하는 화면에서는 사용자가 "드래그가 안 먹는다"고 오인하게 됩니다.
접근성 (Accessibility)#
포인터로만 조작되던 뷰포트를 보조 기술과 키보드에 노출한다.
-
이름과 역할 — 뷰포트는 그룹으로 선언되고 이름을 갖는다. Web은
role="group"+aria-label, Flutter는 같은CoreSemanticRole.group을CoUISemantics로 emit한다. 이름은semanticLabel이 없으면 현재 로케일의scrollableClientLabel에서 온다 — 이름 없는 영역은 스크린 리더가 아무것도 읽지 않기 때문에 폴백이 필요하다. -
키보드 스크롤 — 화살표(한 줄) · PageUp/PageDown(뷰포트의 80%) ·
Home/End(세로 양 끝). 키 이름과 이동량은 양 플랫폼이 같은 Core 함수
(
resolveScrollableKeyIntent)를 읽으므로 갈라질 수 없다. 끝에 닿아 더 움직일 수 없는 키는 소비하지 않는다 — 브라우저가 네이티브overflow: auto박스에서 하는 스크롤 체이닝과 같다. Flutter의 기본ScrollAction은 가장 가까운Scrollable(2D에서는 항상 안쪽 가로축)만 찾아 다른 축에 0을 돌려주므로 여기서 두 축을 직접 라우팅한다. -
포커스 표시 — 뷰포트가 탭 순서에 들어가므로(WCAG 2.4.7) 키보드로
들어왔을 때 링이 보인다. Web은
:focus-visiblescoped rule, Flutter는onShowFocusHighlight+FocusOutline. 포인터로 누른 경우에는 링이 뜨지 않는다 — 두 메커니즘 모두 같은CoreFocusOutlineStyle슬롯에서 값을 읽는다. -
호출자가 이긴다 —
attributes로 넘긴role/aria-label/tabindex(Web)는 위 기본값을 덮는다. 콘텐츠가 키를 직접 소유하는 캔버스류는tabindex: '-1'로 뷰포트를 탭 순서에서 빼면 된다. -
스크롤바는 네이티브 OS 스크롤바 + CoUI
onSurface틴트로 렌더링되어 사용자 설정(감추기/포커스 색)이 반영됩니다. -
keyboardDismissBehavior: CoreKeyboardDismissBehavior.onDrag로 설정하면 드래그 스크롤이 시작될 때document.activeElement(Web) / 현재 포커스된 FocusScope(Flutter)가 해제되어 모바일에서 소프트 키보드가 닫힙니다.