Изменение визуальных свойств существующих объектов через протокол UIAppearance

Уже довольно давно, начиная с iOS 5, у программистов появился удобный механизм стилизации внешнего вида компонентов интерфейсов – UIAppearance.

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

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

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

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

NOTE
iOS applies appearance changes when a view enters a window, it doesn’t change the appearance of a view that’s already in a window. To change the appearance of a view that’s currently in a window, remove the view from the view hierarchy and then put it back.

Как это свойство влияет на подход к использованию данных методов?

Например, сработать установка новых свойств должна обязательно до добавления (а обычно это просто создание) объектов в текущую иерархию представлений на окне приложения. Иначе ничего во внешнем виде объектов интерфейса не изменится. Самая наглядная проблемная ситуация – это разное оформление двух экранов приложения, которые находятся в одном стеке UINavigationController. При переходе с первого на второй экран должен измениться цвет и прочее оформление UINavigationBar как в Apple Music:

Apple Music Design
Apple Music Design

или этом примере:

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

Поведение и реализация визуального стиля второго, особого экрана должны быть полностью инкапсулированы, но в то же время у нас присутствует прямая зависимость с объектом более высокого уровня, а именно с UINavigationController. Он отображает представления экранов и навигационная панель относится к нему, а не к экрану. Дизайн второго экрана требует иного стиля для навигационной панели. И тут просматривается концептуальная ошибка дизайна. Идеальным решением была бы инкапсуляция особенностей отображения навигационной панели не в классе экрана, а в кастомных классах навигационного контроллера и самого UINavigationBar, аналогично тому, как работает стилизация статусной строки: контроллер экрана в специальном методе возвращает элемент перечисления. А уж кто и когда запрашивает это значение, контроллер экрана не волнует и волновать, в соответствии с «голливудским принципом», не должно.

Проблема реализации такого визуального перехода с точки зрения программиста состоит лишь в одном: как вернуть исходное состояние с минимальным числом зависимостей? Действительно, если изменить состояние навигационной палени «снизу», из кода экрана, например, в такой манере:

где-нибудь в методах -viewDidAppear: или -viewWillAppear: то при возвращении на первый экран свойство tintColor останется равным белому цвету. Наивным решением будет попытка сохранить каким-либо образом оригинальное значение, и вернуть его в методах -viewDidDisappear: или -viewWillDisappear:. При этом еще необходимо проверять, что именно происходит со стеком контроллера. Зависимостей становится всё больше…

Но целью данной статьи является иллюстрирование работы с UIAppearance, поэтому продолжим.

Методы UIAppearance не сработают вместо прямого доступа к свойствам navigationBar при аналогичном вызове:

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

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

Здесь appearaceBlock – блок, в котором можно реализовать любые манипуляции с UIAppearance (да и не только с ним). В итоге использование данного метода будет выглядеть примерно так:

В данном подходе, который, конечно, не стоит воспринимать как 100% решение, есть пара нюансов:

  • -applyCustomAppearance должен менять только те свойства, которые устанавливает и главный -applyGlobalAppearance
  • всё равно требуется наблюдать за направлением навигации, если дизайн более глубоких экранов не отличается, чтобы избежать каких-либо нежелательных графических артефактов при переходах между ними

Автор

Егор