Flutter PaginatedDataTable Localization: Paginated Data Grids for Multilingual Apps
PaginatedDataTable is a Flutter widget that displays tabular data with pagination controls, sortable columns, and optional row selection. In multilingual applications, PaginatedDataTable is essential for presenting translated column headers and data cells, localizing pagination labels like "Rows per page" and page indicators, supporting RTL table layouts with reversed column order, and providing accessible sort announcements in the active language.
Understanding PaginatedDataTable in Localization Context
PaginatedDataTable renders a Material Design data table with built-in pagination, sorting, and selection. For multilingual apps, this enables:
- Translated column headers and data content in a paginated grid
- Localized pagination controls ("Rows per page", "1-10 of 50")
- RTL-aware column ordering and alignment
- Sortable columns with translated sort direction indicators
Why PaginatedDataTable Matters for Multilingual Apps
PaginatedDataTable provides:
- Built-in pagination: Translated page navigation labels and row-per-page dropdowns
- Sortable columns: Column headers with localized sort indicators
- Row selection: Checkbox selection with translated "X selected" labels
- Material localization integration: Pagination text automatically uses
MaterialLocalizations
Basic PaginatedDataTable Implementation
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedPaginatedTableExample extends StatefulWidget {
const LocalizedPaginatedTableExample({super.key});
@override
State<LocalizedPaginatedTableExample> createState() =>
_LocalizedPaginatedTableExampleState();
}
class _LocalizedPaginatedTableExampleState
extends State<LocalizedPaginatedTableExample> {
int _rowsPerPage = PaginatedDataTable.defaultRowsPerPage;
int _sortColumnIndex = 0;
bool _sortAscending = true;
late _ProductDataSource _dataSource;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final l10n = AppLocalizations.of(context)!;
_dataSource = _ProductDataSource(l10n);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.productCatalogTitle)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: PaginatedDataTable(
header: Text(l10n.productListHeader),
rowsPerPage: _rowsPerPage,
onRowsPerPageChanged: (value) {
setState(() => _rowsPerPage = value!);
},
sortColumnIndex: _sortColumnIndex,
sortAscending: _sortAscending,
columns: [
DataColumn(
label: Text(l10n.productNameHeader),
onSort: (index, ascending) {
setState(() {
_sortColumnIndex = index;
_sortAscending = ascending;
_dataSource.sort(index, ascending);
});
},
),
DataColumn(
label: Text(l10n.categoryHeader),
onSort: (index, ascending) {
setState(() {
_sortColumnIndex = index;
_sortAscending = ascending;
_dataSource.sort(index, ascending);
});
},
),
DataColumn(
label: Text(l10n.priceHeader),
numeric: true,
onSort: (index, ascending) {
setState(() {
_sortColumnIndex = index;
_sortAscending = ascending;
_dataSource.sort(index, ascending);
});
},
),
DataColumn(
label: Text(l10n.stockHeader),
numeric: true,
),
],
source: _dataSource,
),
),
);
}
}
class _ProductDataSource extends DataTableSource {
final AppLocalizations l10n;
late List<_Product> _products;
_ProductDataSource(this.l10n) {
_products = List.generate(50, (index) {
return _Product(
name: '${l10n.productLabel} ${index + 1}',
category: index % 3 == 0
? l10n.electronicsCategory
: index % 3 == 1
? l10n.clothingCategory
: l10n.booksCategory,
price: (index + 1) * 9.99,
stock: (index * 7) % 100,
);
});
}
void sort(int columnIndex, bool ascending) {
_products.sort((a, b) {
final result = switch (columnIndex) {
0 => a.name.compareTo(b.name),
1 => a.category.compareTo(b.category),
2 => a.price.compareTo(b.price),
_ => 0,
};
return ascending ? result : -result;
});
notifyListeners();
}
@override
DataRow getRow(int index) {
final product = _products[index];
return DataRow(cells: [
DataCell(Text(product.name)),
DataCell(Text(product.category)),
DataCell(Text('\$${product.price.toStringAsFixed(2)}')),
DataCell(Text('${product.stock}')),
]);
}
@override
int get rowCount => _products.length;
@override
bool get isRowCountApproximate => false;
@override
int get selectedRowCount => 0;
}
class _Product {
final String name;
final String category;
final double price;
final int stock;
_Product({
required this.name,
required this.category,
required this.price,
required this.stock,
});
}
Advanced PaginatedDataTable Patterns for Localization
Selectable Rows with Localized Actions
PaginatedDataTable with row selection and a translated action bar showing selected count.
class SelectableLocalizedTable extends StatefulWidget {
const SelectableLocalizedTable({super.key});
@override
State<SelectableLocalizedTable> createState() =>
_SelectableLocalizedTableState();
}
class _SelectableLocalizedTableState extends State<SelectableLocalizedTable> {
late _SelectableDataSource _dataSource;
int _rowsPerPage = 10;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final l10n = AppLocalizations.of(context)!;
_dataSource = _SelectableDataSource(
l10n: l10n,
onSelectionChanged: () => setState(() {}),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final selectedCount = _dataSource.selectedRowCount;
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: PaginatedDataTable(
header: selectedCount > 0
? Text(l10n.selectedItemsLabel(selectedCount))
: Text(l10n.userListHeader),
actions: selectedCount > 0
? [
IconButton(
icon: const Icon(Icons.delete),
tooltip: l10n.deleteSelectedTooltip,
onPressed: () {
_dataSource.deleteSelected();
},
),
IconButton(
icon: const Icon(Icons.archive),
tooltip: l10n.archiveSelectedTooltip,
onPressed: () {},
),
]
: [
IconButton(
icon: const Icon(Icons.add),
tooltip: l10n.addUserTooltip,
onPressed: () {},
),
],
rowsPerPage: _rowsPerPage,
onRowsPerPageChanged: (value) {
setState(() => _rowsPerPage = value!);
},
columns: [
DataColumn(label: Text(l10n.nameHeader)),
DataColumn(label: Text(l10n.emailHeader)),
DataColumn(label: Text(l10n.roleHeader)),
DataColumn(label: Text(l10n.statusHeader)),
],
source: _dataSource,
),
);
}
}
class _SelectableDataSource extends DataTableSource {
final AppLocalizations l10n;
final VoidCallback onSelectionChanged;
final List<bool> _selected;
final List<_UserRow> _users;
_SelectableDataSource({
required this.l10n,
required this.onSelectionChanged,
}) : _users = List.generate(30, (i) => _UserRow(
name: '${l10n.userLabel} ${i + 1}',
email: 'user${i + 1}@example.com',
role: i % 3 == 0
? l10n.adminRole
: i % 3 == 1
? l10n.editorRole
: l10n.viewerRole,
status: i % 4 == 0 ? l10n.inactiveStatus : l10n.activeStatus,
)),
_selected = List.filled(30, false);
void deleteSelected() {
for (int i = _selected.length - 1; i >= 0; i--) {
if (_selected[i]) {
_users.removeAt(i);
_selected.removeAt(i);
}
}
notifyListeners();
onSelectionChanged();
}
@override
DataRow getRow(int index) {
final user = _users[index];
return DataRow(
selected: _selected[index],
onSelectChanged: (selected) {
_selected[index] = selected ?? false;
notifyListeners();
onSelectionChanged();
},
cells: [
DataCell(Text(user.name)),
DataCell(Text(user.email)),
DataCell(Text(user.role)),
DataCell(
Chip(
label: Text(user.status),
backgroundColor: user.status == l10n.activeStatus
? Colors.green.shade100
: Colors.grey.shade200,
),
),
],
);
}
@override
int get rowCount => _users.length;
@override
bool get isRowCountApproximate => false;
@override
int get selectedRowCount => _selected.where((s) => s).length;
}
class _UserRow {
final String name;
final String email;
final String role;
final String status;
_UserRow({
required this.name,
required this.email,
required this.role,
required this.status,
});
}
Locale-Aware Number Formatting in Data Cells
Format numeric data cells using locale-specific number and currency formatters.
import 'package:intl/intl.dart';
class LocaleFormattedDataTable extends StatefulWidget {
const LocaleFormattedDataTable({super.key});
@override
State<LocaleFormattedDataTable> createState() =>
_LocaleFormattedDataTableState();
}
class _LocaleFormattedDataTableState extends State<LocaleFormattedDataTable> {
int _rowsPerPage = PaginatedDataTable.defaultRowsPerPage;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
final currencyFormat = NumberFormat.currency(
locale: locale.toString(),
symbol: l10n.currencySymbol,
);
final numberFormat = NumberFormat.decimalPattern(locale.toString());
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: PaginatedDataTable(
header: Text(l10n.salesReportHeader),
rowsPerPage: _rowsPerPage,
onRowsPerPageChanged: (value) {
setState(() => _rowsPerPage = value!);
},
columns: [
DataColumn(label: Text(l10n.regionHeader)),
DataColumn(label: Text(l10n.revenueHeader), numeric: true),
DataColumn(label: Text(l10n.unitsSoldHeader), numeric: true),
DataColumn(label: Text(l10n.growthHeader), numeric: true),
],
source: _SalesDataSource(
l10n: l10n,
currencyFormat: currencyFormat,
numberFormat: numberFormat,
),
),
);
}
}
class _SalesDataSource extends DataTableSource {
final AppLocalizations l10n;
final NumberFormat currencyFormat;
final NumberFormat numberFormat;
_SalesDataSource({
required this.l10n,
required this.currencyFormat,
required this.numberFormat,
});
final _salesData = <(String, double, int, double)>[
('North America', 125000.50, 4500, 12.5),
('Europe', 98000.75, 3200, 8.3),
('Asia Pacific', 156000.00, 6100, 22.1),
('Latin America', 45000.25, 1800, 15.7),
('Middle East', 32000.00, 900, 5.2),
];
@override
DataRow getRow(int index) {
final (region, revenue, units, growth) = _salesData[index];
return DataRow(cells: [
DataCell(Text(region)),
DataCell(Text(currencyFormat.format(revenue))),
DataCell(Text(numberFormat.format(units))),
DataCell(
Text(
'+${growth.toStringAsFixed(1)}%',
style: TextStyle(
color: growth > 10 ? Colors.green : Colors.orange,
),
),
),
]);
}
@override
int get rowCount => _salesData.length;
@override
bool get isRowCountApproximate => false;
@override
int get selectedRowCount => 0;
}
Filtered Table with Localized Search
PaginatedDataTable with a translated search field that filters rows in real time.
class FilteredLocalizedTable extends StatefulWidget {
const FilteredLocalizedTable({super.key});
@override
State<FilteredLocalizedTable> createState() =>
_FilteredLocalizedTableState();
}
class _FilteredLocalizedTableState extends State<FilteredLocalizedTable> {
final TextEditingController _searchController = TextEditingController();
int _rowsPerPage = PaginatedDataTable.defaultRowsPerPage;
String _searchQuery = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
Padding(
padding: const EdgeInsetsDirectional.fromSTEB(16, 16, 16, 0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: l10n.searchProductsHint,
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
tooltip: l10n.clearSearchTooltip,
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
)
: null,
border: const OutlineInputBorder(),
),
onChanged: (value) {
setState(() => _searchQuery = value.toLowerCase());
},
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: PaginatedDataTable(
header: Text(
_searchQuery.isEmpty
? l10n.allProductsHeader
: l10n.searchResultsHeader(_searchQuery),
),
rowsPerPage: _rowsPerPage,
onRowsPerPageChanged: (value) {
setState(() => _rowsPerPage = value!);
},
columns: [
DataColumn(label: Text(l10n.productNameHeader)),
DataColumn(label: Text(l10n.categoryHeader)),
DataColumn(label: Text(l10n.priceHeader), numeric: true),
],
source: _FilteredDataSource(
l10n: l10n,
searchQuery: _searchQuery,
),
),
),
),
],
);
}
}
class _FilteredDataSource extends DataTableSource {
final AppLocalizations l10n;
final String searchQuery;
late final List<_Product> _filteredProducts;
_FilteredDataSource({required this.l10n, required this.searchQuery}) {
final allProducts = List.generate(50, (i) => _Product(
name: '${l10n.productLabel} ${i + 1}',
category: i % 3 == 0
? l10n.electronicsCategory
: i % 3 == 1
? l10n.clothingCategory
: l10n.booksCategory,
price: (i + 1) * 9.99,
stock: 0,
));
_filteredProducts = searchQuery.isEmpty
? allProducts
: allProducts
.where((p) =>
p.name.toLowerCase().contains(searchQuery) ||
p.category.toLowerCase().contains(searchQuery))
.toList();
}
@override
DataRow getRow(int index) {
final product = _filteredProducts[index];
return DataRow(cells: [
DataCell(Text(product.name)),
DataCell(Text(product.category)),
DataCell(Text('\$${product.price.toStringAsFixed(2)}')),
]);
}
@override
int get rowCount => _filteredProducts.length;
@override
bool get isRowCountApproximate => false;
@override
int get selectedRowCount => 0;
}
RTL Support and Bidirectional Layouts
PaginatedDataTable automatically reverses column order in RTL layouts. Pagination controls also flip to match the reading direction.
class BidirectionalPaginatedTable extends StatefulWidget {
const BidirectionalPaginatedTable({super.key});
@override
State<BidirectionalPaginatedTable> createState() =>
_BidirectionalPaginatedTableState();
}
class _BidirectionalPaginatedTableState
extends State<BidirectionalPaginatedTable> {
int _rowsPerPage = 5;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SingleChildScrollView(
padding: const EdgeInsetsDirectional.all(16),
child: PaginatedDataTable(
header: Text(l10n.orderHistoryHeader),
rowsPerPage: _rowsPerPage,
onRowsPerPageChanged: (value) {
setState(() => _rowsPerPage = value!);
},
columns: [
DataColumn(label: Text(l10n.orderIdHeader)),
DataColumn(label: Text(l10n.dateHeader)),
DataColumn(label: Text(l10n.totalHeader), numeric: true),
DataColumn(label: Text(l10n.statusHeader)),
],
source: _OrderDataSource(l10n: l10n),
),
);
}
}
class _OrderDataSource extends DataTableSource {
final AppLocalizations l10n;
_OrderDataSource({required this.l10n});
@override
DataRow getRow(int index) {
return DataRow(cells: [
DataCell(Text('#${1000 + index}')),
DataCell(Text('2026-02-${(index % 28 + 1).toString().padLeft(2, '0')}')),
DataCell(Text('\$${((index + 1) * 29.99).toStringAsFixed(2)}')),
DataCell(Text(
index % 3 == 0
? l10n.deliveredStatus
: index % 3 == 1
? l10n.shippedStatus
: l10n.processingStatus,
)),
]);
}
@override
int get rowCount => 25;
@override
bool get isRowCountApproximate => false;
@override
int get selectedRowCount => 0;
}
Testing PaginatedDataTable Localization
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
void main() {
Widget buildTestWidget({Locale locale = const Locale('en')}) {
return MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const LocalizedPaginatedTableExample(),
);
}
testWidgets('PaginatedDataTable renders localized headers', (tester) async {
await tester.pumpWidget(buildTestWidget());
await tester.pumpAndSettle();
expect(find.byType(PaginatedDataTable), findsOneWidget);
});
testWidgets('PaginatedDataTable works in RTL', (tester) async {
await tester.pumpWidget(buildTestWidget(locale: const Locale('ar')));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('Pagination controls are present', (tester) async {
await tester.pumpWidget(buildTestWidget());
await tester.pumpAndSettle();
expect(find.byIcon(Icons.chevron_left), findsOneWidget);
expect(find.byIcon(Icons.chevron_right), findsOneWidget);
});
}
Best Practices
Leverage
MaterialLocalizationsfor automatic translation of pagination labels like "Rows per page" and page range indicators -- ensure you provide the correctMaterialLocalizationsdelegate for each locale.Use
numeric: trueon numericDataColumnentries so values align correctly in both LTR and RTL layouts.Format numbers with
NumberFormatfrom theintlpackage using the active locale for currency, percentages, and large numbers in data cells.Provide translated
headerandactionsthat update dynamically when rows are selected, showing localized counts like "3 items selected".Wrap in
SingleChildScrollViewto prevent overflow when translated column headers or data cells are wider than the viewport.Test pagination with various
rowsPerPagevalues to ensure translated content renders correctly across all pages and page sizes.
Conclusion
PaginatedDataTable provides a full-featured paginated data grid for Flutter apps. For multilingual apps, it integrates with MaterialLocalizations for automatic pagination text translation, supports sortable columns with translated headers, and handles RTL column reversal automatically. By combining PaginatedDataTable with locale-aware number formatting, filtered search, and selectable rows with translated action labels, you can build data-rich interfaces that work seamlessly across all supported languages.