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

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

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

[[UINavigationBar appearance] setTintColor:[UIColor whiteColor]];
[[UINavigationBar appearance] setTitleTextAttributes: @{NSForegroundColorAttributeName:[UIColor whiteColor]}];

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

+ (instancetype)appearanceWhenContainedInInstancesOfClasses:(NSArray> *)containerTypes

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

[[UINavigationBar appearanceWhenContainedInInstancesOfClasses:@[[UISplitViewController class]]] setBarTintColor:myColor];
[[UINavigationBar appearanceWhenContainedInInstancesOfClasses:@[[UITabBarController class], [UISplitViewController class]]] setBarTintColor:myTabbedNavBarColor];

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

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, аналогично тому, как работает стилизация статусной строки: контроллер экрана в специальном методе возвращает элемент перечисления. А уж кто и когда запрашивает это значение, контроллер экрана не волнует и волновать, в соответствии с «голливудским принципом», не должно.

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

self.navigationController.navigationBar.tintColor = [UIColor whiteColor];

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

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

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

[[UINavigationBar appearance] setTintColor:[UIColor whiteColor]];

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

UINavigationBar *nb = self.navigationController.navigationBar;
UIView *nbSuperview = nb.superview;
// сохраняем индекс размещения представления
NSUInteger zIdx = [nbSuperview.subviews indexOfObject:nb];
// и удаляем 
[nb removeFromSuperview];
// применяем стилизацию
[[UINavigationBar appearance] setTintColor:[UIColor whiteColor]];
// возвращаем представление
[nbSuperview insertSubview:nb atIndex:zIdx];

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

-(void)applyNavBarAppearance:(void(^)())appearanceBlock
{
    UINavigationBar *nb = self.navigationController.navigationBar;
    UIView *nbSuperview = nb.superview;
    NSUInteger zIdx = [nbSuperview.subviews indexOfObject:nb];
    [nb removeFromSuperview];
    if (appearanceBlock) appearanceBlock();
    nb.alpha = 0.;
    [UIView animateWithDuration:0.5 animations:^{
        [nbSuperview insertSubview:nb atIndex:zIdx];
        nb.alpha = 1.;
    }];
}

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

// Стилизация для особого экрана
-(void)applyCustomAppearance
{
    [self applyNavBarAppearance:^{
        [[UINavigationBar appearance] setTintColor:[UIColor whiteColor]];
        [[UINavigationBar appearance] setTitleTextAttributes: @{NSForegroundColorAttributeName:[UIColor whiteColor]}];
    }];
}

// Возвращение глобальных параметров отображения
-(void)applyGlobalAppearance
{
    [self applyNavBarAppearance:^{
        // Здесь вызывается метод, устанавливающий глобальные свойства
        [MyAppDelegate applyGlobalAppearance];
    }];
}

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

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

Опубликовано

в

от

Метки:

Комментарии

Добавить комментарий