Есть простой способ реализовать переключение языка в Single-Activity приложении. Стек экранов при этом подходе не сбрасывается, пользователь остается там, где переключил язык. Когда пользователь переходит на предыдущие экраны, они сразу отображаются переведенными. А результат локализации чисел, денежных сумм и процентов может удивить дизайнеров.
О чем пойдет речь, а о чем не пойдет?
Далее не будет ничего о:
- Теории, которая лежит в основе форматированного вывода строк, и деталях реализации библиотек, которые этим занимаются. То есть того, что помогло бы вам написать свою библиотеку.
- Ресурсах строковых, векторных и прочих. О том, какие квалификаторы ресурсов использовать, какие картинки на арабском должны отображаться справа налево, а какие нет, и других тонкостях.
- Процессе централизованного перевода ресурсов для всех платформ. Как его организовать, чтобы всем жилось хорошо, даже iOS-никам.
А пойдет речь о:
- Практике. Рассмотрим задачу, ее ограничения и решение с диаграммами, примерами и фрагментами кода.
- API SDK, которое было использовано для этого решения.
- Особенностях форматирования числовых значений для разных региональных стандартов, о которых стоит знать дизайнерам.
Что мы хотим сделать?
Пусть в нашем приложении есть экран с настройками, и мы хотим добавить в него пару новых пунктов, один из которых позволил бы переключать язык приложения, а другой изменять валюту, в которой отображаются денежные суммы. Приведем примеры того, как это может выглядеть.
Кроме перевода текста и отображения верстки справа налево, эти настройки должны влиять на формат отображения числовых значений. Необходимо, чтобы все отображалось согласно выбранной локали.
Архитектурное решение
Представим, что наше приложение написано в соответствии с Single-Activity подходом. Тогда механизм переключения языка может быть реализован следующим образом.
SettingsInteractor
является источником текущего значения языка. Он позволяет подписаться на это значение, получить его синхронно и подписаться только на обновления. В случае необходимости можно ввести дополнительную абстракцию над SettingsInteractor
по принципу разделения интерфейса. На диаграмме несущественные детали опущены.
AppActivity
при создании заменяет контекст на новый, чтобы приложение использовало ресурсы для выбранного языка.
override fun attachBaseContext(base: Context) {
super.attachBaseContext(applySelectedAppLanguage(base))
}
private fun applySelectedAppLanguage(context: Context): Context {
val locale = settingsInteractor.getUserSelectedLanguageBlocking()
val newConfig = Configuration(context.resources.configuration)
Locale.setDefault(locale)
newConfig.setLocale(locale)
return context.createConfigurationContext(newConfig)
}
AppPresenter
в свою очередь подписывается на обновления языка и уведомляет View об изменениях.
override fun onFirstViewAttach() {
super.onFirstViewAttach()
subscribeToLanguageUpdates()
}
private fun subscribeToLanguageUpdates() {
settingsInteractor
.getUserSelectedLanguageUpdates()
.subscribe(
{ newLang ->
viewState.applyNewAppLanguage(newLang)
},
{ error ->
errorHandler.handle(error)
}
)
.disposeOnDestroy()
}
AppActivity
при получении уведомления о смене языка пересоздается.
override fun applyNewAppLanguage(lang: Locale) = recreate()
AppActivity
является единственной в приложении. Все остальные экраны реализованы фрагментами. Поэтому при пересоздании активити стек экранов сохраняется системой. При возврате на предыдущие экраны они будут переинициализированы и отображаться переведенными. Пользователь останется на списке выбора языка и увидит результат своего выбора мгновенно.
Форматирование чисел, денежных сумм и процентов
Кроме замены контекста необходимо форматировать данные – числа, денежные суммы, проценты. Пусть эту задачу каждая View делегирует отдельному компоненту, назовем его UiLocalizer
.
Для преобразования числа в строку UiLocalizer
использует соответствующие инстансы NumberFormat
.
private var numberFormat = NumberFormat.getNumberInstance(lang)
private var percentFormat = NumberFormat.getPercentInstance(lang)
private fun getNumberFormatForCurrency(currency: Currency) =
NumberFormat
.getCurrencyInstance(lang)
.also { it.currency = currency }
Обратите внимание, что валюту необходимо устанавливать отдельно.
Если вы экономите такты CPU и биты памяти, а переключение валюты и языка – основная и часто используемая функция вашего приложения, то здесь, конечно, необходим кэш.
Представление языков и валют
Экземпляры класса Locale
создаются по языковому тегу, который состоит из двухбуквенного кода языка и двухбуквенного кода региона. А экземпляры класса Currency
– по трехбуквенному ISO коду. В этом виде язык и валюта должны сериализовываться для сохранения на диск или передачи по сети, и тогда будет хорошо. Приведем примеры.
// IETF BCP 47 language tag string.
private val langs = arrayOf(
Locale.forLanguageTag("ru-RU"),
Locale.forLanguageTag("en-US"),
Locale.forLanguageTag("en-GB"),
Locale.forLanguageTag("he-IL"),
Locale.forLanguageTag("ar-SA"),
Locale.forLanguageTag("ar-AE"),
Locale.forLanguageTag("fr-FR"),
Locale.forLanguageTag("fr-CH"),
Locale.forLanguageTag("de-DE"),
Locale.forLanguageTag("de-CH"),
Locale.forLanguageTag("da-DK")
)
// ISO 4217 code of the currency.
private val currencies = arrayListOf(
Currency.getInstance("RUB"),
Currency.getInstance("USD"),
Currency.getInstance("GBP"),
Currency.getInstance("ILS"),
Currency.getInstance("SAR"),
Currency.getInstance("AED"),
Currency.getInstance("EUR"),
Currency.getInstance("CHF"),
Currency.getInstance("DKK")
)
Особенности форматирования числовых значений
Результат форматирования чисел в соответствии с региональными стандартами может разойтись с ожидаемым. Символ валюты или ее трехбуквенный код на разных языках будет выводиться по-разному. Знак минуса у отрицательных денежных значений будет появляться в неожиданных местах, а кое-где вместо него будут выводиться скобки. Знак процента может оказаться не совсем тем знаком, к которому мы привыкли.
Дело в том, что с точки зрения региональных шаблонов итоговая строка состоит из префикса и суффикса для положительных и отрицательных чисел, разделителя тысячных и десятичного разделителя, а они разные для разных локалей.
Числа
Language | Negative Prefix | Negative Suffix | Positive Prefix | Positive Suffix | Grouping Separator | Decimal Separator |
---|---|---|---|---|---|---|
ru-RU | "-" | " " | "," | |||
en-US | "-" | "," | "." | |||
iw-IL | "-" | "," | "." | |||
ar-AE | "-" | "٬" | "٫" | |||
fr-FR | "-" | " " | "," | |||
de-DE | "-" | "." | "," | |||
de-CH | "-" | "'" | "." | |||
da-DK | "-" | "." | "," |
Валюты
Language | Negative Prefix | Negative Suffix | Positive Prefix | Positive Suffix | Grouping Separator | Decimal Separator |
---|---|---|---|---|---|---|
ru-RU | "-" | " ₽" | " ₽" | " " | "," | |
en-US | "-$" | "$" | "," | "." | ||
iw-IL | "-" | " ₪" | " ₪" | "," | "." | |
ar-AE | "-" | " د.إ." | " د.إ." | "٬" | "٫" | |
fr-FR | "-" | " €" | " €" | " " | "," | |
de-DE | "-" | " €" | " €" | "." | "," | |
de-CH | "CHF-" | "CHF " | "'" | "." | ||
da-DK | "-" | " kr." | " kr." | "." | "," |
Проценты
Language | Negative Prefix | Negative Suffix | Positive Prefix | Positive Suffix | Grouping Separator | Decimal Separator |
---|---|---|---|---|---|---|
ru-RU | "-" | "%" | "%" | " " | "," | |
en-US | "-" | "%" | "%" | "," | "." | |
iw-IL | "-" | "%" | "%" | "," | "." | |
ar-AE | "-" | " ٪" | " ٪" | "٬" | "٫" | |
fr-FR | "-" | " %" | " %" | " " | "," | |
de-DE | "-" | " %" | " %" | "." | "," | |
de-CH | "-" | "%" | "%" | "'" | "." | |
da-DK | "-" | " %" | " %" | "." | "," |
Более того, результаты форматирования для Android SDK и JDK могут быть разными. При этом все варианты правильные, каждый из них используется в определенных контекстах.
DecimalFormat
Когда мы создаем NumberFormat
для форматирования тех или иных значений, мы получаем объекты класса DecimalFormat
, которые просто сконфигурированы разными шаблонами. Приведя объект к типу DecimalFormat
и используя его интерфейс, можно изменить части шаблона, чтобы все сломать. Но лучше поклоняться данности.
Также можно написать тест, чтобы насладиться разнообразием. Не для всех локалей одна и та же валюта выводится символом.
В итоге
Общая схема решения выглядит следующим образом.
Жизненный цикл AppActivity
является жизненным циклом всего приложения. Поэтому достаточно пересоздать ее, чтобы перезапустить все приложение и применить выбранный язык. А поскольку активити одна, подписку на изменение языка достаточно держать в одном месте – в AppPresenter
.
Как мы увидели, региональные форматы вывода чисел нетривиальны. Не стоит жестко задавать единый шаблон на все случаи жизни. Лучше доверить форматирование SDK и договориться, что числа будут выводиться по стандарту, а не как нарисовано на макетах.
Как проще тестировать? (бонус)
Для экономии времени можно воспользоваться следующим флагом.
android {
...
buildTypes {
debug {
pseudoLocalesEnabled true
}
}
...
}
Выбрать необходимую псевдолокаль в настройках телефона.
И наблюдать, как едет верстка из-за длинного текста, а некоторые элементы UI упорно не хотят отображаться справа налево.
Более подробную информацию можно прочитать в документации.
Стоит отметить, что псевдолокали не будут работать, если вы подменяете контекст, как в решении выше. Вы ведь подменяете контекст. Поэтому необходимо добавить en-XA
и ar-XB
в список выбора языка внутри приложения.
На этом все. Хорошей вам локализации и хорошего настроения!