← Back to Blog

Flutter Offstage Localization: Hidden Widgets for Multilingual Performance

flutteroffstageperformancestatelocalizationtabs

Flutter Offstage Localization: Hidden Widgets for Multilingual Performance

Offstage is a Flutter widget that hides its child from view while keeping it in the widget tree. In multilingual applications, Offstage enables performance-optimized content switching and preloading of localized content without visual disruption.

Understanding Offstage in Localization Context

Offstage removes a widget from the visual layout while maintaining its state and position in the widget tree. For multilingual apps, this enables:

  • Preloading translated content before display
  • Instant language switching without rebuild delays
  • Background state maintenance for multiple language views
  • Performance-optimized conditional rendering

Why Offstage Matters for Multilingual Apps

Offstage provides:

  • State preservation: Maintain widget state while hidden
  • Instant switching: Show preloaded content immediately
  • Performance optimization: Skip painting and compositing for hidden widgets
  • Memory efficiency: More efficient than maintaining separate widget instances

Basic Offstage Implementation

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

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

  @override
  State<LocalizedOffstageExample> createState() =>
      _LocalizedOffstageExampleState();
}

class _LocalizedOffstageExampleState extends State<LocalizedOffstageExample> {
  bool _showDetails = false;

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

    return Column(
      children: [
        ListTile(
          title: Text(l10n.itemTitle),
          subtitle: Text(l10n.itemSubtitle),
          trailing: IconButton(
            icon: Icon(_showDetails ? Icons.expand_less : Icons.expand_more),
            onPressed: () {
              setState(() => _showDetails = !_showDetails);
            },
          ),
        ),
        Offstage(
          offstage: !_showDetails,
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Text(l10n.itemDetails),
          ),
        ),
      ],
    );
  }
}

Tab Content Preloading

Preload All Tab Content

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

  @override
  State<LocalizedTabView> createState() => _LocalizedTabViewState();
}

class _LocalizedTabViewState extends State<LocalizedTabView> {
  int _currentTab = 0;

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

    return Column(
      children: [
        Row(
          children: [
            Expanded(
              child: TextButton(
                onPressed: () => setState(() => _currentTab = 0),
                style: TextButton.styleFrom(
                  backgroundColor: _currentTab == 0
                      ? Theme.of(context).colorScheme.primaryContainer
                      : null,
                ),
                child: Text(l10n.tabOverview),
              ),
            ),
            Expanded(
              child: TextButton(
                onPressed: () => setState(() => _currentTab = 1),
                style: TextButton.styleFrom(
                  backgroundColor: _currentTab == 1
                      ? Theme.of(context).colorScheme.primaryContainer
                      : null,
                ),
                child: Text(l10n.tabDetails),
              ),
            ),
            Expanded(
              child: TextButton(
                onPressed: () => setState(() => _currentTab = 2),
                style: TextButton.styleFrom(
                  backgroundColor: _currentTab == 2
                      ? Theme.of(context).colorScheme.primaryContainer
                      : null,
                ),
                child: Text(l10n.tabReviews),
              ),
            ),
          ],
        ),
        Expanded(
          child: Stack(
            children: [
              Offstage(
                offstage: _currentTab != 0,
                child: OverviewTab(),
              ),
              Offstage(
                offstage: _currentTab != 1,
                child: DetailsTab(),
              ),
              Offstage(
                offstage: _currentTab != 2,
                child: ReviewsTab(),
              ),
            ],
          ),
        ),
      ],
    );
  }
}

class OverviewTab extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    return Center(child: Text(l10n.overviewContent));
  }
}

class DetailsTab extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    return Center(child: Text(l10n.detailsContent));
  }
}

class ReviewsTab extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    return Center(child: Text(l10n.reviewsContent));
  }
}

Maintain Form State Across Tabs

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

  @override
  State<LocalizedMultiStepForm> createState() => _LocalizedMultiStepFormState();
}

class _LocalizedMultiStepFormState extends State<LocalizedMultiStepForm> {
  int _currentStep = 0;
  final _nameController = TextEditingController();
  final _emailController = TextEditingController();
  final _addressController = TextEditingController();

  @override
  void dispose() {
    _nameController.dispose();
    _emailController.dispose();
    _addressController.dispose();
    super.dispose();
  }

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

    return Column(
      children: [
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            _buildStepIndicator(0, l10n.stepPersonal),
            _buildStepIndicator(1, l10n.stepContact),
            _buildStepIndicator(2, l10n.stepAddress),
          ],
        ),
        const SizedBox(height: 24),
        Expanded(
          child: Stack(
            children: [
              Offstage(
                offstage: _currentStep != 0,
                child: _buildPersonalStep(l10n),
              ),
              Offstage(
                offstage: _currentStep != 1,
                child: _buildContactStep(l10n),
              ),
              Offstage(
                offstage: _currentStep != 2,
                child: _buildAddressStep(l10n),
              ),
            ],
          ),
        ),
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            if (_currentStep > 0)
              TextButton(
                onPressed: () => setState(() => _currentStep--),
                child: Text(l10n.previousButton),
              )
            else
              const SizedBox.shrink(),
            FilledButton(
              onPressed: () {
                if (_currentStep < 2) {
                  setState(() => _currentStep++);
                } else {
                  // Submit form
                }
              },
              child: Text(
                _currentStep < 2 ? l10n.nextButton : l10n.submitButton,
              ),
            ),
          ],
        ),
      ],
    );
  }

  Widget _buildStepIndicator(int step, String label) {
    final isActive = _currentStep >= step;
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 8),
      child: Column(
        children: [
          CircleAvatar(
            radius: 16,
            backgroundColor: isActive
                ? Theme.of(context).colorScheme.primary
                : Theme.of(context).colorScheme.surfaceVariant,
            child: Text(
              '${step + 1}',
              style: TextStyle(
                color: isActive
                    ? Theme.of(context).colorScheme.onPrimary
                    : Theme.of(context).colorScheme.onSurfaceVariant,
              ),
            ),
          ),
          const SizedBox(height: 4),
          Text(
            label,
            style: Theme.of(context).textTheme.bodySmall,
          ),
        ],
      ),
    );
  }

  Widget _buildPersonalStep(AppLocalizations l10n) {
    return Padding(
      padding: const EdgeInsets.all(16),
      child: TextField(
        controller: _nameController,
        decoration: InputDecoration(
          labelText: l10n.nameLabel,
          border: const OutlineInputBorder(),
        ),
      ),
    );
  }

  Widget _buildContactStep(AppLocalizations l10n) {
    return Padding(
      padding: const EdgeInsets.all(16),
      child: TextField(
        controller: _emailController,
        decoration: InputDecoration(
          labelText: l10n.emailLabel,
          border: const OutlineInputBorder(),
        ),
      ),
    );
  }

  Widget _buildAddressStep(AppLocalizations l10n) {
    return Padding(
      padding: const EdgeInsets.all(16),
      child: TextField(
        controller: _addressController,
        decoration: InputDecoration(
          labelText: l10n.addressLabel,
          border: const OutlineInputBorder(),
        ),
        maxLines: 3,
      ),
    );
  }
}

Language Preview

Preview Content in Different Languages

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

  @override
  State<LanguagePreview> createState() => _LanguagePreviewState();
}

class _LanguagePreviewState extends State<LanguagePreview> {
  String _previewLocale = 'en';

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

    return Column(
      children: [
        Text(
          l10n.languagePreviewTitle,
          style: Theme.of(context).textTheme.titleMedium,
        ),
        const SizedBox(height: 16),
        SegmentedButton<String>(
          segments: const [
            ButtonSegment(value: 'en', label: Text('EN')),
            ButtonSegment(value: 'de', label: Text('DE')),
            ButtonSegment(value: 'ar', label: Text('AR')),
          ],
          selected: {_previewLocale},
          onSelectionChanged: (selection) {
            setState(() => _previewLocale = selection.first);
          },
        ),
        const SizedBox(height: 24),
        Expanded(
          child: Stack(
            children: [
              Offstage(
                offstage: _previewLocale != 'en',
                child: _buildPreviewCard(
                  'Welcome to our app!',
                  'Discover amazing features.',
                  TextDirection.ltr,
                ),
              ),
              Offstage(
                offstage: _previewLocale != 'de',
                child: _buildPreviewCard(
                  'Willkommen in unserer App!',
                  'Entdecken Sie erstaunliche Funktionen.',
                  TextDirection.ltr,
                ),
              ),
              Offstage(
                offstage: _previewLocale != 'ar',
                child: _buildPreviewCard(
                  'مرحبًا بك في تطبيقنا!',
                  'اكتشف ميزات مذهلة.',
                  TextDirection.rtl,
                ),
              ),
            ],
          ),
        ),
      ],
    );
  }

  Widget _buildPreviewCard(
    String title,
    String subtitle,
    TextDirection direction,
  ) {
    return Directionality(
      textDirection: direction,
      child: Card(
        child: Padding(
          padding: const EdgeInsets.all(24),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                title,
                style: Theme.of(context).textTheme.headlineSmall,
              ),
              const SizedBox(height: 8),
              Text(
                subtitle,
                style: Theme.of(context).textTheme.bodyLarge,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Conditional Feature Display

Feature Flag with Offstage

class FeatureFlaggedContent extends StatelessWidget {
  final String featureKey;
  final Widget child;
  final Map<String, bool> featureFlags;

  const FeatureFlaggedContent({
    super.key,
    required this.featureKey,
    required this.child,
    required this.featureFlags,
  });

  @override
  Widget build(BuildContext context) {
    final isEnabled = featureFlags[featureKey] ?? false;

    return Offstage(
      offstage: !isEnabled,
      child: child,
    );
  }
}

class LocalizedFeatureScreen extends StatelessWidget {
  final Map<String, bool> featureFlags = {
    'premium_content': true,
    'beta_features': false,
    'experimental_ui': false,
  };

  LocalizedFeatureScreen({super.key});

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

    return ListView(
      children: [
        ListTile(
          title: Text(l10n.basicFeature),
          leading: const Icon(Icons.star_border),
        ),
        FeatureFlaggedContent(
          featureKey: 'premium_content',
          featureFlags: featureFlags,
          child: ListTile(
            title: Text(l10n.premiumFeature),
            leading: const Icon(Icons.star),
          ),
        ),
        FeatureFlaggedContent(
          featureKey: 'beta_features',
          featureFlags: featureFlags,
          child: ListTile(
            title: Text(l10n.betaFeature),
            leading: const Icon(Icons.science),
          ),
        ),
      ],
    );
  }
}

Search Results Pattern

Show/Hide Search Results

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

  @override
  State<LocalizedSearchWithOffstage> createState() =>
      _LocalizedSearchWithOffstageState();
}

class _LocalizedSearchWithOffstageState
    extends State<LocalizedSearchWithOffstage> {
  final _searchController = TextEditingController();
  bool _hasSearchResults = false;
  List<String> _results = [];

  void _performSearch(String query) {
    setState(() {
      if (query.isEmpty) {
        _hasSearchResults = false;
        _results = [];
      } else {
        _hasSearchResults = true;
        _results = ['Result 1', 'Result 2', 'Result 3'];
      }
    });
  }

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

    return Column(
      children: [
        TextField(
          controller: _searchController,
          decoration: InputDecoration(
            hintText: l10n.searchHint,
            prefixIcon: const Icon(Icons.search),
            border: const OutlineInputBorder(),
          ),
          onChanged: _performSearch,
        ),
        const SizedBox(height: 16),
        Expanded(
          child: Stack(
            children: [
              Offstage(
                offstage: _hasSearchResults,
                child: Center(
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      const Icon(Icons.search, size: 64),
                      const SizedBox(height: 16),
                      Text(l10n.searchPrompt),
                    ],
                  ),
                ),
              ),
              Offstage(
                offstage: !_hasSearchResults,
                child: ListView.builder(
                  itemCount: _results.length,
                  itemBuilder: (context, index) {
                    return ListTile(
                      title: Text(_results[index]),
                    );
                  },
                ),
              ),
            ],
          ),
        ),
      ],
    );
  }
}

ARB File Structure

English (app_en.arb)

{
  "@@locale": "en",

  "itemTitle": "Product Item",
  "itemSubtitle": "Tap to see details",
  "itemDetails": "This is the detailed description of the product with all specifications and features.",

  "tabOverview": "Overview",
  "tabDetails": "Details",
  "tabReviews": "Reviews",
  "overviewContent": "Product overview content goes here.",
  "detailsContent": "Detailed specifications and information.",
  "reviewsContent": "Customer reviews and ratings.",

  "stepPersonal": "Personal",
  "stepContact": "Contact",
  "stepAddress": "Address",
  "nameLabel": "Full Name",
  "emailLabel": "Email Address",
  "addressLabel": "Street Address",
  "previousButton": "Previous",
  "nextButton": "Next",
  "submitButton": "Submit",

  "languagePreviewTitle": "Preview in Different Languages",

  "basicFeature": "Basic Feature",
  "premiumFeature": "Premium Feature",
  "betaFeature": "Beta Feature",

  "searchHint": "Search...",
  "searchPrompt": "Enter a search term to find results"
}

German (app_de.arb)

{
  "@@locale": "de",

  "itemTitle": "Produktartikel",
  "itemSubtitle": "Tippen für Details",
  "itemDetails": "Dies ist die detaillierte Beschreibung des Produkts mit allen Spezifikationen und Funktionen.",

  "tabOverview": "Übersicht",
  "tabDetails": "Details",
  "tabReviews": "Bewertungen",
  "overviewContent": "Produktübersicht hier.",
  "detailsContent": "Detaillierte Spezifikationen und Informationen.",
  "reviewsContent": "Kundenbewertungen und Ratings.",

  "stepPersonal": "Persönlich",
  "stepContact": "Kontakt",
  "stepAddress": "Adresse",
  "nameLabel": "Vollständiger Name",
  "emailLabel": "E-Mail-Adresse",
  "addressLabel": "Straße",
  "previousButton": "Zurück",
  "nextButton": "Weiter",
  "submitButton": "Absenden",

  "languagePreviewTitle": "Vorschau in verschiedenen Sprachen",

  "basicFeature": "Grundfunktion",
  "premiumFeature": "Premium-Funktion",
  "betaFeature": "Beta-Funktion",

  "searchHint": "Suchen...",
  "searchPrompt": "Suchbegriff eingeben"
}

Arabic (app_ar.arb)

{
  "@@locale": "ar",

  "itemTitle": "عنصر المنتج",
  "itemSubtitle": "انقر لرؤية التفاصيل",
  "itemDetails": "هذا هو الوصف التفصيلي للمنتج مع جميع المواصفات والميزات.",

  "tabOverview": "نظرة عامة",
  "tabDetails": "التفاصيل",
  "tabReviews": "المراجعات",
  "overviewContent": "محتوى نظرة عامة على المنتج.",
  "detailsContent": "المواصفات والمعلومات التفصيلية.",
  "reviewsContent": "مراجعات وتقييمات العملاء.",

  "stepPersonal": "شخصي",
  "stepContact": "اتصال",
  "stepAddress": "العنوان",
  "nameLabel": "الاسم الكامل",
  "emailLabel": "البريد الإلكتروني",
  "addressLabel": "عنوان الشارع",
  "previousButton": "السابق",
  "nextButton": "التالي",
  "submitButton": "إرسال",

  "languagePreviewTitle": "معاينة بلغات مختلفة",

  "basicFeature": "ميزة أساسية",
  "premiumFeature": "ميزة مميزة",
  "betaFeature": "ميزة تجريبية",

  "searchHint": "بحث...",
  "searchPrompt": "أدخل مصطلح البحث للعثور على النتائج"
}

Best Practices Summary

Do's

  1. Use Offstage for state preservation when hiding widgets temporarily
  2. Preload tab content for instant switching
  3. Combine with Stack for layered offstage widgets
  4. Use for form state preservation across multi-step flows
  5. Test memory usage when keeping multiple widgets offstage

Don'ts

  1. Don't use Offstage for simple visibility - use Visibility instead
  2. Don't keep too many heavy widgets offstage - impacts memory
  3. Don't forget offstage widgets still receive lifecycle events
  4. Don't use for access control - widgets are still in the tree

Conclusion

Offstage is an efficient widget for managing content visibility while preserving state in multilingual Flutter applications. By keeping localized content ready in the widget tree, you enable instant language switching and smooth transitions between views. Use Offstage strategically for tabs, multi-step forms, and preloaded content to create responsive, state-preserving multilingual experiences.

Further Reading