← Back to Blog

Flutter Visibility Localization: Conditional Display for Multilingual Apps

fluttervisibilityconditionallocalelocalizationux

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

  1. Use Visibility for conditional locale content instead of ternary operators
  2. Set maintainSize when layout stability matters during visibility changes
  3. Combine with AnimatedOpacity for smooth visibility transitions
  4. Test visibility logic across all supported locales
  5. Provide fallback content when features are unavailable in certain regions

Don'ts

  1. Don't use Visibility for performance-critical hiding - use Offstage instead
  2. Don't forget to handle edge cases where content might not exist
  3. Don't rely solely on visibility for access control - implement proper backend checks
  4. 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.

Further Reading