Carousel
슬라이드 형태로 콘텐츠를 순환하며 보여주는 컴포넌트
목차
슬라이드 형태로 콘텐츠를 순환하며 보여주는 컴포넌트입니다. 자동 재생, 탐색 버튼, 인디케이터를 지원합니다.
기본 사용법 (Basic Usage)#
Carousel(
transition: CarouselTransition.sliding(),
items: [
Container(color: Colors.blue, child: Text('Slide 1')),
Container(color: Colors.green, child: Text('Slide 2')),
Container(color: Colors.orange, child: Text('Slide 3')),
],
autoPlay: true,
showIndicators: true,
showNavigation: true,
)
Carousel(
currentIndex: _index,
onIndexChanged: (i) => setState(() => _index = i),
items: [
div([const Text('Slide 1').bodyMedium.onSurface]),
div([const Text('Slide 2').bodyMedium.onSurface]),
div([const Text('Slide 3').bodyMedium.onSurface]),
],
autoPlay: true,
showIndicators: true,
showNavigation: true,
)
빠른 오버라이드 (Chain)#
이미 만든 Carousel 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
.radius4처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius4 ==
CoreRadius.radius4) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.
class CarouselChainExample extends StatefulWidget {
const CarouselChainExample({super.key});
@override
State<CarouselChainExample> createState() => _CarouselChainExampleState();
}
class _CarouselChainExampleState extends State<CarouselChainExample> {
@override
Widget build(BuildContext context) {
return Carousel(
transition: CarouselTransition.sliding(),
items: [
Container(
alignment: Alignment.center,
color: const Color(0xFF3B82F6),
padding: const CoreEdgeInsets.all(CoreSpace.space64).toValue(geometry: Theme.of(context).geometry),
child: const Text(
'Slide 1',
style: TextStyle(color: Colors.white, fontSize: 18),
),
),
Container(
alignment: Alignment.center,
color: const Color(0xFF22C55E),
padding: const CoreEdgeInsets.all(CoreSpace.space64).toValue(geometry: Theme.of(context).geometry),
child: const Text(
'Slide 2',
style: TextStyle(color: Colors.white, fontSize: 18),
),
),
Container(
alignment: Alignment.center,
color: const Color(0xFFF97316),
padding: const CoreEdgeInsets.all(CoreSpace.space64).toValue(geometry: Theme.of(context).geometry),
child: const Text(
'Slide 3',
style: TextStyle(color: Colors.white, fontSize: 18),
),
),
],
)
.withStyle(
const CoreCarouselStyle(
indicatorActiveColor: CoreColor.token(CoreColors.tertiary),
indicatorSize: CoreSpace.space12,
indicatorSpacing: CoreSpace.space12,
),
)
.radius16;
}
}
class CarouselChainExample extends StatefulComponent {
const CarouselChainExample({super.key});
@override
State<CarouselChainExample> createState() => _CarouselChainExampleState();
}
class _CarouselChainExampleState extends State<CarouselChainExample> {
int _index = 0;
@override
Component build(BuildContext context) {
return Carousel(
currentIndex: _index,
onIndexChanged: (idx) => setState(() => _index = idx),
items: [
div(
[Text('Slide 1')],
styles: Styles(
raw: {
'background': '#3B82F6',
'color': 'white',
'font-size': '18px',
'display': 'flex',
'align-items': 'center',
'justify-content': 'center',
'padding': 'var(--coui-space-64)',
},
),
),
div(
[Text('Slide 2')],
styles: Styles(
raw: {
'background': '#22C55E',
'color': 'white',
'font-size': '18px',
'display': 'flex',
'align-items': 'center',
'justify-content': 'center',
'padding': 'var(--coui-space-64)',
},
),
),
div(
[Text('Slide 3')],
styles: Styles(
raw: {
'background': '#F97316',
'color': 'white',
'font-size': '18px',
'display': 'flex',
'align-items': 'center',
'justify-content': 'center',
'padding': 'var(--coui-space-64)',
},
),
),
],
)
.withStyle(
const CoreCarouselStyle(
indicatorActiveColor: CoreColor.token(CoreColors.tertiary),
indicatorSize: CoreSpace.space12,
indicatorSpacing: CoreSpace.space12,
),
)
.radius16;
}
}
Live Preview#
코드 보기
class CarouselDefaultExample extends StatefulComponent {
const CarouselDefaultExample({super.key});
@override
State<CarouselDefaultExample> createState() =>
_CarouselDefaultExampleState();
}
class _CarouselDefaultExampleState extends State<CarouselDefaultExample> {
int _index = 0;
@override
Component build(BuildContext context) {
return Carousel(
currentIndex: _index,
onIndexChanged: (idx) => setState(() => _index = idx),
items: [
div(
[Text('Slide 1')],
styles: Styles(raw: {
'background': '#3B82F6',
'color': 'white',
'font-size': '18px',
'display': 'flex',
'align-items': 'center',
'justify-content': 'center',
'padding': 'var(--coui-space-64)',
}),
),
div(
[Text('Slide 2')],
styles: Styles(raw: {
'background': '#22C55E',
'color': 'white',
'font-size': '18px',
'display': 'flex',
'align-items': 'center',
'justify-content': 'center',
'padding': 'var(--coui-space-64)',
}),
),
div(
[Text('Slide 3')],
styles: Styles(raw: {
'background': '#F97316',
'color': 'white',
'font-size': '18px',
'display': 'flex',
'align-items': 'center',
'justify-content': 'center',
'padding': 'var(--coui-space-64)',
}),
),
],
);
}
}
class CarouselDefaultExample extends StatefulWidget {
const CarouselDefaultExample({super.key});
@override
State<CarouselDefaultExample> createState() =>
_CarouselDefaultExampleState();
}
class _CarouselDefaultExampleState extends State<CarouselDefaultExample> {
@override
Widget build(BuildContext context) {
return Carousel(
transition: CarouselTransition.sliding(),
items: [
Container(
alignment: Alignment.center,
color: const Color(0xFF3B82F6),
padding: const CoreEdgeInsets.all(CoreSpace.space64).toValue(geometry: Theme.of(context).geometry),
child: const Text('Slide 1',
style: TextStyle(color: Colors.white, fontSize: 18)),
),
Container(
alignment: Alignment.center,
color: const Color(0xFF22C55E),
padding: const CoreEdgeInsets.all(CoreSpace.space64).toValue(geometry: Theme.of(context).geometry),
child: const Text('Slide 2',
style: TextStyle(color: Colors.white, fontSize: 18)),
),
Container(
alignment: Alignment.center,
color: const Color(0xFFF97316),
padding: const CoreEdgeInsets.all(CoreSpace.space64).toValue(geometry: Theme.of(context).geometry),
child: const Text('Slide 3',
style: TextStyle(color: Colors.white, fontSize: 18)),
),
],
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 여러 이미지나 콘텐츠를 슬라이드쇼로 표시할 때
- 프로모션 배너를 순환 표시할 때
대신 다른 컴포넌트를 사용하세요:
- HoverGallery: 시간·이전/다음 탐색 대신 포인터가 놓인 영역에 따라 이미지만 전환할 때
Props / Parameters#
W 는 Flutter의 Widget, Web의 Jaspr Component 입니다. List<W>
는 콘텐츠 목록입니다.
공통 (Flutter · Web)#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
items |
List<W>? |
null |
슬라이드 목록 (eager 경로) |
itemBuilder |
CarouselItemBuilder? |
null |
lazy 슬라이드 빌더 (
items
대신).
(Object context, int index)
— 순환 캐러셀에서
index
는 범위를 벗어날 수 있습니다
|
itemCount |
int? |
null |
아이템 수 (wrap: false 이면 items 또는 itemCount 필수) |
transition |
CoreCarouselTransition |
명시¹ | 전환 모드 (sliding / fading) + 슬라이드 간 gap |
curve |
CoreCubicBezier |
CoreEasing.easeInOut |
전환 easing 토큰 (Flutter Curve / Web cubic-bezier()) |
speed |
Duration |
CoreCarouselStyle.defaultTransitionSpeed |
전환 애니메이션 속도 |
duration |
Duration? |
null |
자동 재생 간격. null 이면 CoreCarouselStyle.defaultAutoplayInterval (양 플랫폼 동일) |
durationBuilder |
CarouselDurationBuilder? |
null |
인덱스별 자동 재생 간격 (duration 오버라이드) |
autoplaySpeed |
Duration? |
null |
자동 재생 전환 속도. 설정 시 그만큼 자동 재생 간격도 늘어납니다 |
autoPlay |
bool? |
null → 간격 유무로 유도 |
자동 재생 on/off. 생략 시 duration·durationBuilder 존재로 결정 |
autoplayReverse |
bool |
false |
자동 재생을 역방향으로 |
waitOnStart |
bool |
false |
첫 자동 전환 전에 한 주기 대기 |
showIndicators |
bool |
true |
인디케이터 표시 |
showNavigation |
bool? |
null |
좌우 버튼 표시.
null
이면
variant
가 답한다 —
slide
는 보이고
banner
는 숨는다
|
variant |
CoreCarouselVariant |
.slide |
무엇을 순환하는가 —
slide
는 점을 뷰포트 위에
indicatorInset
만큼 안쪽으로 띄우고 좌우 버튼을 보인다.
banner
는 점을 뷰포트 아래
indicatorBelowGapStyle
간격으로 흐름에 놓고 좌우 버튼을 기본으로 끈다 — 88 높이 띠에는 32 원판 두 개가 들어갈 자리가 없다. 배너 슬라이드는
items
에
CarouselBanner
를 넣는다
|
size |
CoreCarouselSize |
.md |
안쪽 부품이 쓰는 수치 묶음 — 오늘은 인디케이터 점 크기. 캐러셀 자신의 크기는 부모가 정한다 |
currentIndex |
int |
0 |
현재 슬라이드 인덱스 |
onIndexChanged |
ValueChanged<int>? |
null |
인덱스 변경 콜백 |
alignment |
CoreCarouselAlignment |
.center |
뷰포트 안 활성 슬라이드 정렬 (슬라이드가 뷰포트보다 작을 때만 관측 가능) |
direction |
CoreAxis |
.horizontal |
캐러셀 축 |
reverse |
bool |
false |
아이템 배치 순서 역전 |
sizeConstraint |
CoreCarouselSizeConstraint |
CoreCarouselSizeConstraint.fractional(1) |
슬라이드의 축 방향 크기 (fractional / fixed) |
wrap | bool | true | 무한 순환 |
pauseOnHover |
bool |
true |
호버 시 자동 재생 일시 정지 |
draggable |
bool |
true |
드래그 / 스와이프 가능 여부 |
disableDraggingVelocity |
bool |
false |
드래그 관성(velocity) 비활성화 |
disableOverheadScrolling |
bool |
true |
한 제스처당 한 아이템으로 제한 |
carouselStyle |
CoreCarouselStyle? |
null |
chrome + nested 슬롯 오버라이드 (아래 표 참고) |
Flutter 전용#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
controller |
CarouselController? |
null |
Flutter 런타임의 명령형 제어용 외부 컨트롤러 |
¹ Flutter는 transition 을 요구하고 Web은 생략 시 sliding을 쓰는 soft requiredness 완화를 유지합니다. 공통 호출에는 전환 값을 명시하세요. Flutter는 현재
currentIndex 를 선언하지만 렌더 위치에 적용하지 않아, 이 값에 기대는 공통 제어에는 배선 결손이 있습니다.
Flutter 는 CarouselTransition / CarouselAlignment / CarouselSizeConstraint
/
CarouselFixedConstraint / CarouselFractionalConstraint
/
CarouselItemBuilder / CarouselDurationBuilder 라는 짧은 별칭을 노출하고,
Web 도 CarouselTransition / CarouselItemBuilder
/
CarouselDurationBuilder 를 같은 이름으로 노출합니다 — 그래서 양 플랫폼
호출부 코드가 글자까지 같습니다. Flutter CarouselTransition 은 그 위에
자기 슬라이드 배치 렌더러(layout)를 얹은 서브클래스입니다.
스타일 시스템 (Style System)#
Carousel 의 모든 chrome / dimensional / nested-slot 오버라이드는 CoreCarouselStyle 단일 슬롯으로 흐릅니다. behaviour (autoPlay
/ wrap / pauseOnHover / showIndicators / showNavigation) 는 위젯 파라미터로 직접 전달합니다.
시맨틱 vs 스타일#
-
behaviour: 위젯 파라미터로 직접 (
autoPlay,wrap,pauseOnHover,showIndicators,showNavigation,currentIndex,onIndexChanged) -
chrome / dimensional / 슬롯 스타일:
CoreCarouselStyle한 곳으로 (borderRadius/indicatorActiveColor/indicatorInactiveColor/indicatorSize/indicatorSpacing/indicatorInset/navInset/controlOverlayColor/navFadeDuration/navGapStyle/prevNextButtonStyle)
Resolve chain#
design system default for carousel
→ CoreCarouselTheme.style // 프로젝트 공통
→ parent component slot override
→ widget.carouselStyle // 인스턴스별
각 nested 슬롯 스타일 (prevNextButtonStyle) 은 자기 컴포넌트의 자체 resolve chain 으로 다시 한 번 머지됩니다.
CoreCarouselStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
borderRadius |
CoreBorderRadius? |
Outer carousel viewport corner radius. Falls back to [defaultBorderRadius]. |
indicatorActiveColor |
CoreColor? |
Active indicator dot fill colour. |
indicatorInactiveColor |
CoreColor? |
Inactive indicator dot fill colour. |
indicatorSize |
double? |
Indicator dot diameter (logical px). No paired
default*
: this is a partial forward into the nested
CoreDotIndicatorStyle
(
dotSize
), so
null
lets
DotIndicator
's own resolver supply its per-size default (
defaultsBySize[size]
). A default here would copy the child's default into the parent — what
style-contract.md
forbids ("자식 default 에 reach 하지 마라") — and the two would diverge the first time the child's moves.
Deliberately has no default* — absence is the design.
Partial forward into the nested CoreDotIndicatorStyle (
dotSize: merged.indicatorSize
, raw on both platforms) so null lets DotIndicator's own resolver supply its per-size default. A default here copies the child's default into the parent — the
?? CoreYStyle.defaultX
reach style-contract.md forbids — and the two diverge the first time the child's dot size moves.
|
indicatorSpacing |
double? |
Native
Row.spacing
between indicator dots — forwarded to
CoreDotIndicatorStyle.spacing
, applied as inter-dot Flex spacing (not a
Gap
widget) in logical px.
|
indicatorInset |
CoreEdgeInsets? |
Indicator-row inset from the viewport edge — typically only the
bottom
side is non-zero (default
CoreEdgeInsets.only(bottom: CoreSpace.space16)
); the resolver reads the resolved insets per-side at the paint site so callers can override any axis (e.g. push the row up or pad horizontally).
|
navInset |
CoreEdgeInsets? |
Prev / next navigation button inset from the viewport edge — typically symmetric horizontal (default
CoreEdgeInsets.symmetric(horizontal: CoreSpace.space16)
); the resolver materialises it straight to the nav
Padding
so callers can pad any axis.
|
controlOverlayColor |
CoreColor? |
Surface (background) colour applied behind the prev / next nav controls.
null
→ [defaultControlOverlayColor].
|
navFadeDuration |
Duration? |
Fade duration for the prev / next nav buttons revealing on carousel hover (hidden until the pointer is over the viewport).
null
→ [defaultNavFadeDuration].
|
indicatorBelowGapStyle |
CoreGapStyle? |
Spacer between the viewport and the indicator row when the widget's
variant
is
banner
— forwarded straight to
Gap(gapStyle: …)
. Unused by the
overlay
placement, whose row is anchored by [indicatorInset] instead.
null
→ [defaultIndicatorBelowGapStyle].
|
navGapStyle |
CoreGapStyle? |
Spacer between the previous and the next button when the widget's
variant
is
banner
— forwarded straight to
Gap(gapStyle: …)
. The
banner
variant gathers the pair at the trailing end of the strip (the strip is too short to leave a button at each end), so the two sit side by side and this is the space between them. The
slide
variant puts one button at each end of the viewport, where there is nothing to space, so the slot is unused there.
null
→ [defaultNavGapStyle].
|
prevNextButtonStyle |
CoreButtonStyle? |
Previous / next button chrome. Merged on top of [defaultPrevNextButtonStyle]. |
overlayIndicatorFocusOutlineStyle |
CoreFocusOutlineStyle? |
Focus-ring chrome for the indicator dots when they overlay the slide (the
slide
variant) — forwarded as the indicator row's
CoreDotIndicatorStyle.focusOutlineStyle
. Merged on top of [defaultOverlayIndicatorFocusOutlineStyle]. The
banner
variant lays the dots out below the strip, where the dot indicator's own focus-ring default applies, so this slot does not reach it.
|
동작 스펙 (Behavior)#
-
좌우 버튼은 떠 있는 판입니다 — 불투명한
surfaceRaised채움, 안쪽으로 그리는 1pxoutlineVariant테두리,CoreShadow.sm그림자 (prevNextButtonStyle의 기본값이라 슬라이드가 밝든 어둡든 경계가 보이고, 같은 필드로 덮어씁니다) -
버튼 배치는
variant가 정합니다 —slide는 뷰포트 양 끝에 하나씩,banner는 띠가 짧아 끝에 모을 수 없어 한 쌍을 오른쪽(끝) 가장자리에 모으고navGapStyle(기본 4) 만큼 띄웁니다.slide에서는navGapStyle을 읽지 않습니다 -
인디케이터는 활성 점이
primary, 비활성 점이outline입니다. 단독DotIndicator의 기본(onSurface/outlineVariant)과 다른 것은 캐러셀이 직접 진술한 값이라서이고,indicatorActiveColor/indicatorInactiveColor로 덮어씁니다. 활성 점도 원형 그대로입니다 -
좌우 버튼으로 슬라이드 전환 — 포인터 기기에서는 캐러셀 hover 시에만 페이드 인 (터치 기기는 스와이프 + 인디케이터가 기본 어포던스). hover 영역은 캐러셀 전체라, 점이 뷰포트 아래에 놓이는
banner에서는 점 위에 포인터가 있어도 버튼이 드러납니다 (Flutter · Web 동일) - 인디케이터 dot 클릭으로 직접 이동
-
autoPlay: true시 자동 순환 — 간격은duration(또는durationBuilder)이고, 주지 않으면 디자인 시스템 기본 간격을 씁니다.autoPlay: false는duration이 있어도 끕니다 -
autoPlay를 생략하면 간격 유무로 유도합니다 —autoPlay ?? (durationBuilder != null || duration != null).duration만 준 코드는 그대로 순환하고, 둘 다 없으면 정지합니다 waitOnStart: false(기본) 이면 마운트 직후 첫 전환이 일어나고,true면 한 주기 기다립니다pauseOnHover: true시 호버 시 일시 정지wrap: true시 마지막에서 첫 번째로 순환-
드래그를 놓으면 관성이 감쇠하며 가장 가까운 슬라이드로 스냅합니다.
disableOverheadScrolling: true(기본) 이면 한 제스처가 최대 한 슬라이드만 넘깁니다 -
direction: .vertical은 부모가 높이를 정해줘야 합니다 — 쌓인 슬라이드에서 뷰포트 높이를 역산할 수 없기 때문이며, Flutter 도 주축이 bounded 여야 하는 것과 같은 제약입니다
사용 가이드라인 (Usage Guidelines)#
✅ Do#
direction: .vertical은 부모가 높이를 정해줬을 때만 사용
SizedBox(
height: 400,
child: Carousel(
direction: CoreAxis.vertical,
transition: CarouselTransition.sliding(),
items: slides,
),
)
캐러셀은 쌓인 슬라이드에서 뷰포트 높이를 역산할 수 없습니다 — Flutter의 주축이 bounded여야 하는 제약과 같아서, 세로 방향은 부모가 명시적 높이를 줘야만 정상 렌더됩니다.
❌ Don't#
wrap: false일 때 items/itemCount 생략 금지
// ❌ wrap:false인데 items/itemCount 없음 — assert 실패
Carousel(
wrap: false,
itemBuilder: (context, index) => buildSlide(index),
)
// ✅ itemCount로 경계를 명시
Carousel(
wrap: false,
itemCount: slides.length,
itemBuilder: (context, index) => buildSlide(index),
)
wrap: false는 순환하지 않는 유한 캐러셀을 의미하므로, 마지막 인덱스가 어디인지 알려주는 items 또는 itemCount가 반드시 있어야 합니다.
접근성 (Accessibility)#
역할#
두 플랫폼의 노출 지점이 서로 다릅니다.
-
Web: 루트
<div>가role="region"과aria-label(CouiLocalizations.carouselLabel) 을 emit 합니다. 두 값은 callerattributes뒤에 쓰이므로, caller 가 같은 키를 넘겨도 컴포넌트 값이 이깁니다. 좌우 nav 버튼은aria-label로CouiLocalizations.carouselPreviousSlide/carouselNextSlide를 답니다. 무한 순환용 클론 슬라이드에는inert가, fading 모드의 보이지 않는 높이 앵커 슬라이드에는inert+aria-hidden="true"가 붙고, 인디케이터는DotIndicator에 위임되어role="tablist"+ dot 마다role="tab"/aria-label/aria-current를 얻습니다. -
Flutter: 루트·트랙·슬라이드 어디에도 role 이 없습니다. semantics 주석은 두 곳뿐입니다 — 좌우 nav 버튼을 감싼
Semantics(button: true, label: …)(레이블은CoUILocalizations.carouselPreviousSlide/carouselNextSlide), 그리고 intrinsic 사이징용 보이지 않는 ghost 슬라이드를 가리는ExcludeSemantics. nav 버튼을 감싼AnimatedOpacity는alwaysIncludeSemantics: true라, 버튼이 완전히 투명한 동안에도 그 두 레이블이 semantics 트리에 남습니다.
키보드#
캐러셀 루트가 처리하는 키는 양 플랫폼 모두 없습니다. 슬라이드를 옮기는 화살표·Home/End 가 루트에 없고 드래그는 포인터 전용입니다. 실제로 동작하는 키는 전부 합성된 자식에서 옵니다.
| 키 | 포커스 위치 | 동작 | 플랫폼 |
|---|---|---|---|
Enter / Space |
좌우 nav 버튼 | 이전 / 다음 슬라이드 | Flutter · Web |
← → (세로 방향이면 ↑ ↓) |
인디케이터(DotIndicator) 루트 |
dot 이동 → 해당 슬라이드로 전환 | Flutter · Web |
DotIndicator 는 양쪽 모두 그룹이 단일 탭 스톱인 roving 모델입니다 — Web 은 루트 tabindex="0" + dot 마다
tabindex="-1", Flutter 는 FocusableActionDetector 의 shortcuts + dot 마다 ExcludeFocus. 화살표는 양쪽 다 양 끝에서 clamp 되어 순환하지 않고, Home/End 는 어느 쪽에도 없습니다. 다만 Flutter
DotIndicator 에는 semantics 주석이 없어 tablist/tab 으로 읽히는 것은 Web 뿐입니다.
포커스 관리#
Flutter 캐러셀은 자체 FocusNode / FocusScope 가 없고, nav 버튼만 Button →
Clickable 의 FocusNode 와 포커스 링으로 포커스를 받습니다. 그 버튼들은 AnimatedOpacity(opacity: _hovered ? 1 : 0)
안에 있습니다 — Opacity 는 포커스 순회를 막지 않으므로 완전히 투명한 상태에서도 Tab 이 닿고 Enter 로 눌립니다. 포커스 시 드러내는 처리가 없어 포커스된 버튼이 화면에 보이지 않습니다.
Web 은 루트에 tabindex 가 없고 nav <button> 은 네이티브로 포커스를 받으며, group-focus-within:opacity-100
덕분에 키보드 포커스에서 실제로 드러납니다. 포커스 트랩과 복원은 양쪽 모두 없습니다.
포커스 표시: 이전/다음 버튼은 슬라이드 위에 놓여 뒤의 색을 알 수 없으므로, 링과 버튼 사이 2px 간격을 surface 밴드로 칠합니다(CoreCarouselStyle.defaultPrevNextButtonStyle). 점 인디케이터는 슬라이드 위에 겹치는
slide variant 에서만 같은 밴드를 켜고(defaultOverlayIndicatorFocusOutlineStyle, 인스턴스는
overlayIndicatorFocusOutlineStyle 슬롯으로 덮어씁니다), 점이 띠 아래 페이지 위에 놓이는 banner variant 에서는 다른 컨트롤과 같이 간격이 투명합니다.
스크린 리더#
Web: "Carousel" 로 이름 붙은 landmark region 으로 읽히고, 좌우 nav 버튼은 "이전 슬라이드" / "다음 슬라이드" 로 읽히며, 클론 슬라이드는
inert 로 중복 낭독이 억제되고, dot 은 tablist/tab 으로 읽힙니다. 슬라이드 개수나 현재 위치를 알리는 안내는 없습니다.
Flutter: nav 버튼과 그 "이전/다음 슬라이드" 레이블은 포인터가 캐러셀 위에 있든 없든 semantics 트리에 있습니다 — hover 가 존재하지 않는 터치 기기에서도 같습니다. 슬라이드 자체는 평범한 콘텐츠로 읽히며 region 레이블도, 슬라이드 개수나 현재 위치 안내도 없습니다.
알려진 제약#
-
Flutter: region role 과 레이블이 없습니다(
carouselLabel은 정의만 되어 있고 Web 에서만 참조됩니다). 포커스로 nav 버튼을 드러내는 처리가 없어 포커스된 버튼이 보이지 않습니다.DotIndicator가 tablist/tab semantics 를 노출하지 않아 dot 이 역할 없이 읽힙니다. - Web:
attributes: {'aria-label': …}로 캐러셀 이름을 바꾸려 해도 컴포넌트가 나중에 덮어씁니다. -
양쪽 공통: 현재 슬라이드가 아닌 실제 슬라이드도 보조 기술에서 숨겨지지 않습니다 — 숨김이 걸린 것은 Web 의 클론·높이 앵커와 Flutter 의 ghost 슬라이드뿐입니다.
aria-roledescription="carousel"없음, 슬라이드 전환을 알리는 live region 없음, 캐러셀 루트의 화살표/Home/End 내비게이션 없음, autoplay 정지가 hover 전용이라 키보드 포커스로는 멈출 수 없고 별도 정지 컨트롤도 없음, 드래그는 포인터 전용.
모션 감소 · 고대비 · 최소 터치 타깃처럼 모든 컴포넌트에 공통으로 적용되는 축은 전역 접근성 축 에 정리되어 있습니다.
크로스 플랫폼 차이점 (Platform Differences)#
자동 재생·드래그·슬라이드 전환은 양쪽에 구현돼 있지만 제어 표면이 모두 동등한 것은 아닙니다. 위 API 결손과 다음 렌더 메커니즘을 구분하세요.
| 항목 | Flutter | Web |
|---|---|---|
| sliding 전환 | 슬라이드를 Position 으로 배치 |
flex 라인 + CSS translateX/translateY |
| fading 전환 | Opacity + 진행도 |
절대 배치 슬라이드 + CSS opacity transition |
| easing | CoreCubicBezier → Cubic |
CoreCubicBezier → cubic-bezier() |
| 드래그 | GestureDetector |
pointerdown + document pointer 리스너 |
| 자동 재생 | Ticker 기반 | 체인 Timer 기반 |
| 호버 감지 | MouseRegion |
mouseenter/mouseleave |
좌우 버튼 배치 (banner) |
Row(mainAxisAlignment: .end) 에 Gap(navGapStyle) 로 한 쌍을 끝에 모음 |
한 쌍이 뒤쪽(오른쪽) 앵커
<div>
하나를 같이 쓰고 그 안에서
Gap(navGapStyle)
로 띄움 (가로 방향. 세로 방향은 버튼마다 앵커가 따로)
|
| 좌우 버튼 호버 노출 |
루트
MouseRegion
의
_hovered
가
AnimatedOpacity
를 구동 —
navFadeDuration
·
CoreEasing.move
|
루트
group
+ 각 버튼을 감싼
<div>
의
opacity-0 group-hover:opacity-100
— 같은 시간·곡선. 페이드는
Button
이 아니라 감싸는
<div>
에 있다 (
Button
의 공유
transition
이
!important
이고
opacity
를 이름 짓지 않는다)
|
| 무한 순환 | CarouselController 연속 값 | 가상 무한 트랙 (clone + instant jump) |
| 프로그래매틱 제어 | CarouselController |
StatefulComponent의 currentIndex 갱신 |
관련 컴포넌트 (Related Components)#
- HoverGallery: 호버 기반 이미지 전환
- DotIndicator: 페이지 위치 표시