Flutter RangeSlider Localization: Price Filters, Age Ranges, and Dual-Value Controls
RangeSlider widgets are essential for filtering products by price, selecting age ranges, or setting minimum and maximum values. Localizing these dual-value controls requires attention to number formatting, range labels, and accessible descriptions across different locales. This guide covers everything you need to know about localizing RangeSliders in Flutter.
Understanding RangeSlider Localization Needs
RangeSliders require localization for:
- Number formatting: Currency symbols, decimal separators, thousands grouping
- Range labels: "From X to Y", "Between X and Y"
- Semantic labels: Screen reader announcements
- Division labels: Tick mark values
- Unit display: Currency, percentages, measurements
Basic RangeSlider with Localized Values
Start with a simple price filter RangeSlider:
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedPriceRangeSlider extends StatefulWidget {
final double minPrice;
final double maxPrice;
final RangeValues initialValues;
final ValueChanged<RangeValues> onChanged;
const LocalizedPriceRangeSlider({
super.key,
required this.minPrice,
required this.maxPrice,
required this.initialValues,
required this.onChanged,
});
@override
State<LocalizedPriceRangeSlider> createState() =>
_LocalizedPriceRangeSliderState();
}
class _LocalizedPriceRangeSliderState extends State<LocalizedPriceRangeSlider> {
late RangeValues _currentRange;
@override
void initState() {
super.initState();
_currentRange = widget.initialValues;
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
final currencyFormat = NumberFormat.currency(
locale: locale.toString(),
symbol: '\$',
decimalDigits: 0,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.priceRangeLabel,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
l10n.priceRangeValue(
currencyFormat.format(_currentRange.start),
currencyFormat.format(_currentRange.end),
),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
RangeSlider(
values: _currentRange,
min: widget.minPrice,
max: widget.maxPrice,
divisions: ((widget.maxPrice - widget.minPrice) / 10).round(),
labels: RangeLabels(
currencyFormat.format(_currentRange.start),
currencyFormat.format(_currentRange.end),
),
onChanged: (values) {
setState(() => _currentRange = values);
widget.onChanged(values);
},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(currencyFormat.format(widget.minPrice)),
Text(currencyFormat.format(widget.maxPrice)),
],
),
),
],
);
}
}
ARB entries:
{
"priceRangeLabel": "Price Range",
"@priceRangeLabel": {
"description": "Label for price range slider"
},
"priceRangeValue": "{min} - {max}",
"@priceRangeValue": {
"description": "Display of selected price range",
"placeholders": {
"min": {"type": "String"},
"max": {"type": "String"}
}
}
}
Multi-Currency Price Range Filter
Handle different currencies based on user locale:
class MultiCurrencyRangeSlider extends StatefulWidget {
final String currencyCode;
final double minValue;
final double maxValue;
final ValueChanged<RangeValues> onChanged;
const MultiCurrencyRangeSlider({
super.key,
required this.currencyCode,
required this.minValue,
required this.maxValue,
required this.onChanged,
});
@override
State<MultiCurrencyRangeSlider> createState() =>
_MultiCurrencyRangeSliderState();
}
class _MultiCurrencyRangeSliderState extends State<MultiCurrencyRangeSlider> {
RangeValues _range = const RangeValues(0, 100);
@override
void initState() {
super.initState();
_range = RangeValues(widget.minValue, widget.maxValue);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
final formatter = NumberFormat.currency(
locale: locale.toString(),
symbol: _getCurrencySymbol(widget.currencyCode),
decimalDigits: _getDecimalDigits(widget.currencyCode),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(l10n.filterByPrice),
TextButton(
onPressed: _resetRange,
child: Text(l10n.resetFilter),
),
],
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildValueChip(formatter.format(_range.start)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(l10n.rangeSeparator),
),
_buildValueChip(formatter.format(_range.end)),
],
),
const SizedBox(height: 16),
RangeSlider(
values: _range,
min: widget.minValue,
max: widget.maxValue,
divisions: 20,
onChanged: (values) {
setState(() => _range = values);
widget.onChanged(values);
},
),
],
),
),
],
);
}
Widget _buildValueChip(String value) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(20),
),
child: Text(
value,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontWeight: FontWeight.bold,
),
),
);
}
void _resetRange() {
setState(() {
_range = RangeValues(widget.minValue, widget.maxValue);
});
widget.onChanged(_range);
}
String _getCurrencySymbol(String code) {
switch (code) {
case 'EUR':
return '\u20AC';
case 'GBP':
return '\u00A3';
case 'JPY':
return '\u00A5';
case 'USD':
default:
return '\$';
}
}
int _getDecimalDigits(String code) {
switch (code) {
case 'JPY':
case 'KRW':
return 0;
default:
return 2;
}
}
}
ARB entries:
{
"filterByPrice": "Filter by price",
"@filterByPrice": {
"description": "Price filter section label"
},
"resetFilter": "Reset",
"@resetFilter": {
"description": "Reset filter button"
},
"rangeSeparator": "to",
"@rangeSeparator": {
"description": "Separator between min and max values"
}
}
Age Range Selector with Localized Units
Build an age filter with proper unit localization:
class LocalizedAgeRangeSlider extends StatefulWidget {
final int minAge;
final int maxAge;
final ValueChanged<RangeValues> onChanged;
const LocalizedAgeRangeSlider({
super.key,
this.minAge = 18,
this.maxAge = 65,
required this.onChanged,
});
@override
State<LocalizedAgeRangeSlider> createState() =>
_LocalizedAgeRangeSliderState();
}
class _LocalizedAgeRangeSliderState extends State<LocalizedAgeRangeSlider> {
late RangeValues _ageRange;
@override
void initState() {
super.initState();
_ageRange = RangeValues(
widget.minAge.toDouble(),
widget.maxAge.toDouble(),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.ageRangeLabel,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
l10n.ageRangeDescription(
_ageRange.start.round(),
_ageRange.end.round(),
),
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
RangeSlider(
values: _ageRange,
min: widget.minAge.toDouble(),
max: widget.maxAge.toDouble(),
divisions: widget.maxAge - widget.minAge,
labels: RangeLabels(
l10n.yearsOld(_ageRange.start.round()),
l10n.yearsOld(_ageRange.end.round()),
),
onChanged: (values) {
setState(() => _ageRange = values);
widget.onChanged(values);
},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(l10n.yearsOld(widget.minAge)),
Text(l10n.yearsOld(widget.maxAge)),
],
),
),
],
);
}
}
ARB entries with pluralization:
{
"ageRangeLabel": "Age Range",
"@ageRangeLabel": {
"description": "Label for age range filter"
},
"ageRangeDescription": "Between {minAge} and {maxAge} years old",
"@ageRangeDescription": {
"placeholders": {
"minAge": {"type": "int"},
"maxAge": {"type": "int"}
}
},
"yearsOld": "{count, plural, =1{1 year} other{{count} years}}",
"@yearsOld": {
"placeholders": {
"count": {"type": "int"}
}
}
}
Distance Range with Metric/Imperial Support
Support both kilometers and miles based on locale:
class LocalizedDistanceRangeSlider extends StatefulWidget {
final double maxDistanceKm;
final ValueChanged<RangeValues> onChanged;
const LocalizedDistanceRangeSlider({
super.key,
this.maxDistanceKm = 100,
required this.onChanged,
});
@override
State<LocalizedDistanceRangeSlider> createState() =>
_LocalizedDistanceRangeSliderState();
}
class _LocalizedDistanceRangeSliderState
extends State<LocalizedDistanceRangeSlider> {
RangeValues _range = const RangeValues(0, 50);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
final useImperial = _usesImperialSystem(locale);
final maxValue = useImperial
? widget.maxDistanceKm * 0.621371 // Convert to miles
: widget.maxDistanceKm;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.distanceRangeLabel,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
useImperial
? l10n.distanceRangeMiles(
_range.start.round(),
_range.end.round(),
)
: l10n.distanceRangeKm(
_range.start.round(),
_range.end.round(),
),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
RangeSlider(
values: _range,
min: 0,
max: maxValue,
divisions: 20,
labels: RangeLabels(
useImperial
? l10n.milesShort(_range.start.round())
: l10n.kmShort(_range.start.round()),
useImperial
? l10n.milesShort(_range.end.round())
: l10n.kmShort(_range.end.round()),
),
onChanged: (values) {
setState(() => _range = values);
// Convert back to km if needed
final kmValues = useImperial
? RangeValues(
values.start / 0.621371,
values.end / 0.621371,
)
: values;
widget.onChanged(kmValues);
},
),
],
);
}
bool _usesImperialSystem(Locale locale) {
// US, Liberia, Myanmar use imperial
return ['US', 'LR', 'MM'].contains(locale.countryCode);
}
}
ARB entries:
{
"distanceRangeLabel": "Distance",
"@distanceRangeLabel": {
"description": "Distance range filter label"
},
"distanceRangeKm": "{min} - {max} kilometers",
"@distanceRangeKm": {
"placeholders": {
"min": {"type": "int"},
"max": {"type": "int"}
}
},
"distanceRangeMiles": "{min} - {max} miles",
"@distanceRangeMiles": {
"placeholders": {
"min": {"type": "int"},
"max": {"type": "int"}
}
},
"kmShort": "{value} km",
"@kmShort": {
"placeholders": {
"value": {"type": "int"}
}
},
"milesShort": "{value} mi",
"@milesShort": {
"placeholders": {
"value": {"type": "int"}
}
}
}
Accessible RangeSlider with Screen Reader Support
Create fully accessible range sliders:
class AccessibleRangeSlider extends StatefulWidget {
final String label;
final double min;
final double max;
final RangeValues initialValues;
final String Function(double) formatValue;
final ValueChanged<RangeValues> onChanged;
const AccessibleRangeSlider({
super.key,
required this.label,
required this.min,
required this.max,
required this.initialValues,
required this.formatValue,
required this.onChanged,
});
@override
State<AccessibleRangeSlider> createState() => _AccessibleRangeSliderState();
}
class _AccessibleRangeSliderState extends State<AccessibleRangeSlider> {
late RangeValues _values;
@override
void initState() {
super.initState();
_values = widget.initialValues;
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Semantics(
label: l10n.rangeSliderAccessibilityLabel(widget.label),
value: l10n.rangeSliderAccessibilityValue(
widget.formatValue(_values.start),
widget.formatValue(_values.end),
),
hint: l10n.rangeSliderAccessibilityHint,
slider: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.label,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
l10n.selectedRange(
widget.formatValue(_values.start),
widget.formatValue(_values.end),
),
),
const SizedBox(height: 16),
SliderTheme(
data: SliderTheme.of(context).copyWith(
showValueIndicator: ShowValueIndicator.always,
),
child: RangeSlider(
values: _values,
min: widget.min,
max: widget.max,
divisions: ((widget.max - widget.min) / 5).round(),
labels: RangeLabels(
widget.formatValue(_values.start),
widget.formatValue(_values.end),
),
semanticFormatterCallback: (value) {
return widget.formatValue(value);
},
onChanged: (values) {
setState(() => _values = values);
widget.onChanged(values);
},
onChangeStart: (values) {
_announceChange(l10n.rangeSliderStartAdjusting);
},
onChangeEnd: (values) {
_announceChange(
l10n.rangeSliderSelectionConfirmed(
widget.formatValue(values.start),
widget.formatValue(values.end),
),
);
},
),
),
],
),
);
}
void _announceChange(String message) {
SemanticsService.announce(message, TextDirection.ltr);
}
}
ARB entries:
{
"rangeSliderAccessibilityLabel": "{label} range selector",
"@rangeSliderAccessibilityLabel": {
"placeholders": {
"label": {"type": "String"}
}
},
"rangeSliderAccessibilityValue": "From {min} to {max}",
"@rangeSliderAccessibilityValue": {
"placeholders": {
"min": {"type": "String"},
"max": {"type": "String"}
}
},
"rangeSliderAccessibilityHint": "Swipe left or right to adjust the range",
"selectedRange": "Selected: {min} - {max}",
"@selectedRange": {
"placeholders": {
"min": {"type": "String"},
"max": {"type": "String"}
}
},
"rangeSliderStartAdjusting": "Adjusting range",
"rangeSliderSelectionConfirmed": "Range set from {min} to {max}",
"@rangeSliderSelectionConfirmed": {
"placeholders": {
"min": {"type": "String"},
"max": {"type": "String"}
}
}
}
Product Filter with Multiple Ranges
Build a complete product filter with localized ranges:
class LocalizedProductFilter extends StatefulWidget {
final ValueChanged<ProductFilterValues> onFilterChanged;
const LocalizedProductFilter({
super.key,
required this.onFilterChanged,
});
@override
State<LocalizedProductFilter> createState() => _LocalizedProductFilterState();
}
class ProductFilterValues {
final RangeValues priceRange;
final RangeValues ratingRange;
final RangeValues discountRange;
ProductFilterValues({
required this.priceRange,
required this.ratingRange,
required this.discountRange,
});
}
class _LocalizedProductFilterState extends State<LocalizedProductFilter> {
RangeValues _priceRange = const RangeValues(0, 1000);
RangeValues _ratingRange = const RangeValues(1, 5);
RangeValues _discountRange = const RangeValues(0, 100);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
final currencyFormat = NumberFormat.currency(
locale: locale.toString(),
symbol: '\$',
decimalDigits: 0,
);
final percentFormat = NumberFormat.percentPattern(locale.toString());
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.filterTitle,
style: Theme.of(context).textTheme.headlineSmall,
),
TextButton(
onPressed: _resetAllFilters,
child: Text(l10n.resetAllFilters),
),
],
),
const Divider(height: 32),
// Price Range
_buildFilterSection(
title: l10n.priceRangeLabel,
valueDisplay: l10n.priceRangeValue(
currencyFormat.format(_priceRange.start),
currencyFormat.format(_priceRange.end),
),
child: RangeSlider(
values: _priceRange,
min: 0,
max: 1000,
divisions: 20,
labels: RangeLabels(
currencyFormat.format(_priceRange.start),
currencyFormat.format(_priceRange.end),
),
onChanged: (values) {
setState(() => _priceRange = values);
_notifyFilterChange();
},
),
),
const SizedBox(height: 24),
// Rating Range
_buildFilterSection(
title: l10n.ratingRangeLabel,
valueDisplay: l10n.ratingRangeValue(
_ratingRange.start.toStringAsFixed(1),
_ratingRange.end.toStringAsFixed(1),
),
child: RangeSlider(
values: _ratingRange,
min: 1,
max: 5,
divisions: 8,
labels: RangeLabels(
l10n.starsRating(_ratingRange.start.toStringAsFixed(1)),
l10n.starsRating(_ratingRange.end.toStringAsFixed(1)),
),
onChanged: (values) {
setState(() => _ratingRange = values);
_notifyFilterChange();
},
),
),
const SizedBox(height: 24),
// Discount Range
_buildFilterSection(
title: l10n.discountRangeLabel,
valueDisplay: l10n.discountRangeValue(
_discountRange.start.round(),
_discountRange.end.round(),
),
child: RangeSlider(
values: _discountRange,
min: 0,
max: 100,
divisions: 10,
labels: RangeLabels(
'${_discountRange.start.round()}%',
'${_discountRange.end.round()}%',
),
onChanged: (values) {
setState(() => _discountRange = values);
_notifyFilterChange();
},
),
),
],
),
);
}
Widget _buildFilterSection({
required String title,
required String valueDisplay,
required Widget child,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
valueDisplay,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
child,
],
);
}
void _resetAllFilters() {
setState(() {
_priceRange = const RangeValues(0, 1000);
_ratingRange = const RangeValues(1, 5);
_discountRange = const RangeValues(0, 100);
});
_notifyFilterChange();
}
void _notifyFilterChange() {
widget.onFilterChanged(ProductFilterValues(
priceRange: _priceRange,
ratingRange: _ratingRange,
discountRange: _discountRange,
));
}
}
ARB entries:
{
"filterTitle": "Filters",
"resetAllFilters": "Reset all",
"ratingRangeLabel": "Rating",
"ratingRangeValue": "{min} - {max} stars",
"@ratingRangeValue": {
"placeholders": {
"min": {"type": "String"},
"max": {"type": "String"}
}
},
"starsRating": "{rating} stars",
"@starsRating": {
"placeholders": {
"rating": {"type": "String"}
}
},
"discountRangeLabel": "Discount",
"discountRangeValue": "{min}% - {max}% off",
"@discountRangeValue": {
"placeholders": {
"min": {"type": "int"},
"max": {"type": "int"}
}
}
}
RTL Support for RangeSlider
Handle RTL layouts properly:
class RTLAwareRangeSlider extends StatelessWidget {
final RangeValues values;
final double min;
final double max;
final ValueChanged<RangeValues> onChanged;
final String startLabel;
final String endLabel;
const RTLAwareRangeSlider({
super.key,
required this.values,
required this.min,
required this.max,
required this.onChanged,
required this.startLabel,
required this.endLabel,
});
@override
Widget build(BuildContext context) {
final isRTL = Directionality.of(context) == TextDirection.rtl;
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(isRTL ? endLabel : startLabel),
Text(isRTL ? startLabel : endLabel),
],
),
),
const SizedBox(height: 8),
Directionality(
// Force LTR for the slider itself to maintain consistent behavior
textDirection: TextDirection.ltr,
child: RangeSlider(
values: values,
min: min,
max: max,
onChanged: onChanged,
),
),
],
);
}
}
Testing RangeSlider Localization
Write comprehensive tests:
void main() {
group('LocalizedPriceRangeSlider', () {
testWidgets('formats currency for US locale', (tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en', 'US'),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
home: Scaffold(
body: LocalizedPriceRangeSlider(
minPrice: 0,
maxPrice: 1000,
initialValues: const RangeValues(100, 500),
onChanged: (_) {},
),
),
),
);
expect(find.text('\$100 - \$500'), findsOneWidget);
});
testWidgets('formats currency for German locale', (tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('de', 'DE'),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
home: Scaffold(
body: LocalizedPriceRangeSlider(
minPrice: 0,
maxPrice: 1000,
initialValues: const RangeValues(100, 500),
onChanged: (_) {},
),
),
),
);
// German uses different format: 100 € - 500 €
expect(find.textContaining('100'), findsWidgets);
expect(find.textContaining('500'), findsWidgets);
});
testWidgets('updates values when slider changes', (tester) async {
RangeValues? capturedValues;
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en', 'US'),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
home: Scaffold(
body: LocalizedPriceRangeSlider(
minPrice: 0,
maxPrice: 1000,
initialValues: const RangeValues(100, 500),
onChanged: (values) => capturedValues = values,
),
),
),
);
final slider = find.byType(RangeSlider);
expect(slider, findsOneWidget);
});
});
}
Best Practices Summary
- Format numbers for locale: Use
NumberFormatfor currencies and decimals - Handle measurement units: Support metric and imperial based on locale
- Provide clear labels: Show both current selection and available range
- Ensure accessibility: Add semantic labels and announcements
- Support RTL: Test with Arabic and Hebrew locales
- Test edge cases: Zero values, maximum values, and decimal precision
Conclusion
Localizing RangeSlider widgets in Flutter involves proper number formatting, locale-aware units, and accessible semantic labels. By implementing currency-aware price filters, metric/imperial distance ranges, and comprehensive accessibility support, you create filter interfaces that feel native to users worldwide. Remember to test with multiple locales and verify that screen readers announce range changes correctly.