Flutter Lists, Grids and Scrolling Widgets: Localization Guide
Lists are where translated content meets performance. A ListView of a thousand rows cannot afford a layout pass per language, a DataTable has headers that grow in German, and a horizontal PageView has to scroll the other way in Arabic. This guide covers every list, grid, sliver and scroll widget once, with the rule each one needs. It replaces the per-widget posts we used to publish.
ListView, ListTile, ExpansionTile, ReorderableListView, AnimatedList
ListView scrolls vertically, which is the same in every language. Its rows are the point. A ListTile has leading, title, subtitle and trailing; leading and trailing swap sides in RTL on their own. Keep title to one key and subtitle to another.
ListTile(
leading: const Icon(Icons.receipt_long),
title: Text(context.l10n.orderTitle(order.number)),
subtitle: Text(DateFormat.yMMMd(locale).format(order.date)),
trailing: Text(NumberFormat.simpleCurrency(locale: locale).format(order.total)),
)
Two performance notes that matter more with long translations:
- Use
ListView.builder, neverListView(children: [...]), for anything over a screenful. Translations do not change this, but a language switch rebuilds every row, and only builder lists rebuild lazily. - Do not set
itemExtentto a value measured in English. A fixed extent clips a two-line Finnish subtitle. If you need the performance, useprototypeItemwith a row holding your longest expected text.
ExpansionTile has title, subtitle and children. Its expand arrow is mirrored automatically. ReorderableListView shows a drag handle on the trailing side and announces "Move up" and "Move down" through flutter_localizations. AnimatedList is a ListView with insert and remove animations; the slide direction of a removed row should be AxisDirection aware when you animate horizontally, see the animation widgets guide.
GridView, SliverGrid, Wrap
Grids do not mirror by default for the item order: the first item of a GridView is top-start, which is top-right in Arabic, and that is correct. What breaks is a tile with a fixed childAspectRatio and a caption that grows. Either give the caption maxLines: 2 with overflow: TextOverflow.ellipsis, or compute the ratio from the text scale:
GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.8 / MediaQuery.textScalerOf(context).scale(1),
),
itemBuilder: (context, i) => ProductTile(products[i]),
)
For short, variable-width items (tags, chips, language pickers) a Wrap is better than a grid: it flows in text direction and never clips.
PageView, TabBarView, horizontal ListView
Horizontal scrolling follows Directionality. A PageView in an Arabic app starts on the right and swipes left to go forward; TabBarView does the same. This is what users expect, and you break it the moment you hard-code a swipe gesture or an arrow icon. If a horizontal list must stay left-to-right (a timeline, a chart), wrap it in Directionality(textDirection: TextDirection.ltr, child: ...) explicitly, and keep the text inside it localized.
The "next page" button should use Icons.arrow_forward, which mirrors, and call controller.nextPage(), which already means "forward in reading direction".
CustomScrollView and slivers: SliverAppBar, SliverList, SliverGrid, SliverToBoxAdapter, NestedScrollView
Slivers are plain widgets inside a CustomScrollView. SliverAppBar has a title and FlexibleSpaceBar(title:); with centerTitle left unset, the title sits at the start of the bar and moves in RTL. A long German title in a collapsing app bar needs titlePadding and expandedTitleScale set so the expanded title does not run under the actions.
SliverAppBar(
pinned: true,
expandedHeight: 180,
flexibleSpace: FlexibleSpaceBar(
title: Text(context.l10n.ordersTitle, maxLines: 1, overflow: TextOverflow.ellipsis),
titlePadding: const EdgeInsetsDirectional.only(start: 56, bottom: 16),
expandedTitleScale: 1.3,
),
)
SliverList and SliverGrid take the rules of ListView and GridView. SliverToBoxAdapter wraps a normal widget; it is where a localized section header usually lives. NestedScrollView coordinates an outer SliverAppBar with inner tab lists, nothing locale-specific beyond the app bar title.
SingleChildScrollView, Scrollbar, ListWheelScrollView, DraggableScrollableSheet
SingleChildScrollView is the widget that keeps a form usable in Russian when the labels push the submit button off-screen. Wrap any form in it. Scrollbar sits on the trailing edge and moves to the left in RTL by itself.
ListWheelScrollView is the iOS-style wheel picker. The items are localized text; nothing else changes. DraggableScrollableSheet has a drag handle and a child; its semantics label ("Bottom sheet") comes from flutter_localizations.
DataTable, PaginatedDataTable, Table
Tables are the hardest widgets to localize because headers have a fixed width and cell text does not. Rules:
- Header labels are keys and should be short on purpose; put the long version in a
Tooltipon the header. - Numeric columns are
numeric: true, which right-aligns them in LTR and left-aligns in RTL. - Sort arrows and the "Rows per page" footer of
PaginatedDataTablecome fromflutter_localizations. - Format every number and date in a cell with
intl. Never put'${row.price} €'in a cell; the symbol goes before the number in English and after it in German.
DataTable(
columns: [
DataColumn(label: Text(context.l10n.colProduct)),
DataColumn(label: Text(context.l10n.colQty), numeric: true),
DataColumn(label: Text(context.l10n.colTotal), numeric: true),
],
rows: [
for (final line in lines)
DataRow(cells: [
DataCell(Text(line.name)),
DataCell(Text(NumberFormat.decimalPattern(locale).format(line.qty))),
DataCell(Text(NumberFormat.simpleCurrency(locale: locale).format(line.total))),
]),
],
)
Table (the low-level one) has no header concept; it lays out children in text direction and mirrors in RTL. Column widths in Table are the same trap as SizedBox(width:): prefer IntrinsicColumnWidth or FlexColumnWidth over FixedColumnWidth next to text.
RefreshIndicator, Dismissible
RefreshIndicator has a semanticsLabel ("Refresh") from Material localizations; override it with a key only if you want product wording. Dismissible swipes a row away. Its background shows on the leading side and secondaryBackground on the trailing side, both mirrored in RTL, so "swipe start-to-end to archive" works in Arabic without code changes. The confirm dialog it may open is yours to localize.
Sorting and searching localized rows
A list sorted with compareTo puts "Österreich" after "Zimbabwe". Sort with a collator or, in Dart without one, with toLowerCase() plus accent stripping as described in locale-aware sorting in Dart. For grouped lists (contacts by first letter) the section letters themselves differ per alphabet; derive them from the sorted data, not from A to Z.
Checklist
ListView.builderfor anything long, no measureditemExtent.- Captions in grid tiles have
maxLinesandoverflow. - Horizontal scroll and arrows rely on
Directionality, no hard-coded swipe directions. - Table headers short, with tooltips; numeric columns marked
numeric. - Numbers and dates in rows go through
intl. - Sorting is locale-aware.
Related
- Layout widgets and RTL
- Navigation, dialogs and app structure
- Flutter DateFormat by locale
- How to translate a Flutter app
The rows need translated keys in every language first. FlutterLocalisation translates new keys with AI, keeps plurals and placeholders intact, and commits the ARB files to your repo.