Flutter Wrap Widget Localization: Responsive Tags, Chips, and Flow Layouts
The Wrap widget is essential for creating responsive layouts where children flow naturally across lines. Localizing Wrap requires handling varying text lengths, RTL support, dynamic spacing, and accessibility features. This guide covers everything you need to know about localizing Wrap in Flutter.
Understanding Wrap Localization
Wrap requires localization for:
- Tag and chip labels: Text that varies significantly in length
- Flow direction: Proper handling for RTL languages
- Spacing adjustments: Accommodating different text densities
- Action labels: Buttons and interactive elements
- Empty states: When no items are available
- Overflow handling: Managing very long translations
Basic Wrap with Localized Tags
Start with a simple localized tag layout:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedWrap extends StatelessWidget {
const LocalizedWrap({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final tags = [
l10n.tagPopular,
l10n.tagNew,
l10n.tagFeatured,
l10n.tagSale,
l10n.tagLimited,
l10n.tagExclusive,
l10n.tagBestSeller,
l10n.tagRecommended,
];
return Scaffold(
appBar: AppBar(
title: Text(l10n.tagsTitle),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.selectTagsMessage,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: tags.map((tag) {
return Chip(
label: Text(tag),
onDeleted: () {},
deleteButtonTooltipMessage: l10n.removeTagTooltip,
);
}).toList(),
),
],
),
),
);
}
}
Wrap with Adaptive Spacing for Different Languages
Adjust spacing based on language characteristics:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class AdaptiveSpacingWrap extends StatelessWidget {
const AdaptiveSpacingWrap({super.key});
double _getSpacing(BuildContext context) {
final locale = Localizations.localeOf(context);
// Languages with compact characters can use smaller spacing
final compactLanguages = ['zh', 'ja', 'ko'];
if (compactLanguages.contains(locale.languageCode)) {
return 4.0;
}
// Languages with longer words need more spacing
final verboseLanguages = ['de', 'ru', 'fi', 'pl'];
if (verboseLanguages.contains(locale.languageCode)) {
return 12.0;
}
return 8.0;
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final spacing = _getSpacing(context);
final categories = [
l10n.categoryElectronics,
l10n.categoryClothing,
l10n.categoryHome,
l10n.categorySports,
l10n.categoryBooks,
l10n.categoryMusic,
l10n.categoryMovies,
l10n.categoryGames,
];
return Scaffold(
appBar: AppBar(
title: Text(l10n.categoriesTitle),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.browseCategoriesMessage,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
Wrap(
spacing: spacing,
runSpacing: spacing,
children: categories.map((category) {
return ActionChip(
label: Text(category),
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.selectedCategory(category)),
),
);
},
);
}).toList(),
),
],
),
),
);
}
}
Wrap with RTL Support
Handle right-to-left layouts properly:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class RTLWrap extends StatefulWidget {
const RTLWrap({super.key});
@override
State<RTLWrap> createState() => _RTLWrapState();
}
class _RTLWrapState extends State<RTLWrap> {
final Set<String> _selectedFilters = {};
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final isRTL = Directionality.of(context) == TextDirection.rtl;
final filters = [
(l10n.filterPrice, Icons.attach_money),
(l10n.filterRating, Icons.star),
(l10n.filterDistance, Icons.location_on),
(l10n.filterOpen, Icons.access_time),
(l10n.filterDelivery, Icons.delivery_dining),
(l10n.filterPickup, Icons.store),
];
return Scaffold(
appBar: AppBar(
title: Text(l10n.filtersTitle),
actions: [
if (_selectedFilters.isNotEmpty)
TextButton(
onPressed: () {
setState(() => _selectedFilters.clear());
},
child: Text(l10n.clearAllButton),
),
],
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.activeFiltersLabel,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 12),
Wrap(
// Wrap automatically handles RTL direction
spacing: 8,
runSpacing: 8,
children: filters.map((filter) {
final (label, icon) = filter;
final isSelected = _selectedFilters.contains(label);
return FilterChip(
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: isSelected
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurface,
),
const SizedBox(width: 4),
Text(label),
],
),
selected: isSelected,
onSelected: (selected) {
setState(() {
if (selected) {
_selectedFilters.add(label);
} else {
_selectedFilters.remove(label);
}
});
},
);
}).toList(),
),
if (_selectedFilters.isNotEmpty) ...[
const SizedBox(height: 24),
Text(
l10n.selectedFiltersCount(_selectedFilters.length),
style: Theme.of(context).textTheme.bodyMedium,
),
],
],
),
),
);
}
}
Wrap with Selectable Skills/Interests
Create a localized skill selection interface:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class SkillSelectionWrap extends StatefulWidget {
const SkillSelectionWrap({super.key});
@override
State<SkillSelectionWrap> createState() => _SkillSelectionWrapState();
}
class _SkillSelectionWrapState extends State<SkillSelectionWrap> {
final Set<String> _selectedSkills = {};
static const int maxSelections = 5;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final skillGroups = {
l10n.skillGroupTechnical: [
l10n.skillFlutter,
l10n.skillDart,
l10n.skillJavaScript,
l10n.skillPython,
l10n.skillSwift,
l10n.skillKotlin,
],
l10n.skillGroupDesign: [
l10n.skillUIDesign,
l10n.skillUXResearch,
l10n.skillPrototyping,
l10n.skillBranding,
],
l10n.skillGroupSoft: [
l10n.skillCommunication,
l10n.skillTeamwork,
l10n.skillProblemSolving,
l10n.skillLeadership,
],
};
return Scaffold(
appBar: AppBar(
title: Text(l10n.selectSkillsTitle),
),
body: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.selectSkillsMessage(maxSelections),
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 8),
Text(
l10n.skillsSelectedCount(
_selectedSkills.length,
maxSelections,
),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: _selectedSkills.length >= maxSelections
? Colors.orange
: Colors.grey[600],
),
),
const SizedBox(height: 24),
...skillGroups.entries.map((group) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
group.key,
style:
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: group.value.map((skill) {
final isSelected = _selectedSkills.contains(skill);
final canSelect = _selectedSkills.length <
maxSelections ||
isSelected;
return ChoiceChip(
label: Text(skill),
selected: isSelected,
onSelected: canSelect
? (selected) {
setState(() {
if (selected) {
_selectedSkills.add(skill);
} else {
_selectedSkills.remove(skill);
}
});
}
: null,
);
}).toList(),
),
const SizedBox(height: 24),
],
);
}),
],
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _selectedSkills.isNotEmpty
? () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
l10n.skillsSavedMessage(_selectedSkills.length),
),
),
);
}
: null,
child: Text(l10n.saveSkillsButton),
),
),
),
],
),
);
}
}
Wrap with Input Chips
Handle user input with localized chips:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class InputChipsWrap extends StatefulWidget {
const InputChipsWrap({super.key});
@override
State<InputChipsWrap> createState() => _InputChipsWrapState();
}
class _InputChipsWrapState extends State<InputChipsWrap> {
final List<String> _tags = [];
final TextEditingController _controller = TextEditingController();
void _addTag(String tag) {
final trimmed = tag.trim();
if (trimmed.isNotEmpty && !_tags.contains(trimmed)) {
setState(() {
_tags.add(trimmed);
});
_controller.clear();
}
}
void _removeTag(String tag) {
setState(() {
_tags.remove(tag);
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(l10n.addTagsTitle),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.addTagsDescription,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
TextField(
controller: _controller,
decoration: InputDecoration(
hintText: l10n.tagInputHint,
suffixIcon: IconButton(
icon: const Icon(Icons.add),
tooltip: l10n.addTagTooltip,
onPressed: () => _addTag(_controller.text),
),
border: const OutlineInputBorder(),
),
onSubmitted: _addTag,
textInputAction: TextInputAction.done,
),
const SizedBox(height: 16),
if (_tags.isEmpty)
Text(
l10n.noTagsMessage,
style: TextStyle(
color: Colors.grey[600],
fontStyle: FontStyle.italic,
),
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.yourTagsLabel,
style: Theme.of(context).textTheme.titleSmall,
),
TextButton(
onPressed: () {
setState(() => _tags.clear());
},
child: Text(l10n.clearAllButton),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _tags.map((tag) {
return InputChip(
label: Text(tag),
onDeleted: () => _removeTag(tag),
deleteButtonTooltipMessage: l10n.removeTagTooltip,
);
}).toList(),
),
],
),
const Spacer(),
Text(
l10n.tagCountInfo(_tags.length),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
],
),
),
);
}
}
Accessibility for Wrap
Ensure proper accessibility labels:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class AccessibleWrap extends StatefulWidget {
const AccessibleWrap({super.key});
@override
State<AccessibleWrap> createState() => _AccessibleWrapState();
}
class _AccessibleWrapState extends State<AccessibleWrap> {
final Set<String> _selectedOptions = {};
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final options = [
(l10n.optionExpress, l10n.optionExpressDescription),
(l10n.optionStandard, l10n.optionStandardDescription),
(l10n.optionEconomy, l10n.optionEconomyDescription),
(l10n.optionPickup, l10n.optionPickupDescription),
];
return Scaffold(
appBar: AppBar(
title: Text(l10n.shippingOptionsTitle),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Semantics(
header: true,
child: Text(
l10n.selectShippingMessage,
style: Theme.of(context).textTheme.titleMedium,
),
),
const SizedBox(height: 16),
Semantics(
label: l10n.shippingOptionsAccessibilityLabel,
child: Wrap(
spacing: 8,
runSpacing: 8,
children: options.map((option) {
final (label, description) = option;
final isSelected = _selectedOptions.contains(label);
return Semantics(
button: true,
selected: isSelected,
label: '$label. $description. ${isSelected ? l10n.selectedLabel : l10n.notSelectedLabel}',
child: ChoiceChip(
label: Text(label),
selected: isSelected,
onSelected: (selected) {
setState(() {
if (selected) {
_selectedOptions.add(label);
} else {
_selectedOptions.remove(label);
}
});
// Announce selection change
final message = selected
? l10n.optionSelectedAnnouncement(label)
: l10n.optionDeselectedAnnouncement(label);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
duration: const Duration(seconds: 1),
),
);
},
tooltip: description,
),
);
}).toList(),
),
),
const SizedBox(height: 24),
if (_selectedOptions.isNotEmpty)
Semantics(
liveRegion: true,
child: Text(
l10n.selectedOptionsCount(_selectedOptions.length),
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
),
);
}
}
Wrap with Long Text Handling
Handle translations that vary significantly in length:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LongTextWrap extends StatelessWidget {
const LongTextWrap({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
// These could have very different lengths in different languages
final features = [
l10n.featureFreeShipping,
l10n.featureEasyReturns,
l10n.featureSecurePayment,
l10n.featureCustomerSupport,
l10n.featureFastDelivery,
l10n.featureQualityGuarantee,
];
return Scaffold(
appBar: AppBar(
title: Text(l10n.featuresTitle),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.ourFeaturesMessage,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: features.map((feature) {
return ConstrainedBox(
// Limit maximum width to prevent overly long chips
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.45,
),
child: Chip(
avatar: const Icon(Icons.check_circle, size: 18),
label: Text(
feature,
overflow: TextOverflow.ellipsis,
),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
);
}).toList(),
),
const SizedBox(height: 32),
Text(
l10n.badgesTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
_buildBadge(context, l10n.badgeVerified, Colors.blue),
_buildBadge(context, l10n.badgeTrusted, Colors.green),
_buildBadge(context, l10n.badgePremium, Colors.orange),
_buildBadge(context, l10n.badgeTopRated, Colors.purple),
],
),
],
),
),
);
}
Widget _buildBadge(BuildContext context, String label, Color color) {
return Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.4,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.verified, size: 16, color: color),
const SizedBox(width: 6),
Flexible(
child: Text(
label,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
ARB Translations for Wrap
Add these entries to your ARB files:
{
"tagsTitle": "Tags",
"@tagsTitle": {
"description": "Title for tags screen"
},
"selectTagsMessage": "Select tags to filter results",
"removeTagTooltip": "Remove tag",
"tagPopular": "Popular",
"tagNew": "New",
"tagFeatured": "Featured",
"tagSale": "Sale",
"tagLimited": "Limited Edition",
"tagExclusive": "Exclusive",
"tagBestSeller": "Best Seller",
"tagRecommended": "Recommended",
"categoriesTitle": "Categories",
"browseCategoriesMessage": "Browse by category",
"selectedCategory": "Selected: {category}",
"@selectedCategory": {
"placeholders": {
"category": {"type": "String"}
}
},
"categoryElectronics": "Electronics",
"categoryClothing": "Clothing",
"categoryHome": "Home & Garden",
"categorySports": "Sports",
"categoryBooks": "Books",
"categoryMusic": "Music",
"categoryMovies": "Movies",
"categoryGames": "Games",
"filtersTitle": "Filters",
"clearAllButton": "Clear All",
"activeFiltersLabel": "Active filters",
"filterPrice": "Price",
"filterRating": "Rating",
"filterDistance": "Distance",
"filterOpen": "Open Now",
"filterDelivery": "Delivery",
"filterPickup": "Pickup",
"selectedFiltersCount": "{count} filters selected",
"@selectedFiltersCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"selectSkillsTitle": "Select Your Skills",
"selectSkillsMessage": "Choose up to {max} skills that best describe you",
"@selectSkillsMessage": {
"placeholders": {
"max": {"type": "int"}
}
},
"skillsSelectedCount": "{current} of {max} selected",
"@skillsSelectedCount": {
"placeholders": {
"current": {"type": "int"},
"max": {"type": "int"}
}
},
"skillGroupTechnical": "Technical Skills",
"skillGroupDesign": "Design Skills",
"skillGroupSoft": "Soft Skills",
"skillFlutter": "Flutter",
"skillDart": "Dart",
"skillJavaScript": "JavaScript",
"skillPython": "Python",
"skillSwift": "Swift",
"skillKotlin": "Kotlin",
"skillUIDesign": "UI Design",
"skillUXResearch": "UX Research",
"skillPrototyping": "Prototyping",
"skillBranding": "Branding",
"skillCommunication": "Communication",
"skillTeamwork": "Teamwork",
"skillProblemSolving": "Problem Solving",
"skillLeadership": "Leadership",
"skillsSavedMessage": "{count} skills saved successfully",
"@skillsSavedMessage": {
"placeholders": {
"count": {"type": "int"}
}
},
"saveSkillsButton": "Save Skills",
"addTagsTitle": "Add Tags",
"addTagsDescription": "Add tags to help organize your content",
"tagInputHint": "Enter a tag...",
"addTagTooltip": "Add tag",
"noTagsMessage": "No tags added yet",
"yourTagsLabel": "Your tags",
"tagCountInfo": "{count} tags added",
"@tagCountInfo": {
"placeholders": {
"count": {"type": "int"}
}
},
"shippingOptionsTitle": "Shipping Options",
"selectShippingMessage": "Select your preferred shipping methods",
"shippingOptionsAccessibilityLabel": "Available shipping options",
"optionExpress": "Express",
"optionExpressDescription": "Delivery in 1-2 business days",
"optionStandard": "Standard",
"optionStandardDescription": "Delivery in 3-5 business days",
"optionEconomy": "Economy",
"optionEconomyDescription": "Delivery in 7-10 business days",
"optionPickup": "Store Pickup",
"optionPickupDescription": "Pick up at your nearest store",
"selectedLabel": "selected",
"notSelectedLabel": "not selected",
"optionSelectedAnnouncement": "{option} selected",
"@optionSelectedAnnouncement": {
"placeholders": {
"option": {"type": "String"}
}
},
"optionDeselectedAnnouncement": "{option} deselected",
"@optionDeselectedAnnouncement": {
"placeholders": {
"option": {"type": "String"}
}
},
"selectedOptionsCount": "{count} options selected",
"@selectedOptionsCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"featuresTitle": "Features",
"ourFeaturesMessage": "Why choose us",
"featureFreeShipping": "Free Shipping",
"featureEasyReturns": "Easy Returns",
"featureSecurePayment": "Secure Payment",
"featureCustomerSupport": "24/7 Customer Support",
"featureFastDelivery": "Fast Delivery",
"featureQualityGuarantee": "Quality Guarantee",
"badgesTitle": "Trust Badges",
"badgeVerified": "Verified Seller",
"badgeTrusted": "Trusted Brand",
"badgePremium": "Premium Quality",
"badgeTopRated": "Top Rated"
}
Testing Wrap Localization
Write tests for your localized Wrap:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
void main() {
group('LocalizedWrap', () {
testWidgets('displays localized tags in English', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: const LocalizedWrap(),
),
);
expect(find.text('Popular'), findsOneWidget);
expect(find.text('New'), findsOneWidget);
expect(find.text('Featured'), findsOneWidget);
});
testWidgets('displays localized tags in Spanish', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('es'),
home: const LocalizedWrap(),
),
);
expect(find.text('Popular'), findsOneWidget);
expect(find.text('Nuevo'), findsOneWidget);
expect(find.text('Destacado'), findsOneWidget);
});
testWidgets('handles RTL layout correctly', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('ar'),
home: const Directionality(
textDirection: TextDirection.rtl,
child: RTLWrap(),
),
),
);
// Verify Wrap respects RTL direction
final wrap = tester.widget<Wrap>(find.byType(Wrap));
expect(wrap, isNotNull);
});
testWidgets('skill selection respects maximum', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: const SkillSelectionWrap(),
),
);
expect(find.text('0 of 5 selected'), findsOneWidget);
// Select a skill
await tester.tap(find.text('Flutter'));
await tester.pumpAndSettle();
expect(find.text('1 of 5 selected'), findsOneWidget);
});
});
}
Summary
Localizing Wrap in Flutter requires:
- Flexible spacing that adapts to different language characteristics
- RTL support with automatic flow direction handling
- Constrained widths for chips with varying text lengths
- Grouped sections with localized headers
- Input handling for user-generated tags
- Accessibility labels for screen reader users
- Selection feedback in the user's language
- Comprehensive testing across different locales
Wrap is essential for creating responsive, flowing layouts, and proper localization ensures your tags, chips, and badges look polished for users in any language.