Building a Custom Language Picker Widget in Flutter: Complete Guide
Every multilingual Flutter app needs a way for users to switch languages. This guide shows you how to build beautiful, accessible language picker widgets that work seamlessly with Flutter's localization system.
What You'll Build
By the end of this guide, you'll have:
- A dropdown language selector
- A bottom sheet language picker with flags
- A full-screen language selection page
- A compact icon-only selector for app bars
Prerequisites
Make sure you have Flutter localization set up. If not, check our Flutter ARB guide first.
Basic Dropdown Language Picker
Start with a simple dropdown selector:
import 'package:flutter/material.dart';
class LanguageDropdown extends StatelessWidget {
final Locale currentLocale;
final List<Locale> supportedLocales;
final ValueChanged<Locale> onLocaleChanged;
const LanguageDropdown({
super.key,
required this.currentLocale,
required this.supportedLocales,
required this.onLocaleChanged,
});
@override
Widget build(BuildContext context) {
return DropdownButton<Locale>(
value: currentLocale,
onChanged: (locale) {
if (locale != null) {
onLocaleChanged(locale);
}
},
items: supportedLocales.map((locale) {
return DropdownMenuItem<Locale>(
value: locale,
child: Text(_getLanguageName(locale)),
);
}).toList(),
);
}
String _getLanguageName(Locale locale) {
switch (locale.languageCode) {
case 'en':
return 'English';
case 'es':
return 'Español';
case 'fr':
return 'Français';
case 'de':
return 'Deutsch';
case 'ar':
return 'العربية';
case 'zh':
return '中文';
case 'ja':
return '日本語';
case 'ko':
return '한국어';
case 'pt':
return 'Português';
case 'ru':
return 'Русский';
default:
return locale.languageCode;
}
}
}
Language Model with Flags
Create a model to store language info:
class LanguageOption {
final Locale locale;
final String name;
final String nativeName;
final String flag;
const LanguageOption({
required this.locale,
required this.name,
required this.nativeName,
required this.flag,
});
static const List<LanguageOption> all = [
LanguageOption(
locale: Locale('en'),
name: 'English',
nativeName: 'English',
flag: '🇺🇸',
),
LanguageOption(
locale: Locale('es'),
name: 'Spanish',
nativeName: 'Español',
flag: '🇪🇸',
),
LanguageOption(
locale: Locale('fr'),
name: 'French',
nativeName: 'Français',
flag: '🇫🇷',
),
LanguageOption(
locale: Locale('de'),
name: 'German',
nativeName: 'Deutsch',
flag: '🇩🇪',
),
LanguageOption(
locale: Locale('ar'),
name: 'Arabic',
nativeName: 'العربية',
flag: '🇸🇦',
),
LanguageOption(
locale: Locale('zh'),
name: 'Chinese',
nativeName: '中文',
flag: '🇨🇳',
),
LanguageOption(
locale: Locale('ja'),
name: 'Japanese',
nativeName: '日本語',
flag: '🇯🇵',
),
LanguageOption(
locale: Locale('ko'),
name: 'Korean',
nativeName: '한국어',
flag: '🇰🇷',
),
LanguageOption(
locale: Locale('pt'),
name: 'Portuguese',
nativeName: 'Português',
flag: '🇧🇷',
),
LanguageOption(
locale: Locale('ru'),
name: 'Russian',
nativeName: 'Русский',
flag: '🇷🇺',
),
LanguageOption(
locale: Locale('it'),
name: 'Italian',
nativeName: 'Italiano',
flag: '🇮🇹',
),
LanguageOption(
locale: Locale('nl'),
name: 'Dutch',
nativeName: 'Nederlands',
flag: '🇳🇱',
),
LanguageOption(
locale: Locale('hi'),
name: 'Hindi',
nativeName: 'हिन्दी',
flag: '🇮🇳',
),
LanguageOption(
locale: Locale('tr'),
name: 'Turkish',
nativeName: 'Türkçe',
flag: '🇹🇷',
),
];
static LanguageOption? fromLocale(Locale locale) {
try {
return all.firstWhere(
(lang) => lang.locale.languageCode == locale.languageCode,
);
} catch (_) {
return null;
}
}
}
Bottom Sheet Language Picker
A beautiful bottom sheet picker with flags:
class LanguageBottomSheet extends StatelessWidget {
final Locale currentLocale;
final ValueChanged<Locale> onLocaleChanged;
const LanguageBottomSheet({
super.key,
required this.currentLocale,
required this.onLocaleChanged,
});
static Future<void> show(
BuildContext context, {
required Locale currentLocale,
required ValueChanged<Locale> onLocaleChanged,
}) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => LanguageBottomSheet(
currentLocale: currentLocale,
onLocaleChanged: onLocaleChanged,
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.9,
expand: false,
builder: (context, scrollController) {
return Column(
children: [
// Handle bar
Container(
margin: const EdgeInsets.only(top: 12, bottom: 8),
width: 40,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.onSurface.withOpacity(0.3),
borderRadius: BorderRadius.circular(2),
),
),
// Title
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Select Language',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const Divider(height: 1),
// Language list
Expanded(
child: ListView.builder(
controller: scrollController,
itemCount: LanguageOption.all.length,
itemBuilder: (context, index) {
final language = LanguageOption.all[index];
final isSelected =
language.locale.languageCode == currentLocale.languageCode;
return ListTile(
leading: Text(
language.flag,
style: const TextStyle(fontSize: 28),
),
title: Text(language.nativeName),
subtitle: Text(language.name),
trailing: isSelected
? Icon(
Icons.check_circle,
color: theme.colorScheme.primary,
)
: null,
selected: isSelected,
onTap: () {
onLocaleChanged(language.locale);
Navigator.pop(context);
},
);
},
),
),
],
);
},
);
}
}
// Usage:
IconButton(
icon: const Icon(Icons.language),
onPressed: () => LanguageBottomSheet.show(
context,
currentLocale: currentLocale,
onLocaleChanged: (locale) {
// Update app locale
},
),
)
Full-Screen Language Selection Page
For onboarding or settings:
class LanguageSelectionPage extends StatefulWidget {
final Locale initialLocale;
const LanguageSelectionPage({
super.key,
required this.initialLocale,
});
@override
State<LanguageSelectionPage> createState() => _LanguageSelectionPageState();
}
class _LanguageSelectionPageState extends State<LanguageSelectionPage> {
late Locale _selectedLocale;
String _searchQuery = '';
@override
void initState() {
super.initState();
_selectedLocale = widget.initialLocale;
}
List<LanguageOption> get _filteredLanguages {
if (_searchQuery.isEmpty) {
return LanguageOption.all;
}
final query = _searchQuery.toLowerCase();
return LanguageOption.all.where((lang) {
return lang.name.toLowerCase().contains(query) ||
lang.nativeName.toLowerCase().contains(query) ||
lang.locale.languageCode.contains(query);
}).toList();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Language'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, _selectedLocale),
child: const Text('Done'),
),
],
),
body: Column(
children: [
// Search bar
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
decoration: InputDecoration(
hintText: 'Search languages...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
),
onChanged: (value) {
setState(() => _searchQuery = value);
},
),
),
// Language grid
Expanded(
child: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 2.5,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: _filteredLanguages.length,
itemBuilder: (context, index) {
final language = _filteredLanguages[index];
final isSelected = language.locale.languageCode ==
_selectedLocale.languageCode;
return Material(
color: isSelected
? theme.colorScheme.primaryContainer
: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () {
setState(() => _selectedLocale = language.locale);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.outline.withOpacity(0.3),
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
Text(
language.flag,
style: const TextStyle(fontSize: 24),
),
const SizedBox(width: 8),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
language.nativeName,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
Text(
language.name,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface
.withOpacity(0.6),
),
overflow: TextOverflow.ellipsis,
),
],
),
),
if (isSelected)
Icon(
Icons.check,
color: theme.colorScheme.primary,
size: 20,
),
],
),
),
),
);
},
),
),
],
),
);
}
}
// Usage:
final selectedLocale = await Navigator.push<Locale>(
context,
MaterialPageRoute(
builder: (context) => LanguageSelectionPage(
initialLocale: currentLocale,
),
),
);
if (selectedLocale != null) {
// Update app locale
}
Compact App Bar Language Selector
A minimal flag-only button for app bars:
class CompactLanguageSelector extends StatelessWidget {
final Locale currentLocale;
final VoidCallback onTap;
const CompactLanguageSelector({
super.key,
required this.currentLocale,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final language = LanguageOption.fromLocale(currentLocale);
return Tooltip(
message: language?.name ?? 'Change language',
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
language?.flag ?? '🌐',
style: const TextStyle(fontSize: 20),
),
const SizedBox(width: 4),
Icon(
Icons.arrow_drop_down,
size: 20,
color: Theme.of(context).colorScheme.onSurface,
),
],
),
),
),
);
}
}
// Usage in AppBar:
AppBar(
title: const Text('My App'),
actions: [
CompactLanguageSelector(
currentLocale: currentLocale,
onTap: () => LanguageBottomSheet.show(
context,
currentLocale: currentLocale,
onLocaleChanged: onLocaleChanged,
),
),
const SizedBox(width: 8),
],
)
Popup Menu Language Selector
A dropdown menu that appears from any widget:
class LanguagePopupMenu extends StatelessWidget {
final Locale currentLocale;
final ValueChanged<Locale> onLocaleChanged;
final Widget? child;
const LanguagePopupMenu({
super.key,
required this.currentLocale,
required this.onLocaleChanged,
this.child,
});
@override
Widget build(BuildContext context) {
final currentLanguage = LanguageOption.fromLocale(currentLocale);
return PopupMenuButton<Locale>(
initialValue: currentLocale,
onSelected: onLocaleChanged,
offset: const Offset(0, 40),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
itemBuilder: (context) {
return LanguageOption.all.map((language) {
final isSelected =
language.locale.languageCode == currentLocale.languageCode;
return PopupMenuItem<Locale>(
value: language.locale,
child: Row(
children: [
Text(language.flag, style: const TextStyle(fontSize: 20)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
language.nativeName,
style: const TextStyle(fontWeight: FontWeight.w500),
),
Text(
language.name,
style: TextStyle(
fontSize: 12,
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.6),
),
),
],
),
),
if (isSelected)
Icon(
Icons.check,
color: Theme.of(context).colorScheme.primary,
size: 18,
),
],
),
);
}).toList();
},
child: child ??
Chip(
avatar: Text(currentLanguage?.flag ?? '🌐'),
label: Text(currentLanguage?.nativeName ?? 'Language'),
),
);
}
}
// Usage:
LanguagePopupMenu(
currentLocale: currentLocale,
onLocaleChanged: (locale) {
// Update app locale
},
)
Integrating with State Management
With Provider
class LocaleProvider extends ChangeNotifier {
Locale _locale = const Locale('en');
Locale get locale => _locale;
void setLocale(Locale locale) {
_locale = locale;
notifyListeners();
_saveLocale(locale);
}
Future<void> loadSavedLocale() async {
final prefs = await SharedPreferences.getInstance();
final languageCode = prefs.getString('language_code');
if (languageCode != null) {
_locale = Locale(languageCode);
notifyListeners();
}
}
Future<void> _saveLocale(Locale locale) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('language_code', locale.languageCode);
}
}
// In your widget:
Consumer<LocaleProvider>(
builder: (context, provider, _) {
return LanguagePopupMenu(
currentLocale: provider.locale,
onLocaleChanged: provider.setLocale,
);
},
)
With Riverpod
final localeProvider = StateNotifierProvider<LocaleNotifier, Locale>((ref) {
return LocaleNotifier();
});
class LocaleNotifier extends StateNotifier<Locale> {
LocaleNotifier() : super(const Locale('en')) {
_loadSavedLocale();
}
Future<void> _loadSavedLocale() async {
final prefs = await SharedPreferences.getInstance();
final languageCode = prefs.getString('language_code');
if (languageCode != null) {
state = Locale(languageCode);
}
}
Future<void> setLocale(Locale locale) async {
state = locale;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('language_code', locale.languageCode);
}
}
// In your widget:
Consumer(
builder: (context, ref, _) {
final locale = ref.watch(localeProvider);
return LanguagePopupMenu(
currentLocale: locale,
onLocaleChanged: (newLocale) {
ref.read(localeProvider.notifier).setLocale(newLocale);
},
);
},
)
Accessibility Considerations
Make your language picker accessible:
class AccessibleLanguageSelector extends StatelessWidget {
final Locale currentLocale;
final ValueChanged<Locale> onLocaleChanged;
const AccessibleLanguageSelector({
super.key,
required this.currentLocale,
required this.onLocaleChanged,
});
@override
Widget build(BuildContext context) {
final language = LanguageOption.fromLocale(currentLocale);
return Semantics(
label: 'Current language: ${language?.name ?? "Unknown"}. '
'Double tap to change language.',
button: true,
child: InkWell(
onTap: () => _showLanguageDialog(context),
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ExcludeSemantics(
child: Text(
language?.flag ?? '🌐',
style: const TextStyle(fontSize: 24),
),
),
const SizedBox(width: 8),
Text(language?.nativeName ?? 'Language'),
const Icon(Icons.arrow_drop_down),
],
),
),
),
);
}
void _showLanguageDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Language'),
content: SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: LanguageOption.all.length,
itemBuilder: (context, index) {
final lang = LanguageOption.all[index];
final isSelected =
lang.locale.languageCode == currentLocale.languageCode;
return Semantics(
selected: isSelected,
child: ListTile(
leading: ExcludeSemantics(
child: Text(lang.flag, style: const TextStyle(fontSize: 24)),
),
title: Text(lang.nativeName),
subtitle: Text(lang.name),
trailing: isSelected ? const Icon(Icons.check) : null,
onTap: () {
onLocaleChanged(lang.locale);
Navigator.pop(context);
},
),
);
},
),
),
),
);
}
}
Testing Your Language Picker
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('shows current language', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: LanguagePopupMenu(
currentLocale: const Locale('es'),
onLocaleChanged: (_) {},
),
),
),
);
expect(find.text('Español'), findsOneWidget);
});
testWidgets('calls onLocaleChanged when language selected', (tester) async {
Locale? selectedLocale;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: LanguagePopupMenu(
currentLocale: const Locale('en'),
onLocaleChanged: (locale) => selectedLocale = locale,
),
),
),
);
// Open popup
await tester.tap(find.byType(LanguagePopupMenu));
await tester.pumpAndSettle();
// Select Spanish
await tester.tap(find.text('Español'));
await tester.pumpAndSettle();
expect(selectedLocale, const Locale('es'));
});
}
Conclusion
A well-designed language picker improves user experience and accessibility. Choose the right pattern for your app:
- Dropdown: Simple apps, limited space
- Bottom Sheet: Mobile-first apps
- Full Page: Onboarding, many languages
- Popup Menu: Settings screens
- Compact Selector: App bars, toolbars
Remember to persist the user's choice and make your picker accessible to all users.
Need to manage translations for all those languages? FlutterLocalisation makes it easy to handle ARB files for any number of languages with team collaboration and AI-powered translations.