Flutter Visibility Localization: Conditional Display for Multilingual Apps
Visibility is a Flutter widget that controls whether its child is visible, while optionally maintaining its layout space. In multilingual applications, Visibility enables conditional content display that adapts to different languages, user preferences, and localization requirements.
Understanding Visibility in Localization Context
Visibility shows or hides widgets while offering control over whether hidden widgets maintain their space in the layout. For multilingual apps, this enables:
- Language-specific content that appears only for certain locales
- Feature toggles that respect regional availability
- Progressive disclosure patterns for different markets
- Conditional UI elements based on translation availability
Why Visibility Matters for Multilingual Apps
Visibility provides:
- Locale-based display: Show content only for specific languages
- Layout preservation: Maintain spacing when hiding elements
- Performance control: Option to skip building hidden widgets
- Graceful degradation: Hide untranslated content seamlessly
Basic Visibility Implementation
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedVisibilityExample extends StatelessWidget {
const LocalizedVisibilityExample({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.welcomeTitle,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 16),
Visibility(
visible: locale.languageCode == 'en',
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(l10n.englishOnlyFeature),
),
),
),
],
);
}
}
Locale-Based Visibility
Show Content for Specific Languages
class LocaleVisibility extends StatelessWidget {
final Widget child;
final List<String> visibleLocales;
final bool maintainSize;
const LocaleVisibility({
super.key,
required this.child,
required this.visibleLocales,
this.maintainSize = false,
});
@override
Widget build(BuildContext context) {
final currentLocale = Localizations.localeOf(context);
final isVisible = visibleLocales.contains(currentLocale.languageCode);
return Visibility(
visible: isVisible,
maintainSize: maintainSize,
maintainAnimation: maintainSize,
maintainState: maintainSize,
child: child,
);
}
}
class RegionalPromotion extends StatelessWidget {
const RegionalPromotion({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
LocaleVisibility(
visibleLocales: const ['en', 'de', 'fr'],
child: Card(
color: Theme.of(context).colorScheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Text(
l10n.europePromoTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(l10n.europePromoDescription),
],
),
),
),
),
LocaleVisibility(
visibleLocales: const ['ja', 'zh', 'ko'],
child: Card(
color: Theme.of(context).colorScheme.secondaryContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Text(
l10n.asiaPromoTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(l10n.asiaPromoDescription),
],
),
),
),
),
],
);
}
}
RTL-Specific Content
class DirectionalVisibility extends StatelessWidget {
final Widget child;
final bool showInRtl;
final bool showInLtr;
const DirectionalVisibility({
super.key,
required this.child,
this.showInRtl = true,
this.showInLtr = true,
});
@override
Widget build(BuildContext context) {
final isRtl = Directionality.of(context) == TextDirection.rtl;
final isVisible = isRtl ? showInRtl : showInLtr;
return Visibility(
visible: isVisible,
child: child,
);
}
}
class DirectionalHelpText extends StatelessWidget {
const DirectionalHelpText({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
DirectionalVisibility(
showInRtl: true,
showInLtr: false,
child: Text(
l10n.rtlLayoutHint,
style: Theme.of(context).textTheme.bodySmall,
),
),
DirectionalVisibility(
showInRtl: false,
showInLtr: true,
child: Text(
l10n.ltrLayoutHint,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
);
}
}
Feature Availability
Regional Feature Toggle
class RegionalFeature extends StatelessWidget {
final Widget child;
final List<String> availableRegions;
final Widget? unavailableWidget;
const RegionalFeature({
super.key,
required this.child,
required this.availableRegions,
this.unavailableWidget,
});
@override
Widget build(BuildContext context) {
final locale = Localizations.localeOf(context);
final isAvailable = availableRegions.contains(locale.countryCode) ||
availableRegions.contains(locale.languageCode);
if (isAvailable) {
return child;
}
return unavailableWidget ?? const SizedBox.shrink();
}
}
class PaymentOptions extends StatelessWidget {
const PaymentOptions({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.paymentMethodsTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
RegionalFeature(
availableRegions: const ['US', 'CA', 'GB', 'AU'],
child: ListTile(
leading: const Icon(Icons.credit_card),
title: Text(l10n.creditCardPayment),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
),
),
RegionalFeature(
availableRegions: const ['DE', 'AT', 'NL', 'BE'],
child: ListTile(
leading: const Icon(Icons.account_balance),
title: Text(l10n.sofortPayment),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
),
),
RegionalFeature(
availableRegions: const ['CN'],
child: ListTile(
leading: const Icon(Icons.qr_code),
title: Text(l10n.alipayPayment),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
),
),
],
);
}
}
Maintain Layout Space
Visibility with Size Preservation
class LocalizedFormWithOptionalFields extends StatelessWidget {
final bool showOptionalFields;
const LocalizedFormWithOptionalFields({
super.key,
this.showOptionalFields = true,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
decoration: InputDecoration(
labelText: l10n.requiredFieldName,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
decoration: InputDecoration(
labelText: l10n.requiredFieldEmail,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
Visibility(
visible: showOptionalFields,
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: TextField(
decoration: InputDecoration(
labelText: l10n.optionalFieldPhone,
border: const OutlineInputBorder(),
),
),
),
const SizedBox(height: 24),
FilledButton(
onPressed: () {},
child: Text(l10n.submitButton),
),
],
);
}
}
Animated Visibility
Fade Visibility Transition
class AnimatedLocalizedVisibility extends StatefulWidget {
final bool isVisible;
final Widget child;
const AnimatedLocalizedVisibility({
super.key,
required this.isVisible,
required this.child,
});
@override
State<AnimatedLocalizedVisibility> createState() =>
_AnimatedLocalizedVisibilityState();
}
class _AnimatedLocalizedVisibilityState
extends State<AnimatedLocalizedVisibility> {
@override
Widget build(BuildContext context) {
return AnimatedOpacity(
opacity: widget.isVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: Visibility(
visible: widget.isVisible,
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: widget.child,
),
);
}
}
class LocalizedExpandableSection extends StatefulWidget {
const LocalizedExpandableSection({super.key});
@override
State<LocalizedExpandableSection> createState() =>
_LocalizedExpandableSectionState();
}
class _LocalizedExpandableSectionState
extends State<LocalizedExpandableSection> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
ListTile(
title: Text(l10n.advancedOptionsTitle),
trailing: Icon(
_isExpanded ? Icons.expand_less : Icons.expand_more,
),
onTap: () {
setState(() => _isExpanded = !_isExpanded);
},
),
AnimatedLocalizedVisibility(
isVisible: _isExpanded,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
SwitchListTile(
title: Text(l10n.optionOne),
value: true,
onChanged: (value) {},
),
SwitchListTile(
title: Text(l10n.optionTwo),
value: false,
onChanged: (value) {},
),
],
),
),
),
],
);
}
}
Conditional Content Loading
Show Loading or Content
class LocalizedConditionalContent extends StatelessWidget {
final bool isLoading;
final bool hasError;
final Widget content;
const LocalizedConditionalContent({
super.key,
required this.isLoading,
required this.hasError,
required this.content,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Stack(
children: [
Visibility(
visible: !isLoading && !hasError,
child: content,
),
Visibility(
visible: isLoading,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(l10n.loadingMessage),
],
),
),
),
Visibility(
visible: hasError,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48),
const SizedBox(height: 16),
Text(l10n.errorMessage),
const SizedBox(height: 16),
FilledButton(
onPressed: () {},
child: Text(l10n.retryButton),
),
],
),
),
),
],
);
}
}
ARB File Structure
English (app_en.arb)
{
"@@locale": "en",
"welcomeTitle": "Welcome",
"englishOnlyFeature": "This feature is available in English-speaking regions.",
"europePromoTitle": "European Special Offer",
"europePromoDescription": "Get 20% off on all items this week!",
"asiaPromoTitle": "Asia Pacific Promotion",
"asiaPromoDescription": "Free shipping on orders over $50!",
"rtlLayoutHint": "Content flows from right to left",
"ltrLayoutHint": "Content flows from left to right",
"paymentMethodsTitle": "Payment Methods",
"creditCardPayment": "Credit Card",
"sofortPayment": "Sofort Banking",
"alipayPayment": "Alipay",
"requiredFieldName": "Name *",
"requiredFieldEmail": "Email *",
"optionalFieldPhone": "Phone (optional)",
"submitButton": "Submit",
"advancedOptionsTitle": "Advanced Options",
"optionOne": "Enable notifications",
"optionTwo": "Dark mode",
"loadingMessage": "Loading...",
"errorMessage": "Something went wrong",
"retryButton": "Try Again"
}
German (app_de.arb)
{
"@@locale": "de",
"welcomeTitle": "Willkommen",
"englishOnlyFeature": "Diese Funktion ist in englischsprachigen Regionen verfügbar.",
"europePromoTitle": "Europäisches Sonderangebot",
"europePromoDescription": "Diese Woche 20% Rabatt auf alle Artikel!",
"asiaPromoTitle": "Asien-Pazifik-Aktion",
"asiaPromoDescription": "Kostenloser Versand ab 50€!",
"rtlLayoutHint": "Inhalt fließt von rechts nach links",
"ltrLayoutHint": "Inhalt fließt von links nach rechts",
"paymentMethodsTitle": "Zahlungsmethoden",
"creditCardPayment": "Kreditkarte",
"sofortPayment": "Sofort-Überweisung",
"alipayPayment": "Alipay",
"requiredFieldName": "Name *",
"requiredFieldEmail": "E-Mail *",
"optionalFieldPhone": "Telefon (optional)",
"submitButton": "Absenden",
"advancedOptionsTitle": "Erweiterte Optionen",
"optionOne": "Benachrichtigungen aktivieren",
"optionTwo": "Dunkelmodus",
"loadingMessage": "Wird geladen...",
"errorMessage": "Etwas ist schiefgelaufen",
"retryButton": "Erneut versuchen"
}
Arabic (app_ar.arb)
{
"@@locale": "ar",
"welcomeTitle": "مرحباً",
"englishOnlyFeature": "هذه الميزة متاحة في المناطق الناطقة بالإنجليزية.",
"europePromoTitle": "عرض أوروبي خاص",
"europePromoDescription": "احصل على خصم 20% على جميع المنتجات هذا الأسبوع!",
"asiaPromoTitle": "عرض آسيا والمحيط الهادئ",
"asiaPromoDescription": "شحن مجاني للطلبات فوق 50 دولار!",
"rtlLayoutHint": "المحتوى يتدفق من اليمين إلى اليسار",
"ltrLayoutHint": "المحتوى يتدفق من اليسار إلى اليمين",
"paymentMethodsTitle": "طرق الدفع",
"creditCardPayment": "بطاقة ائتمان",
"sofortPayment": "سوفورت المصرفية",
"alipayPayment": "علي باي",
"requiredFieldName": "الاسم *",
"requiredFieldEmail": "البريد الإلكتروني *",
"optionalFieldPhone": "الهاتف (اختياري)",
"submitButton": "إرسال",
"advancedOptionsTitle": "خيارات متقدمة",
"optionOne": "تفعيل الإشعارات",
"optionTwo": "الوضع الداكن",
"loadingMessage": "جاري التحميل...",
"errorMessage": "حدث خطأ ما",
"retryButton": "حاول مرة أخرى"
}
Best Practices Summary
Do's
- Use Visibility for conditional locale content instead of ternary operators
- Set maintainSize when layout stability matters during visibility changes
- Combine with AnimatedOpacity for smooth visibility transitions
- Test visibility logic across all supported locales
- Provide fallback content when features are unavailable in certain regions
Don'ts
- Don't use Visibility for performance-critical hiding - use Offstage instead
- Don't forget to handle edge cases where content might not exist
- Don't rely solely on visibility for access control - implement proper backend checks
- Don't hide critical navigation based on locale without alternatives
Conclusion
Visibility is a powerful widget for creating adaptive multilingual interfaces that respond to locale, region, and user preferences. By controlling what content appears for different languages and regions, you can create tailored experiences that feel native to each market. Use Visibility thoughtfully to enhance your localized app without compromising on layout consistency or user experience.