Хватит блокировать вертикальную или горизонтальную ориентацию экрана в своих проектах на Unity! В этой статье мы рассмотрим небольшой скрипт, который я использовал в своем проекте.

При разработке приложения "Россети Книга Достижений", в прибавку к непростой задаче об оптимизации интерфейса на разные экрана, добавилась задача о поддержке разных ориентаций устройства, и вот как я ее решил:

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

Код модели для хранения значений RectTransform
[Serializable] // Аттрибут, чтобы Unity сохранял наш объект
public class SavedRect
{
  // Присутствуют ли полезные данные в этом объекте
    public bool isInitialized = false; 

  // Поля для хранения данных из RectTransform
    public Vector3 anchoredPosition;
    public Vector2 sizeDelta;
    public Vector2 minAnchor;
    public Vector2 maxAnchor;
    public Vector2 pivot;
    public Vector3 rotation;
    public Vector3 scale;

    /// <summary>
    /// Сохраняет данные из RectTransform в этот объект.
    /// </summary>
    /// <param name="rect"></param>
    public void SaveDataFromRectTransform(RectTransform rect)
    {
        if (rect == null) // null игнорируем 
            return;

        isInitialized = true; // теперь полезные данные есть

      // сохраняем данные
        anchoredPosition = rect.anchoredPosition3D;
        sizeDelta = rect.sizeDelta;
        minAnchor = rect.anchorMin;
        maxAnchor = rect.anchorMax;
        pivot = rect.pivot;
        rotation = rect.localEulerAngles;
        scale = rect.localScale;
    }

    /// <summary>
    /// Выгружает данные из этого объекта в RectTransform
    /// </summary>
    /// <param name="rect"></param>
    public void PutDataToRectTransform(RectTransform rect)
    {
      // игнорируем null или пустые данные
        if (rect == null || !isInitialized) 
            return;

      // выгружаем данные
        rect.anchoredPosition3D = anchoredPosition;
        rect.sizeDelta = sizeDelta;
        rect.anchorMin = minAnchor;
        rect.anchorMax = maxAnchor;
        rect.pivot = pivot;
        rect.localEulerAngles = rotation;
        rect.localScale = scale;
    }
}

И теперь мы можем реализовать скрипт, который будет хранить данные для двух ориентаций экрана:

Код контроллера положения UI элемента
[ExecuteAlways] // выполняем скрипт и в Play, и в Edit моде
[RequireComponent(typeof(RectTransform))] // нужен RectTransform на GameObject
public sealed class OrientationController : MonoBehaviour
{
	  // Хранилище для вертикальной ориентации
    public SavedRect verticalRect = new SavedRect();
    // Хранилище для горизонтальной ориентации
    public SavedRect horizontalRect = new SavedRect();
		
    // Закэшированный RectTransform
    private RectTransform _rect;

    private void Awake()
    {
        _rect = GetComponent<RectTransform>();
        // Подписываемся на событие
        OrientationChanged += OnOrientationChanged;
        // Проводим инициализационное принудительное обновление
        OnOrientationChanged(this, isVertical);
    }

    public void SaveCurrentState()
    {
        if (isVertical)
            verticalRect.SaveDataFromRectTransform(_rect);
        else
            horizontalRect.SaveDataFromRectTransform(_rect);
    }

    public void PutCurrentState()
    {
        OnOrientationChanged(this, isVertical);
    }

    private void OnOrientationChanged(object sender, bool isVertical)
    {
        if (isVertical)
            verticalRect.PutDataToRectTransform(_rect);
        else
            horizontalRect.PutDataToRectTransform(_rect);
    }

    private void OnDestroy()
    {
    		// отписываемся от события
        OrientationChanged -= OnOrientationChanged;
    }


    // Static
    public static bool isVertical;
    // событие смены ориентации
    private static event EventHandler<bool> OrientationChanged;
    // метод для вызова события извне
    public static void FireOrientationChanged(object s, bool isVertical) => OrientationChanged?.Invoke(s, isVertical);
		
    /// статический конструктор нестатического класса вызывается 1 раз
    /// до первой инициализации объекта этого типа
    static OrientationController()
    {
    		// обновляем isVertical при срабатывании события
        OrientationChanged += (s, e) => isVertical = e;
    }
}

В этом скрипте присутствует OrientationChanged ивент, его можно вызывать через FireOrientationChanged(...), для обновления UI. Напишем тестовый скрипт, который будет этим управлять:

Код скрипта, проверяющего изменения ориентации
[ExecuteAlways] // скрипт работает всегда
/// этот аттрибут нужен, чтобы быть уверенным, в том, что
/// этот компонент будет инициализирован до OrientationController
[DefaultExecutionOrder(-10)] 
public class OrientationChecker : MonoBehaviour
{
    private void Awake()
    {
    		// принудительное инициализационной обновление
        HandleOrientation();
    }

    void Update()
    {
    		// проверяем ориентацию каждый кадр
        HandleOrientation();
    }

    private void HandleOrientation()
    {
    		/// Если последняя ориентация была вертикальной
        /// и ширина экрана больше высоты ...
        if (OrientationController.isVertical &&
            Screen.width > Screen.height)
        {
        		// ... то вызываем событие, и передаем isVertical = false
            OrientationController.FireOrientationChanged(this, false);
        }
        else
        /// Иначе, если последняя ориентация была горизонтальной
        /// и ширина экрана меньше высоты ...
        if (!OrientationController.isVertical &&
            Screen.width < Screen.height)
        {
        		// ... то вызываем событие, и передаем isVertical = true
            OrientationController.FireOrientationChanged(this, true);
        }
    }
}

Представленный вариант так же обрабатывает ориентацию на ПК, если вдруг вы не ограничиваетесь приложениями на мобильные устройства, он может определять книжный (Portrait) и альбомный (Landscape) режимы отображения монитора.

Теперь напишем скрипт для инспектора, что бы сохранять и выгружать позицию в режиме Edit, а не только Play. Вы могли уже заметить, что в скриптах используется аттрибут ExecuteAlways, что заставляет скрипт быть активным всегда. И так, код кастомного инспектора:

Код скрипта, изменяющего отображение компонента в инспекторе
/// Код внутри этой инструкции будет удален из билда
/// это нужно, т.к. в билде нет библиотеки UnityEditor
/// что вызовет ошибку компиляции
#if UNITY_EDITOR 
using UnityEditor;

// Помечаем, что это кастомный инспектор для OrientationController
[CustomEditor(typeof(OrientationController))]
// Помечаем, что этот компонент поддерживает редактирование сразу нескольких объектов
[CanEditMultipleObjects]
public class OrientationControllerEditor : Editor
{
    public override void OnInspectorGUI()
    {
        serializedObject.Update();
				
        // Выводим текущую ориентацию в самом верху ...
        GUILayout.Label("Current orientation: " +
            (OrientationController.isVertical ? "Vertical" : "Horizontal"));
				
        // ... рисуем стандартный испектор ...
        base.DrawDefaultInspector();

        var controllers = targets;
				
        // ... после отрисовываем кнопку Save ...
        if (GUILayout.Button("Save values"))
        		// этот цикл тут для поддержки редактирования нескольких объектов
            foreach(var controller in controllers)
                ((OrientationController)controller).SaveCurrentState();
				
        // ... и после кнопку Put
        if (GUILayout.Button("Put values"))
        		// этот цикл тут для поддержки редактирования нескольких объектов
            foreach (var controller in controllers)
                ((OrientationController)controller).PutCurrentState();
				
        // сохраняем изменения
        serializedObject.ApplyModifiedProperties();
    }
}
#endif

В этом скрипте используется инструкция компилятору "#if UNITY_EDITOR", это сделано с расчетом на незнакомых с спец. папками пользователей, если вы умеете создавать и использовать папку Editor в проекте, то можете удалить эти инструкции :)

И так, у нас все есть, добавляем на сцену OrientationChecker и на все необходимые UI элемнты OrientationController, при нажатии на кнопку "Save values" - компонент возьмет данные из RectTransform и сохранит у себя в нужную модель, согласно ориентации, при нажатии на "Put values" - вставит из соответствующего контейнера данные в RectTransform.

Изображение компонента в инспекторе (Внутри спойлера, т.к. я не умею скейлить изображения на хабре :P )

И мем связанный с моим неумением скейлить изображения:

Что бы изменять ориентацию в Unity Editor, нужно либо добавить свою (в выпадающий список на скриншоте ниже, через плюсик в конце), либо выбрать существующий. Чтобы получить тот же список, что и у меня, необходимо в качестве Platform выбрать iOS (или Android).

Спойлер

Да, это я использую Unity 2019.2 в 2022 году :)

На более новых версиях все будет выглядеть и работать так же, проверено до Unity 2021.3.x

И вот как выглядит результат, меняем ориентации - меняется Layout, так же он меняется сразу, если подгружать новую сцену:

Landscape ориентация, с настроенным OrientationController на квадратах слева, и без OrientationController на квадрате справа и стрелке.
Landscape ориентация, с настроенным OrientationController на квадратах слева, и без OrientationController на квадрате справа и стрелке.
Portrait ориентация, здесь можно увидеть преимущества скрипта над системой RectTransform из коробки Unity.
Portrait ориентация, здесь можно увидеть преимущества скрипта над системой RectTransform из коробки Unity.

Скрипт можно усложнять до бесконечности: добавить плавные переходы UI элементов на новые позиции, добавить управление AnimatorController для переключения паков анимаций для Landscape на Portrait и обратно и т.д.; подстроив его под любые нужны проекта.

Буду рад дополнениям и критике в комментариях, код продублирован на git'е:
AlexMorOR/Unity-UIOrientation (github.com)

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


  1. Tr0sT
    09.08.2022 21:11
    +1

    Это работает для такого простого случая, но что случится, если кто-то перевернёт экран во время анимации, или после перемещения какого-нибудь элемента со своей стартовой позиции?

    Обычно для поддержки нескольких ориентаций экрана, или более точного лэйаута (например, отдельно для 4:3 планшета и 21:9 смартфона) создают под каждый случай свою вьюшку. И при ивенте смене ориентации пересоздают и переинициализируют окошки.


    1. AlexMorOR Автор
      10.08.2022 17:01

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

      Насчет анимаций: в большинстве случае анимации перемещения интерфейса длятся <1 сек, а при смене ориентации проигрывается анимация поворота длиной 0.5 сек, за это время можно сменить набор анимаций и перевести элемент в конечную точку запущенной анимации, пример можно увидеть в приложении.

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

      Ссылки на приложение:
      https://apps.apple.com/de/app/россети-центр-книга-достижений/id1498965479
      https://play.google.com/store/apps/details?id=com.NexusStudio.mrskglorybook&hl=af&gl=US


  1. DezmontDeXa
    10.08.2022 16:09

    Когда посмотрел первый кусок кода и ушел. Инкапсуляция где?


    1. AlexMorOR Автор
      10.08.2022 16:17

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


      1. DezmontDeXa
        10.08.2022 17:07

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

        В вашем виде внутренние данные объекта полностью открыты для всех, в том числе для других скриптов, которые вроде как не должны изменять SavedRect, иначе какой из него Saved.


        1. AlexMorOR Автор
          10.08.2022 17:37

          Да, Вы правы, но видимой проблемы или преимущества это изменение не дает.