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#
코드 보기
class RangeDefaultExample extends StatefulComponent {
const RangeDefaultExample({super.key});
@override
State<RangeDefaultExample> createState() => _RangeDefaultExampleState();
}
class _RangeDefaultExampleState extends State<RangeDefaultExample> {
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;
}),
);
}
}
class RangeDefaultExample extends StatefulWidget {
const RangeDefaultExample({super.key});
@override
State<RangeDefaultExample> createState() => _RangeDefaultExampleState();
}
class _RangeDefaultExampleState extends State<RangeDefaultExample> {
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;
}),
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 최소/최대 두 값으로 구간을 선택받는 경우 (가격대 필터 등)
- 핸들 사이 구간을 시각적으로 강조해야 하는 경우
대신 다른 컴포넌트를 사용하세요:
Slider: 단일 값을 선택하는 경우
Props / Parameters#
W는 Flutter의 Widget, Web의 Component를 뜻하는 합성 슬롯 타입입니다. 값과 step은 같은 사용자 값 단위를 사용하며 트랙의 논리 px 길이와 구분합니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
minValue |
double |
0.0 |
하단(최소) 핸들의 현재 값 |
maxValue |
double |
100.0 |
상단(최대) 핸들의 현재 값 |
min | double | 0.0 | 트랙의 절대 최소값 |
max | double | 100.0 | 트랙의 절대 최대값 |
step | double? | null | 값 증가 단위 |
divisions |
int? |
null |
트랙의 이산 분할 개수 |
enabled | bool | true | 상호작용 가능 여부 |
onChanged |
void Function(double, double)? |
null |
값 변경 콜백 (minValue, maxValue) |
leading | W? | null | 슬라이더 앞 콘텐츠 |
trailing | W? | 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 / PageDown | 10 스텝 단위로 증가 / 감소 |
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 의
attributespassthrough 는 루트에만 닿습니다. 대안: 앱의CouiLocalizations/CoUILocalizations구현에서rangeMinValueLabel/rangeMaxValueLabel문구 자체를 바꾸면 전역으로 반영됩니다. -
두 핸들이 하나의 범위 컨트롤이라는 관계가 노출되지 않습니다. 조건: 모든
인스턴스. 대안 — Web 은
Range(attributes: {'role': 'group', 'aria-label': ...})로 루트에 그룹 역할과 이름을 실을 수 있습니다(루트는 caller attributes 를 그대로 내보냅니다). Flutter 는 호출부에서Semantics로 감싸야 합니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | Range | Range |
| 드래그 | GestureDetector 수평 드래그 |
mousedown + document drag listener |
| 핸들 렌더링 | <div> 동등 Container |
<div> |
| 키보드 | 핸들마다 FocusableActionDetector + FocusNode |
핸들마다 tabindex="0" + keydown |
관련 컴포넌트 (Related Components)#
- Slider: 단일 값 선택 슬라이더