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

Range

두 개의 핸들로 값 구간을 선택하는 듀얼 핸들 슬라이더

목차

두 개의 핸들로 [minValue, maxValue] 구간을 선택하는 듀얼 핸들 슬라이더입니다. 단일 값을 선택하는 Slider와 달리, 가격대·날짜 범위·필터 구간 같은 범위 선택에 사용합니다.

기본 사용법 (Basic Usage)#

// 기본 범위 슬라이더
Range(
  minValue: minVal,
  maxValue: maxVal,
  onChanged: (min, max) => setState(() {
    minVal = min;
    maxVal = max;
  }),
)

// 구간 / 분할 조정
Range(
  minValue: 25,
  maxValue: 75,
  min: 0,
  max: 200,
  divisions: 20,
  onChanged: (min, max) => updateRange(min, max),
)
// 기본 범위 슬라이더
Range(
  minValue: minVal,
  maxValue: maxVal,
  onChanged: (min, max) => setState(() {
    minVal = min;
    maxVal = max;
  }),
)

// 구간 / 분할 조정
Range(
  minValue: 25,
  maxValue: 75,
  min: 0,
  max: 200,
  divisions: 20,
  onChanged: (min, max) => updateRange(min, max),
)

빠른 오버라이드 (Chain)#

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

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

  @override
  State<RangeChainExample> createState() => _RangeChainExampleState();
}

class _RangeChainExampleState extends State<RangeChainExample> {
  double _min = 20;
  double _max = 80;

  @override
  Widget build(BuildContext context) {
    return Range(
          minValue: _min,
          maxValue: _max,
          onChanged: (min, max) => setState(() {
            _min = min;
            _max = max;
          }),
        )
        .withStyle(
          const CoreRangeStyle(
            trackHeight: CoreSpace.space12,
            thumbSize: CoreSpace.space24,
            activeTrackColor: CoreColor.token(CoreColors.tertiary),
          ),
        )
        .radius16;
  }
}
class RangeChainExample extends StatefulComponent {
  const RangeChainExample({super.key});

  @override
  State<RangeChainExample> createState() => _RangeChainExampleState();
}

class _RangeChainExampleState extends State<RangeChainExample> {
  double _min = 20;
  double _max = 80;

  @override
  Component build(BuildContext context) {
    return Range(
          minValue: _min,
          maxValue: _max,
          onChanged: (min, max) => setState(() {
            _min = min;
            _max = max;
          }),
        )
        .withStyle(
          const CoreRangeStyle(
            trackHeight: CoreSpace.space12,
            thumbSize: CoreSpace.space24,
            activeTrackColor: CoreColor.token(CoreColors.tertiary),
          ),
        )
        .radius16;
  }
}

Live Preview#

사용 시기 (When to Use)#

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

  • 최소/최대 두 값으로 구간을 선택받는 경우 (가격대 필터 등)
  • 핸들 사이 구간을 시각적으로 강조해야 하는 경우

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

  • Slider: 단일 값을 선택하는 경우

Props / Parameters#

W는 Flutter의 Widget, Web의 Component를 뜻하는 합성 슬롯 타입입니다. 값과 step은 같은 사용자 값 단위를 사용하며 트랙의 논리 px 길이와 구분합니다.

속성타입기본값설명
minValue double 0.0 하단(최소) 핸들의 현재 값
maxValue double 100.0 상단(최대) 핸들의 현재 값
mindouble0.0트랙의 절대 최소값
maxdouble100.0트랙의 절대 최대값
stepdouble?null값 증가 단위
divisions int? null 트랙의 이산 분할 개수
enabledbooltrue상호작용 가능 여부
onChanged void Function(double, double)? null 값 변경 콜백 (minValue, maxValue)
leadingW?null슬라이더 앞 콘텐츠
trailingW?null슬라이더 뒤 콘텐츠
rangeStyle CoreRangeStyle? null 트랙 색상 / 두께 / 핸들 / 간격 오버라이드

스타일 시스템 (Style System)#

CoreRangeStyle 필드#

필드타입설명
activeTrackColor CoreColor? Active (selected, between-thumbs) track fill colour.
inactiveTrackColor CoreColor? Inactive (outside-selection) track fill colour.
disabledActiveTrackColor CoreColor? Active (selected) track fill colour when disabled.
disabledInactiveTrackColor CoreColor? Inactive track fill colour when disabled.
disabledThumbColor CoreColor? Thumb background fill colour when disabled.
disabledThumbBorderColor CoreColor? Thumb border stroke colour when disabled.
trackHeight double? Track height override (logical px).
borderRadius CoreBorderRadius? Corner radius override for the track / fill / thumbs. null → [defaultBorderRadius] (full pill).
thumbColor CoreColor? Thumb background fill colour.
thumbBorderColor CoreColor? Thumb border stroke colour.
thumbSize double? Thumb size (width = height) override (logical px).
thumbBorderWidth double? Thumb border stroke width override (logical px). Defaults to [defaultThumbBorderWidth].
minMaxGapStyle CoreGapStyle? Nested [CoreGapStyle] slot for the gap between the slider and its leading / trailing Gap siblings (min-side / max-side affordance). Forwarded to the Gap siblings flanking the slider (Flutter) or used to drive the root row gap CSS (Web). Merged on top of [defaultMinMaxGapStyle].
focusOutlineStyle CoreFocusOutlineStyle? Keyboard focus ring override, forwarded to the FocusOutline the focused thumb composes. null defers to [defaultFocusOutlineStyle], and any field left null inside it defers to FocusOutline 's own resolver.

동작 스펙 (Behavior)#

인터랙션#

  • 드래그: 드래그 시작 지점에서 가까운 핸들이 선택되어 이동
  • 탭: 탭한 위치에서 가까운 핸들이 그 지점으로 이동
  • 핸들끼리 교차하지 않도록 minValue ≤ maxValue 가 항상 유지됨
  • enabled가 false이거나 onChanged가 null이면 비활성

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

최소·최대 두 값을 동시에 고르는 구간 입력에는 Range를 사용합니다. 하나의 값만 조절하면 Slider, 조절 없이 완료 비율을 읽게 하려면 Progress를 선택합니다. Range는 콜백에서 새 두 값을 알리고 호출자가 다시 전달해야 하며 핸들이 서로 교차하지 않게 제한합니다.

✅ Do#

정확한 단위가 필요하면 step 을 명시적으로 지정

Range(
  minValue: 25,
  maxValue: 75,
  min: 0,
  max: 200,
  step: 5,
  onChanged: (min, max) => updateRange(min, max),
)

한 스텝의 크기는 step → (max - min) / divisions → (max - min) 의 1% 순으로 결정됩니다. 화살표 키 조작 단위를 정확히 통제하려면 divisions 대신 step 을 직접 지정하는 편이 의도를 명확히 드러냅니다.


❌ Don't#

step과 divisions를 동시에 지정하고 divisions가 적용될 거라 기대하지 않기

// ❌ step 이 있으면 divisions 는 무시됨 — 20 분할이 아니라 5 단위로 움직임
Range(
  minValue: 25,
  maxValue: 75,
  min: 0,
  max: 200,
  step: 5,
  divisions: 20,
  onChanged: (min, max) => updateRange(min, max),
)

step이 설정되면 divisions보다 우선합니다. 두 값을 모두 주면 어느 쪽이 실제로 적용되는지 코드만 보고는 알 수 없어 혼란을 줍니다 — 하나만 선택하세요.

접근성 (Accessibility)#

역할#

두 핸들이 각자 독립적인 슬라이더입니다 — 트랙 자체는 시맨틱이 없는 순수 시각 요소입니다(ARIA APG "Slider (Multi-Thumb)" 패턴). 하나의 결합된 tabstop 으로는 aria-valuenow 가 요구하는 단일 숫자에 두 값을 담을 수 없고, 화살표 키로 "어느 쪽을 움직이는지" 표현할 방법도 없기 때문입니다.

  • Web: 각 핸들 <div> 에 role="slider" +
    • aria-valuenow — 그 핸들의 현재 값
    • aria-valuemin / aria-valuemax — 상호 제약: min 핸들의 상한은 max 핸들의 현재 값, max 핸들의 하한은 min 핸들의 현재 값 (서로를 앞지를 수 없음)
    • aria-label — "최솟값" / "최댓값" (CouiLocalizations 의 rangeMinValueLabel / rangeMaxValueLabel)
    • 비활성 시 aria-disabled="true", 활성일 때만 tabindex="0"
  • Flutter: 각 핸들에 Semantics(slider: true, label: <같은 로케일 문자열>, value: '<값>'). 경계값(min/max)과 비활성 플래그는 실리지 않습니다 — Web 의 aria-valuemin / aria-valuemax / aria-disabled 에 대응하는 것이 없습니다.

두 핸들을 하나의 범위 컨트롤로 묶는 그룹 역할은 어느 플랫폼에도 없습니다 — Web 루트는 caller 가 넘긴 attributes 만 달고 나가고, Flutter 에도 두 Semantics 를 감싸는 노드가 없습니다. leading / trailing 슬롯도 핸들의 라벨로 연결되지 않습니다.

키보드#

활성 상태(enabled: true + onChanged 있음)일 때 각 핸들을 Tab 으로 개별 포커스할 수 있고, 포커스된 핸들에 한해 — 양 플랫폼 동일:

키동작
→ / ↑한 스텝 증가
← / ↓한 스텝 감소
PageUp / PageDown10 스텝 단위로 증가 / 감소
Home / End그 핸들의 하한 / 상한으로 점프
Tab / Shift+Tab다른 핸들 또는 바깥 요소로 이동

Home / End 는 전역 min/max 가 아니라 상호 제약 경계로 갑니다 — 예를 들어 min 핸들의 End 는 max 핸들의 현재 값으로 이동합니다.

한 스텝의 크기는 step → (max - min) / divisions → (max - min) 의 1% 순으로 결정됩니다 (Slider 와 동일 규칙).

스크린 리더#

  • 이름: 두 핸들 모두 활성 로케일의 rangeMinValueLabel / rangeMaxValueLabel 에서 옵니다 (한국어 "최솟값" / "최댓값"). 양 플랫폼이 같은 키를 읽습니다.
  • 값: Web 은 현재값과 함께 그 핸들이 갈 수 있는 범위까지 읽히지만, Flutter 는 값 문자열만 실려 "어디까지 올릴 수 있는지"가 전달되지 않습니다.
  • 통보: 라이브 리전이 아닙니다. 핸들에 포커스가 없는 동안 값이 바뀌면 통보되지 않습니다.

포커스 관리#

오버레이가 아니므로 진입 시 포커스 이동이 없고 트랩도 없습니다. 한 Range 는 두 개의 tab stop(핸들 각각)을 만들며, 비활성이면 두 핸들 모두 tab stop 이 아닙니다.

트랙을 드래그하거나 탭하면 가장 가까운 핸들이 선택되고 그 핸들에 키보드 포커스도 함께 옮겨집니다 — 드래그로 대략 맞춘 뒤 화살표 키로 미세 조정하는 흐름이 별도 Tab 없이 이어집니다.

포커스 링은 양 플랫폼 모두 FocusOutline 이 그립니다. rangeStyle.focusOutlineStyle (CoreFocusOutlineStyle) 로 색·두께뿐 아니라 align · offsetColor · duration 까지 정하고, 지정하지 않은 필드는 기본값을 따릅니다. 핸들은 원형이라 링의 모양은 CoreFocusOutlineShape.circle 입니다.

Range(
  minValue: 20,
  maxValue: 80,
  onChanged: (min, max) => setState(() { /* ... */ }),
  rangeStyle: const CoreRangeStyle(
    focusOutlineStyle: CoreFocusOutlineStyle(
      borderColor: CoreColor.token(CoreColors.error),
    ),
  ),
)

알려진 제약#

  • Flutter 핸들이 경계값을 알리지 않습니다. 조건: 모든 인스턴스. Web 이 aria-valuemin / aria-valuemax 로 상호 제약을 노출하는 자리에 Flutter 는 Semantics(value:) 만 있어, 리더가 "이 핸들이 어디까지 갈 수 있는지"를 읽지 못합니다. 대안: 없음 — Semantics 는 컴포넌트 내부에서 생성되고 그 슬롯이 노출되어 있지 않습니다.
  • Flutter 는 enabled: false 를 시맨틱으로 알리지 않습니다. Web 은 각 핸들에 aria-disabled="true" 를 붙이지만 Flutter 쪽 Semantics 에는 enabled 플래그가 없습니다(양쪽 다 포커스는 받지 않습니다). 대안: 없음.
  • Flutter 에는 스크린 리더 증감 액션이 등록되지 않습니다. onIncrease / onDecrease 가 없어 VoiceOver·TalkBack 의 슬라이더 조정 제스처로는 핸들을 움직일 수 없습니다(하드웨어 키보드 화살표는 위 표대로 동작합니다). 대안: 없음.
  • 핸들 라벨을 인스턴스별로 바꿀 수 없습니다. 조건: 모든 인스턴스. 라벨은 핸들 내부에서 로케일 문자열로 채워지고, Web 의 attributes passthrough 는 루트에만 닿습니다. 대안: 앱의 CouiLocalizations / CoUILocalizations 구현에서 rangeMinValueLabel / rangeMaxValueLabel 문구 자체를 바꾸면 전역으로 반영됩니다.
  • 두 핸들이 하나의 범위 컨트롤이라는 관계가 노출되지 않습니다. 조건: 모든 인스턴스. 대안 — Web 은 Range(attributes: {'role': 'group', 'aria-label': ...}) 로 루트에 그룹 역할과 이름을 실을 수 있습니다(루트는 caller attributes 를 그대로 내보냅니다). Flutter 는 호출부에서 Semantics 로 감싸야 합니다.

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

항목FlutterWeb
클래스명RangeRange
드래그 GestureDetector 수평 드래그 mousedown + document drag listener
핸들 렌더링 <div> 동등 Container <div>
키보드 핸들마다 FocusableActionDetector + FocusNode 핸들마다 tabindex="0" + keydown
  • Slider: 단일 값 선택 슬라이더