Flutter DataTable Localization: Headers, Pagination, and Sorting
DataTables are essential for displaying structured information in Flutter applications. From user lists to product inventories, properly localizing table content ensures your data is accessible to global audiences. This guide covers everything from column headers to pagination controls.
Understanding DataTable Components
A typical DataTable includes several localizable elements:
- Column headers: Label text and sort indicators
- Cell content: Data formatted for locale (dates, numbers, currencies)
- Pagination: Page info, rows per page, navigation labels
- Empty states: No data messages
- Selection labels: Selected count, select all text
- Sort tooltips: Ascending/descending indicators
Basic DataTable Localization
Let's start with a simple localized DataTable:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:intl/intl.dart';
class LocalizedDataTable extends StatelessWidget {
final List<User> users;
const LocalizedDataTable({super.key, required this.users});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context).toString();
return DataTable(
columns: [
DataColumn(
label: Text(l10n.columnName),
tooltip: l10n.sortByName,
),
DataColumn(
label: Text(l10n.columnEmail),
tooltip: l10n.sortByEmail,
),
DataColumn(
label: Text(l10n.columnJoinDate),
tooltip: l10n.sortByDate,
),
DataColumn(
label: Text(l10n.columnStatus),
tooltip: l10n.sortByStatus,
),
],
rows: users.map((user) {
final dateFormatter = DateFormat.yMMMd(locale);
return DataRow(
cells: [
DataCell(Text(user.name)),
DataCell(Text(user.email)),
DataCell(Text(dateFormatter.format(user.joinDate))),
DataCell(_buildStatusChip(context, user.status, l10n)),
],
);
}).toList(),
);
}
Widget _buildStatusChip(BuildContext context, UserStatus status, AppLocalizations l10n) {
final statusText = switch (status) {
UserStatus.active => l10n.statusActive,
UserStatus.inactive => l10n.statusInactive,
UserStatus.pending => l10n.statusPending,
};
final color = switch (status) {
UserStatus.active => Colors.green,
UserStatus.inactive => Colors.grey,
UserStatus.pending => Colors.orange,
};
return Chip(
label: Text(statusText),
backgroundColor: color.withOpacity(0.2),
labelStyle: TextStyle(color: color.shade700),
);
}
}
ARB Files for DataTable Localization
English (app_en.arb)
{
"@@locale": "en",
"columnName": "Name",
"@columnName": {
"description": "Header for name column"
},
"columnEmail": "Email",
"@columnEmail": {
"description": "Header for email column"
},
"columnJoinDate": "Join Date",
"@columnJoinDate": {
"description": "Header for join date column"
},
"columnStatus": "Status",
"@columnStatus": {
"description": "Header for status column"
},
"columnActions": "Actions",
"@columnActions": {
"description": "Header for actions column"
},
"columnAmount": "Amount",
"@columnAmount": {
"description": "Header for amount column"
},
"sortByName": "Sort by name",
"@sortByName": {
"description": "Tooltip for name column sort"
},
"sortByEmail": "Sort by email",
"@sortByEmail": {
"description": "Tooltip for email column sort"
},
"sortByDate": "Sort by date",
"@sortByDate": {
"description": "Tooltip for date column sort"
},
"sortByStatus": "Sort by status",
"@sortByStatus": {
"description": "Tooltip for status column sort"
},
"sortAscending": "Sorted ascending",
"@sortAscending": {
"description": "Accessibility label for ascending sort"
},
"sortDescending": "Sorted descending",
"@sortDescending": {
"description": "Accessibility label for descending sort"
},
"statusActive": "Active",
"@statusActive": {
"description": "Active status label"
},
"statusInactive": "Inactive",
"@statusInactive": {
"description": "Inactive status label"
},
"statusPending": "Pending",
"@statusPending": {
"description": "Pending status label"
},
"rowsPerPage": "Rows per page:",
"@rowsPerPage": {
"description": "Label for rows per page selector"
},
"pageInfo": "{start}-{end} of {total}",
"@pageInfo": {
"description": "Pagination info showing current range",
"placeholders": {
"start": {"type": "int"},
"end": {"type": "int"},
"total": {"type": "int"}
}
},
"firstPage": "First page",
"@firstPage": {
"description": "Tooltip for first page button"
},
"previousPage": "Previous page",
"@previousPage": {
"description": "Tooltip for previous page button"
},
"nextPage": "Next page",
"@nextPage": {
"description": "Tooltip for next page button"
},
"lastPage": "Last page",
"@lastPage": {
"description": "Tooltip for last page button"
},
"selectedItems": "{count, plural, =0{No items selected} =1{1 item selected} other{{count} items selected}}",
"@selectedItems": {
"description": "Selected items count",
"placeholders": {
"count": {"type": "int"}
}
},
"selectAll": "Select all",
"@selectAll": {
"description": "Tooltip for select all checkbox"
},
"noDataAvailable": "No data available",
"@noDataAvailable": {
"description": "Message when table is empty"
},
"loadingData": "Loading data...",
"@loadingData": {
"description": "Message while data is loading"
}
}
Spanish (app_es.arb)
{
"@@locale": "es",
"columnName": "Nombre",
"columnEmail": "Correo electronico",
"columnJoinDate": "Fecha de registro",
"columnStatus": "Estado",
"columnActions": "Acciones",
"columnAmount": "Cantidad",
"sortByName": "Ordenar por nombre",
"sortByEmail": "Ordenar por correo",
"sortByDate": "Ordenar por fecha",
"sortByStatus": "Ordenar por estado",
"sortAscending": "Ordenado ascendente",
"sortDescending": "Ordenado descendente",
"statusActive": "Activo",
"statusInactive": "Inactivo",
"statusPending": "Pendiente",
"rowsPerPage": "Filas por pagina:",
"pageInfo": "{start}-{end} de {total}",
"firstPage": "Primera pagina",
"previousPage": "Pagina anterior",
"nextPage": "Pagina siguiente",
"lastPage": "Ultima pagina",
"selectedItems": "{count, plural, =0{Ningun elemento seleccionado} =1{1 elemento seleccionado} other{{count} elementos seleccionados}}",
"selectAll": "Seleccionar todo",
"noDataAvailable": "No hay datos disponibles",
"loadingData": "Cargando datos..."
}
Arabic (app_ar.arb)
{
"@@locale": "ar",
"columnName": "الاسم",
"columnEmail": "البريد الإلكتروني",
"columnJoinDate": "تاريخ الانضمام",
"columnStatus": "الحالة",
"columnActions": "الإجراءات",
"columnAmount": "المبلغ",
"sortByName": "ترتيب حسب الاسم",
"sortByEmail": "ترتيب حسب البريد",
"sortByDate": "ترتيب حسب التاريخ",
"sortByStatus": "ترتيب حسب الحالة",
"sortAscending": "مرتب تصاعدياً",
"sortDescending": "مرتب تنازلياً",
"statusActive": "نشط",
"statusInactive": "غير نشط",
"statusPending": "قيد الانتظار",
"rowsPerPage": "عدد الصفوف:",
"pageInfo": "{start}-{end} من {total}",
"firstPage": "الصفحة الأولى",
"previousPage": "الصفحة السابقة",
"nextPage": "الصفحة التالية",
"lastPage": "الصفحة الأخيرة",
"selectedItems": "{count, plural, =0{لم يتم تحديد أي عنصر} =1{تم تحديد عنصر واحد} two{تم تحديد عنصران} few{تم تحديد {count} عناصر} many{تم تحديد {count} عنصراً} other{تم تحديد {count} عنصر}}",
"selectAll": "تحديد الكل",
"noDataAvailable": "لا توجد بيانات متاحة",
"loadingData": "جاري تحميل البيانات..."
}
PaginatedDataTable with Full Localization
class LocalizedPaginatedDataTable extends StatefulWidget {
final List<Product> products;
const LocalizedPaginatedDataTable({super.key, required this.products});
@override
State<LocalizedPaginatedDataTable> createState() =>
_LocalizedPaginatedDataTableState();
}
class _LocalizedPaginatedDataTableState
extends State<LocalizedPaginatedDataTable> {
int _sortColumnIndex = 0;
bool _sortAscending = true;
int _rowsPerPage = 10;
final Set<int> _selectedRows = {};
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context).toString();
final currencyFormat = NumberFormat.currency(
locale: locale,
symbol: _getCurrencySymbol(locale),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Selection header
if (_selectedRows.isNotEmpty)
Container(
padding: const EdgeInsets.all(16),
color: Theme.of(context).colorScheme.primaryContainer,
child: Row(
children: [
Text(
l10n.selectedItems(_selectedRows.length),
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
TextButton.icon(
onPressed: _deleteSelected,
icon: const Icon(Icons.delete),
label: Text(l10n.deleteSelected),
),
],
),
),
// Data table
Expanded(
child: SingleChildScrollView(
child: PaginatedDataTable(
header: Text(l10n.productsTableTitle),
columns: [
DataColumn(
label: Text(l10n.columnName),
tooltip: l10n.sortByName,
onSort: (index, ascending) => _sort(index, ascending),
),
DataColumn(
label: Text(l10n.columnCategory),
tooltip: l10n.sortByCategory,
),
DataColumn(
label: Text(l10n.columnPrice),
tooltip: l10n.sortByPrice,
numeric: true,
onSort: (index, ascending) => _sort(index, ascending),
),
DataColumn(
label: Text(l10n.columnStock),
tooltip: l10n.sortByStock,
numeric: true,
),
DataColumn(
label: Text(l10n.columnActions),
),
],
source: _ProductDataSource(
products: widget.products,
l10n: l10n,
currencyFormat: currencyFormat,
selectedRows: _selectedRows,
onSelectionChanged: (index, selected) {
setState(() {
if (selected) {
_selectedRows.add(index);
} else {
_selectedRows.remove(index);
}
});
},
),
sortColumnIndex: _sortColumnIndex,
sortAscending: _sortAscending,
rowsPerPage: _rowsPerPage,
availableRowsPerPage: const [5, 10, 25, 50],
onRowsPerPageChanged: (value) {
setState(() => _rowsPerPage = value ?? 10);
},
showCheckboxColumn: true,
showFirstLastButtons: true,
),
),
),
],
);
}
void _sort(int columnIndex, bool ascending) {
setState(() {
_sortColumnIndex = columnIndex;
_sortAscending = ascending;
});
}
void _deleteSelected() {
// Handle deletion
}
String _getCurrencySymbol(String locale) {
return switch (locale) {
'ar' => 'ر.س',
'es' => '€',
_ => '\$',
};
}
}
class _ProductDataSource extends DataTableSource {
final List<Product> products;
final AppLocalizations l10n;
final NumberFormat currencyFormat;
final Set<int> selectedRows;
final Function(int, bool) onSelectionChanged;
_ProductDataSource({
required this.products,
required this.l10n,
required this.currencyFormat,
required this.selectedRows,
required this.onSelectionChanged,
});
@override
DataRow? getRow(int index) {
if (index >= products.length) return null;
final product = products[index];
final isSelected = selectedRows.contains(index);
return DataRow(
selected: isSelected,
onSelectChanged: (selected) {
onSelectionChanged(index, selected ?? false);
},
cells: [
DataCell(Text(product.name)),
DataCell(Text(_getLocalizedCategory(product.category))),
DataCell(Text(currencyFormat.format(product.price))),
DataCell(_buildStockIndicator(product.stock)),
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit),
tooltip: l10n.editAction,
onPressed: () {},
),
IconButton(
icon: const Icon(Icons.delete),
tooltip: l10n.deleteAction,
onPressed: () {},
),
],
),
),
],
);
}
String _getLocalizedCategory(String category) {
return switch (category) {
'electronics' => l10n.categoryElectronics,
'clothing' => l10n.categoryClothing,
'food' => l10n.categoryFood,
_ => category,
};
}
Widget _buildStockIndicator(int stock) {
final color = stock > 10 ? Colors.green : (stock > 0 ? Colors.orange : Colors.red);
final text = stock > 0 ? '$stock' : l10n.outOfStock;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(text),
],
);
}
@override
bool get isRowCountApproximate => false;
@override
int get rowCount => products.length;
@override
int get selectedRowCount => selectedRows.length;
}
Locale-Aware Number and Currency Formatting
class LocalizedAmountCell extends StatelessWidget {
final double amount;
final String currencyCode;
const LocalizedAmountCell({
super.key,
required this.amount,
this.currencyCode = 'USD',
});
@override
Widget build(BuildContext context) {
final locale = Localizations.localeOf(context).toString();
final formatter = NumberFormat.currency(
locale: locale,
symbol: _getCurrencySymbol(currencyCode),
decimalDigits: 2,
);
return Text(
formatter.format(amount),
textAlign: TextAlign.end,
);
}
String _getCurrencySymbol(String code) {
return switch (code) {
'EUR' => '€',
'GBP' => '£',
'SAR' => 'ر.س',
'JPY' => '¥',
_ => '\$',
};
}
}
Empty State Handling
class DataTableWithEmptyState extends StatelessWidget {
final List<dynamic> data;
final bool isLoading;
final Widget Function(BuildContext) tableBuilder;
const DataTableWithEmptyState({
super.key,
required this.data,
required this.isLoading,
required this.tableBuilder,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (isLoading) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(l10n.loadingData),
],
),
);
}
if (data.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.table_chart_outlined,
size: 64,
color: Theme.of(context).colorScheme.outline,
),
const SizedBox(height: 16),
Text(
l10n.noDataAvailable,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
l10n.noDataDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
],
),
);
}
return tableBuilder(context);
}
}
RTL Support for DataTables
class RtlAwareDataTable extends StatelessWidget {
final List<DataColumn> columns;
final List<DataRow> rows;
const RtlAwareDataTable({
super.key,
required this.columns,
required this.rows,
});
@override
Widget build(BuildContext context) {
final isRtl = Directionality.of(context) == TextDirection.rtl;
return DataTable(
// Columns order should remain logical (not reversed)
columns: columns,
rows: rows,
// Checkbox column position adjusts automatically
columnSpacing: isRtl ? 24 : 56,
horizontalMargin: isRtl ? 12 : 24,
);
}
}
Custom Pagination Controls
class LocalizedPaginationControls extends StatelessWidget {
final int currentPage;
final int totalPages;
final int rowsPerPage;
final int totalRows;
final List<int> availableRowsPerPage;
final ValueChanged<int> onPageChanged;
final ValueChanged<int> onRowsPerPageChanged;
const LocalizedPaginationControls({
super.key,
required this.currentPage,
required this.totalPages,
required this.rowsPerPage,
required this.totalRows,
required this.availableRowsPerPage,
required this.onPageChanged,
required this.onRowsPerPageChanged,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final start = (currentPage * rowsPerPage) + 1;
final end = ((currentPage + 1) * rowsPerPage).clamp(0, totalRows);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Rows per page selector
Text(l10n.rowsPerPage),
const SizedBox(width: 8),
DropdownButton<int>(
value: rowsPerPage,
items: availableRowsPerPage
.map((count) => DropdownMenuItem(
value: count,
child: Text('$count'),
))
.toList(),
onChanged: (value) {
if (value != null) onRowsPerPageChanged(value);
},
),
const SizedBox(width: 32),
// Page info
Text(l10n.pageInfo(start, end, totalRows)),
const SizedBox(width: 16),
// Navigation buttons
IconButton(
icon: const Icon(Icons.first_page),
tooltip: l10n.firstPage,
onPressed: currentPage > 0 ? () => onPageChanged(0) : null,
),
IconButton(
icon: const Icon(Icons.chevron_left),
tooltip: l10n.previousPage,
onPressed:
currentPage > 0 ? () => onPageChanged(currentPage - 1) : null,
),
IconButton(
icon: const Icon(Icons.chevron_right),
tooltip: l10n.nextPage,
onPressed: currentPage < totalPages - 1
? () => onPageChanged(currentPage + 1)
: null,
),
IconButton(
icon: const Icon(Icons.last_page),
tooltip: l10n.lastPage,
onPressed: currentPage < totalPages - 1
? () => onPageChanged(totalPages - 1)
: null,
),
],
),
);
}
}
Testing DataTable Localization
void main() {
group('DataTable Localization Tests', () {
testWidgets('displays localized column headers', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('es'),
home: const Scaffold(
body: LocalizedDataTable(users: []),
),
),
);
expect(find.text('Nombre'), findsOneWidget);
expect(find.text('Correo electronico'), findsOneWidget);
});
testWidgets('formats dates according to locale', (tester) async {
final testDate = DateTime(2024, 3, 15);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('es'),
home: Scaffold(
body: LocalizedDataTable(
users: [
User(
name: 'Test',
email: 'test@test.com',
joinDate: testDate,
status: UserStatus.active,
),
],
),
),
),
);
// Spanish date format: 15 mar 2024
expect(find.textContaining('mar'), findsOneWidget);
});
testWidgets('shows localized empty state', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('ar'),
home: Scaffold(
body: DataTableWithEmptyState(
data: [],
isLoading: false,
tableBuilder: (_) => const SizedBox(),
),
),
),
);
expect(find.text('لا توجد بيانات متاحة'), findsOneWidget);
});
});
}
Best Practices Summary
- Use locale-aware formatters: Always use
NumberFormatandDateFormatwith the current locale - Localize all interactive elements: Column headers, tooltips, pagination controls
- Handle pluralization: Selection counts and pagination info need proper plural forms
- Support RTL layouts: Test with Arabic/Hebrew to ensure proper alignment
- Provide empty states: Localized messages when no data is available
- Test with real data: Ensure text doesn't overflow with longer translations
Conclusion
Localizing DataTables in Flutter requires attention to detail across multiple components - from column headers to pagination controls. By following the patterns in this guide, you can create data displays that feel native to users regardless of their language or locale preferences.
Remember that data formatting (dates, numbers, currencies) should always respect the user's locale, and interactive elements need proper tooltips and accessibility labels in all supported languages.