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

DotIndicator

페이지나 슬라이드 위치를 점으로 표시하는 인디케이터 컴포넌트

목차

캐러셀, 온보딩 화면 등에서 현재 페이지 위치를 점으로 표시하는 인디케이터 컴포넌트입니다.

기본 사용법 (Basic Usage)#

// 기본 인디케이터
DotIndicator(
  index: currentPage,
  length: 5,
)

// 색상 지정
DotIndicator(
  index: currentPage,
  length: 3,
  dotIndicatorStyle: CoreDotIndicatorStyle(
    activeColor: CoreColor.token(CoreColors.primary),
    inactiveColor: CoreColor.token(CoreColors.surfaceContainerHigh),
  ),
)

// 크기 지정 — sm / md(기본) / lg 가 점 지름과 간격을 함께 정합니다
DotIndicator(
  index: currentPage,
  length: 4,
  size: CoreDotIndicatorSize.lg,
)

// PageView와 연동 (점 클릭으로 이동)
PageView(
  onPageChanged: (index) => setState(() => currentPage = index),
  children: pages,
),
DotIndicator(
  index: currentPage,
  length: pages.length,
  onChanged: (newIndex) => unawaited(pageController.animateToPage(
    newIndex,
    duration: const Duration(milliseconds: CoreDuration.moderate),
    curve: Curves.easeInOut,
  )),
)
// 기본 인디케이터
DotIndicator(
  index: currentPage,
  length: 5,
)

// 색상 지정
DotIndicator(
  index: currentPage,
  length: 3,
  dotIndicatorStyle: CoreDotIndicatorStyle(
    activeColor: CoreColor.token(CoreColors.primary),
    inactiveColor: CoreColor.token(CoreColors.surfaceContainerHigh),
  ),
)

// 크기 지정 — sm / md(기본) / lg 가 점 지름과 간격을 함께 정합니다
DotIndicator(
  index: currentPage,
  length: 4,
  size: CoreDotIndicatorSize.lg,
)

// 세로 방향 + 페이지 연동 (onChanged 콜백)
DotIndicator(
  index: currentPage,
  length: pages.length,
  direction: CoreAxis.vertical,
  onChanged: handlePageChange,
)

Live Preview#

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • 캐러셀이나 PageView에서 현재 페이지 위치를 표시할 때
  • 온보딩 화면에서 전체 단계 중 현재 위치를 안내할 때
  • 슬라이드나 갤러리에서 탐색 위치 표시와 함께 클릭으로 직접 이동을 제공할 때

대신 다른 컴포넌트를 사용하세요:

  • Steps: 완료/진행 중/미완료 상태가 있는 단계별 진행 상황을 표시할 때
  • Progress: 진행값을 퍼센트로 표시할 때
  • Pagination: 점 대신 페이지 번호 목록을 탐색해야 할 때

Props / Parameters#

속성타입기본값설명
indexint필수현재 활성 점 인덱스 (0-based)
lengthint필수총 점 개수
onChanged CoreDotIndicatorOnChanged? null 점 클릭 시 페이지 이동 핸들러
direction CoreAxis CoreAxis.horizontal 레이아웃 방향 (Flutter / Web 동일)
size CoreDotIndicatorSize CoreDotIndicatorSize.md 점 지름 + 점 간격 ( sm 6/6 · md 8/8 · lg 12/12 px). widget-only 시맨틱 식별자 — 테마가 override 하지 않음
dotIndicatorStyle CoreDotIndicatorStyle? null chrome / 치수 단일 진입점 (아래 표 참고)

스타일 시스템 (Style System)#

모든 chrome / 치수 override 는 dotIndicatorStyle 슬롯 하나로 흐릅니다.

CoreDotIndicatorStyle 필드#

필드타입설명
spacing double? Spacing between individual dots (logical px). null defers to [defaultsBySize] for the active CoreDotIndicatorSize .
padding CoreEdgeInsets? Uniform padding around the dot indicator container.
dotSize double? Dot size override (logical px). null defers to [defaultsBySize] for the active CoreDotIndicatorSize .
activeLength double? Active indicator length along the layout axis, in logical pixels. When omitted, it uses the resolved [dotSize] and remains circular.
borderRadius CoreBorderRadius? Dot border radius override.
activeColor CoreColor? Active dot colour override.
inactiveColor CoreColor? Inactive dot fill override. Defaults to [defaultInactiveColor] — a solid outlineVariant disc. CoreColor.transparent restores a ring (fill absent, [inactiveBorderColor] alone).
inactiveBorderColor CoreColor? Inactive dot border colour override.
inactiveBorderWidth double? Inactive dot border width override (logical px).
hoverBorderColor CoreColor? Hover border colour override for clickable inactive dots. null defers to [defaultHoverBorderColor]. Only reaches the screen when a border width is stated — see [defaultInactiveBorderWidth].
transitionDuration Duration? Transition duration override for active / hover state changes. null defers to [defaultTransitionDuration].
focusOutlineStyle CoreFocusOutlineStyle? Focus ring chrome drawn around the selected dot while the indicator group holds keyboard focus (roving radio-group model — the group is one tab stop and arrow keys move the selection). Raw-forwarded so the focus-outline layer fills its own defaults.

크기별 기본값 (CoreDotIndicatorStyle.defaultsBySize)#

size 가 고르는 rung 입니다. dotSize / spacing 을 슬롯으로 주면 그 값이 rung 을 덮습니다.

sizedotSizespacing
sm CoreSpace.space6 (6px) CoreSpace.space6 (6px)
md (기본) CoreSpace.space8 (8px) CoreSpace.space8 (8px)
lg CoreSpace.space12 (12px) CoreSpace.space12 (12px)

Resolve chain#

CoreDotIndicatorStyle.defaultX / defaultsBySize[size] (static const, 단일 출처)
  → CoreDotIndicatorTheme.style                   // 프로젝트 공통
  → widget.dotIndicatorStyle                      // 인스턴스별

빠른 오버라이드 (Chain)#

이미 만든 DotIndicator 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다. .radius4처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius4 == CoreRadius.radius4) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.

class DotIndicatorChainExample extends StatefulWidget {
  const DotIndicatorChainExample({super.key});

  @override
  State<DotIndicatorChainExample> createState() => _DotIndicatorChainExampleState();
}

class _DotIndicatorChainExampleState extends State<DotIndicatorChainExample> {
  int _index = 2;

  @override
  Widget build(BuildContext context) {
    return DotIndicator(
          index: _index,
          length: 5,
          onChanged: (v) => setState(() => _index = v),
        )
        .withStyle(
          const CoreDotIndicatorStyle(
            activeColor: CoreColor.token(CoreColors.tertiary),
            dotSize: CoreSpace.space16,
            spacing: CoreSpace.space12,
          ),
        )
        .radius16;
  }
}
class DotIndicatorChainExample extends StatefulComponent {
  const DotIndicatorChainExample({super.key});

  @override
  State<DotIndicatorChainExample> createState() => _DotIndicatorChainExampleState();
}

class _DotIndicatorChainExampleState extends State<DotIndicatorChainExample> {
  int _index = 2;

  @override
  Component build(BuildContext context) {
    return DotIndicator(
          index: _index,
          length: 5,
          onChanged: (v) => setState(() => _index = v),
        )
        .withStyle(
          const CoreDotIndicatorStyle(
            activeColor: CoreColor.token(CoreColors.tertiary),
            dotSize: CoreSpace.space16,
            spacing: CoreSpace.space12,
          ),
        )
        .radius16;
  }
}

변형 (Variants)#

기본형 (가로)#

동일한 크기의 점으로 활성/비활성을 색상으로 구분합니다.

DotIndicator(index: 0, length: 4)

세로형#

direction 으로 축을 바꿉니다 — 화살표 키도 ↑/↓ 로 따라갑니다.

DotIndicator(
  index: 0,
  length: 4,
  direction: CoreAxis.vertical,
)

클릭 가능형#

onChanged 를 주면 점이 클릭 대상이 되고 키보드 조작이 켜집니다.

DotIndicator(
  index: currentPage,
  length: 4,
  onChanged: (i) => setState(() => currentPage = i),
)

동작 스펙 (Behavior)#

인터랙션#

  • 점 클릭: onChanged 가 있을 때만 활성화되며 클릭한 인덱스를 콜백으로 전달
  • hover: 클릭 가능한 비활성 점이 살짝 확대됩니다. 기본 점은 테두리 폭이 0 이므로 hoverBorderColor 는 폭을 직접 진술한 호출자에게만 닿습니다
  • index 변경: 외부에서 상태를 바꾸면 활성 점이 갱신됩니다 (컴포넌트는 자체 상태를 갖지 않음)
  • 가로 방향은 Left / Right, 세로 방향은 Up / Down으로 다음 index를 요청하며 끝에서 순환하지 않고 0~length - 1 에 클램핑합니다. 콜백 뒤 부모가 index를 갱신해야 선택이 바뀝니다.
  • 길이·인덱스 유효성은 호출부가 보장하세요. 표시용이면 onChanged: null 이며 점은 페이지 내용을 자동으로 교체하지 않습니다.

애니메이션#

  • 색상 / hover scale 전환: dotIndicatorStyle.transitionDuration (기본 200ms)
  • Flutter: AnimatedContainer + AnimatedScale, Web: CSS transition

사용 가이드라인 (Usage Guidelines)#

✅ Do#

PageView와 함께 연동

Column(
  children: [
    Expanded(
      child: PageView(
        onPageChanged: (index) => setState(() => _currentPage = index),
        children: onboardingPages,
      ),
    ),
    Padding(
      padding: const EdgeInsets.only(bottom: CoreSpace.space24),
      child: DotIndicator(
        index: _currentPage,
        length: onboardingPages.length,
        onChanged: (index) => unawaited(pageController.animateToPage(
          index,
          duration: const Duration(milliseconds: CoreDuration.moderate),
          curve: Curves.easeInOut,
        )),
      ),
    ),
  ],
)

PageView와 연동하여 현재 페이지를 실시간으로 반영하고 점 클릭으로 직접 이동할 수 있게 합니다.


❌ Don't#

많은 점을 나열하지 않기

// ❌ 너무 많은 점
DotIndicator(
  index: currentPage,
  length: 15, // 너무 많음
)

점이 너무 많으면 현재 위치 파악이 어렵고 터치 타겟이 작아집니다. 5개 이하를 권장하며, 그 이상은 Pagination 사용을 고려하세요.

✅ Do#

활성 상태를 색상만으로 구분하지 않기

DotIndicator(
  index: currentPage,
  length: 4,
  dotIndicatorStyle: CoreDotIndicatorStyle(
    activeColor: CoreColor.token(CoreColors.onSurface),
    // 비활성을 채움 없이 테두리만으로 — 색 강제 모드는 채움도 테두리도
    // 시스템 색으로 접지만 형태는 그대로 두므로, 원과 링의 차이는 남습니다.
    // 기본은 둘 다 채워진 원이라 그 모드에서 활성/비활성이 같아 보입니다
    inactiveColor: CoreColor.transparent,
    inactiveBorderColor: CoreColor.token(CoreColors.outlineVariant),
    inactiveBorderWidth: CoreStrokeWidth.stroke2,
  ),
)

채움 유무까지 다르면 색을 구별하기 어려운 사용자도 활성 상태를 인식할 수 있습니다.


❌ Don't#

클릭 가능한 점을 너무 작게 두지 않기

// ❌ 클릭 가능한데 점이 4px
DotIndicator(
  index: currentPage,
  length: 4,
  onChanged: handleDotTap,
  dotIndicatorStyle: CoreDotIndicatorStyle(
    dotSize: CoreSpace.space4, // 너무 작음
    padding: CoreEdgeInsets.zero, // 히트 영역까지 함께 줄어듦
  ),
)

onChanged 가 있으면 각 점이 탭 타겟입니다. 히트 영역은 dotSize 와 padding / spacing 이 함께 만들므로, 기본값(md: 8px 점 + 8px 간격 + 8px 여백)보다 줄일 때는 최소 터치 타겟(→ 전역 접근성 축)을 확인하세요.

접근성 (Accessibility)#

키보드는 onChanged 가 있고 항목이 있을 때만 켜집니다. 표시 전용은 조작 가능한 버튼 대신 현재 위치와 전체 개수를 읽습니다. 항목이 없으면 접근성 트리에서 제외합니다.

역할#

  • Web — 컨테이너에 role="tablist" + aria-label, 각 점에 role="tab" + aria-label(항목 번호), 활성 점에 aria-current="true". aria-selected 와 aria-controls 는 emit 하지 않습니다. 점의 role 은 Clickable 의 기본 role="button" 을 덮어쓴 것입니다.
  • Flutter — 컨테이너를 Semantics(container: true, label: ...dotIndicatorLabel) 로, 각 점을 Semantics(label: ...dotIndicatorGoToItem(i + 1), button: interactive, selected: i == index) 와 Clickable 을 점마다 하나의 노드로 합칩니다. tablist / tab 에 대응하는 role 은 없고 선택 상태는 selected 플래그로 전달됩니다.
  • 표시 전용 — Flutter와 Web 모두 현지화된 dotIndicatorPosition(current, total)을 하나의 읽기 전용 위치로 제공하며 개별 점은 접근성 트리에서 숨깁니다.

키보드#

인디케이터 전체가 하나의 탭 정지이고, 화살표 키가 선택과 포커스 링을 함께 옮깁니다 (라디오 그룹 모델 — 개별 점은 탭 순서에서 빠집니다). 선택이 포커스를 따라가므로 별도 활성화 키가 없습니다.

키FlutterWeb
→ / ← (가로) 다음/이전 점 선택 (onChanged 호출) 같음
↓ / ↑ (세로)다음/이전 점 선택같음
Tab인디케이터 밖 다음 요소로 이동같음
Enter / Space 없음 없음
Home / End 없음 없음

양 끝에서는 clamp 되어 순환하지 않습니다.

스크린 리더#

  • 이름 — 컨테이너는 dotIndicatorLabel(기본 "Page navigation"), 각 점은 dotIndicatorGoToItem(i + 1)(기본 "Go to item N"). 양 플랫폼이 같은 두 로컬라이제이션 멤버를 같은 값으로 읽으므로 이름은 동일합니다. 표시 전용은 dotIndicatorPosition(기본 "Item N of M")을 사용합니다.
  • 선택 상태의 전달 방식은 갈립니다 — Flutter 는 Semantics(selected:) 로, Web 은 aria-current="true" 로 표시합니다. Web 은 role="tab" 이 기대하는 aria-selected 를 내지 않습니다.
  • 통보 — live region 은 없습니다. 화살표로 선택을 옮겨도 포커스는 그룹 루트에 남으므로, 선택이 바뀐 사실이 자동으로 통보되지 않습니다.

포커스 관리#

  • 진입 / 이탈 — 오버레이가 아니라 열고 닫는 동작이 없습니다. onChanged 가 있으면 그룹 루트가 탭 정지가 되고(Web tabindex="0", Flutter FocusableActionDetector), Tab 은 그대로 인디케이터 밖으로 빠져나갑니다.
  • 트랩 — 없습니다.
  • 점을 클릭했을 때 — Web 은 focusin 에서 포커스를 그룹 루트로 되돌리고, Flutter 는 각 점을 ExcludeFocus 로 감싸 애초에 포커스가 가지 않습니다. 둘 다 포커스가 그룹에 남아 화살표 키가 이어서 동작합니다.
  • 포커스 링 — 선택된 점 주위에만 원형으로 그려지며(focusOutlineStyle 로 조정), 키보드 포커스일 때만 나타납니다(Flutter onShowFocusHighlight, Web group-focus-visible:).

알려진 제약#

  • 점이 나타내는 대상(어떤 슬라이드인지)은 번호로만 전달됩니다.
  • 라벨을 인스턴스별로 바꿀 수 없습니다. 그룹·점 라벨은 로컬라이제이션 멤버에서만 오고, Web 루트는 aria-label 을 caller attributes 보다 나중에 병합해 덮으므로 attributes: {'aria-label': ...} 도 통하지 않습니다. 대안: 로케일 구현에서 dotIndicatorLabel / dotIndicatorGoToItem 을 바꿉니다.

전역으로 적용되는 축(동작 줄이기·고대비·색 강제 모드·최소 터치 타겟)은 전역 접근성 축에 있습니다.

크로스 플랫폼 차이점 (Platform Differences)#

항목FlutterWeb
클래스명DotIndicatorDotIndicator
애니메이션 AnimatedContainer + AnimatedScale CSS transition
키보드 FocusableActionDetector + 화살표 shortcut Clickable + 화살표 키 핸들러
시맨틱 그룹 로케일 이름·각 점 label / selected role="tablist" / role="tab" / aria-current
  • Carousel: 내장 인디케이터가 있는 캐러셀 컴포넌트 (DotIndicator 통합)
  • Steps: 단계 완료 상태가 있는 진행 표시가 필요할 때
  • Progress: 퍼센트 기반 진행률 표시에 사용

조합 예제#

// DotIndicator + Carousel 외부 연동
Stacks(
  alignment: CoreAlignment.bottomCenter,
  children: [
    Carousel(
      items: items,
      transition: const SlidingCarouselTransition(),
      showIndicators: false,
      currentIndex: currentPage,
      onIndexChanged: (index) => setState(() => currentPage = index),
    ),
    Position(
      bottom: CoreSpace.space16,
      child: DotIndicator(
        index: currentPage,
        length: items.length,
        onChanged: (index) => setState(() => currentPage = index),
      ),
    ),
  ],
)