Flutter Expanded Localization: Flexible Layouts for Multilingual Apps
Expanded is a Flutter widget that expands a child to fill available space in a Row, Column, or Flex. In multilingual applications, Expanded helps create adaptive layouts that distribute space intelligently regardless of content length variations.
Understanding Expanded in Localization Context
Expanded forces its child to fill the remaining available space along the main axis of a Flex parent. For multilingual apps, this creates powerful capabilities:
- Content areas adapt to remaining space after fixed elements
- Variable-length text doesn't affect sibling element positions
- RTL layouts work seamlessly with proper flex distribution
- Different language lengths are accommodated naturally
Why Expanded Matters for Multilingual Apps
Flexible expansion ensures:
- Adaptive layouts: Content areas adjust automatically
- Consistent positioning: Fixed elements stay in place
- Text accommodation: Long translations have room to grow
- RTL compatibility: Layouts work in both directions
Basic Expanded Implementation
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedExpandedExample extends StatelessWidget {
const LocalizedExpandedExample({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Row(
children: [
const Icon(Icons.info_outline),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.infoMessage,
style: Theme.of(context).textTheme.bodyMedium,
),
),
TextButton(
onPressed: () {},
child: Text(l10n.learnMore),
),
],
);
}
}
List Item Layouts
Localized List Tile
class LocalizedListItem extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final Widget? trailing;
final VoidCallback? onTap;
const LocalizedListItem({
super.key,
required this.icon,
required this.title,
required this.subtitle,
this.trailing,
this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsetsDirectional.only(
start: 16,
end: 16,
top: 12,
bottom: 12,
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (trailing != null) ...[
const SizedBox(width: 8),
trailing!,
],
],
),
),
);
}
}
// Usage
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ListView(
children: [
LocalizedListItem(
icon: Icons.notifications,
title: l10n.settingsNotifications,
subtitle: l10n.settingsNotificationsDesc,
trailing: Switch(value: true, onChanged: (_) {}),
),
LocalizedListItem(
icon: Icons.language,
title: l10n.settingsLanguage,
subtitle: l10n.settingsLanguageDesc,
trailing: const Icon(Icons.chevron_right),
onTap: () {},
),
LocalizedListItem(
icon: Icons.dark_mode,
title: l10n.settingsTheme,
subtitle: l10n.settingsThemeDesc,
trailing: const Icon(Icons.chevron_right),
onTap: () {},
),
],
);
}
}
Message Bubble Layout
class LocalizedMessageBubble extends StatelessWidget {
final String message;
final String timestamp;
final bool isMe;
final String? avatarUrl;
const LocalizedMessageBubble({
super.key,
required this.message,
required this.timestamp,
required this.isMe,
this.avatarUrl,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (!isMe && avatarUrl != null) ...[
CircleAvatar(
radius: 16,
backgroundImage: NetworkImage(avatarUrl!),
),
const SizedBox(width: 8),
],
Flexible(
child: Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.7,
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: isMe
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isMe ? 16 : 4),
bottomRight: Radius.circular(isMe ? 4 : 16),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
message,
style: TextStyle(
color: isMe
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
timestamp,
style: TextStyle(
fontSize: 11,
color: isMe
? Theme.of(context).colorScheme.onPrimary.withOpacity(0.7)
: Theme.of(context).colorScheme.outline,
),
),
],
),
),
),
],
),
);
}
}
Form Layouts with Expanded
Search Bar with Button
class LocalizedSearchBar extends StatelessWidget {
final TextEditingController controller;
final VoidCallback onSearch;
const LocalizedSearchBar({
super.key,
required this.controller,
required this.onSearch,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: l10n.searchHint,
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
),
),
),
const SizedBox(width: 12),
FilledButton(
onPressed: onSearch,
style: FilledButton.styleFrom(
minimumSize: const Size(0, 56),
),
child: Text(l10n.searchButton),
),
],
),
);
}
}
Input with Units
class LocalizedUnitInput extends StatelessWidget {
final String label;
final String unit;
final TextEditingController controller;
const LocalizedUnitInput({
super.key,
required this.label,
required this.unit,
required this.controller,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsetsDirectional.only(start: 4, bottom: 8),
child: Text(
label,
style: Theme.of(context).textTheme.labelLarge,
),
),
Row(
children: [
Expanded(
child: TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
border: OutlineInputBorder(),
),
),
),
Container(
width: 80,
height: 56,
alignment: Alignment.center,
margin: const EdgeInsetsDirectional.only(start: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(
unit,
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
],
);
}
}
// Usage
class MeasurementForm extends StatelessWidget {
const MeasurementForm({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
LocalizedUnitInput(
label: l10n.heightLabel,
unit: l10n.unitCm,
controller: TextEditingController(),
),
const SizedBox(height: 16),
LocalizedUnitInput(
label: l10n.weightLabel,
unit: l10n.unitKg,
controller: TextEditingController(),
),
],
),
);
}
}
Weighted Flex Distribution
Multi-Column Expanded Layout
class LocalizedWeightedLayout extends StatelessWidget {
const LocalizedWeightedLayout({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Row(
children: [
// 1/3 of available width
Expanded(
flex: 1,
child: _InfoCard(
title: l10n.columnSmall,
color: Colors.blue.shade100,
),
),
const SizedBox(width: 16),
// 2/3 of available width
Expanded(
flex: 2,
child: _InfoCard(
title: l10n.columnLarge,
color: Colors.green.shade100,
),
),
],
);
}
}
class _InfoCard extends StatelessWidget {
final String title;
final Color color;
const _InfoCard({
required this.title,
required this.color,
});
@override
Widget build(BuildContext context) {
return Container(
height: 100,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
),
);
}
}
Adaptive Language-Based Weights
class AdaptiveExpandedLayout extends StatelessWidget {
final Widget primary;
final Widget secondary;
const AdaptiveExpandedLayout({
super.key,
required this.primary,
required this.secondary,
});
@override
Widget build(BuildContext context) {
final locale = Localizations.localeOf(context);
final weights = _getFlexWeights(locale);
return Row(
children: [
Expanded(
flex: weights.primary,
child: primary,
),
const SizedBox(width: 16),
Expanded(
flex: weights.secondary,
child: secondary,
),
],
);
}
({int primary, int secondary}) _getFlexWeights(Locale locale) {
switch (locale.languageCode) {
case 'de': // German needs more space
case 'ru': // Russian
return (primary: 3, secondary: 2);
case 'ja': // Japanese is compact
case 'zh': // Chinese
return (primary: 2, secondary: 2);
default:
return (primary: 2, secondary: 1);
}
}
}
Navigation and Header Layouts
Header with Title and Actions
class LocalizedHeader extends StatelessWidget {
final String title;
final String? subtitle;
final List<Widget> actions;
const LocalizedHeader({
super.key,
required this.title,
this.subtitle,
this.actions = const [],
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
...actions.map((action) => Padding(
padding: const EdgeInsetsDirectional.only(start: 8),
child: action,
)),
],
),
);
}
}
// Usage
class PageWithHeader extends StatelessWidget {
const PageWithHeader({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
LocalizedHeader(
title: l10n.dashboardTitle,
subtitle: l10n.dashboardSubtitle,
actions: [
IconButton(
icon: const Icon(Icons.filter_list),
onPressed: () {},
),
IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () {},
),
],
),
// Rest of the page content
],
);
}
}
Price and Quantity Displays
Product Price Row
class LocalizedPriceRow extends StatelessWidget {
final String productName;
final String quantity;
final String price;
const LocalizedPriceRow({
super.key,
required this.productName,
required this.quantity,
required this.price,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Expanded(
flex: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
productName,
style: Theme.of(context).textTheme.titleSmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
SizedBox(
width: 60,
child: Text(
quantity,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
),
Expanded(
flex: 1,
child: Text(
price,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.end,
),
),
],
),
);
}
}
// Cart summary
class CartSummary extends StatelessWidget {
const CartSummary({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
LocalizedPriceRow(
productName: l10n.productName1,
quantity: 'x2',
price: '\$29.99',
),
LocalizedPriceRow(
productName: l10n.productName2,
quantity: 'x1',
price: '\$49.99',
),
const Divider(),
Row(
children: [
Expanded(
child: Text(
l10n.totalLabel,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Text(
'\$109.97',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
],
);
}
}
ARB File Structure
English (app_en.arb)
{
"@@locale": "en",
"infoMessage": "This feature requires an active subscription to use.",
"@infoMessage": {
"description": "Info message about subscription"
},
"learnMore": "Learn more",
"searchHint": "Search...",
"searchButton": "Search",
"settingsNotifications": "Notifications",
"settingsNotificationsDesc": "Manage push notifications and alerts",
"settingsLanguage": "Language",
"settingsLanguageDesc": "Choose your preferred language",
"settingsTheme": "Theme",
"settingsThemeDesc": "Switch between light and dark mode",
"heightLabel": "Height",
"weightLabel": "Weight",
"unitCm": "cm",
"unitKg": "kg",
"columnSmall": "Sidebar",
"columnLarge": "Main Content",
"dashboardTitle": "Dashboard",
"dashboardSubtitle": "Welcome back, John",
"productName1": "Premium Wireless Headphones",
"productName2": "Bluetooth Speaker Pro",
"totalLabel": "Total"
}
German (app_de.arb)
{
"@@locale": "de",
"infoMessage": "Diese Funktion erfordert ein aktives Abonnement zur Nutzung.",
"learnMore": "Mehr erfahren",
"searchHint": "Suchen...",
"searchButton": "Suchen",
"settingsNotifications": "Benachrichtigungen",
"settingsNotificationsDesc": "Push-Benachrichtigungen und Warnungen verwalten",
"settingsLanguage": "Sprache",
"settingsLanguageDesc": "Wählen Sie Ihre bevorzugte Sprache",
"settingsTheme": "Thema",
"settingsThemeDesc": "Zwischen hellem und dunklem Modus wechseln",
"heightLabel": "Größe",
"weightLabel": "Gewicht",
"unitCm": "cm",
"unitKg": "kg",
"columnSmall": "Seitenleiste",
"columnLarge": "Hauptinhalt",
"dashboardTitle": "Dashboard",
"dashboardSubtitle": "Willkommen zurück, John",
"productName1": "Premium Kabellose Kopfhörer",
"productName2": "Bluetooth-Lautsprecher Pro",
"totalLabel": "Gesamt"
}
Arabic (app_ar.arb)
{
"@@locale": "ar",
"infoMessage": "تتطلب هذه الميزة اشتراكاً نشطاً للاستخدام.",
"learnMore": "اعرف المزيد",
"searchHint": "بحث...",
"searchButton": "بحث",
"settingsNotifications": "الإشعارات",
"settingsNotificationsDesc": "إدارة الإشعارات والتنبيهات",
"settingsLanguage": "اللغة",
"settingsLanguageDesc": "اختر لغتك المفضلة",
"settingsTheme": "المظهر",
"settingsThemeDesc": "التبديل بين الوضع الفاتح والداكن",
"heightLabel": "الطول",
"weightLabel": "الوزن",
"unitCm": "سم",
"unitKg": "كغ",
"columnSmall": "الشريط الجانبي",
"columnLarge": "المحتوى الرئيسي",
"dashboardTitle": "لوحة التحكم",
"dashboardSubtitle": "مرحباً بعودتك، جون",
"productName1": "سماعات لاسلكية فاخرة",
"productName2": "مكبر صوت بلوتوث برو",
"totalLabel": "الإجمالي"
}
Best Practices Summary
Do's
- Use Expanded for flexible content areas that should fill available space
- Combine with flex values for weighted distribution
- Apply text overflow handling within Expanded widgets
- Test with RTL languages to verify proper direction handling
- Use CrossAxisAlignment for proper vertical alignment
Don'ts
- Don't nest Expanded widgets without proper Flex parents
- Don't use Expanded in scrollable containers (use Flexible or remove)
- Don't assume fixed content widths for expanded children
- Don't forget to test with verbose languages
Accessibility Considerations
class AccessibleExpandedRow extends StatelessWidget {
final String label;
final String value;
const AccessibleExpandedRow({
super.key,
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
return Semantics(
label: '$label: $value',
child: Row(
children: [
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium,
),
),
Text(
value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
);
}
}
Conclusion
Expanded is fundamental for creating adaptive layouts in multilingual Flutter applications. By using Expanded strategically with proper flex values and overflow handling, you can build interfaces that gracefully accommodate varying content lengths while maintaining visual consistency. Always test your layouts with your longest translations and in RTL mode to ensure they work correctly across all supported languages.