← Back to Blog

Flutter Expanded Localization: Flexible Layouts for Multilingual Apps

flutterexpandedflexlayoutlocalizationresponsive

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

  1. Use Expanded for flexible content areas that should fill available space
  2. Combine with flex values for weighted distribution
  3. Apply text overflow handling within Expanded widgets
  4. Test with RTL languages to verify proper direction handling
  5. Use CrossAxisAlignment for proper vertical alignment

Don'ts

  1. Don't nest Expanded widgets without proper Flex parents
  2. Don't use Expanded in scrollable containers (use Flexible or remove)
  3. Don't assume fixed content widths for expanded children
  4. 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.

Further Reading