Flutter Flexible Localization: Adaptive Layouts for Global Apps
Flexible is a Flutter widget that controls how a child flexes within a Row, Column, or Flex. Unlike Expanded, Flexible allows its child to be smaller than the available space. In multilingual applications, Flexible provides precise control over how content areas share and adapt to available space.
Understanding Flexible in Localization Context
Flexible gives its child the ability to expand to fill available space but doesn't require it. For multilingual apps, this distinction is crucial:
- Content can be its natural size when space permits
- Long translations can expand when needed
- RTL layouts maintain proper proportional behavior
- Different language lengths are handled gracefully
Why Flexible Matters for Multilingual Apps
Adaptive flexibility ensures:
- Natural sizing: Content uses only the space it needs
- Graceful expansion: Long text can grow when available
- Consistent layouts: Visual harmony across languages
- Responsive design: Layouts adapt to screen sizes
Basic Flexible Implementation
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedFlexibleExample extends StatelessWidget {
const LocalizedFlexibleExample({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Row(
children: [
Flexible(
child: Text(
l10n.shortMessage,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
TextButton(
onPressed: () {},
child: Text(l10n.actionButton),
),
],
);
}
}
Flexible vs Expanded
Visual Comparison
class FlexibleVsExpandedDemo extends StatelessWidget {
const FlexibleVsExpandedDemo({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// With Flexible - takes only needed space
Text(
l10n.flexibleLabel,
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 8),
Container(
color: Colors.grey.shade200,
padding: const EdgeInsets.all(8),
child: Row(
children: [
Flexible(
child: Container(
color: Colors.blue.shade200,
padding: const EdgeInsets.all(8),
child: Text(l10n.shortText),
),
),
const SizedBox(width: 8),
Container(
color: Colors.green.shade200,
padding: const EdgeInsets.all(8),
child: Text(l10n.fixedContent),
),
],
),
),
const SizedBox(height: 24),
// With Expanded - takes all available space
Text(
l10n.expandedLabel,
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 8),
Container(
color: Colors.grey.shade200,
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: Container(
color: Colors.blue.shade200,
padding: const EdgeInsets.all(8),
child: Text(l10n.shortText),
),
),
const SizedBox(width: 8),
Container(
color: Colors.green.shade200,
padding: const EdgeInsets.all(8),
child: Text(l10n.fixedContent),
),
],
),
),
],
);
}
}
Tag and Chip Layouts
Flexible Tag Row
class LocalizedTagRow extends StatelessWidget {
final List<String> tags;
final int maxVisibleTags;
const LocalizedTagRow({
super.key,
required this.tags,
this.maxVisibleTags = 3,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final visibleTags = tags.take(maxVisibleTags).toList();
final remainingCount = tags.length - maxVisibleTags;
return Row(
children: [
Flexible(
child: Wrap(
spacing: 8,
runSpacing: 4,
children: visibleTags.map((tag) => _Tag(label: tag)).toList(),
),
),
if (remainingCount > 0) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Text(
l10n.moreItems(remainingCount),
style: Theme.of(context).textTheme.labelSmall,
),
),
],
],
);
}
}
class _Tag extends StatelessWidget {
final String label;
const _Tag({required this.label});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
);
}
}
Filter Chips with Flexible
class LocalizedFilterChips extends StatefulWidget {
const LocalizedFilterChips({super.key});
@override
State<LocalizedFilterChips> createState() => _LocalizedFilterChipsState();
}
class _LocalizedFilterChipsState extends State<LocalizedFilterChips> {
final Set<String> _selected = {};
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final filters = [
l10n.filterAll,
l10n.filterActive,
l10n.filterCompleted,
l10n.filterPending,
];
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: filters.map((filter) {
final isSelected = _selected.contains(filter);
return Padding(
padding: const EdgeInsetsDirectional.only(end: 8),
child: Flexible(
child: FilterChip(
label: Text(filter),
selected: isSelected,
onSelected: (selected) {
setState(() {
if (selected) {
_selected.add(filter);
} else {
_selected.remove(filter);
}
});
},
),
),
);
}).toList(),
),
);
}
}
Card Content Layouts
Flexible Card Content
class LocalizedFlexibleCard extends StatelessWidget {
final IconData icon;
final String title;
final String description;
final String? actionLabel;
final VoidCallback? onAction;
const LocalizedFlexibleCard({
super.key,
required this.icon,
required this.title,
required this.description,
this.actionLabel,
this.onAction,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: Theme.of(context).colorScheme.primary,
size: 24,
),
),
const SizedBox(width: 16),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
description,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
if (actionLabel != null && onAction != null) ...[
const SizedBox(height: 12),
TextButton(
onPressed: onAction,
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(actionLabel!),
),
],
],
),
),
],
),
),
);
}
}
Notification and Alert Layouts
Flexible Alert Banner
class LocalizedAlertBanner extends StatelessWidget {
final IconData icon;
final String message;
final String? actionLabel;
final VoidCallback? onAction;
final VoidCallback? onDismiss;
final Color? backgroundColor;
const LocalizedAlertBanner({
super.key,
required this.icon,
required this.message,
this.actionLabel,
this.onAction,
this.onDismiss,
this.backgroundColor,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: backgroundColor ?? Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
icon,
size: 20,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
const SizedBox(width: 12),
Flexible(
child: Text(
message,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
if (actionLabel != null && onAction != null) ...[
const SizedBox(width: 12),
TextButton(
onPressed: onAction,
style: TextButton.styleFrom(
minimumSize: Size.zero,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
),
child: Text(actionLabel!),
),
],
if (onDismiss != null) ...[
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: onDismiss,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
],
),
);
}
}
// Usage
class NotificationExample extends StatelessWidget {
const NotificationExample({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
LocalizedAlertBanner(
icon: Icons.info_outline,
message: l10n.updateAvailableMessage,
actionLabel: l10n.updateNow,
onAction: () {},
onDismiss: () {},
),
const SizedBox(height: 12),
LocalizedAlertBanner(
icon: Icons.warning_amber_outlined,
message: l10n.lowStorageWarning,
backgroundColor: Colors.orange.shade100,
onDismiss: () {},
),
],
);
}
}
User Profile Layouts
Flexible User Info Row
class LocalizedUserInfoRow extends StatelessWidget {
final String avatarUrl;
final String name;
final String subtitle;
final Widget? trailing;
const LocalizedUserInfoRow({
super.key,
required this.avatarUrl,
required this.name,
required this.subtitle,
this.trailing,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
CircleAvatar(
radius: 24,
backgroundImage: NetworkImage(avatarUrl),
),
const SizedBox(width: 12),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
if (trailing != null) ...[
const SizedBox(width: 12),
trailing!,
],
],
);
}
}
// Usage
class UserListExample extends StatelessWidget {
const UserListExample({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ListView(
padding: const EdgeInsets.all(16),
children: [
LocalizedUserInfoRow(
avatarUrl: 'https://i.pravatar.cc/150?img=1',
name: l10n.userName1,
subtitle: l10n.userRole1,
trailing: FilledButton.tonal(
onPressed: () {},
child: Text(l10n.followButton),
),
),
const Divider(height: 32),
LocalizedUserInfoRow(
avatarUrl: 'https://i.pravatar.cc/150?img=2',
name: l10n.userName2,
subtitle: l10n.userRole2,
trailing: OutlinedButton(
onPressed: () {},
child: Text(l10n.messageButton),
),
),
],
);
}
}
FlexFit Options
Comparing FlexFit.tight vs FlexFit.loose
class FlexFitComparison extends StatelessWidget {
const FlexFitComparison({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.flexFitLooseLabel,
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 8),
Container(
color: Colors.grey.shade200,
padding: const EdgeInsets.all(8),
child: Row(
children: [
Flexible(
fit: FlexFit.loose, // Default - doesn't force expansion
child: Container(
color: Colors.blue.shade200,
padding: const EdgeInsets.all(8),
child: Text(l10n.content),
),
),
const SizedBox(width: 8),
Container(
color: Colors.green.shade200,
padding: const EdgeInsets.all(8),
child: const Text('Fixed'),
),
],
),
),
const SizedBox(height: 24),
Text(
l10n.flexFitTightLabel,
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 8),
Container(
color: Colors.grey.shade200,
padding: const EdgeInsets.all(8),
child: Row(
children: [
Flexible(
fit: FlexFit.tight, // Same as Expanded - forces expansion
child: Container(
color: Colors.blue.shade200,
padding: const EdgeInsets.all(8),
child: Text(l10n.content),
),
),
const SizedBox(width: 8),
Container(
color: Colors.green.shade200,
padding: const EdgeInsets.all(8),
child: const Text('Fixed'),
),
],
),
),
],
);
}
}
Language-Adaptive Flex
Adaptive Flex Based on Language
class AdaptiveFlexLayout extends StatelessWidget {
final Widget child;
final int baseFlex;
const AdaptiveFlexLayout({
super.key,
required this.child,
this.baseFlex = 1,
});
@override
Widget build(BuildContext context) {
final locale = Localizations.localeOf(context);
final adjustedFlex = _getAdjustedFlex(locale);
return Flexible(
flex: adjustedFlex,
child: child,
);
}
int _getAdjustedFlex(Locale locale) {
switch (locale.languageCode) {
case 'de': // German typically needs more space
case 'ru': // Russian
case 'fi': // Finnish
return baseFlex + 1;
case 'ja': // Japanese is more compact
case 'zh': // Chinese
case 'ko': // Korean
return baseFlex;
default:
return baseFlex;
}
}
}
ARB File Structure
English (app_en.arb)
{
"@@locale": "en",
"shortMessage": "Quick update available",
"actionButton": "Update",
"flexibleLabel": "Flexible (uses natural size)",
"expandedLabel": "Expanded (fills available space)",
"shortText": "Short",
"fixedContent": "Fixed",
"moreItems": "+{count} more",
"@moreItems": {
"placeholders": {
"count": {"type": "int"}
}
},
"filterAll": "All",
"filterActive": "Active",
"filterCompleted": "Completed",
"filterPending": "Pending",
"updateAvailableMessage": "A new version is available. Update now to get the latest features.",
"updateNow": "Update",
"lowStorageWarning": "Your device is running low on storage space.",
"userName1": "Sarah Johnson",
"userRole1": "Product Designer",
"userName2": "Michael Chen",
"userRole2": "Software Engineer",
"followButton": "Follow",
"messageButton": "Message",
"flexFitLooseLabel": "FlexFit.loose (default)",
"flexFitTightLabel": "FlexFit.tight (like Expanded)",
"content": "Content"
}
German (app_de.arb)
{
"@@locale": "de",
"shortMessage": "Schnelles Update verfügbar",
"actionButton": "Aktualisieren",
"flexibleLabel": "Flexible (verwendet natürliche Größe)",
"expandedLabel": "Expanded (füllt verfügbaren Platz)",
"shortText": "Kurz",
"fixedContent": "Fest",
"moreItems": "+{count} weitere",
"filterAll": "Alle",
"filterActive": "Aktiv",
"filterCompleted": "Abgeschlossen",
"filterPending": "Ausstehend",
"updateAvailableMessage": "Eine neue Version ist verfügbar. Aktualisieren Sie jetzt, um die neuesten Funktionen zu erhalten.",
"updateNow": "Aktualisieren",
"lowStorageWarning": "Auf Ihrem Gerät ist der Speicherplatz knapp.",
"userName1": "Sarah Johnson",
"userRole1": "Produktdesignerin",
"userName2": "Michael Chen",
"userRole2": "Softwareentwickler",
"followButton": "Folgen",
"messageButton": "Nachricht",
"flexFitLooseLabel": "FlexFit.loose (Standard)",
"flexFitTightLabel": "FlexFit.tight (wie Expanded)",
"content": "Inhalt"
}
Arabic (app_ar.arb)
{
"@@locale": "ar",
"shortMessage": "تحديث سريع متاح",
"actionButton": "تحديث",
"flexibleLabel": "مرن (يستخدم الحجم الطبيعي)",
"expandedLabel": "موسع (يملأ المساحة المتاحة)",
"shortText": "قصير",
"fixedContent": "ثابت",
"moreItems": "+{count} المزيد",
"filterAll": "الكل",
"filterActive": "نشط",
"filterCompleted": "مكتمل",
"filterPending": "معلق",
"updateAvailableMessage": "يتوفر إصدار جديد. قم بالتحديث الآن للحصول على أحدث الميزات.",
"updateNow": "تحديث",
"lowStorageWarning": "مساحة التخزين على جهازك تنفد.",
"userName1": "سارة جونسون",
"userRole1": "مصممة منتجات",
"userName2": "مايكل تشين",
"userRole2": "مهندس برمجيات",
"followButton": "متابعة",
"messageButton": "رسالة",
"flexFitLooseLabel": "FlexFit.loose (افتراضي)",
"flexFitTightLabel": "FlexFit.tight (مثل Expanded)",
"content": "محتوى"
}
Best Practices Summary
Do's
- Use Flexible when content size varies and shouldn't force expansion
- Choose FlexFit.loose for content that should use natural size
- Combine with overflow handling (TextOverflow.ellipsis)
- Use flex values to control proportional space allocation
- Test with varying content lengths across languages
Don'ts
- Don't confuse with Expanded when you want natural sizing
- Don't forget overflow handling for text content
- Don't use in scrollable containers without consideration
- Don't ignore RTL testing for flexible layouts
Accessibility Considerations
class AccessibleFlexibleContent extends StatelessWidget {
final String content;
final String semanticHint;
const AccessibleFlexibleContent({
super.key,
required this.content,
required this.semanticHint,
});
@override
Widget build(BuildContext context) {
return Semantics(
hint: semanticHint,
child: Flexible(
child: Text(
content,
overflow: TextOverflow.ellipsis,
),
),
);
}
}
Conclusion
Flexible provides fine-grained control over space allocation in multilingual Flutter applications. Unlike Expanded, it allows content to remain at its natural size while still participating in flex layout. This makes it ideal for situations where content length varies across languages but shouldn't necessarily fill all available space. Use Flexible strategically alongside Expanded to create layouts that adapt gracefully to different content lengths.