← Back to Blog

Flutter Bottom Sheet Localization: Modal and Persistent Sheets

flutterbottomsheetmodallocalizationfiltersactions

Flutter Bottom Sheet Localization: Modal and Persistent Sheets

Bottom sheets are versatile UI components that slide up from the bottom of the screen. Proper localization ensures users worldwide can interact with sheet content effectively. This guide covers everything you need to know about localizing bottom sheets in Flutter.

Understanding Bottom Sheet Localization

Bottom sheet localization involves several key elements:

  1. Sheet titles - Clear headers describing the sheet purpose
  2. Action buttons - Confirm, cancel, and custom actions
  3. List items - Selectable options and menu items
  4. Form content - Input fields and labels
  5. Drag indicators - Accessibility for sheet interaction
  6. RTL support - Right-to-left content layout

Setting Up Bottom Sheet Localization

ARB File Structure

{
  "@@locale": "en",

  "bottomSheetDragHint": "Drag to resize",
  "@bottomSheetDragHint": {
    "description": "Accessibility hint for draggable bottom sheet"
  },

  "bottomSheetClose": "Close",
  "@bottomSheetClose": {
    "description": "Close bottom sheet button"
  },

  "bottomSheetDone": "Done",
  "@bottomSheetDone": {
    "description": "Done button text"
  },

  "bottomSheetApply": "Apply",
  "@bottomSheetApply": {
    "description": "Apply changes button"
  },

  "bottomSheetReset": "Reset",
  "@bottomSheetReset": {
    "description": "Reset to defaults button"
  },

  "shareSheetTitle": "Share",
  "@shareSheetTitle": {
    "description": "Share bottom sheet title"
  },

  "shareViaCopy": "Copy link",
  "@shareViaCopy": {
    "description": "Copy link option"
  },

  "shareViaEmail": "Email",
  "@shareViaEmail": {
    "description": "Share via email option"
  },

  "shareViaMessage": "Message",
  "@shareViaMessage": {
    "description": "Share via message option"
  },

  "shareViaMore": "More options",
  "@shareViaMore": {
    "description": "More sharing options"
  },

  "filterSheetTitle": "Filters",
  "@filterSheetTitle": {
    "description": "Filter bottom sheet title"
  },

  "filterByCategory": "Category",
  "@filterByCategory": {
    "description": "Filter by category label"
  },

  "filterByPrice": "Price Range",
  "@filterByPrice": {
    "description": "Filter by price label"
  },

  "filterByRating": "Rating",
  "@filterByRating": {
    "description": "Filter by rating label"
  },

  "filterApplied": "{count, plural, =0{No filters} =1{1 filter applied} other{{count} filters applied}}",
  "@filterApplied": {
    "description": "Applied filters count",
    "placeholders": {
      "count": {"type": "int"}
    }
  },

  "sortSheetTitle": "Sort by",
  "@sortSheetTitle": {
    "description": "Sort options sheet title"
  },

  "sortNewest": "Newest first",
  "@sortNewest": {
    "description": "Sort by newest"
  },

  "sortOldest": "Oldest first",
  "@sortOldest": {
    "description": "Sort by oldest"
  },

  "sortPriceLow": "Price: Low to High",
  "@sortPriceLow": {
    "description": "Sort by price ascending"
  },

  "sortPriceHigh": "Price: High to Low",
  "@sortPriceHigh": {
    "description": "Sort by price descending"
  },

  "sortPopular": "Most Popular",
  "@sortPopular": {
    "description": "Sort by popularity"
  },

  "sortRating": "Highest Rated",
  "@sortRating": {
    "description": "Sort by rating"
  },

  "actionSheetTitle": "Actions",
  "@actionSheetTitle": {
    "description": "Action sheet title"
  },

  "actionEdit": "Edit",
  "@actionEdit": {
    "description": "Edit action"
  },

  "actionDelete": "Delete",
  "@actionDelete": {
    "description": "Delete action"
  },

  "actionDuplicate": "Duplicate",
  "@actionDuplicate": {
    "description": "Duplicate action"
  },

  "actionArchive": "Archive",
  "@actionArchive": {
    "description": "Archive action"
  },

  "actionReport": "Report",
  "@actionReport": {
    "description": "Report action"
  }
}

Korean Translations

{
  "@@locale": "ko",

  "bottomSheetDragHint": "드래그하여 크기 조절",
  "bottomSheetClose": "닫기",
  "bottomSheetDone": "완료",
  "bottomSheetApply": "적용",
  "bottomSheetReset": "초기화",
  "shareSheetTitle": "공유",
  "shareViaCopy": "링크 복사",
  "shareViaEmail": "이메일",
  "shareViaMessage": "메시지",
  "shareViaMore": "더 보기",
  "filterSheetTitle": "필터",
  "filterByCategory": "카테고리",
  "filterByPrice": "가격대",
  "filterByRating": "평점",
  "filterApplied": "{count, plural, =0{필터 없음} =1{필터 1개 적용됨} other{필터 {count}개 적용됨}}",
  "sortSheetTitle": "정렬",
  "sortNewest": "최신순",
  "sortOldest": "오래된순",
  "sortPriceLow": "가격: 낮은순",
  "sortPriceHigh": "가격: 높은순",
  "sortPopular": "인기순",
  "sortRating": "평점순",
  "actionSheetTitle": "작업",
  "actionEdit": "편집",
  "actionDelete": "삭제",
  "actionDuplicate": "복제",
  "actionArchive": "보관",
  "actionReport": "신고"
}

Arabic Translations (RTL)

{
  "@@locale": "ar",

  "bottomSheetDragHint": "اسحب لتغيير الحجم",
  "bottomSheetClose": "إغلاق",
  "bottomSheetDone": "تم",
  "bottomSheetApply": "تطبيق",
  "bottomSheetReset": "إعادة تعيين",
  "shareSheetTitle": "مشاركة",
  "shareViaCopy": "نسخ الرابط",
  "shareViaEmail": "البريد الإلكتروني",
  "shareViaMessage": "رسالة",
  "shareViaMore": "المزيد من الخيارات",
  "filterSheetTitle": "الفلاتر",
  "filterByCategory": "الفئة",
  "filterByPrice": "نطاق السعر",
  "filterByRating": "التقييم",
  "filterApplied": "{count, plural, =0{لا فلاتر} =1{فلتر واحد مطبق} two{فلتران مطبقان} few{{count} فلاتر مطبقة} many{{count} فلتراً مطبقاً} other{{count} فلتر مطبق}}",
  "sortSheetTitle": "ترتيب حسب",
  "sortNewest": "الأحدث أولاً",
  "sortOldest": "الأقدم أولاً",
  "sortPriceLow": "السعر: من الأقل للأعلى",
  "sortPriceHigh": "السعر: من الأعلى للأقل",
  "sortPopular": "الأكثر شعبية",
  "sortRating": "الأعلى تقييماً",
  "actionSheetTitle": "الإجراءات",
  "actionEdit": "تعديل",
  "actionDelete": "حذف",
  "actionDuplicate": "تكرار",
  "actionArchive": "أرشفة",
  "actionReport": "إبلاغ"
}

Building Localized Bottom Sheets

Modal Bottom Sheet

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

class BottomSheetService {
  static Future<void> showShare(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return showModalBottomSheet(
      context: context,
      builder: (context) => SafeArea(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            _buildDragHandle(),
            Padding(
              padding: const EdgeInsets.all(16),
              child: Text(
                l10n.shareSheetTitle,
                style: Theme.of(context).textTheme.titleLarge,
              ),
            ),
            ListTile(
              leading: const Icon(Icons.copy),
              title: Text(l10n.shareViaCopy),
              onTap: () {
                Navigator.pop(context);
                // Copy link
              },
            ),
            ListTile(
              leading: const Icon(Icons.email),
              title: Text(l10n.shareViaEmail),
              onTap: () {
                Navigator.pop(context);
                // Share via email
              },
            ),
            ListTile(
              leading: const Icon(Icons.message),
              title: Text(l10n.shareViaMessage),
              onTap: () {
                Navigator.pop(context);
                // Share via message
              },
            ),
            ListTile(
              leading: const Icon(Icons.more_horiz),
              title: Text(l10n.shareViaMore),
              onTap: () {
                Navigator.pop(context);
                // More options
              },
            ),
            const SizedBox(height: 16),
          ],
        ),
      ),
    );
  }

  static Widget _buildDragHandle() {
    return Center(
      child: Container(
        margin: const EdgeInsets.only(top: 12),
        width: 40,
        height: 4,
        decoration: BoxDecoration(
          color: Colors.grey.shade300,
          borderRadius: BorderRadius.circular(2),
        ),
      ),
    );
  }
}

Sort Options Sheet

enum SortOption { newest, oldest, priceLow, priceHigh, popular, rating }

class SortBottomSheet extends StatelessWidget {
  final SortOption currentSort;
  final Function(SortOption) onSortChanged;

  const SortBottomSheet({
    super.key,
    required this.currentSort,
    required this.onSortChanged,
  });

  static Future<SortOption?> show(
    BuildContext context, {
    required SortOption currentSort,
  }) {
    return showModalBottomSheet<SortOption>(
      context: context,
      builder: (context) => SortBottomSheet(
        currentSort: currentSort,
        onSortChanged: (sort) => Navigator.pop(context, sort),
      ),
    );
  }

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

    return SafeArea(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _buildHeader(context, l10n),
          _buildSortOption(
            context,
            SortOption.newest,
            l10n.sortNewest,
            Icons.schedule,
          ),
          _buildSortOption(
            context,
            SortOption.oldest,
            l10n.sortOldest,
            Icons.history,
          ),
          _buildSortOption(
            context,
            SortOption.priceLow,
            l10n.sortPriceLow,
            Icons.arrow_upward,
          ),
          _buildSortOption(
            context,
            SortOption.priceHigh,
            l10n.sortPriceHigh,
            Icons.arrow_downward,
          ),
          _buildSortOption(
            context,
            SortOption.popular,
            l10n.sortPopular,
            Icons.trending_up,
          ),
          _buildSortOption(
            context,
            SortOption.rating,
            l10n.sortRating,
            Icons.star,
          ),
          const SizedBox(height: 16),
        ],
      ),
    );
  }

  Widget _buildHeader(BuildContext context, AppLocalizations l10n) {
    return Padding(
      padding: const EdgeInsets.all(16),
      child: Row(
        children: [
          Text(
            l10n.sortSheetTitle,
            style: Theme.of(context).textTheme.titleLarge,
          ),
          const Spacer(),
          IconButton(
            icon: const Icon(Icons.close),
            onPressed: () => Navigator.pop(context),
            tooltip: l10n.bottomSheetClose,
          ),
        ],
      ),
    );
  }

  Widget _buildSortOption(
    BuildContext context,
    SortOption option,
    String label,
    IconData icon,
  ) {
    final isSelected = currentSort == option;

    return ListTile(
      leading: Icon(icon),
      title: Text(label),
      trailing: isSelected
          ? Icon(Icons.check, color: Theme.of(context).colorScheme.primary)
          : null,
      selected: isSelected,
      onTap: () => onSortChanged(option),
    );
  }
}

Filter Bottom Sheet

class FilterBottomSheet extends StatefulWidget {
  final FilterState initialFilters;

  const FilterBottomSheet({
    super.key,
    required this.initialFilters,
  });

  static Future<FilterState?> show(
    BuildContext context, {
    required FilterState initialFilters,
  }) {
    return showModalBottomSheet<FilterState>(
      context: context,
      isScrollControlled: true,
      builder: (context) => DraggableScrollableSheet(
        initialChildSize: 0.7,
        minChildSize: 0.5,
        maxChildSize: 0.95,
        expand: false,
        builder: (context, scrollController) => FilterBottomSheet(
          initialFilters: initialFilters,
        ),
      ),
    );
  }

  @override
  State<FilterBottomSheet> createState() => _FilterBottomSheetState();
}

class _FilterBottomSheetState extends State<FilterBottomSheet> {
  late FilterState _filters;

  @override
  void initState() {
    super.initState();
    _filters = widget.initialFilters.copyWith();
  }

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

    return Column(
      children: [
        _buildHeader(l10n),
        Expanded(
          child: ListView(
            padding: const EdgeInsets.all(16),
            children: [
              _buildCategoryFilter(l10n),
              const SizedBox(height: 24),
              _buildPriceFilter(l10n),
              const SizedBox(height: 24),
              _buildRatingFilter(l10n),
            ],
          ),
        ),
        _buildFooter(l10n),
      ],
    );
  }

  Widget _buildHeader(AppLocalizations l10n) {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        border: Border(
          bottom: BorderSide(color: Colors.grey.shade200),
        ),
      ),
      child: Row(
        children: [
          Text(
            l10n.filterSheetTitle,
            style: Theme.of(context).textTheme.titleLarge,
          ),
          const Spacer(),
          TextButton(
            onPressed: _resetFilters,
            child: Text(l10n.bottomSheetReset),
          ),
          IconButton(
            icon: const Icon(Icons.close),
            onPressed: () => Navigator.pop(context),
            tooltip: l10n.bottomSheetClose,
          ),
        ],
      ),
    );
  }

  Widget _buildCategoryFilter(AppLocalizations l10n) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          l10n.filterByCategory,
          style: Theme.of(context).textTheme.titleMedium,
        ),
        const SizedBox(height: 8),
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: Category.values.map((category) {
            final isSelected = _filters.categories.contains(category);
            return FilterChip(
              label: Text(category.getLocalizedName(context)),
              selected: isSelected,
              onSelected: (selected) {
                setState(() {
                  if (selected) {
                    _filters.categories.add(category);
                  } else {
                    _filters.categories.remove(category);
                  }
                });
              },
            );
          }).toList(),
        ),
      ],
    );
  }

  Widget _buildPriceFilter(AppLocalizations l10n) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          l10n.filterByPrice,
          style: Theme.of(context).textTheme.titleMedium,
        ),
        const SizedBox(height: 8),
        RangeSlider(
          values: RangeValues(_filters.minPrice, _filters.maxPrice),
          min: 0,
          max: 1000,
          divisions: 20,
          labels: RangeLabels(
            '\$${_filters.minPrice.round()}',
            '\$${_filters.maxPrice.round()}',
          ),
          onChanged: (values) {
            setState(() {
              _filters.minPrice = values.start;
              _filters.maxPrice = values.end;
            });
          },
        ),
      ],
    );
  }

  Widget _buildRatingFilter(AppLocalizations l10n) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          l10n.filterByRating,
          style: Theme.of(context).textTheme.titleMedium,
        ),
        const SizedBox(height: 8),
        Row(
          children: List.generate(5, (index) {
            final rating = index + 1;
            return IconButton(
              icon: Icon(
                rating <= _filters.minRating ? Icons.star : Icons.star_border,
                color: Colors.amber,
              ),
              onPressed: () {
                setState(() {
                  _filters.minRating = rating;
                });
              },
            );
          }),
        ),
      ],
    );
  }

  Widget _buildFooter(AppLocalizations l10n) {
    final filterCount = _filters.activeFilterCount;

    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        border: Border(
          top: BorderSide(color: Colors.grey.shade200),
        ),
      ),
      child: Row(
        children: [
          Expanded(
            child: Text(
              l10n.filterApplied(filterCount),
              style: Theme.of(context).textTheme.bodyMedium,
            ),
          ),
          ElevatedButton(
            onPressed: () => Navigator.pop(context, _filters),
            child: Text(l10n.bottomSheetApply),
          ),
        ],
      ),
    );
  }

  void _resetFilters() {
    setState(() {
      _filters = FilterState.defaults();
    });
  }
}

class FilterState {
  Set<Category> categories;
  double minPrice;
  double maxPrice;
  int minRating;

  FilterState({
    required this.categories,
    required this.minPrice,
    required this.maxPrice,
    required this.minRating,
  });

  factory FilterState.defaults() {
    return FilterState(
      categories: {},
      minPrice: 0,
      maxPrice: 1000,
      minRating: 0,
    );
  }

  FilterState copyWith({
    Set<Category>? categories,
    double? minPrice,
    double? maxPrice,
    int? minRating,
  }) {
    return FilterState(
      categories: categories ?? Set.from(this.categories),
      minPrice: minPrice ?? this.minPrice,
      maxPrice: maxPrice ?? this.maxPrice,
      minRating: minRating ?? this.minRating,
    );
  }

  int get activeFilterCount {
    int count = 0;
    if (categories.isNotEmpty) count++;
    if (minPrice > 0 || maxPrice < 1000) count++;
    if (minRating > 0) count++;
    return count;
  }
}

enum Category {
  electronics,
  clothing,
  books,
  home;

  String getLocalizedName(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    switch (this) {
      case Category.electronics:
        return l10n.categoryElectronics;
      case Category.clothing:
        return l10n.categoryClothing;
      case Category.books:
        return l10n.categoryBooks;
      case Category.home:
        return l10n.categoryHome;
    }
  }
}

Action Sheet

class ActionBottomSheet extends StatelessWidget {
  final String? title;
  final List<ActionSheetItem> actions;

  const ActionBottomSheet({
    super.key,
    this.title,
    required this.actions,
  });

  static Future<T?> show<T>(
    BuildContext context, {
    String? title,
    required List<ActionSheetItem<T>> actions,
  }) {
    return showModalBottomSheet<T>(
      context: context,
      builder: (context) => ActionBottomSheet(
        title: title,
        actions: actions,
      ),
    );
  }

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

    return SafeArea(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          if (title != null)
            Padding(
              padding: const EdgeInsets.all(16),
              child: Text(
                title!,
                style: Theme.of(context).textTheme.titleMedium,
              ),
            ),
          ...actions.map((action) => ListTile(
                leading: Icon(
                  action.icon,
                  color: action.isDestructive
                      ? Theme.of(context).colorScheme.error
                      : null,
                ),
                title: Text(
                  action.label,
                  style: action.isDestructive
                      ? TextStyle(color: Theme.of(context).colorScheme.error)
                      : null,
                ),
                onTap: () {
                  Navigator.pop(context, action.value);
                },
              )),
          const SizedBox(height: 8),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16),
            child: SizedBox(
              width: double.infinity,
              child: OutlinedButton(
                onPressed: () => Navigator.pop(context),
                child: Text(l10n.bottomSheetClose),
              ),
            ),
          ),
          const SizedBox(height: 16),
        ],
      ),
    );
  }
}

class ActionSheetItem<T> {
  final String label;
  final IconData icon;
  final T value;
  final bool isDestructive;

  const ActionSheetItem({
    required this.label,
    required this.icon,
    required this.value,
    this.isDestructive = false,
  });
}

// Usage
Future<void> showItemActions(BuildContext context) async {
  final l10n = AppLocalizations.of(context)!;

  final result = await ActionBottomSheet.show<String>(
    context,
    title: l10n.actionSheetTitle,
    actions: [
      ActionSheetItem(
        label: l10n.actionEdit,
        icon: Icons.edit,
        value: 'edit',
      ),
      ActionSheetItem(
        label: l10n.actionDuplicate,
        icon: Icons.copy,
        value: 'duplicate',
      ),
      ActionSheetItem(
        label: l10n.actionArchive,
        icon: Icons.archive,
        value: 'archive',
      ),
      ActionSheetItem(
        label: l10n.actionDelete,
        icon: Icons.delete,
        value: 'delete',
        isDestructive: true,
      ),
    ],
  );

  // Handle result
}

Accessibility

Accessible Bottom Sheet

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

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

    return Semantics(
      label: l10n.shareSheetTitle,
      explicitChildNodes: true,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Semantics(
            hint: l10n.bottomSheetDragHint,
            child: Container(
              margin: const EdgeInsets.only(top: 12),
              width: 40,
              height: 4,
              decoration: BoxDecoration(
                color: Colors.grey.shade300,
                borderRadius: BorderRadius.circular(2),
              ),
            ),
          ),
          Semantics(
            header: true,
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Text(
                l10n.shareSheetTitle,
                style: Theme.of(context).textTheme.titleLarge,
              ),
            ),
          ),
          // Sheet content...
        ],
      ),
    );
  }
}

Testing Bottom Sheet Localization

void main() {
  group('Bottom Sheet Localization', () {
    testWidgets('displays localized share options', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          locale: const Locale('ko'),
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          home: Builder(
            builder: (context) => Scaffold(
              body: ElevatedButton(
                onPressed: () => BottomSheetService.showShare(context),
                child: const Text('Share'),
              ),
            ),
          ),
        ),
      );

      await tester.tap(find.text('Share'));
      await tester.pumpAndSettle();

      expect(find.text('공유'), findsOneWidget);
      expect(find.text('링크 복사'), findsOneWidget);
    });

    testWidgets('filter sheet shows localized labels', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          locale: const Locale('ar'),
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          home: const Directionality(
            textDirection: TextDirection.rtl,
            child: FilterBottomSheet(
              initialFilters: FilterState.defaults(),
            ),
          ),
        ),
      );

      expect(find.text('الفلاتر'), findsOneWidget);
    });
  });
}

Best Practices

  1. Use clear titles - Describe what the sheet is for
  2. Provide close options - Always let users dismiss the sheet
  3. Show applied state - Display filter/sort counts
  4. Support gestures - Allow drag to dismiss
  5. Test RTL layouts - Ensure content displays correctly
  6. Add accessibility hints - Describe drag interactions

Conclusion

Proper bottom sheet localization ensures users worldwide can interact with these versatile UI components effectively. By following these patterns, your Flutter bottom sheets will feel native in any language.

Additional Resources