← Back to Blog

Flutter ExpansionTile Localization: Collapsible Sections and FAQ Panels

flutterexpansiontilefaqlocalizationcollapsibleaccordion

Flutter ExpansionTile Localization: Collapsible Sections and FAQ Panels

ExpansionTiles are essential for organizing content in collapsible sections throughout Flutter applications. From FAQ pages to settings panels, properly localizing expansion content ensures users can navigate and understand your hierarchical UI. This guide covers all aspects of ExpansionTile localization.

Understanding ExpansionTile Components

ExpansionTiles have several localizable elements:

  • Title: Main header text
  • Subtitle: Secondary description
  • Leading/Trailing icons: Semantic labels
  • Children content: Expanded content area
  • Accessibility labels: Screen reader descriptions

Basic ExpansionTile Localization

Let's start with simple localized expansion tiles:

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class LocalizedExpansionTile extends StatelessWidget {
  const LocalizedExpansionTile({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return ExpansionTile(
      leading: Icon(
        Icons.settings,
        semanticLabel: l10n.settingsIcon,
      ),
      title: Text(l10n.accountSettings),
      subtitle: Text(l10n.accountSettingsDescription),
      children: [
        ListTile(
          title: Text(l10n.changePassword),
          onTap: () {},
        ),
        ListTile(
          title: Text(l10n.updateEmail),
          onTap: () {},
        ),
        ListTile(
          title: Text(l10n.deleteAccount),
          textColor: Colors.red,
          onTap: () {},
        ),
      ],
    );
  }
}

ARB Translations for ExpansionTiles

Define comprehensive translations:

{
  "accountSettings": "Account Settings",
  "accountSettingsDescription": "Manage your account preferences",
  "settingsIcon": "Settings",
  "changePassword": "Change Password",
  "updateEmail": "Update Email Address",
  "deleteAccount": "Delete Account",

  "expandSection": "Expand {section}",
  "@expandSection": {
    "description": "Accessibility hint for expanding a section",
    "placeholders": {
      "section": {
        "type": "String",
        "example": "Account Settings"
      }
    }
  },
  "collapseSection": "Collapse {section}",
  "@collapseSection": {
    "placeholders": {
      "section": { "type": "String" }
    }
  },
  "sectionExpanded": "{section} expanded",
  "@sectionExpanded": {
    "placeholders": {
      "section": { "type": "String" }
    }
  },
  "sectionCollapsed": "{section} collapsed",
  "@sectionCollapsed": {
    "placeholders": {
      "section": { "type": "String" }
    }
  }
}

FAQ Page Implementation

Build a complete localized FAQ page:

class FAQPage extends StatelessWidget {
  const FAQPage({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Scaffold(
      appBar: AppBar(
        title: Text(l10n.faqTitle),
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          Text(
            l10n.faqSubtitle,
            style: Theme.of(context).textTheme.titleMedium,
          ),
          const SizedBox(height: 16),
          _FAQSection(
            category: l10n.faqCategoryGeneral,
            items: [
              _FAQItem(
                question: l10n.faqQuestion1,
                answer: l10n.faqAnswer1,
              ),
              _FAQItem(
                question: l10n.faqQuestion2,
                answer: l10n.faqAnswer2,
              ),
            ],
          ),
          _FAQSection(
            category: l10n.faqCategoryBilling,
            items: [
              _FAQItem(
                question: l10n.faqBillingQuestion1,
                answer: l10n.faqBillingAnswer1,
              ),
              _FAQItem(
                question: l10n.faqBillingQuestion2,
                answer: l10n.faqBillingAnswer2,
              ),
            ],
          ),
          _FAQSection(
            category: l10n.faqCategoryTechnical,
            items: [
              _FAQItem(
                question: l10n.faqTechQuestion1,
                answer: l10n.faqTechAnswer1,
              ),
            ],
          ),
        ],
      ),
    );
  }
}

class _FAQItem {
  final String question;
  final String answer;

  const _FAQItem({
    required this.question,
    required this.answer,
  });
}

class _FAQSection extends StatelessWidget {
  final String category;
  final List<_FAQItem> items;

  const _FAQSection({
    required this.category,
    required this.items,
  });

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Card(
      margin: const EdgeInsets.only(bottom: 16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Padding(
            padding: const EdgeInsets.all(16),
            child: Text(
              category,
              style: Theme.of(context).textTheme.titleLarge,
            ),
          ),
          ...items.map((item) => _LocalizedFAQTile(item: item)),
        ],
      ),
    );
  }
}

class _LocalizedFAQTile extends StatelessWidget {
  final _FAQItem item;

  const _LocalizedFAQTile({required this.item});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Semantics(
      label: l10n.faqQuestionLabel(item.question),
      hint: l10n.tapToExpand,
      child: ExpansionTile(
        title: Text(
          item.question,
          style: const TextStyle(fontWeight: FontWeight.w500),
        ),
        expandedCrossAxisAlignment: CrossAxisAlignment.start,
        childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
        children: [
          Text(item.answer),
        ],
      ),
    );
  }
}

Settings Panel with Nested Expansion

Handle nested expandable settings:

class LocalizedSettingsPanel extends StatelessWidget {
  const LocalizedSettingsPanel({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return ListView(
      children: [
        // Appearance settings with nested options
        ExpansionTile(
          leading: const Icon(Icons.palette),
          title: Text(l10n.appearanceSettings),
          subtitle: Text(l10n.appearanceDescription),
          children: [
            ExpansionTile(
              title: Text(l10n.themeOptions),
              children: [
                RadioListTile<String>(
                  title: Text(l10n.themeLight),
                  value: 'light',
                  groupValue: 'light',
                  onChanged: (_) {},
                ),
                RadioListTile<String>(
                  title: Text(l10n.themeDark),
                  value: 'dark',
                  groupValue: 'light',
                  onChanged: (_) {},
                ),
                RadioListTile<String>(
                  title: Text(l10n.themeSystem),
                  subtitle: Text(l10n.themeSystemDescription),
                  value: 'system',
                  groupValue: 'light',
                  onChanged: (_) {},
                ),
              ],
            ),
            ExpansionTile(
              title: Text(l10n.fontSettings),
              children: [
                ListTile(
                  title: Text(l10n.fontSize),
                  trailing: DropdownButton<String>(
                    value: 'medium',
                    items: [
                      DropdownMenuItem(
                        value: 'small',
                        child: Text(l10n.fontSmall),
                      ),
                      DropdownMenuItem(
                        value: 'medium',
                        child: Text(l10n.fontMedium),
                      ),
                      DropdownMenuItem(
                        value: 'large',
                        child: Text(l10n.fontLarge),
                      ),
                    ],
                    onChanged: (_) {},
                  ),
                ),
              ],
            ),
          ],
        ),

        // Notification settings
        ExpansionTile(
          leading: const Icon(Icons.notifications),
          title: Text(l10n.notificationSettings),
          subtitle: Text(l10n.notificationDescription),
          children: [
            SwitchListTile(
              title: Text(l10n.pushNotifications),
              subtitle: Text(l10n.pushNotificationsDescription),
              value: true,
              onChanged: (_) {},
            ),
            SwitchListTile(
              title: Text(l10n.emailNotifications),
              subtitle: Text(l10n.emailNotificationsDescription),
              value: false,
              onChanged: (_) {},
            ),
            SwitchListTile(
              title: Text(l10n.smsNotifications),
              value: false,
              onChanged: (_) {},
            ),
          ],
        ),

        // Privacy settings
        ExpansionTile(
          leading: const Icon(Icons.lock),
          title: Text(l10n.privacySettings),
          subtitle: Text(l10n.privacyDescription),
          children: [
            ListTile(
              title: Text(l10n.dataCollection),
              trailing: Switch(
                value: true,
                onChanged: (_) {},
              ),
            ),
            ListTile(
              title: Text(l10n.downloadData),
              trailing: const Icon(Icons.download),
              onTap: () {},
            ),
            ListTile(
              title: Text(l10n.deleteAllData),
              textColor: Colors.red,
              trailing: const Icon(Icons.delete_forever, color: Colors.red),
              onTap: () {},
            ),
          ],
        ),
      ],
    );
  }
}

ExpansionPanelList with Localization

For mutually exclusive expansion:

class LocalizedExpansionPanelList extends StatefulWidget {
  const LocalizedExpansionPanelList({super.key});

  @override
  State<LocalizedExpansionPanelList> createState() =>
      _LocalizedExpansionPanelListState();
}

class _LocalizedExpansionPanelListState
    extends State<LocalizedExpansionPanelList> {
  int _expandedIndex = -1;

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    final panels = [
      _PanelData(
        header: l10n.shippingInfo,
        icon: Icons.local_shipping,
        content: l10n.shippingInfoContent,
      ),
      _PanelData(
        header: l10n.paymentMethods,
        icon: Icons.payment,
        content: l10n.paymentMethodsContent,
      ),
      _PanelData(
        header: l10n.returnPolicy,
        icon: Icons.assignment_return,
        content: l10n.returnPolicyContent,
      ),
    ];

    return SingleChildScrollView(
      child: ExpansionPanelList(
        elevation: 1,
        expandedHeaderPadding: const EdgeInsets.all(0),
        expansionCallback: (index, isExpanded) {
          setState(() {
            _expandedIndex = isExpanded ? -1 : index;
          });
        },
        children: panels.asMap().entries.map((entry) {
          final index = entry.key;
          final panel = entry.value;
          final isExpanded = _expandedIndex == index;

          return ExpansionPanel(
            canTapOnHeader: true,
            isExpanded: isExpanded,
            headerBuilder: (context, isExpanded) {
              return Semantics(
                label: panel.header,
                hint: isExpanded
                    ? l10n.collapseSection(panel.header)
                    : l10n.expandSection(panel.header),
                child: ListTile(
                  leading: Icon(panel.icon),
                  title: Text(
                    panel.header,
                    style: TextStyle(
                      fontWeight: isExpanded
                          ? FontWeight.bold
                          : FontWeight.normal,
                    ),
                  ),
                ),
              );
            },
            body: Padding(
              padding: const EdgeInsets.all(16),
              child: Text(panel.content),
            ),
          );
        }).toList(),
      ),
    );
  }
}

class _PanelData {
  final String header;
  final IconData icon;
  final String content;

  const _PanelData({
    required this.header,
    required this.icon,
    required this.content,
  });
}

Animated Expansion with RTL Support

Handle RTL layout for expansion tiles:

class RTLAwareExpansionTile extends StatelessWidget {
  final String title;
  final String subtitle;
  final List<Widget> children;
  final IconData leadingIcon;

  const RTLAwareExpansionTile({
    super.key,
    required this.title,
    required this.subtitle,
    required this.children,
    required this.leadingIcon,
  });

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final isRTL = Directionality.of(context) == TextDirection.rtl;

    return ExpansionTile(
      leading: Icon(
        leadingIcon,
        semanticLabel: title,
      ),
      // Trailing icon automatically flips in RTL
      title: Text(title),
      subtitle: Text(subtitle),
      tilePadding: EdgeInsets.only(
        left: isRTL ? 8 : 16,
        right: isRTL ? 16 : 8,
      ),
      expandedCrossAxisAlignment: CrossAxisAlignment.start,
      expandedAlignment: Alignment.centerLeft,
      children: children,
    );
  }
}

Category Expansion with Count Badges

Show item counts in expansion headers:

class CategoryExpansionTile extends StatelessWidget {
  final String categoryName;
  final int itemCount;
  final List<Widget> items;

  const CategoryExpansionTile({
    super.key,
    required this.categoryName,
    required this.itemCount,
    required this.items,
  });

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return ExpansionTile(
      title: Row(
        children: [
          Expanded(child: Text(categoryName)),
          Container(
            padding: const EdgeInsets.symmetric(
              horizontal: 8,
              vertical: 2,
            ),
            decoration: BoxDecoration(
              color: Theme.of(context).primaryColor.withOpacity(0.1),
              borderRadius: BorderRadius.circular(12),
            ),
            child: Text(
              l10n.itemCount(itemCount),
              style: Theme.of(context).textTheme.bodySmall,
            ),
          ),
        ],
      ),
      subtitle: Text(
        l10n.categoryItemsDescription(itemCount),
      ),
      children: items,
    );
  }
}

Complete ARB File for Expansion Tiles

{
  "faqTitle": "Frequently Asked Questions",
  "faqSubtitle": "Find answers to common questions",
  "faqCategoryGeneral": "General",
  "faqCategoryBilling": "Billing & Payments",
  "faqCategoryTechnical": "Technical Support",

  "faqQuestion1": "How do I create an account?",
  "faqAnswer1": "To create an account, click the Sign Up button on the home page and follow the registration process.",
  "faqQuestion2": "Can I change my username?",
  "faqAnswer2": "Yes, you can change your username in the Account Settings section.",

  "faqBillingQuestion1": "What payment methods do you accept?",
  "faqBillingAnswer1": "We accept credit cards, debit cards, PayPal, and bank transfers.",
  "faqBillingQuestion2": "How do I cancel my subscription?",
  "faqBillingAnswer2": "You can cancel your subscription at any time from your Account Settings.",

  "faqTechQuestion1": "The app is running slowly. What can I do?",
  "faqTechAnswer1": "Try clearing the app cache, updating to the latest version, or restarting your device.",

  "faqQuestionLabel": "Question: {question}",
  "@faqQuestionLabel": {
    "placeholders": { "question": { "type": "String" } }
  },
  "tapToExpand": "Tap to see the answer",

  "appearanceSettings": "Appearance",
  "appearanceDescription": "Customize how the app looks",
  "themeOptions": "Theme",
  "themeLight": "Light",
  "themeDark": "Dark",
  "themeSystem": "System Default",
  "themeSystemDescription": "Follows your device settings",
  "fontSettings": "Font",
  "fontSize": "Size",
  "fontSmall": "Small",
  "fontMedium": "Medium",
  "fontLarge": "Large",

  "notificationSettings": "Notifications",
  "notificationDescription": "Manage how you receive updates",
  "pushNotifications": "Push Notifications",
  "pushNotificationsDescription": "Receive instant alerts on your device",
  "emailNotifications": "Email Notifications",
  "emailNotificationsDescription": "Get updates in your inbox",
  "smsNotifications": "SMS Notifications",

  "privacySettings": "Privacy & Data",
  "privacyDescription": "Control your data and privacy",
  "dataCollection": "Allow Analytics",
  "downloadData": "Download My Data",
  "deleteAllData": "Delete All My Data",

  "shippingInfo": "Shipping Information",
  "shippingInfoContent": "We offer free standard shipping on orders over $50. Express shipping is available for an additional fee.",
  "paymentMethods": "Payment Methods",
  "paymentMethodsContent": "We accept Visa, MasterCard, American Express, PayPal, and Apple Pay.",
  "returnPolicy": "Return Policy",
  "returnPolicyContent": "Items can be returned within 30 days of purchase for a full refund. Items must be in original condition.",

  "expandSection": "Tap to expand {section}",
  "@expandSection": {
    "placeholders": { "section": { "type": "String" } }
  },
  "collapseSection": "Tap to collapse {section}",
  "@collapseSection": {
    "placeholders": { "section": { "type": "String" } }
  },

  "itemCount": "{count} items",
  "@itemCount": {
    "placeholders": { "count": { "type": "int" } }
  },
  "categoryItemsDescription": "{count, plural, =0{No items} =1{1 item available} other{{count} items available}}",
  "@categoryItemsDescription": {
    "placeholders": { "count": { "type": "int" } }
  }
}

Testing ExpansionTile Localization

Test your localized expansion tiles:

void main() {
  group('ExpansionTile Localization Tests', () {
    testWidgets('displays localized title and subtitle', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          locale: const Locale('es'),
          home: const Scaffold(
            body: LocalizedSettingsPanel(),
          ),
        ),
      );

      expect(find.text('Apariencia'), findsOneWidget);
      expect(find.text('Notificaciones'), findsOneWidget);
    });

    testWidgets('expands to show localized content', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          locale: const Locale('en'),
          home: const Scaffold(
            body: SingleChildScrollView(
              child: LocalizedExpansionTile(),
            ),
          ),
        ),
      );

      // Tap to expand
      await tester.tap(find.text('Account Settings'));
      await tester.pumpAndSettle();

      // Verify expanded content
      expect(find.text('Change Password'), findsOneWidget);
      expect(find.text('Update Email Address'), findsOneWidget);
    });

    testWidgets('FAQ items are accessible', (tester) async {
      final semanticsHandle = tester.ensureSemantics();

      await tester.pumpWidget(
        MaterialApp(
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          home: const Scaffold(
            body: FAQPage(),
          ),
        ),
      );

      // Check semantics include proper labels
      final semantics = tester.getSemantics(
        find.text('How do I create an account?'),
      );
      expect(semantics.label, isNotEmpty);

      semanticsHandle.dispose();
    });
  });
}

Best Practices

  1. Use clear, concise headers that describe the collapsed content
  2. Provide semantic labels for accessibility
  3. Group related items in logical categories
  4. Consider initial expansion state based on user context
  5. Test expansion animations with different content lengths
  6. Handle RTL layouts for expansion icons and content alignment

Conclusion

ExpansionTile localization involves translating headers, descriptions, and content while maintaining proper accessibility. By following these patterns, you can create intuitive collapsible interfaces that work seamlessly across languages and cultures. Remember to test with screen readers and various locales to ensure an inclusive user experience.