ScrollableClient | CoUI
본문으로 건너뛰기
LogoCoUI

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#

Web
Offset: 0.0, 0.0
Viewport: 0 × 0
Drag or scroll to move around this 1200 × 560 content.
Flutter
Loading Flutter...
코드 보기
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 키 이름 변환 한 단계만 자기 쪽에 갖습니다.

키동작FlutterWeb
↑ / ↓ 세로 ±50px (keyboardLineStep) ✅ ✅
← / →가로 ±50px✅✅
PageUp / PageDown세로 ±(뷰포트 높이 × 0.8)✅✅
Home / End세로 축의 처음 / 끝✅✅
가로축의 Home/End/Page — 없음 없음
  • 뷰포트 자신이 포커스를 가진 경우에만 반응합니다 — 안쪽 TextField 가 포커스를 쥐고 있으면 화살표·Home/End 는 캐럿 몫으로 남습니다 (Flutter node.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", Flutter FocusableActionDetector + 전용 FocusNode.
  • 포인터로 눌러도 포커스는 가져가되 링은 뜨지 않습니다. Flutter 는 Listener.onPointerDown 에서 requestFocus 하고 링은 onShowFocusHighlight(키보드 전용)로만 켭니다. Web 은 브라우저가 tabindex 요소를 mousedown 에 포커스하고 링은 :focus-visible 로만 붙습니다.
  • 포커스 링은 두 플랫폼 모두 같은 CoreFocusOutlineStyle 슬롯에서 값을 읽습니다. Web 은 scoped <style> 규칙(#<scopeId>:focus-visible)으로 나가므로 per-instance ringOpacity 도 반영됩니다.
  • keyboardDismissBehavior: CoreKeyboardDismissBehavior.onDrag 면 드래그 스크롤이 시작될 때 현재 포커스가 해제됩니다 — Web 은 document.activeElement blur, 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)#

항목FlutterWeb
빌더 좌표/크기 Offset / Size (dx, dy) / (width, height) record
스크롤 엔진 TwoDimensionalScrollable과 축별 controller native DOM overflow와 scroll/pointer 이벤트
역방향 축프레임워크 axis directionCSS transform 기반 방향 반전
포커스·키보드 FocusNode와 Core 키 intent, FocusOutline tabindex, DOM keydown와 같은 Core intent, :focus-visible
고급 배관 flutterVerticalDetails · flutterHorizontalDetails · primary · dragStartBehavior 브라우저 DOM 스크롤·포인터 메커니즘

관련 컴포넌트#

  • Table — ScrollableClient와 함께 가상화/frozen 셀 구현
  • Resizable — 사용자 지정 분할 패널