ScrollArea
콘텐츠를 스크롤 가능한 뷰포트로 감싸고 디자인 시스템 스크롤바를 입히는 래퍼
목차
아무 child 나 스크롤 가능한 뷰포트로 감싸고 슬림한 디자인 시스템 스크롤바를 입히는 래퍼입니다. 엣지를 그라데이션으로 가리는
FadeScroll 과 달리 콘텐츠를 그대로 보여주고, 오버플로는 스크롤바로만 알립니다. Flutter / Web 동일 API 입니다.
Live Preview#
코드 보기
class ScrollAreaDefaultExample extends StatelessComponent {
const ScrollAreaDefaultExample({super.key});
@override
Component build(BuildContext context) {
return div(
styles: Styles(raw: {'width': '260px'}),
[
ScrollArea(
scrollAreaStyle: const CoreScrollAreaStyle(maxHeight: 180),
child: div(
classes: 'flex flex-col gap-${CoreSpace.scale.space8}',
[
for (var i = 1; i <= 20; i += 1) Text('스크롤 항목 $i').bodyMedium,
],
),
),
],
);
}
}
class ScrollAreaDefaultExample extends StatelessWidget {
const ScrollAreaDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 260,
child: ScrollArea(
scrollAreaStyle: const CoreScrollAreaStyle(maxHeight: 180),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: Theme.of(context).geometry.space(CoreSpace.space8),
children: [
for (var i = 1; i <= 20; i += 1) Text('스크롤 항목 $i').bodyMedium,
],
),
),
);
}
}
사용법#
// Flutter / Web 동일
ScrollArea(
scrollAreaStyle: const CoreScrollAreaStyle(maxHeight: 180),
child: Column(
children: [for (var i = 0; i < 20; i += 1) Text('Item $i')],
),
)
빠른 오버라이드 (Chain)#
이미 만든 ScrollArea 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class ScrollAreaChainExample extends StatelessWidget {
const ScrollAreaChainExample({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 260,
child:
ScrollArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: Theme.of(context).geometry.space(CoreSpace.space8),
children: [
for (var i = 1; i <= 20; i += 1) Text('스크롤 항목 $i').bodyMedium,
],
),
).withStyle(
const CoreScrollAreaStyle(
height: CoreSpace.space192,
maxHeight: CoreSpace.space192,
scrollbarStyle: CoreScrollbarStyle(
thumbColor: CoreColor.token(CoreColors.primary),
thumbRadius: CoreBorderRadius.all(CoreRadius.radius9999),
thickness: CoreStrokeWidth.stroke8,
),
),
),
);
}
}
class ScrollAreaChainExample extends StatelessComponent {
const ScrollAreaChainExample({super.key});
@override
Component build(BuildContext context) {
return div(
styles: Styles(raw: {'width': '260px'}),
[
ScrollArea(
child: div(
classes: 'flex flex-col gap-${CoreSpace.scale.space8}',
[
for (var i = 1; i <= 20; i += 1) Text('스크롤 항목 $i').bodyMedium,
],
),
).withStyle(
const CoreScrollAreaStyle(
height: CoreSpace.space192,
maxHeight: CoreSpace.space192,
scrollbarStyle: CoreScrollbarStyle(
thumbColor: CoreColor.token(CoreColors.primary),
thumbRadius: CoreBorderRadius.all(CoreRadius.radius9999),
thickness: CoreStrokeWidth.stroke8,
),
),
),
],
);
}
}
Props / Parameters#
W는 Flutter의 Widget, Web의 Component를 뜻하는 콘텐츠 타입입니다. controller는 Flutter 런타임 인프라로 Web의 DOM 스크롤과 구분합니다.
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
child | W | 필수 | 뷰포트 안에서 스크롤되는 콘텐츠 |
direction |
CoreScrollAreaDirection |
vertical |
스크롤 축 (vertical / horizontal) |
showScrollbar |
bool |
true |
디자인 시스템 스크롤바 표시 여부 (false 면 스크롤은 되되 바는 숨김) |
scrollAreaStyle |
CoreScrollAreaStyle? |
null |
chrome 단일 진입점 (아래 필드 표) |
controller |
ScrollController? |
null (Flutter 전용 런타임 인프라) |
외부에서 스크롤 위치를 구동. Web 은 브라우저 native overflow 로 동작 |
CoreScrollAreaStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
height |
double? |
Fixed viewport height in logical pixels. When null the scroll area adopts its parent's height (or its child's intrinsic height when no parent constraint exists).
No default, and must not have one.
Both fields together are a switch, not a measurement: Flutter builds a
ConstrainedBox
only under
if (resolved.height != null || resolved.maxHeight != null)
and returns the unwrapped scroll view otherwise, and Web writes the
height
declaration only inside
if (height != null)
. A constant would make that branch permanently taken, so every scroll area in the tree would be clamped to one height regardless of the space its parent gave it — and a scroll area that ignores its parent is the one thing this component must not do.
|
maxHeight |
double? |
Maximum viewport height in logical pixels. When set, the scroll area sizes to the child's intrinsic height up to this maximum and becomes scrollable when overflowed. Mutually composes with [height] (host platforms apply both when both are set). No default, and must not have one — it is the other half of the switch described on [height], and a constant would additionally introduce a scroll boundary where the caller asked for none. |
scrollbarStyle |
CoreScrollbarStyle? |
Nested chrome for the composed
Scrollbar
(thumb colour / thickness / corner radius / minimum length / track colour). When null falls back to [defaultScrollbarStyle]; partial overrides merge on top of it.
|
동작 스펙 (Behavior)#
direction으로 한 축의 스크롤을 선택하고 콘텐츠가 뷰포트를 넘을 때 그 축으로 이동합니다. height·maxHeight는 논리 px 단위의 뷰포트 제약이며 생략하면 부모 제약을 따릅니다.
showScrollbar는 합성된 Scrollbar의 always/hidden 표시 정책을 바꾸며 스크롤 자체를 끄지 않습니다. 자체 로딩·선택·스크롤 값 콜백은 없고 자식 콘텐츠의 상태는 호출자가 관리합니다.
사용 가이드라인 (Usage Guidelines)#
일반적인 단일 축 뷰포트에는 ScrollArea를 사용합니다. 이미 만든 Flutter 스크롤러에 바만 합성하려면 Scrollbar, 2축 오프셋·뷰포트 크기를 빌더에서 읽으려면 ScrollableClient를 선택합니다. 가장자리 페이드가 필요하면 FadeScroll을 비교하세요.
기존 스크롤 경로에 가운데 버튼 자동 스크롤을 덧붙이려면 ScrollViewInterceptor를 사용합니다.
✅ Do#
가변 길이 콘텐츠에는 maxHeight 를 명시
ScrollArea(
scrollAreaStyle: const CoreScrollAreaStyle(maxHeight: 240),
child: Column(children: [for (final item in items) Text(item)]),
)
maxHeight 를 주지 않으면 부모 제약이 없는 한 자식의 intrinsic 높이를 그대로 채택합니다 — 이 경우 넘침 자체가 생기지 않아 스크롤바도 등장하지 않습니다.
❌ Don't#
대체 어포던스 없이 showScrollbar: false 사용 금지
// ❌ 넘침을 알릴 다른 수단 없이 스크롤바만 숨김
ScrollArea(
showScrollbar: false,
scrollAreaStyle: const CoreScrollAreaStyle(maxHeight: 180),
child: longContent,
)
콘텐츠가 더 있다는 사실을 알리는 유일한 시각 단서가 사라집니다 — 그라데이션이나 페이지네이션처럼 이를 대체할 어포던스가 없으면 사용자는 스크롤이 가능하다는 것 자체를 알 수 없습니다.
접근성 (Accessibility)#
역할#
양 플랫폼 모두 역할을 내보내지 않습니다. Flutter 는 SingleChildScrollView 에
Scrollbar 를 합성하고 ConstrainedBox 로 감쌀 뿐 Semantics
노드를 만들지 않으며,
Web 은 합성된 Scrollbar 가 그리는 overflow-* 클래스가 붙은 <div>
하나만 내보내고
role / aria-* 는 하나도 붙이지 않습니다(호출자가 넘긴 attributes
는 그대로 통과).
즉 "여기가 스크롤되는 영역"이라는 사실 자체가 보조기술에 전달되지 않습니다.
영역 이름이 필요하면 소비자가 직접 붙여야 합니다 — Web 은 attributes 로
role="region" · aria-label 을, Flutter 는 바깥에 자기
Semantics 를 감싸는 방식입니다.
키보드#
이 컴포넌트가 해석하는 키는 없습니다. Flutter 에서 방향키 · Page · Home · End 로
스크롤되는 것은 SingleChildScrollView 안의 프레임워크 Scrollable
이 주는 동작이고,
Web 에서는 브라우저가 주는 동작입니다 — 그리고 Web 쪽은 스크롤 컨테이너가 포커스를
받았을 때만 동작합니다(아래 제약 참조). Web 의 onKeyDown / onKeyUp
은 호출자가
넘긴 핸들러를 루트에 연결하는 통과 슬롯일 뿐, 컴포넌트가 채우는 값이 아닙니다.
포커스 관리#
포커스를 받지 않습니다. 양 플랫폼 모두 FocusNode / Focus / tabindex 가 없고
포커스 링도 그리지 않습니다. 탭 순서에 등장하지 않으며, 포커스를 가두거나 되돌리지도
않습니다 — 콘텐츠 안의 포커스 가능한 요소는 각자의 동작을 그대로 유지합니다.
스크린 리더#
영역 자체에는 이름이 없습니다 — Flutter 는 프레임워크 Scrollable 이 기여하는 스크롤
액션이, Web 은 이름 없는 <div> 안쪽 콘텐츠가 읽힙니다.
showScrollbar 가 켜져 있으면 합성된 Scrollbar 가 스크롤
위치를 0–100 퍼센트로 안내합니다 (Web role="scrollbar" + aria-valuenow, Flutter
slider Semantics). 남은 분량 자체는 어느 쪽에서도 안내되지 않습니다.
알려진 제약#
-
Web 스크롤 컨테이너에
tabindex가 없어 키보드만 쓰는 사용자가 이 영역을 스크롤할 수 있는지가 전적으로 브라우저 동작에 달려 있습니다. 키보드로 확실히 스크롤시키려면 소비자가attributes로tabindex="0"과role="region"·aria-label을 직접 넣어야 합니다. -
showScrollbar: false는 넘침을 알리는 유일한 시각 단서를 없애면서 대체 어포던스를 제공하지 않습니다 — 콘텐츠가 더 있다는 사실이 시각적으로도 음성으로도 드러나지 않습니다. -
controller는 Flutter 전용이라 크로스 플랫폼 프로그래매틱 스크롤 경로가 없습니다. "특정 항목으로 스크롤" 같은 동작을 양 플랫폼에서 동일하게 제공하려면 소비자 쪽에서 플랫폼별로 구현해야 합니다.
reduced motion · 고대비 · 최소 터치 타겟 등 컴포넌트를 가로지르는 축은 전역 접근성 축에서 다룹니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 스크롤 엔진 | SingleChildScrollView | 합성된 Scrollbar 루트의 native overflow |
| 외부 위치 제어 | ScrollController; 생략 시 내부 생성·dispose | 브라우저 DOM 스크롤 |
| 높이 적용 | 현재 ConstrainedBox의 최대 높이로 height ?? maxHeight 사용 |
CSS height와 max-height 적용 |
| 키보드 진입 | Flutter 스크롤러의 프레임워크 동작 | 브라우저 포커스 가능한 scroller 지원에 따름; 명시적 tabindex는 호출자 배선 |
현재 Flutter의 height 경로는 최소 높이를 0으로 두므로 CSS의 고정 높이와 동일하게 해석하면 안 됩니다. 높이가 중요한 조합은 두 렌더링을 확인하세요.