← Back to Blog

Fix Flutter Showing the Wrong Chinese: zh-Hans vs zh-Hant

flutteri18nl10nchineselocale-resolutionarb

Fix Flutter Showing the Wrong Chinese: zh-Hans vs zh-Hant

Your app ships app_zh.arb and app_zh_TW.arb, you added Locale('zh', 'TW') to supportedLocales, and a user in Hong Kong still sees Simplified Chinese. Or a Taiwanese tester reports that the app is in Chinese, but half the characters look "mainland style". Both symptoms have the same root cause: Flutter resolves Chinese by script, and Locale('zh', 'TW') never declares one.

This failure shows up repeatedly in the Flutter tracker — #146966 (Traditional Chinese rendering with the wrong characters on some devices) and #159216 (Chinese not detected, English shown instead). Notably, both were closed as invalid — the framework is behaving exactly as documented. That's good news: it means you can fix this entirely in your own MaterialApp config, today.

How Flutter decides which Chinese you get

Modern devices report Chinese with a script subtag: zh-Hans-CN (mainland), zh-Hant-TW (Taiwan), zh-Hant-HK (Hong Kong), zh-Hans-SG (Singapore). If you don't provide a localeListResolutionCallback, Flutter runs basicLocaleListResolution, which walks the user's preferred locales and matches against your supportedLocales in this priority order:

  1. Perfect match (languageCode + scriptCode + countryCode)
  2. languageCode + scriptCode
  3. languageCode + countryCode
  4. languageCode only
  5. countryCode only
  6. The first entry in supportedLocales

The trap is level 4: a language-only match returns the first supported locale whose languageCode is zh — regardless of script. Whichever Chinese variant you happened to list first silently becomes the fallback for every Chinese-speaking user your explicit entries don't cover.

Reproduce it in five minutes

Here is the configuration most apps start with:

MaterialApp(
  supportedLocales: const [
    Locale('en'),
    Locale('zh'),        // Simplified strings in app_zh.arb
    Locale('zh', 'TW'),  // Traditional strings in app_zh_TW.arb
  ],
  // ...
)

Run it on a real device and cycle the system language:

Device language Device reports Resolution step App shows
简体中文 (中国) zh-Hans-CN language-only → zh Simplified ✅
繁體中文 (台灣) zh-Hant-TW language + country → zh_TW Traditional ✅
繁體中文 (香港) zh-Hant-HK language-only → first zh Simplified ❌
繁體中文 (澳門) zh-Hant-MO language-only → first zh Simplified ❌

Hong Kong and Macau users get Simplified Chinese because zh-Hant-HK matches neither zh_TW's country nor any script (your locales declared none), so resolution falls through to the first zh in the list. Flip the list order and you break the mainland instead. There is no ordering of script-less locales that gets all four rows right.

Before fixing anything, log what your users' devices actually send — this is also the fastest way to diagnose reports like #159216, where the OS itself listed English above Chinese in the user's preferred languages:

localeListResolutionCallback: (locales, supported) {
  debugPrint('Device locale list: $locales');
  return basicLocaleListResolution(locales, supported);
},

Step 1: Declare Chinese with Locale.fromSubtags

The official i18n docs recommend the full Locale.fromSubtags constructor for Chinese precisely because it supports scriptCode:

supportedLocales: const [
  Locale('en'),
  Locale('zh'), // Simplified Chinese — app_zh.arb
  Locale.fromSubtags(
    languageCode: 'zh',
    scriptCode: 'Hant',
  ), // Traditional Chinese — app_zh_Hant.arb
  Locale.fromSubtags(
    languageCode: 'zh',
    scriptCode: 'Hant',
    countryCode: 'HK',
  ), // Hong Kong Traditional — app_zh_Hant_HK.arb
],

With a Hant-scripted entry present, zh-Hant-TW, zh-Hant-HK, and zh-Hant-MO all hit the language + script rule (level 2) instead of falling through to language-only. This single change fixes most "flutter zh-Hant not working" reports.

Step 2: Name your ARB files by script, not just country

flutter gen-l10n derives each file's locale from its name and understands four-letter script subtags:

lib/l10n/
├── app_en.arb          // template
├── app_zh.arb          // Simplified (bare zh defaults to Hans, per CLDR)
├── app_zh_Hant.arb     // Traditional — serves TW, MO, and unknown Hant users
└── app_zh_Hant_HK.arb  // Hong Kong wording differences (optional)

Prefer app_zh_Hant.arb over app_zh_TW.arb: a country-named file only matches its own country, while a script-named file covers every Traditional-script user. Keep the region file only for genuinely regional wording (香港 uses 登入 vs some mainland apps' 登录-style vocabulary differences, taxi vs 的士, and so on).

Step 3: Put the default variant first

Order still matters for users your callback or the level-4 fallback can't classify. List the variant with your larger audience first among the zh entries — it becomes the language-only winner for anything unexpected. In the list above, bare zh (Simplified) is that default; swap it with zh_Hant if your market is primarily Taiwan and Hong Kong.

Step 4: The copy-paste localeListResolutionCallback

One gap remains: older Android builds and some OEM ROMs report script-less locales like plain zh_TW. With script-only supportedLocales, that device falls through to language-only again — a Taiwanese user on an old phone gets Simplified. This callback infers the script from the country when the OS omits it, and delegates everything else to Flutter's default algorithm:

import 'package:flutter/widgets.dart';

Locale? resolveWithChineseSupport(
  List<Locale>? deviceLocales,
  Iterable<Locale> supportedLocales,
) {
  for (final device in deviceLocales ?? const <Locale>[]) {
    if (device.languageCode == 'zh') {
      // Infer the script when the OS omits it (plain zh_TW / zh_CN).
      final script = device.scriptCode ??
          switch (device.countryCode) {
            'TW' || 'HK' || 'MO' => 'Hant',
            _ => 'Hans', // CN, SG, and bare zh default to Simplified.
          };

      Locale? scriptOnlyMatch;
      for (final supported in supportedLocales) {
        if (supported.languageCode != 'zh') continue;
        // Convention: a bare zh entry (app_zh.arb) holds Simplified.
        final supportedScript = supported.scriptCode ?? 'Hans';
        if (supportedScript != script) continue;
        if (supported.countryCode == device.countryCode) {
          return supported; // e.g. zh_Hant_HK for a Hong Kong device.
        }
        scriptOnlyMatch ??= supported; // e.g. zh_Hant for zh-Hant-MO.
      }
      if (scriptOnlyMatch != null) return scriptOnlyMatch;
      // No zh variant supported: try the user's next preferred language.
    } else if (supportedLocales
        .any((s) => s.languageCode == device.languageCode)) {
      // A higher-ranked non-Chinese language we support wins.
      break;
    }
  }
  return basicLocaleListResolution(deviceLocales, supportedLocales);
}

Wire it up and you're done:

MaterialApp(
  localizationsDelegates: AppLocalizations.localizationsDelegates,
  supportedLocales: AppLocalizations.supportedLocales,
  localeListResolutionCallback: resolveWithChineseSupport,
)

This handles Hans/Hant/HK/TW/SG correctly: exact country match first, then same-script match, then the user's next preferred language, then Flutter's default algorithm.

When the strings are right but the glyphs look wrong

Issue #146966 describes a subtler variant: correct Traditional text, wrong-looking characters (骨, 過, 麵 drawn in mainland style). That's Han unification — Simplified and Traditional typography share many Unicode code points, and the renderer picks glyph shapes based on the resolved locale's script. If your app resolves to zh instead of zh_Hant, the text engine may select a Simplified-styled CJK font even for Traditional strings. The fixes above address this too, because they make the resolved locale carry Hant. (That specific issue was ultimately device-specific — a HarmonyOS phone missing proper Traditional font coverage — which no app-side code can fully fix.)

Keep zh, zh_Hant, and zh_Hant_HK from drifting apart

Once you split Chinese into two or three ARB files, every new string needs to land in all of them, and a missed key silently falls back to the template language. FlutterLocalisation manages exactly this: its ARB editor works directly on your app_<locale>.arb files across all your locales in one place instead of hand-editing parallel JSON, and its ICU plural validation flags locales missing a plural category their language needs. The free tier is enough to try it on a real project — and if you're auditing which locales to support next, our complete Flutter locale list covers every language code Flutter ships translations for.

Try FlutterLocalisation free and stop shipping the wrong Chinese.