Что вы знаете о том, как добавить поддержку языков, которые пишутся справа налево (Right to Left, RTL), в iOS‑приложение? Нужно использовать leading и trailing вместо left и right, а ещё… Вот и мы больше ничего не знали, но пришлось разобраться.
Мы готовим приложение Додо Пиццы к локализации на арабский язык. В статье хотим поделиться находками и рассказать, зачем нам поддержка RTL в приложении, почему не достаточно просто адаптировать вёрстку в коде для поддержки RTL, зачем мы перерисовывали иллюстрации и чем отличается арабский знак процента от европейского. Ещё покажем много скриншотов и поделимся шпаргалками по поддержке RTL в коде.
Breaking news! Додо открыли пиццерию в Дубае. Для разработчиков это значит, что нужно подготовить приложение для запуска в новой стране. Обычно бóльшая часть работы здесь ложится на бэкендеров, а нам в приложениях нужно только новый язык добавить.
Локализация-то у нас уже давно настроена, но запуск в Дубае особенный. Локализация приложения для Дубая отличается от локализации для всех предыдущих стран, потому что в арабском языке буквы пишутся справа налево. А это значит, что люди читают справа налево. И весь контент они воспринимают справа налево. У них меняется направление взгляда. А значит и в приложении…
Вначале включаем RTL
Мы начали с того, что погуглили, а как это вообще делать исследования проблемы и поиска вариантов решения нашей задачи.
У Apple есть хорошие материалы по теме, можно начать с них:
Design for Arabic WWDC22,
Get it right (to left) WWDC22,
Right to left Apple HIG,
Документация Apple Supporting Right‑to‑Left Languages.
Затем решили прочувствовать RTL на себе и пошли тыкать наше приложение. Если вы захотите посмотреть, как будет выглядеть ваше приложение с включенным RTL, то вот как это можно сделать:
В Xcode перейти в Edit Scheme (option+click по текущему таргету) → Перейти на вкладку Options → В App Language выбрать Right-to-Left Pseudolanguage вместо System Language → Перезапустить приложение
В выпадающем меню Xcode будет два pseudolanguage: Right-to-Left и Right-to-Left With Right-to-Left Strings.
Первый просто выровняет все локальные строки приложения направо. Второй перевернёт в них буквы.
Обратите внимание, что при выборе Right-to-Left With Right-to-Left Strings изменятся только локальные строки. Строки, которые приходят с бэка, будут показываться в том виде, в котором он их присылает.
Смотреть только на своё приложение — плохой вариант, потому что нет насмотренности на RTL-приложения. Чтобы эту насмотренность развить, мы смотрели разные адаптированные приложения и сайты.
А после того, как мы немного привыкли к RTL и всё рассмотрели в нашем приложении, мы приступили к составлению документа со скриншотами всех экранов. Делали по два скриншота каждого экрана (обычный и с включённым RTL), а потом для каждого экрана описывали, что работает не так, как должно. Когда мы это сделали, стало понятно, что многие проблемы повторяются от экрана к экрану. Мы их сгруппировали. Дальше расскажем про каждую группу проблем.
Удивляемся работе Xcode с RTL-строками
Бонус-раздел. Зацените, как Xcode ведёт себя с RTL-строками:
На видео всегда нажимаем стрелочку вправо.
Для сравнения, в Android Studio поведение отличается. В студии курсор всегда движется в том направлении, какую стрелочку ты нажимаешь на клавиатуре. Может, это настраивается где-то, мы не смотрели.
Смотрим, что там сломалось в приложении
Давайте пройдёмся по группам проблем.
Вёрстка вьюшек и ячеек
При адаптации приложения под RTL-языки iOS делает бóльшую часть работы за нас. В приложении есть экраны, которые уже выглядят так, как и должны, когда включаешь RTL.
Самая большая группа проблем — это когда вьюшки свёрстаны так, что iOS не может сама их правильно перевернуть.
В каждую из таких вьюшек нужно зайти, разобраться, почему возникает проблема, и поправить её руками.
Посмотрите, например, на фиолетовый виджет активного заказа:
Заголовок и основной текст нужно выровнять по правому краю, картинку и основной текст — поменять местами.
В чём ошибка: виджет активного заказа свёрстан на фреймах, которые не учитывают RTL-ориентацию. Мы предпочитаем не использовать вёрстку на фреймах, но в данном случае это обосновано требованиями к виджету.
Чтобы виджет корректно отображался, нужно узнать текущую ориентацию с помощью свойства view.effectiveUserInterfaceLayoutDirection и пересчитать фреймы для RTL-ориентации.
Окна с пищевой ценностью и с ингредиентами, которые можно исключить, совсем поплыли:
В чём ошибки:
в окне с КБЖУ у всех названий стояло выравнивание слева, у всех значений — справа. Нужно учитывать RTL при выставлении textAlignment;
в окне с ингредиентами у кнопки «Закрыть» установлен alignment = .left, а список ингредиентов свёрстан на фреймах.
Адаптация приложения под RTL стала отличным поводом заглянуть на все экраны и убедиться, что используемые компоненты соответствуют дизайн-системе. Так, например, мы полностью переделали диалог удаления ингредиентов.
Чекаут тоже выглядит не очень хорошо:
В чём ошибка: кажется, что экран совсем рассыпался, но на самом деле всё не страшно. Просто стоял неправильный textAlignment для UILabel, а картинки не были отзеркалены.
На списке стран вначале должен отображаться флаг, потом название страны. Значит, в RTL флаг должен быть справа от страны.
В чём ошибка: флаг — это эмодзи. В коде было так:
var nameWithFlag: String {
"(isoCode.emoji) (name)"
}
Обратите внимание на кнопку «Показать ближайшие пиццерии»:
В чём ошибка: у кнопки были захардкожены left и right imageEdgeInsets на сториборде. Переделали на NSDirectionalEdgeInsets, которым можно задать leading и trailing инсеты, вместо left и right.
Также в коде встретилось огромное количество мест с повторяющимися ошибками:
у UILabel установлен textAlignment = .left или textAlignment = .right. В зависимости от сценария нужно использовать textAlignment = .natural или устанавливать textAlignment в зависимости от значения effectiveUserInterfaceLayoutDirection. В случае использования .natural, если у пользователя установлена LTR-ориентация, то текст будет выравниваться по левому краю, а если RTL, то по правому;
кое-где использовались констрейнты left и right вместо leading и trailing;
парочка экранов была свёрстана с использованием PinLayout и не поддерживала RTL. Откуда у нас в проекте вообще взялся PinLayout? Это уже совсем другая история, однажды расскажем её в Telegram-канале Dodo Mobile ;)
Кастомные UI-элементы
Бывает, что разработчики всё сделали как положено: использовали leading и trailing, чтобы, если вдруг будет RTL, всё работало чётко. В нескольких местах проекта мы столкнулись с ошибками, связанными с этим. Так получилось с полем ввода номера телефона:
В чём ошибка: iOS отрабатывала правильно — автоматически переворачивала поле для ввода номера, потому что в вёрстке использовались leading и trailing констрейнты. Но нам нужно, чтобы поле всегда отображалось слева направо (как в России), потому что номера телефонов всегда пишутся слева направо. Проще всего было зафиксировать поле с помощью semanticContentAttribute = .forceLeftToRight.
Забавно сломался инпут для ввода кода из СМС. При вводе цифры в нём появляются как обычно (слева направо), а точки на фоне исчезают справа налево. На скриншоте введены три цифры и осталась видна одна точка — под единицей.
В чём ошибка: точки-плейсхолдеры лежат в UIStackView. Стек автоматически переворачивается при включении RTL, но ему тоже можно установить semanticContentMode = .forceLeftToRight.
На пиццах из половинок скруглённые индикаторы прокрутки отзеркалились и теперь находятся по центру пиццы:
В чём ошибка: к полосам прокрутки стояли констрейнты leading и trailing. iOS их переворачивала. Но экран пиццы из половинок универсальный для RTL и LTR, поэтому на нём ничего не нужно переворачивать. Поменяли констрейнты на left и right.
У всплывающей подсказки стрелочка должна переместиться налево:
В чём ошибка: sourceRect, от которого рисуется стрелочка, считался вручную. Теперь делаем разные расчёты для LTR и RTL.
SegmentedControl у нас самописный. Он выглядит нормально, но сегменты в нём должны поменяться местами.
В чём ошибка: для каждого сегмента рассчитывались фреймы. Добавили альтернативные расчёты для RTL.
Коллекции
Некоторые коллекции, например, топпинги к продукту, не отзеркалились. На скриншоте пустой слот в коллекции должен быть внизу слева, а не справа.
Некоторые коллекции, например, категории в меню, отзеркалились (стали выровнены справа). Но элементы в них располагаются не в правильном порядке. На скриншоте категория «Пицца» должна быть первой, то есть самой правой.
На экране с продуктами, которые можно приобрести за додокоины, элементы в коллекции стоят на нужных местах, но по умолчанию показывается последний элемент. В этой коллекции первая категория товаров — это напитки. Если на скриншоте проскролить категории вбок, то напитки будут стоять на нужном месте.
Для всех проблем с коллекциями нам помогло одно решение — использовать UICollectionViewFlowLayout, который поддерживает RTL. Чтобы UICollectionViewFlowLayout отзеркаливался, следует создать класс, наследующийся от UICollectionViewFlowLayout, и переопределить свойство flipsHorizontallyInOppositeLayoutDirection. По умолчанию оно false, а должно быть true. Тогда коллекции с таким лейаутом будут отзеркаливаться.
class RTLSupportedCollectionViewFlowLayout: UICollectionViewFlowLayout {
override var flipsHorizontallyInOppositeLayoutDirection: Bool {
true
}
}
Иконки, которые имеют направление
Отдельная тема — это картинки. Некоторые из них нужно отзеркалить, а некоторые — нет. Это целая наука!
Посмотрим на экран выбора способа доставки. Курьера на первой иконке нужно отзеркалить, потому что иконка показывает направление движения. То есть курьер должен идти навстречу пользователю, а не от него.
Нож и вилку не надо зеркалить. Мы узнавали, в арабских странах держат нож в правой руке.
На следующем скриншоте около адреса доставки стрелочка не перевернулась. Её нужно перевернуть.
Звёздочку на виджете активного заказа тоже нужно перевернуть, потому что люди, у которых выбран арабский язык, будут смотреть на виджет справа налево. Звёздочка должна встречать взгляд пользователей лицом, а не спиной.
Пиццы можно не зеркалить, потому что это авторская фотография. Фотограф выстраивал композицию и всё такое. Такие фото нужно оставлять как есть.
А вот бейджик «2в1» на пицце нужно переместить в другой угол.
На этом этапе нужно рассмотреть каждую картинку в приложении и решить, какие из них перевернуть, а какие нет.
Для тех иконок, которые нужно перевернуть, можно выставить специальную настройку:
let image = UIImage(named: "some-image")
imageView.image = image?.imageFlippedForRightToLeftLayoutDirection()
Этот код проверяет выбранную ориентацию и говорит imageView перевернуть картинку.
В документации Apple встречается код, который через NSAffineTransform отзеркаливает саму картинку. Но мы не стали использовать этот вариант.
Не каждую картинку можно автоматически отзеркалить. Например, у нас есть картинки с логотипом Додо (оранжевой буквой D и вписанной в неё птичкой). Если их отзеркалить, то логотип будет перевернут — так делать неправильно.
Картинки, которые нельзя автоматически перевернуть, нужно перерисовать и положить в проект. Для такого ассета можно настроить локализацию:
Бейджики на фото в меню
Про бейджики на фото уже упоминалось выше. В RTL они должны располагаться в левом верхнем углу фото.
Сейчас бейджики — часть фотографии. В планах сделать так, чтобы они рисовались в приложении. Тогда мы сможем автоматически менять их расположение при включении RTL. А пока это ещё одна из причин не переворачивать фото.
Анимации
Все анимации тоже стоит перепроверить, у нас их немного, но и те, что есть, в RTL работают неправильно.
Анимация добавления в корзину
Одна из вещей, которая меняет своё направление при включении RTL, — это UITabBar. Самая первая вкладка будет располагаться справа, а последняя — слева.
Зацените, как ведёт себя анимация добавления товара в корзину. Товар всегда улетает в крайнюю правую вкладку таббара. А должен улетать в корзину.
Анимация статуса заказа
Анимация загрузки должна происходить в другую сторону.
Third party
Не забываем проверить сторонние сервисы. Вот как обстоит ситуация у нас.
На трансляции из Ivideon есть текст. У этого текста выравнивание по центру, а сам текст можно поменять в настройках трансляции. Значит, проблем нет.
А вот чат и капча совсем не адаптированы под RTL. Самостоятельно мы это исправить не сможем, значит, придётся обсуждать потенциальные доработки с разработчиками SDK.
WebView
У нас есть WebView в проекте. Они открывают ссылки на наш сайт, гугл-документы, социальные сети и так далее.
С самими WebView ничего делать не нужно, но нужно не забыть прицепить ссылки на локализованные страницы.
Разбираемся, где баг, а где фича
Когда мы первый раз включили RTL в приложении, то наши мозги сломались.
Это очень тяжело и непривычно воспринимать. Всё выглядит дико и ты не понимаешь: это выглядит плохо, потому что непривычно или потому что это сломано.
А что-то выглядит нормально, и ты не понимаешь: ты уже привык к RTL или это выглядит нормально, потому что элемент отображается неправильно и выровнен слева направо.
Была пара мест, в которых мы вначале напряглись, а потом оказалось, что всё нормально. В этом разделе расскажем про такие места.
Поля ввода
Первое — это поля ввода.
Мы переключили приложение в RTL и начали тыкать по полям ввода, чтобы что-то написать. Текст появлялся не с той стороны от каретки, а сама каретка не двигалась. Мы расстроились, что это придётся фиксить.
Оказалось, что если поле ввода находится в режиме RTL и ты начинаешь вводить русские/английские буквы, то они появляются слева от каретки, а сама каретка остаётся на месте.
Зато если ты начинаешь вводить арабские буквы, то они появляются справа от каретки, а сама каретка смещается влево.
Так и должно быть.
Поле ввода промокода
Промокоды у нас содержат только английские буквы. Английские буквы пишутся слева направо. Значит, поле ввода должно быть выровнено слева.
Plural-строки (.stringsdict)
Ещё у нас сломались все множественные формы строк в приложении.
Мы в самом начале статьи рассказывали про два вида pseudolanguage в проекте. Оказалось, что тот, который переворачивает буквы, ломает plural-строки.
Ошибка возникает только при отладке, на проде с арабскими строками всё работает корректно.
Радуемся, что не всё сломалось
Нам не потребовалось ничего дорабатывать в:
UINavigationBar (в Android, например, все стрелочки «Назад» в навбаре сломались);
UITabBar;
UIStackView;
UIPageViewController;
Swipe to delete в таблицах;
сепараторы в таблицах (у нас некоторые сепараторы имеют отступ от границы экрана только с одной стороны. Вот этот отступ и должен отзеркалиться. На Android это автоматически не заработало);
поля ввода (UITextField, UITextView, UISearchBar);
анимации UINavigationController (push/pop) и возвращение назад свайпом.
Все перечисленные элементы автоматически переворачиваются при переключении на RTL-язык: меняется порядок табов, элементов в стеке, направление анимаций UINavigationController и так далее.
Мы были приятно удивлены, когда убедились, что это тоже не требует доработок:
звёзды для оценки заказа;
SDK для сторис.
Разбираемся с неочевидными штуками
Во время локализации приложения на арабский язык недостаточно адаптировать UI, чтобы он корректно отображался для RTL-языков. Важно учесть и другие особенности языка и культуры.
Восточные цифры
В арабском языке для обозначения чисел используются восточные арабские цифры. Они отличаются от тех, к которым мы привыкли:
В приложении есть много мест, где используются цифры: цены, граммовки, номера телефонов и так далее. Почти во всех местах нужно использовать восточные цифры, но бывают исключения. Например, в ценниках и номерах телефонов нужно использовать восточные цифры, а вот для номеров банковских карт используются только западные цифры.
С помощью форматтеров можно конвертировать одни цифры в другие:
extension String {
var toLatnDigits: String? {
let numberFormatter: NumberFormatter = NumberFormatter()
numberFormatter.locale = Locale(identifier: "ru_RU")
guard let latnNumber = numberFormatter.number(from: self) else { return nil }
return numberFormatter.string(from: latnNumber)
}
}
Сами же числа всегда пишутся слева направо, в том числе и номера телефонов.
Кстати, в некоторых случаях iOS самостоятельно конвертирует западные цифры в восточные. Например, если бы эта строка была на арабском:
"resendCodeInDSecWithParam" = "Если код не придет,\n можно получить новый через %d сек";
То в форматированную строку подставилось бы количество секунд, написанное с использованием восточных цифр:
let string = String.localizedStringWithFormat(
NSLocalizedString(
"resendCodeInDSecWithParam",
bundle: bundle,
comment: "Signup screen"
),
Int(self.seconds)
)
Не забываем проверить поля ввода, предполагающие ввод цифр. У нас, например, в поле для сдачи на чекауте нельзя ввести ничего, кроме западных цифр.
Начать использовать восточные цифры в приложении — большая задача, которая затрагивает не только приложения, но и бэкенд. Мы приняли решение не поддерживать восточные цифры в первом релизе приложения с поддержкой арабского языка. Чтобы зафиксировать в приложении использование западных цифр, используем особый формат локали: арабская с западными цифрами (ar-u-nu-latn).
Что с какой стороны писать
Не забываем проверить, что с какой стороны от числа: единицы измерения пишутся слева ( ١٠٠ غ), а знак валюты пишется справа от числа (١٠٠$).
Если указываешь временной интервал, то слева пишется время конца, а справа — время начала. Например, если пиццерия работает с 10 до 18 часов, то это записывается следующим образом: 18:00 - 10:00.
Слова на другом языке
Если в арабском тексте встречаются иностранные слова, то их не нужно писать задом наперёд. Не нужно переворачивать названия на русском и английском языках. Например, PayPal, Apple Pay.
Пунктуация
В арабском отличаются некоторые знаки препинания и прочие символы. Например, в нём используется другой знак процента ٪. У нас в приложении есть картинка с процентом, которую мы используем на экранах со скидками и акциями. Это как раз случай, когда картинку нельзя просто развернуть, её нужно будет заменить другой, с другим знаком процента.
Европейский знак % будет понятен, но использование арабского приоритетнее.
Локализацию знака процента можно сделать через локализацию строк или с помощью форматтера:
Не так:
label.text = String(localized: "(percentComplete)% complete")
А так:
label.text = String(localized: "(percentComplete.formatted(.percent)) complete")
Также в арабском не принято использовать знаки, обозначающие номер (№ и #). Мы же, например, используем такие знаки, когда показываем номер заказа в приложении.
Привычные нам знаки переворачиваются: ، ؛ ⸮.
Арабские люди
Чтобы картинки, на которых изображены люди, работали так, как вы этого ожидаете, т.е. привлекали внимание или вызывали тёплые чувства, пользователи должны ассоциировать себя с персонажами на этих картинках.
Для того чтобы достичь нужного эффекта, стоит заменить людей типичной европейской внешности на людей более близких восточной аудитории. Например, стоит отказаться от использования персонажей в зимней одежде и шапках-ушанках.
Мы привыкли, что локализация — это адаптация приложения под конкретный язык. Недавно мы добавили в приложение Додо Пиццы возможность ручной смены языка. И пока мы обсуждали картинки для ОАЭ, пришли к выводу, что нам нужно уметь показывать разные иллюстрации в зависимости от выбранной страны, а не от выбранного языка. Так и сделали для ОАЭ.
Код, который подставляет нужную картинку в зависимости от текущей страны, можно переиспользовать и в других странах. Например, устанавливать зимние картинки там, где это актуально:
Решаем, как будем тестировать
Изменений получается много, все они интерфейсные. Тестировать RTL-ориентацию будем через снепшот-тесты. Для снепшот-тестов мы используем библиотеку SnapshotTesting. Там есть возможность сделать снепшот, установив нужную ориентацию через traits:
let rtlTrait = UITraitCollection(layoutDirection: .rightToLeft)
assertSnapshot(
matching: sut,
as: .image(traits: rtlTrait),
testName: QuickSpec.current.name
)
Для удобства написали хелпер, чтобы одним тестом проверять сразу несколько снепшотов для LTR и RTL:
itShouldSnapshot(
configs: [.default, .rightToLeft],
matching: sut,
configuration: { sut in
let product = OrderHistoryViewModel.OrderProduct(
name: "Сырные палочки с Песто",
size: "16 шт",
imagePlaceholder: .imgPizzaGift,
category: .pizza
)
var viewModel = OrderHistoryViewModel.PastOrderItem()
viewModel.name = "Сырные палочки с Песто"
viewModel.totalPriceString = "249 ₽"
viewModel.products = [product]
sut.configure(viewModel: viewModel)
},
perceptualPrecision: precision,
size: { sut in
sut.sizeFitting(width: 320)
}
)
Чуть позже прокачали его, чтобы ещё проверять dynamic type и тёмную тему.
Делаем выводы
iOS сильно облегчает работу по локализации приложения под RTL-языки. Можно сказать, что всё, что нам нужно адаптировать в приложении — это техдолг, который выстрелил.
Выводы:
Если сразу правильно верстать, то всю остальную работу iOS сделает за вас.
Чем меньше кастомных элементов, тем лучше это выглядит в RTL.
На вёрстке и переводах дело не заканчивается, есть много культурных особенностей, про которые тоже важно не забыть.
Направление текста — это особенность выбранного языка, но есть вещи, которые зависят не от языка, а от страны. Например, иллюстрации в приложении.
Не все картинки можно отзеркалить автоматически. Что-то придётся перерисовать.
Мы очень привыкли к LTR, поэтому готовое адаптированное приложение лучше показать кому-нибудь из носителей арабского языка, чтобы точно ничего не пропустить.
Снепшот-тесты ускоряют разработку и помогают понять, что вы ничего не сломали в процессе.
Поддержка RTL — это не разовое мероприятие, а непрерывный процесс. Каждая новая фича должна поддерживать RTL. Значит, нужно рассказать команде, на что обращать внимание при проектировании, составить чек-листы для тестирования и инструкции для разработчиков.
ОАЭ — мультинациональная страна. Нельзя оставить приложение только на арабском, английский тоже нужно поддерживать.