← Back to Blog

Fix Dart compareTo: Locale-Aware Sorting for Accented Names

flutterdarti18nl10nsortingcollation

Fix Dart compareTo: Locale-Aware Sorting for Accented Names

Sort a Flutter contact list with List.sort() and the moment a name carries an accent, the order breaks:

final names = ['Zimmer', 'Árbol', 'Adams', 'Öberg'];
names.sort(); // List.sort with no comparator uses String.compareTo
print(names); // [Adams, Zimmer, Árbol, Öberg]

Every human expects Árbol between Adams and Zimmer. Instead, both accented names land after Z. Ship that in a contact list, country picker, or product catalog and users in Spain, Sweden, Türkiye, or Vietnam notice immediately.

This post covers why compareTo gets accents in the wrong order, a copy-paste comparator that fixes most Latin-script apps, when to reach for the ICU4X-based intl4x package instead, and tests that prove your picker sorts correctly.

Why compareTo puts accented names last

String.compareTo doesn't sort alphabetically — it compares UTF-16 code units numerically:

Character Code point Decimal
Z U+005A 90
a U+0061 97
z U+007A 122
Á U+00C1 193
Ö U+00D6 214
é U+00E9 233
ö U+00F6 246

Á (193) is numerically greater than Z (90), so Árbol sorts after Zimmer. Same for Ñ, Ö, Ü and every other accented capital. Case is broken too: 'apple'.compareTo('Banana') is positive because B (66) is less than a (97), so mixed-case lists interleave wrongly before accents even enter the picture.

What you actually want is collation — locale-aware string comparison that knows á is a variant of a in Spanish, that ö is a letter after z in Swedish, and that case shouldn't dominate ordering.

Why there's no built-in fix in Dart

If you've searched this, you probably found flutter/flutter#27549, "API for string collation (locale-aware string comparison)". Its summary nails the frustration: Flutter uses ICU under the hood, which implements all the necessary collation algorithms — but exposes no API to use them.

The issue was filed in February 2019 and closed two days later with "please file this against the Dart SDK". On the Dart side, the underlying request has been waiting even longer: dart-lang/sdk#3174, covering Unicode normalization and accent handling, has been open since 2012. As of Dart 3.x there is still no collator in dart:core, and package:intl — the official i18n package — covers dates, numbers, and plurals but not collation.

That leaves two realistic options: a small folding comparator you own, or the experimental intl4x package from the Dart team.

The 20-line fix: diacritic folding + case folding

For most Latin-script apps — English, Spanish, French, Portuguese, Italian, Dutch, German dictionary order — "correct" means: ignore accents, ignore case, then tie-break on the raw string so Arbol and Árbol still order deterministically. That's about 20 lines of dependency-free Dart:

const Map<String, String> _fold = {
  'à': 'a', 'á': 'a', 'â': 'a', 'ã': 'a', 'ä': 'a', 'å': 'a', 'æ': 'ae',
  'ç': 'c', 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e',
  'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i', 'ñ': 'n',
  'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', 'ö': 'o', 'ø': 'o', 'œ': 'oe',
  'ù': 'u', 'ú': 'u', 'û': 'u', 'ü': 'u', 'ý': 'y', 'ÿ': 'y', 'ß': 'ss',
};

String foldForSort(String s) => s
    .toLowerCase()
    // Strip combining accents (é typed as e + U+0301 on some inputs).
    .replaceAll(RegExp('[\u0300-\u036f]'), '')
    .split('')
    .map((c) => _fold[c] ?? c)
    .join();

int compareLocaleAware(String a, String b) {
  final folded = foldForSort(a).compareTo(foldForSort(b));
  return folded != 0 ? folded : a.compareTo(b);
}

Use it anywhere you sort:

contacts.sort((a, b) => compareLocaleAware(a.name, b.name));
// [Adams, Árbol, Öberg, Zimmer]

If you need coverage beyond Western European letters (Vietnamese, Czech, Polish diacritics), swap the hand-rolled map for package:diacritic's removeDiacritics, which maintains a much larger table — the comparator's shape stays identical.

Where folding is deliberately "wrong"

Folding implements one specific collation: the accents-are-decoration model. Some locales disagree, and it's worth knowing exactly where the 20-liner stops:

  • Swedish, Finnish, Danish, Norwegian: Å, Ä/Æ, Ö/Ø are distinct letters at the end of the alphabet. A Swedish user expects Öberg after Zimmer — ironically, raw compareTo lands closer to Swedish order than folding does.
  • Spanish: the RAE sorts ñ as its own letter between n and o, so Ñandú belongs after Novak, not before it.
  • German phonebook order (DIN 5007-2): ö sorts as oe in name directories, while dictionaries fold it to o.

This is exactly why collation is locale-dependent — and the signal that you've outgrown folding.

When to use intl4x instead

intl4x is the Dart team's next-generation i18n package (developed in dart-lang/i18n). On native platforms it wraps ICU4X, the Rust reimplementation of ICU; on web it delegates to the browser's built-in Intl. It gives you real per-locale ICU collation:

import 'package:intl4x/collation.dart';

void main() {
  final list = ['Z', 'a', 'z', 'ä'];
  final german = Collation(locale: Locale.parse('de-DE'));
  list.sort(german.compare);
  print(list); // [a, ä, z, Z]
}

Swap de-DE for sv-SE and ä jumps to the end of the alphabet, as Swedish users expect.

Reach for it when your sorted content is user-generated across many locales, when you support non-Latin scripts (folding does nothing for Arabic, Greek, Cyrillic, CJK, or Thai), or when you need search-versus-sort semantics — its Usage enum distinguishes the two, because in German ['AE', 'Ä'] is the right order for searching but ['Ä', 'AE'] for sorting.

Two caveats before you add it: the package is explicitly experimental (0.17.0 at the time of writing, with a 1.0 alpha in progress), and its native backend ships via Dart build hooks — stable since Flutter 3.38 / Dart 3.10, but a newer toolchain requirement than many CI setups have. For a stable Latin-script app, the folding comparator is the lower-risk choice today; keep it behind one function so swapping in a real collator later is a one-line change.

Prove it with tests

Lock the behavior in with a unit test:

test('sorts accented names like a person, not a code chart', () {
  final names = ['Zimmer', 'Öberg', 'Álvarez', 'Adams'];
  names.sort(compareLocaleAware);
  expect(names, ['Adams', 'Álvarez', 'Öberg', 'Zimmer']);
});

And a widget test that checks what the user actually sees in a picker:

class CountryPicker extends StatelessWidget {
  const CountryPicker({super.key, required this.countries});
  final List<String> countries;

  @override
  Widget build(BuildContext context) {
    final sorted = [...countries]..sort(compareLocaleAware);
    return Scaffold(
      body: ListView(
        children: [for (final c in sorted) ListTile(title: Text(c))],
      ),
    );
  }
}

testWidgets('country picker renders accented names in order', (tester) async {
  const countries = ['Türkiye', 'Zambia', 'Österreich', 'Åland Islands', 'Argentina'];
  await tester.pumpWidget(
    const MaterialApp(home: CountryPicker(countries: countries)),
  );

  final rendered = tester
      .widgetList<Text>(find.descendant(
        of: find.byType(ListView),
        matching: find.byType(Text),
      ))
      .map((t) => t.data)
      .toList();

  expect(rendered,
      ['Åland Islands', 'Argentina', 'Österreich', 'Türkiye', 'Zambia']);
});

If you later switch to intl4x, keep this test and parameterize it per locale — the Swedish run should expect Österreich after Zambia.

Sort translations at runtime, not in your ARB files

One last trap: the strings in that picker are usually translations. Country names, categories, and product labels live in your app_en.arb, app_es.arb, app_sv.arb files — and their alphabetical order differs in every locale ("Germany", "Alemania", and "Tyskland" sort in three different places). Never bake a sorted order into your data; sort the localized strings at render time with a locale-aware comparator.

Keeping those per-locale ARB files complete and consistent is its own job. FlutterLocalisation's ARB editor lets you edit app_<locale>.arb translations in a UI instead of raw JSON and manage every locale side by side, and it validates ICU plural syntax — flagging locales missing a plural category the language actually requires, like a dropped many in Polish or Arabic. There's a free tier, and pricing scales from there; more Flutter i18n deep dives live on the blog.

Try FlutterLocalisation free — and let your pickers sort themselves out.