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
- Use Offstage for state preservation when hiding widgets temporarily
- Preload tab content for instant switching
- Combine with Stack for layered offstage widgets
- Use for form state preservation across multi-step flows
- Test memory usage when keeping multiple widgets offstage
Don'ts
- Don't use Offstage for simple visibility - use Visibility instead
- Don't keep too many heavy widgets offstage - impacts memory
- Don't forget offstage widgets still receive lifecycle events
- 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.