Вместо заголовка, пожалуй, лучше всего описать мои впечатления одной известной фразой: «Переключить LTR на RTL в рантайме? Приключение на пять минут. Зашли и вышли». Но, как вы уже догадались, всё оказалось да‑а-алеко не так просто

Представьте: пользователь переключает язык приложения с английского на арабский. Интерфейс перестраивается, кнопки меняются местами, навигация переезжает на другую сторону. Вроде всё работает.

Но затем открываешь следующий экран и замечаешь, что стрелка Back смотрит не туда. Возвращаешься назад, открываешь форму, а текстовое поле ведёт себя странно при вводе латиницы. Ещё одно переключение языка, и часть интерфейса снова оказывается в LTR.

При этом UIKit совершенно спокойно сообщает:

window.effectiveUserInterfaceLayoutDirection == .rightToLeft
// true

Получается довольно странная ситуация. UIKit считает, что направление уже изменилось, но интерфейс с этим не вполне согласен.

Именно с этого началось моё исследование динамического переключения RTL.

Задача выглядела вполне обычной: дать пользователю возможность менять язык непосредственно в настройках приложения. Без перезапуска, сброса навигации и потери текущего состояния экрана.

С английским и другими LTR‑языками особых сложностей не возникало. Арабский быстро показал, что смена локализации и смена направления интерфейса в UIKit устроены совершенно по‑разному.

Первое решение, которое почти работает

UIKit предоставляет semanticContentAttribute. Через него можно указать, в каком направлении должно отображаться содержимое представления.

Для начала попробуем изменить направление всего окна:

window.semanticContentAttribute = .forceRightToLeft

Проверяем:

print(window.effectiveUserInterfaceLayoutDirection)
// rightToLeft

Отлично. Теперь заставим интерфейс обновиться:

window.rootViewController?.view.setNeedsLayout()
window.rootViewController?.view.layoutIfNeeded()

И действительно, часть элементов переместится.

Constraints, использующие leading и trailing, способны учитывать новое эффективное направление. Некоторые системные компоненты тоже перестроятся.

Но на этом хорошие новости заканчиваются.

Один экран выглядит правильно, на другом остались старые отступы. Где‑то текст выровнен слева, хотя контейнер уже находится справа. Кастомные изображения не поменяли ориентацию.

Первая мысль: возможно, мы недостаточно глубоко обновили иерархию.

Можно рекурсивно пройтись по всем UIView и вызвать setNeedsLayout().

Но это не решает проблему.

layoutIfNeeded() запускает компоновку, когда она необходима. Он не пересоздаёт содержимое компонентов и не пересчитывает произвольные значения, которые разработчик сохранил при их инициализации.

Например, если компонент один раз вычислил isRTL и сохранил результат, изменение направления родительского представления не заставит его автоматически пересчитать эту переменную.

Значит, проблема глубже обычного обновления layout.

Один экран, два направления

Чтобы разобраться, полезно посмотреть, что UIKit знает о каждом представлении.

У UIView есть два интересующих нас свойства:

view.semanticContentAttribute
view.effectiveUserInterfaceLayoutDirection

Первое описывает семантику, которую мы задали. Второе возвращает направление, которое UIKit определил с учётом этой семантики и окружающего контекста.

Добавим небольшой диагностический метод:

func inspect(_ view: UIView, depth: Int = 0) {
    let prefix = String(repeating: "  ", count: depth)

    print(
        "\(prefix)\(type(of: view))",
        "semantic: \(view.semanticContentAttribute.rawValue)",
        "direction: \(view.effectiveUserInterfaceLayoutDirection.rawValue)"
    )

    view.subviews.forEach {
        inspect($0, depth: depth + 1)
    }
}

Теперь можно увидеть примерно такую картину:

UIWindow
  semantic: forceRightToLeft
  direction: RTL

  RootView
    semantic: unspecified
    direction: RTL

    ContentView
      semantic: forceLeftToRight
      direction: LTR

Это условный пример, но он показывает вполне реальный механизм UIKit.

Мы переключили окно в RTL. Корневое представление унаследовало направление. А ContentView продолжает использовать LTR, потому что ему явно установили .forceLeftToRight.

Причём такое значение могло появиться задолго до добавления арабской локализации.

Когда приложение поддерживает только LTR‑языки, подобные настройки практически незаметны.

Здесь решение относительно простое: убрать принудительное направление там, где оно не требуется.

view.semanticContentAttribute = .unspecified

Но дальше обнаруживается более интересная ситуация.

Допустим, мы проверили иерархию. Все необходимые представления сообщают RTL. А один из компонентов всё равно продолжает использовать старое направление.

Откуда он его берёт?

Оказывается, у UIKit есть ещё один источник направления

Помимо semanticContentAttribute, существует:

traitCollection.layoutDirection

И вот здесь начинается самое интересное.

Предположим, приложение запустилось в LTR. Затем пользователь выбрал арабский язык, а мы изменили семантику окна.

Проверим два значения:

let effectiveDirection =
    window.effectiveUserInterfaceLayoutDirection

let traitDirection =
    window.traitCollection.layoutDirection

Они не обязаны совпадать.

Например, в определённой конфигурации можно получить:

effectiveDirection: rightToLeft
traitDirection: leftToRight

Почему?

Потому что semanticContentAttribute и UITraitCollection решают разные задачи.

semanticContentAttribute участвует в определении направления конкретного представления.

UITraitCollection содержит характеристики окружения, предоставленные UIKit.

Изменение одного механизма не гарантирует изменения другого.

Посмотрим ещё выше, на уровень сцены:

let sceneDirection =
    window.windowScene?
        .traitCollection
        .layoutDirection

Здесь также может сохраняться LTR.

При этом у UIWindowScene нет публичного сеттера, через который можно просто присвоить новое значение layoutDirection.

Это объясняет, почему принудительное изменение направления окна не всегда приводит к поведению, идентичному запуску приложения сразу на арабском языке.

Но само несовпадение значений ещё не является багом.

Проблема возникает, когда разные части приложения используют разные источники направления.

Например, один компонент проверяет:

view.effectiveUserInterfaceLayoutDirection

А другой:

traitCollection.layoutDirection

Если эти значения различаются, компоненты могут принять противоположные решения.

Первый разместит иконку справа, второй слева.

И оба будут работать согласно своей реализации.

Вот здесь первоначальная гипотеза о недостаточном обновлении Auto Layout окончательно перестаёт объяснять происходящее.

Почему UIKit не сообщает, что направление изменилось?

Следующая идея вполне логична.

Если направление изменяется, можно подписаться на соответствующее событие и обновлять компоненты.

В UIKit долгое время для наблюдения за изменениями окружения использовался:

override func traitCollectionDidChange(
    _ previousTraitCollection: UITraitCollection?
) {
    super.traitCollectionDidChange(
        previousTraitCollection
    )

    updateInterface()
}

Но есть проблема.

Если мы изменили semanticContentAttribute, это ещё не означает, что изменился traitCollection.

Следовательно, UIKit не обязан вызывать traitCollectionDidChange.

На iOS 17 появился более современный механизм:

registerForTraitChanges(
    [UITraitLayoutDirection.self]
) { (view: UIView, _) in
    view.updateInterface()
}

Он удобнее для отслеживания соответствующего trait, но принципиально ситуацию не меняет.

Мы наблюдаем за изменениями UITraitCollection, а не за любым изменением эффективного направления представления.

Если изменилось только semanticContentAttribute, полагаться на такой callback нельзя.

Именно поэтому динамическое переключение языка лучше делать явным событием внутри приложения.

Например, сервис локализации сообщает, что язык изменился. Компоненты, которым недостаточно автоматического поведения UIKit, обновляют своё состояние.

Но какие именно компоненты требуют такого обновления?

Оказалось, что далеко не только контейнеры.

Auto Layout уже RTL, а текст ещё нет

Возьмём UILabel с атрибутированной строкой:

let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .left

let attributedText = NSAttributedString(
    string: "Example",
    attributes: [
        .paragraphStyle: paragraphStyle
    ]
)

label.attributedText = attributedText

После переключения направления сам UILabel может переместиться на другую сторону экрана.

А текст останется выровненным слева.

Здесь UIKit ничего не нарушает.

Мы явно указали .left, и это значение осталось внутри NSAttributedString.

Можно сколько угодно вызывать:

label.setNeedsLayout()
label.setNeedsDisplay()

Результат не изменится.

В какой‑то момент становится понятно, что мы пытаемся решить проблему не на том уровне.

Auto Layout отвечает за расположение представлений. Он не должен переписывать текстовые атрибуты.

Для обычного текста часто подходит:

paragraphStyle.alignment = .natural

Но .natural тоже не означает автоматическое выравнивание относительно интерфейса.

Естественное направление английской строки остаётся LTR, даже если она находится внутри арабского интерфейса.

Если нужно строго следовать направлению UI, выравнивание придётся определять самостоятельно:

let alignment: NSTextAlignment =
    isRTL ? .right : .left

И пересоздавать соответствующие атрибуты при переключении языка.

Похожая проблема возникает с кешированными изображениями и другими параметрами, вычисленными при создании компонента.

Общий признак один: значение было определено раньше и больше не обновлялось.

С UITextField всё ещё интереснее

На обычных текстовых метках проблему можно решить относительно быстро.

Но попробуем переключить интерфейс на арабский и открыть поле ввода.

textField.semanticContentAttribute = .forceRightToLeft
textField.textAlignment = .right

Пока пользователь вводит арабский текст, поведение может выглядеть ожидаемо.

Теперь попробуем ввести английское имя. Или номер телефона. Или строку, в которой арабский текст смешан с латиницей и цифрами.

Тут становится заметно, что мы имеем дело сразу с несколькими независимыми понятиями.

Направление интерфейса определяет расположение компонентов.

Выравнивание текста определяет, к какой стороне будет прижато содержимое.

А визуальный порядок символов зависит от Unicode Bidirectional Algorithm.

Латинские символы сохраняют собственное направление внутри арабского текста. Положение каретки, выделение и отображение знаков пунктуации зависят от контекста.

В UIKit можно управлять базовым направлением письма:

textField.setBaseWritingDirection(
    .rightToLeft,
    for: .all
)

Но и это не универсальное решение.

Например, для номера телефона обычно требуется сохранить привычный порядок цифр независимо от языка интерфейса.

Для поля свободного ввода могут действовать другие правила.

Значит, глобальное применение .forceRightToLeft ко всем текстовым полям не подходит.

И это уже не недостаток Auto Layout. Здесь нужно определить поведение каждого типа ввода.

Почему стрелка Back смотрит не туда

После текстовых компонентов можно перейти к навигации.

Допустим, UINavigationBar уже перестроился под RTL. Кнопки находятся на нужных местах, но стрелка возврата по‑прежнему указывает влево.

Проверяем реализацию и обнаруживаем кастомный back indicator:

navigationBar.setBackIndicatorImage(
    image,
    transitionMaskImage: image
)

UIKit не обязан автоматически зеркалировать произвольное изображение.

Если используется стандартная навигация, лучше по возможности оставить системное поведение.

Для собственного интерфейса можно использовать направленные SF Symbols:

UIImage(systemName: "chevron.backward")

Либо подготовить изображение с поддержкой зеркалирования.

Но и здесь есть ограничение: далеко не все изображения должны менять ориентацию при переключении RTL.

Например, логотипы и фотографии обычно не зеркалируют.

Поэтому глобальная обработка всех UIImageView создаст новые ошибки вместо исправления старых.

А жесты вообще ничего не знают о RTL

Допустим, в приложении есть карточка, которую пользователь смахивает влево.

Обработчик:

let translation = gesture.translation(in: view)

if translation.x < 0 {
    performAction()
}

После перехода на арабский интерфейс направление действия может потребоваться изменить.

Но UIPanGestureRecognizer продолжит возвращать физическое смещение в координатах представления.

UIKit не станет автоматически менять знак translation.x.

Для жестов, которые должны следовать логическому направлению интерфейса, можно использовать нормализацию:

func logicalTranslation(
    _ translation: CGFloat,
    isRTL: Bool
) -> CGFloat {
    translation * (isRTL ? -1 : 1)
}

Теперь остальная логика может работать с одинаковым направлением независимо от локализации.

Правда, это решение нужно применять последовательно: к перемещению, скорости и условиям завершения жеста.

Иначе карточка будет двигаться правильно, но действие сработает в противоположную сторону.

Получается, что RTL затрагивает даже те части приложения, которые визуально никак не связаны с текстом.

Может, проще пересоздать весь интерфейс?

На этом этапе возникает закономерный вопрос.

Если UIKit не обновляет все компоненты автоматически, почему бы не пересоздать их уже с правильным направлением?

Например:

func rebuildInterface(
    window: UIWindow,
    direction: UISemanticContentAttribute
) {
    window.semanticContentAttribute = direction

    let rootController = makeRootController()

    rootController.view.semanticContentAttribute =
        direction

    window.rootViewController = rootController
}

Здесь makeRootController() обозначает условную фабрику корневого контроллера.

У подхода есть преимущество.

Новые компоненты создаются уже после выбора языка. Они могут сразу сформировать нужные строки, изображения, constraints и прочие параметры.

Не приходится искать каждый объект, который когда‑то сохранил старое значение isRTL.

Но теперь представим, что пользователь находится на четвёртом экране навигационного стека и заполняет форму.

После замены rootViewController всё это состояние может исчезнуть.

Значит, придётся сохранять и восстанавливать навигацию, данные форм, положение прокрутки и другие параметры интерфейса.

Кроме того, пересоздание корневого контроллера не изменяет системные traits сцены.

То есть мы можем устранить проблемы с устаревшим состоянием компонентов, но не обязаны устранить расхождения между semanticContentAttribute и UITraitCollection.

Решение работает в определённых условиях, но универсальным его назвать нельзя.

Почему я отказался бы от глобального swizzling

Когда обычные механизмы не дают полного контроля, появляется соблазн вмешаться в жизненный цикл UIKit.

Например, перехватывать layoutSubviews() и автоматически применять нужное направление ко всем представлениям.

На первый взгляд это выглядит удобно: одна точка, через которую проходит практически весь интерфейс.

Но layoutSubviews() может вызываться многократно даже при обычном взаимодействии с экраном.

Если внутри него менять свойства, влияющие на layout, можно получить дополнительные проходы компоновки.

При этом swizzling всё равно не решает проблему закешированных атрибутированных строк, изображений и пользовательской логики.

В результате мы усложняем поведение UIKit, но не устраняем исходное ограничение.

Поэтому глобальный swizzling для переключения RTL я бы не использовал.

Где на самом деле находится решение

После всех этих экспериментов становится заметна общая закономерность.

Проблемы возникают в компонентах, которые используют направление интерфейса, но не умеют обновлять зависимое состояние после его изменения.

Одни читают effectiveUserInterfaceLayoutDirection.

Другие используют traitCollection.layoutDirection.

Третьи вообще один раз вычисляют isRTL и сохраняют его в свойстве.

Именно это разнообразие источников делает динамическое переключение непредсказуемым.

Поэтому я бы строил решение вокруг единого состояния локализации, а не пытался заставить UIKit автоматически обновить абсолютно всё.

Например:

enum LayoutDirection {
    case leftToRight
    case rightToLeft

    var semanticAttribute: UISemanticContentAttribute {
        switch self {
        case .leftToRight:
            return .forceLeftToRight

        case .rightToLeft:
            return .forceRightToLeft
        }
    }
}

Выбранный язык определяет направление приложения.

При переключении сервис локализации обновляет своё состояние и отправляет событие об изменении.

После этого приложение применяет новое направление к соответствующим окнам и обновляет компоненты, зависящие от локализации.

При необходимости часть интерфейса можно пересоздать.

Но решение о пересоздании должно приниматься осознанно, с учётом сохранения пользовательского состояния.

Как проверить, что всё действительно работает

Самая опасная ошибка при тестировании RTL — проверять только запуск приложения сразу на арабском языке.

В таком сценарии многие компоненты изначально получают правильное направление.

Настоящие проблемы появляются при переключении уже существующего интерфейса.

Поэтому я бы обязательно проверял:

Cold Start → LTR
Cold Start → RTL

LTR → RTL
RTL → LTR

LTR → RTL → LTR
RTL → LTR → RTL

Особенно полезны повторные переключения.

Они обнаруживают компоненты, которые умеют один раз применить RTL, но не умеют вернуться обратно.

Отдельно стоит проверять модальные экраны, навигацию, поля ввода со смешанным текстом и кастомные жесты.

А при появлении странного поведения первым делом смотреть три значения:

view.effectiveUserInterfaceLayoutDirection

view.traitCollection.layoutDirection

window.windowScene?
    .traitCollection
    .layoutDirection

Если они различаются, нужно выяснить, каким из них руководствуется проблемный компонент.

Вместо заключения

Изначально я пытался найти способ переключить весь UIKit в RTL одним действием.

Сначала казалось, что достаточно изменить semanticContentAttribute. Затем, что нужно принудительно обновить layout. После этого возникла идея пересоздать интерфейс целиком.

Каждый подход решал часть проблем, но ни один не закрывал все случаи.

Причина оказалась в устройстве самого механизма локализации: UIKit предоставляет несколько способов определения направления, а уже созданные компоненты могут хранить собственное состояние, которое не обновляется автоматически.

В результате самым полезным инструментом оказался не очередной вызов layoutIfNeeded(), а обычная диагностика.

Когда видишь, какое направление получает конкретный компонент, откуда он его берёт и когда вычисляет зависимые параметры, проблема перестаёт выглядеть случайной.

И следующий RTL‑баг уже не хочется исправлять дополнительным forceRightToLeft.

Сначала хочется проверить, кто именно продолжает считать, что интерфейс всё ещё LTR.

Комментарии (7)


  1. Saidai
    21.10.2026 13:37

     пользователь переключает язык приложения с английского на арабский

    Если переключает через системные настройки, а эта возможность есть у каждого приложения, то при следующем запуске, приложение запускается с нуля и проблем не должно быть.


    1. Eyho Автор
      21.10.2026 13:37

      Так в этом и прикол - что сделать в рантайме без перезапуска


      1. Saidai
        21.10.2026 13:37

        Имхо, фича "менять язык рантайме" сама по себе спорная, стоит ли она поддержки и траты времени?


        1. Eyho Автор
          21.10.2026 13:37

          Если приложение специализированно под работу с иностранцами. И критически важно иметь вариант поменять язык на любой стадии работы приложения. В том числе туда обратно.

          То да


    1. Eyho Автор
      21.10.2026 13:37

      Если задача не стоит делать это в рантайме или если не нужно что бы мп могло работать одновременно на нескольких локалях и семантики одновременно

      Типо часть на арабском часть на англиском и тд - то вариант с настройками и перезапуском куда проще


  1. AnnSerba
    21.10.2026 13:37

    По ходу разбора вспомнил ещё одно место, где направление теряется без всякого коварства UIKit: appearance-прокси применяется в момент создания представления, а не при изменении. Если направление задавалось через UIView.appearance().semanticContentAttribute — а это типичный способ «задать сразу везде», — то после переключения языка все уже созданные элементы остаются в старом направлении. Выглядит ровно как ваш эффект «часть интерфейса снова в LTR».

    С картинками та же история: imageFlippedForRightToLeftLayoutDirection переворачивает стрелку по семантике вьюхи в момент отрисовки, поэтому если направление выставляется руками при переключении, Back придётся перевернуть явно — заменой ассета, иначе он смотрит влево.

    И к вычислению целевого направления: удобнее UIView.userInterfaceLayoutDirection(for:), чем Application.shared — последний следует системной локали и про ваш app-level override ничего не знает.


    1. Eyho Автор
      21.10.2026 13:37

      Да, там как оказалось много приколов. Поэтому в рантайме лучше этого не делать без необходимости