ScrollableClient
오프셋을 builder에 전달하는 2D 스크롤 뷰포트
목차
고정 크기 뷰포트 안에서 2D 스크롤을 제공하고, 매 스크롤 프레임마다 현재
offset / viewportSize를 builder 콜백에 전달하는 컴포넌트입니다.
Table의 가로/세로 고정 셀이나 가상화 레이아웃처럼 스크롤 위치에 따라
렌더링을 조정해야 하는 서브트리에 최적화되어 있습니다.
기본 사용법 (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}) 레코드입니다.
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 CoreEdgeInsets.all(CoreSpace.space16).toValue(geometry: theme.geometry),
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
Props / Parameters#
W는 Flutter의 Widget, Web의 Component입니다. 빌더의 Off/Sz는 Flutter에서
Offset/Size, Web에서 ScrollableOffset/ScrollableSize
record를 사용하며 숫자는 논리 px 단위입니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
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
outline
rule.
null
(or any unset field on the slot) defers to [defaultFocusOutlineStyle].
|
동작 스펙 (Behavior)#
스크롤 위치와 뷰포트 크기를 builder(context, offset, viewportSize, child)에 전달합니다. child는 그대로 전달되는 정적 서브트리입니다. 축별
initialOffset은 초기 위치, direction은 축의 진행 방향을 정합니다. diagonalDragBehavior는 대각선 이동의 축 잠금 방식을 선택합니다. 키보드는 방향키·PageUp/PageDown·Home/End를 처리하며 경계에서 이동하지 못하면 키를 소비하지 않습니다.
showScrollbar: false는 바만 숨기고 스크롤을 유지합니다.
사용 가이드라인 (Usage Guidelines)#
오프셋·뷰포트에 따른 렌더링 또는 2축 이동이 필요할 때 사용합니다. 한 축의 정적 콘텐츠 뷰포트는 ScrollArea가 더 직접적이고, 자동 왕복 표시가 목적이면 OverflowMarquee를 선택합니다.
기존 스크롤러의 바만 구성하면 Scrollbar, 가운데 버튼 자동 스크롤은 ScrollViewInterceptor의 역할입니다.
✅ 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)#
포인터로만 조작되던 뷰포트를 보조 기술과 키보드에 노출합니다.
역할#
뷰포트는 이름을 가진 하나의 그룹으로 선언됩니다. 역할 값은 양 플랫폼이 같은 CoreScrollableClientContract.semanticRole(CoreSemanticRole.group)을 읽습니다.
-
Web — 루트
<div>에role="group"+aria-label+tabindex="0". -
Flutter — 같은 role 을
CoUISemantics로 emit 합니다.group은 Flutter 에 대응 role 이 없어 시맨틱 컨테이너 경계(container:)로 표현됩니다 — 이름은 붙지만 리더가 "그룹"이라고 부르지는 않습니다.
Web 은 컴포넌트 기본값을 먼저 깔고 호출자 attributes 를 나중에 병합하므로 role / aria-label
/ tabindex 를 호출자가 덮어씁니다. 콘텐츠가 키를 직접 소유하는 캔버스류는 attributes: {'tabindex': '-1'}
로 뷰포트를 탭 순서에서 뺄 수 있습니다.
키보드#
두 플랫폼이 같은 Core 함수(resolveScrollableKeyIntent)를 읽으므로 키 이름과 이동량은 갈라질 수 없습니다. Flutter 는 LogicalKeyboardKey
→ DOM 키 이름 변환 한 단계만 자기 쪽에 갖습니다.
| 키 | 동작 | Flutter | Web |
|---|---|---|---|
↑ / ↓ |
세로 ±50px (keyboardLineStep) |
✅ | ✅ |
← / → | 가로 ±50px | ✅ | ✅ |
PageUp / PageDown | 세로 ±(뷰포트 높이 × 0.8) | ✅ | ✅ |
Home / End | 세로 축의 처음 / 끝 | ✅ | ✅ |
가로축의 Home/End/Page |
— | 없음 | 없음 |
-
뷰포트 자신이 포커스를 가진 경우에만 반응합니다 — 안쪽
TextField가 포커스를 쥐고 있으면 화살표·Home/End 는 캐럿 몫으로 남습니다 (Flutternode.hasPrimaryFocus, Web 은 이벤트 타깃이 뷰포트인지 확인). Alt/Ctrl/Cmd조합은 브라우저·OS 몫이라 건드리지 않습니다.-
끝에 닿아 더 움직일 수 없는 키는 소비하지 않습니다 — 바깥 스크롤러로 체이닝됩니다(브라우저가 네이티브
overflow: auto박스에서 하는 것과 같습니다). - Web 은 호출자의
onKeyDown을 먼저 실행하고,preventDefault된 키는 건드리지 않습니다. -
Flutter 의 기본
ScrollAction은 가장 가까운Scrollable(2D 에서는 항상 안쪽 가로축)만 찾아 다른 축에 0 을 돌려주므로, 여기서 두 축을 직접 라우팅합니다.
스크린 리더#
이름은 semanticLabel 이 없으면 현재 로케일의 scrollableClientLabel(한국어 "스크롤 영역", 영어 "Scrollable region")에서 옵니다 — 이름 없는 영역은 리더가 아무것도 읽지 않기 때문에 폴백이 필요합니다.
스크롤 위치가 바뀌었다고 알리는 live region 은 없습니다. 이 컴포넌트는 영역에 이름만 얹고, 스크롤 위치·범위와 리더가 수행하는 스크롤 액션은 Flutter 쪽
TwoDimensionalScrollable 이 담당합니다.
포커스 관리#
- 진입 / 이탈 / 트랩 — 없음. 오버레이가 아니므로 포커스를 옮기지 않고,
Tab은 뷰포트를 지나 그대로 빠져나갑니다. -
뷰포트 자신이 탭 정지점 하나가 됩니다(WCAG 2.4.7) — Web
tabindex="0", FlutterFocusableActionDetector+ 전용FocusNode. -
포인터로 눌러도 포커스는 가져가되 링은 뜨지 않습니다. Flutter 는
Listener.onPointerDown에서requestFocus하고 링은onShowFocusHighlight(키보드 전용)로만 켭니다. Web 은 브라우저가tabindex요소를 mousedown 에 포커스하고 링은:focus-visible로만 붙습니다. -
포커스 링은 두 플랫폼 모두 같은
CoreFocusOutlineStyle슬롯에서 값을 읽습니다. Web 은 scoped<style>규칙(#<scopeId>:focus-visible)으로 나가므로 per-instanceringOpacity도 반영됩니다. -
keyboardDismissBehavior: CoreKeyboardDismissBehavior.onDrag면 드래그 스크롤이 시작될 때 현재 포커스가 해제됩니다 — Web 은document.activeElementblur, Flutter 는FocusManager.instance.primaryFocus?.unfocus(). 모바일 소프트 키보드를 닫기 위한 것이고, 그 순간 포커스는 어디에도 남지 않습니다.
알려진 제약#
-
뷰포트를 탭 순서에서 빼는 방법이 플랫폼마다 다릅니다. Web 은 호출자
attributes로tabindex="-1"을 넘기면 되지만 Flutter 생성자에는 포커스 관련 파라미터가 없어(FocusableActionDetector가 항상 노드를 설치합니다) 밖에서 감싸야 합니다 —ExcludeFocusTraversal또는Focus(descendantsAreTraversable: false). 콘텐츠 자신이 화살표를 소유하는 캔버스·에디터에서 필요합니다. -
Home/End/PageUp/PageDown은 세로 축 전용입니다. 가로로만 스크롤하는 뷰포트에는 양 끝으로 뛰는 키가 없어 화살표로 한 줄씩만 이동합니다. -
PageUp/PageDown은 뷰포트가 측정되기 전에는 아무 일도 하지 않습니다. 페이지 이동량이 뷰포트 높이에서 파생되므로 높이가 0 인 첫 프레임에는 no-op 으로 해석됩니다.
모션 감소 · 고대비 · 최소 터치 타깃처럼 모든 컴포넌트에 공통으로 적용되는 축은 전역 접근성 축 에 정리되어 있습니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 빌더 좌표/크기 | Offset / Size |
(dx, dy) / (width, height) record |
| 스크롤 엔진 | TwoDimensionalScrollable과 축별 controller |
native DOM overflow와 scroll/pointer 이벤트 |
| 역방향 축 | 프레임워크 axis direction | CSS transform 기반 방향 반전 |
| 포커스·키보드 | FocusNode와 Core 키 intent, FocusOutline | tabindex, DOM keydown와 같은 Core intent, :focus-visible |
| 고급 배관 |
flutterVerticalDetails
·
flutterHorizontalDetails
·
primary
·
dragStartBehavior
|
브라우저 DOM 스크롤·포인터 메커니즘 |