Разработка под Apple iOS / Настройка внешнего вида UIPopoverController

в 14:59, , рубрики: apple, iOS, ipad, внешний вид, всплывающие окна, разработка, метки: , , , , ,

Разработка под Apple iOS / Настройка внешнего вида UIPopoverController
UIPopoverController или всплывающее окно (далее просто «поповер») элемент далеко не новый. На Хабре есть одна вводная статья на эту тему и несколько упоминаний в других топиках. Чаще всего поповеры используются «как есть» и не требуют каких-либо модификаций, но в некоторых проектах возникает необходимость изменить внешний вид этого элемента. Как раз о том как это сделать и будет эта статья.
Статья не просто перевод или пересказ документации Apple. Я столкнулся с проблемой в реальном проекте, пропустил материал сквозь себя (в хорошем смысле слова), приготовил тщательно разжеванное объяснение и, напоследок, приправил все это конкретной реализацией, которая может пригодиться и вам.
Зачем это нужно?

Как я писал выше, я столкнулся с такой необходимостью на примере конкретного проекта. Изначально приложение было написано под iPhone и «выполнено» в красных тонах, а именно использовался метод appearance (внешний вид) класса UINavigationBar
[[UINavigationBar appearance] setTintColor: [UIColor colorWithRed:0.481 green:0.065 blue:0.081 alpha:1.000]];
[[UINavigationBar appearance] setBackgroundImage:[UIImage imageNamed:@"navbar"] forBarMetrics:UIBarMetricsDefault];

Результат был примерно таким:
Когда на базе существующего приложения начали делать версию для iPad понадобилось поместить UINavigationController внутрь поповера.
Можно, конечно, вернуть дефолтный внешний вид классу UINavigationBar, если он отображается внутри поповера
[[UINavigationBar appearanceWhenContainedIn:[UIPopoverController class], nil] setBackgroundImage:nil forBarMetrics:UIBarMetricsDefault];
[[UINavigationBar appearanceWhenContainedIn:[UIPopoverController class], nil] setTintColor:[UIColor clearColor]];

В принципе не смертельно, но, допустим, заказчик (а он всегда прав) сказал что так не пойдет и «поповеры перекрасить!». Тут-то нам и пригодится свойство popoverBackgroundViewClass класса UIPopoverController. Наша задача — унаследовать класс UIPopoverBackgroundView, четко следуя документации.
Наследование UIPopoverBackgroundView

Документация, конечно же, подробно описывает что и как делать, какие методы переопределить и для чего. Дополнительно даются практические рекомендации — лучше использовать изображения и класс UIImageView для отрисовки фона и стрелок. Все это «на словах», я лично легче воспринимаю текст если к нему прилагаются иллюстрации, поэтому попробую восполнить этот «пробел». Параллельно начнем писать реализацию нашего конкретного подкласса UIPopoverBackgroundView. Первое что мы сделаем, просто унаследуем его и оставим пока так, без реализации.
#import

@interface MBPopoverBackgroundView : UIPopoverBackgroundView
@end

Анатомия UIPopoverController
UIPopoverController состоит из стрелки (Arrow), фона (Background), содержимого или контента (Content View), и UIView в котором все это добро содержится и отрисовывается.
Стрелка
По сути «стрелка» в данном контексте чисто образный термин. Мы ограничены только собственной фантазией и здравым смыслом выбирая внешний вид стрелки. Это может быть пунктирная линия, кривая, произвольная картинка. Мы можем использовать просто UIView с переопределенным методом draw и рисовать функциями gl***, можно использовать анимированный UIImageView и т.д. Единственное что нужно помнить — ширина основания стрелки (arrowBase) и ее высота (arrowHeight) остаются неизменными для всех экземпляров нашего класса. Хотя и это ограничение можно в какой-то степени обойти, но об этом позже.
Сейчас же выберем UIImageView для представления стрелки, следуя советам Apple. Также обратим внимание на методы класса +(CGFloat)arrowBase и +(CGFloat)arrowHeight. По умолчанию они оба выбрасывают исключение, поэтому мы обязаны их переопределить в своем подклассе.
Для простоты изложения, просто договоримся, что изображение стрелки у нас есть и хранится оно в файле «popover-arrow.png». Теперь можно смело это все закодить
@interface MBPopoverBackgroundView ()
// image view для стрелки
@property (nonatomic, strong) UIImageView *arrowImageView;
@end

@implementation MBPopoverBackgroundView
@synthesize arrowImageView = _arrowImageView;

// основание стрелки (arrow base)
+ (CGFloat)arrowBase {
// возвращаем ширину изображения
return [UIImage imageNamed:@"popover-arrow.png"].size.width;
}

// высота стрелки (arrow height)
+ (CGFloat)arrowHeight {
// возвращаем высоту изображения
return [UIImage imageNamed:@"popover-arrow.png"].size.height;
}

// инициализация
- (id)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (!self) return nil;

// создаем image view для стрелки
self.arrowImageView = [[UIImageView alloc] initWithImage:@"popover-arrow.png"];
[self addSubview:_arrowImageView];

return self;
}

@end

Но и это еще не все касательно стрелки. В наши обязанности также входит переопределение двух свойств
@property (nonatomic, readwrite) UIPopoverArrowDirection arrowDirection;
@property (nonatomic, readwrite) CGFloat arrowOffset;

иначе мы поймаем все то же исключение при попытке вызвать setter или getter для любого их них.
Направление стрелки (arrowDirection) говорит нам куда стрелка указывает (вверх, вниз, влево, вправо) и где она собственно располагается. Смещение стрелки (arrowOffset) это расстояние от центра нашего view до линии проходящей через центр стрелки, в общем, посмотрите на иллюстрацию, там все наглядно изображено, смещения отмечены синим цветом. Смещения вверх и влево имеют отрицательное значение.
Документация рекомендует реализовать setter и getter для этих свойств. Но я на практике выяснил, что можно объявить эти свойства и синтезировать нужные методы
@interface MBPopoverBackgroundView ()
// свойства для направления и смещения стрелки
@property (nonatomic, readwrite) UIPopoverArrowDirection arrowDirection;
@property (nonatomic, readwrite) CGFloat arrowOffset;
@end

@implementation MBPopoverBackgroundView
@synthesize arrowDirection = _arrowDirection;
@synthesize arrowOffset = _arrowOffset;
@end

Изменение любого из этих свойств — сигнал к тому что нужно изменить размеры и расположение стрелки и фона. Воспользуемся механизмом Key-Value Observing для этих целей. Как только свойство изменилось — сообщим нашему MBPopoverBackgroundView что пора бы навести порядок и расставить детей (subviews) по местам, т.е. вызовем setNeedsLayout. Это, в свою очередь, приведет к вызову layoutSubviews в следующий подходящий момент (когда именно решает операционка). Про реализацию layoutSubviews будет сказано подробно немного позже.
- (id)initWithFrame:(CGRect)frame {
// *** код пропущен ***
[self addObserver:self forKeyPath:@"arrowDirection" options:0 context:nil];
[self addObserver:self forKeyPath:@"arrowOffset" options:0 context:nil];
return self;
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
// можно было бы проверить какое именно свойство изменилось
// но в нашем случае любое изменение требует вызова setNeedsLayout
[self setNeedsLayout];
}

- (void)dealloc {
[self removeObserver:self forKeyPath:@"arrowDirection"];
[self removeObserver:self forKeyPath:@"arrowOffset"];
// *** дальнейшая "зачистка" ***
[super dealloc];
}

Фон

Большая часть сказанного по поводу стрелки относится и к фону. Мы точно также выберем UIImageView для конкретной реализации. Но, в то время как стрелка не меняет своих размеров, фон ведет себя совершенно иначе. В вашем приложении вы будете использовать поповеры для множества целей и запихивать внутрь содержимое различных размеров. Фон должен выглядеть одинаково хорошо как для небольшой всплывающей подсказки, так и невообразимого поповера в пол экрана. Apple рекомендует использовать растягиваемые (stretchable) изображения, класс UIImageView предоставляет для этих целей метод resizableImageWithCapInsets:(UIEdgeInsets)capInsets. Для примера я создал простенький фон, прямоугольник размером 128х128 со скругленными углами и залитый одним цветом без градиентов, теней и прочих эффектов. Назовем файл «popover-background.png».
@property (nonatomic, strong) UIImageView *backgroundImageView;
// ***
@synthesize backgroundImageView = _backgroundImageView;

- (id)initWithFrame:(CGRect)frame {
// ***
UIEdgeInsets bgCapInsets = UIEdgeInsetsMake(12, 12, 12, 12);
UIImage *bgImage = [[UIImage imageNamed:@"popover-backgroung.png"] resizableImageWithCapInsets:bgCapInsets];
self.backgroundImageView = [[UIImageView alloc] initWithImage:bgImage];

[self addSubview:_backgroundImageView];
// ***
}

Параметры растягивания задаются с помощью отступов (UIEdgeInsets). Конкретные значения зависят от выбранного изображения. В моем случае, например, радиус скругления углов равен 10, так что по идее и отступ можно было брать равным 10 от всех границ, но это не существенно.
Контент

Контент или содержимое, это то, что отображается внутри поповера. В контексте UIPopoverBackgroundView мы не имеем никакого влияния на содержимое и его размер, даже наоборот, именно размер контента определяет размер поповера, а значит и размер UIPopoverBackgroundView.
Вот как это происходит. Когда UIPopoverController готов отрисовать поповер, ему точно известен размер контента и позиция откуда рисовать поповер, остается только выяснить сколько еще добавить по краям, чтобы уместить туда стрелку и фон, другими словами, вычислить свойство frame для нашего MBPopoverBackgroundView.
Именно для для этих целей используются методы +(CGFloat)arrowHeight и +(UIEdgeInsets)contentViewInsets. Первый сообщает высоту стрелки, второй говорит насколько фон больше контента, возвращая отступы от краев контента до краев фона. Используя всю эту информацию, UIPopoverController выберет направление для стрелки и инициализирует объект класса UIPopoverBackgroundView (точнее нашего конкретного подкласса), задав ему конкретные размеры, после чего мы должны разместить наши стрелку и фон как полагается.
Переопределим contentViewInsets. Для примера сделаем отступ равным 10 по всем краям. Можно задать и отрицательные отступы, не думаю что получится что-то хорошее, но ведь можно же…
+ (UIEdgeInsets)contentViewInsets {
// отступы от краев контента до краев фона
return UIEdgeInsetsMake(10, 10, 10, 10);
}

Теперь вокруг нашего контента будет рамка из фона толщиной в 10 пикселов.
Расположение (Layout)

И наконец последний этап — правильно разместить стрелку и фон, учитывая направление стрелки, ее смещение, и конкретные размеры нашего UIPopoverBackgroundView.
Для этого реализуем метод layoutSubviews.
#pragma mark - Subviews Layout
// расположение элементов, вызывается в ответ на setNeedsLayout и другие события
- (void)layoutSubviews {
// выбираем правильные размер и позицию для стрелки и фона

// фон
CGRect bgRect = self.bounds;
// используем направление стрелки, чтобы знать с какой стороны нужно "урезать" фон
// сначала, вычтем высоту/ширину стрелки, если это необходимо
BOOL cutWidth = (_arrowDirection == UIPopoverArrowDirectionLeft || _arrowDirection == UIPopoverArrowDirectionRight);
// если стрелка слева или справа, вычитаем ее высоту из ширины фона
bgRect.size.width -= cutWidth * [self.class arrowHeight];
BOOL cutHeight = (_arrowDirection == UIPopoverArrowDirectionUp || _arrowDirection == UIPopoverArrowDirectionDown);
// если стрелка сверху или снизу, вычитаем ее высоту из высоты фона
bgRect.size.height -= cutHeight * [self.class arrowHeight];

// далее, подправим координаты origin point (левый верхний угол)
// для случаев когда стрелка вверху (опускаем вниз) или слева (сдвигаем вправо)
if (_arrowDirection == UIPopoverArrowDirectionUp) {
bgRect.origin.y += [self.class arrowHeight];
} else if (_arrowDirection == UIPopoverArrowDirectionLeft) {
bgRect.origin.x += [self.class arrowHeight];
}

// применим новые размер и позицию к фону
_backgroundImageView.frame = bgRect;

// стрелка - используем ее направление (arrowDirection) и смещение (arrowOffset) для окончательного раположения
// в силу того, что мы используем однин image view для отрисовки всех направлений стрелки
// мы будет использовать афинные преобразования (трансформации или transformations), а именно отражение и поворот
// важно: рассчитывать размеры и позицию стрелки нужно после применения преобразований
CGRect arrowRect = CGRectZero;
UIEdgeInsets bgCapInsets = UIEdgeInsetsMake(12, 12, 12, 12); // отступы использованные для фонового изображения
switch (_arrowDirection) {
case UIPopoverArrowDirectionUp:
_arrowImageView.transform = CGAffineTransformMakeScale(1, 1); // отменим какие-либо преобразования
// важно: используем frame, а не bounds, потому что bounds не изменяется после трасформаций
arrowRect = _arrowImageView.frame;
// используем смещение для вычисления origin
arrowRect.origin.x = self.bounds.size.width / 2 + _arrowOffset - arrowRect.size.width / 2;
arrowRect.origin.y = 0;
break;
case UIPopoverArrowDirectionDown:
_arrowImageView.transform = CGAffineTransformMakeScale(1, -1); // отразим по вертикали (переворот)
arrowRect = _arrowImageView.frame;
// используем смещение для вычисления origin
arrowRect.origin.x = self.bounds.size.width / 2 + _arrowOffset - arrowRect.size.width / 2;
arrowRect.origin.y = self.bounds.size.height - arrowRect.size.height;
break;
case UIPopoverArrowDirectionLeft:
_arrowImageView.transform = CGAffineTransformMakeRotation(-M_PI_2); // поворот на 90 градусов против часовой стрелки
arrowRect = _arrowImageView.frame;
// используем смещение для вычисления origin
arrowRect.origin.x = 0;
arrowRect.origin.y = self.bounds.size.height / 2 + _arrowOffset - arrowRect.size.height / 2;
// последняя проверка - убедимся что стрелка не осталась под поповером
// такое случается когда на экране появляется клавиатура, при этом уменьшая размеры поповера
// дополнительно, учитываем нижний отступ bgCapInsets.bottom, чтобы все стыковалось как следует
// со скругленными углами
arrowRect.origin.y = fminf(self.bounds.size.height - arrowRect.size.height - bgCapInsets.bottom, arrowRect.origin.y);
// похожая корректировка на случай если стрелка вылезла слишком высоко вверх
arrowRect.origin.y = fmaxf(bgCapInsets.top, arrowRect.origin.y);
break;
case UIPopoverArrowDirectionRight:
_arrowImageView.transform = CGAffineTransformMakeRotation(M_PI_2); // поворот на 90 градусов по часовой стрелке
arrowRect = _arrowImageView.frame;
arrowRect.origin.x = self.bounds.size.width - arrowRect.size.width;
arrowRect.origin.y = self.bounds.size.height / 2 + _arrowOffset - arrowRect.size.height / 2;
// по аналогии со случаем UIPopoverArrowDirectionLeft
arrowRect.origin.y = fminf(self.bounds.size.height - arrowRect.size.height - bgCapInsets.bottom, arrowRect.origin.y);
arrowRect.origin.y = fmaxf(bgCapInsets.top, arrowRect.origin.y);
break;

default:
break;
}

// задаем стрелке новые позицию и размер
_arrowImageView.frame = arrowRect;
}

Последние штрихи

Весь код, приведенный выше, справляется с поставленной задачей, а именно, позволяет вам создавать альтернативный внешний вид для поповеров. Тем не менее, в этом коде имеется ряд минусов, например, имена файлов для стрелки и фона наглухо прописаны в коде. Чтобы использовать зеленый поповер вместо красного, вам придется создавать еще один подкласс и переопределять методы, зависящие от конкретных имен файлов. То же самое касается параметров использованных для растяжения фона и отступов от краев контента.
Хотелось бы иметь больше гибкости, что я и постарался сделать.
Я добавил несколько методов класса, с говорящими названиями
@interface MBPopoverBackgroundView : UIPopoverBackgroundView
// настройка внешнего вида поповера
+ (void)initialize; // инициализация (при старте приложения)
+ (void)cleanup; // убираем за собой (при завершении приложения)
+ (void)setArrowImageName:(NSString *)imageName; // задать имя файла для изображения стрелки
+ (void)setBackgroundImageName:(NSString *)imageName; // задать имя файла для фона
+ (void)setBackgroundImageCapInsets:(UIEdgeInsets)capInsets; // задать отступы для растягивания фона
+ (void)setContentViewInsets:(UIEdgeInsets)insets; // задать отступы от краев контента
@end

Конечно же, все объекты данного класса будут рисовать одинаковые стрелку и фон, но вы имеете возможность использовать один и тот же код в разных проектах, не изменяя его. Если же в рамках одного приложения вам нужны поповеры разных цветов и оттенков — просто наследуйте MBPopoverBackgroundView, по одному наследнику на каждый внешний вид, или вызывайте set*** для MBPopoverBackgroundView каждый раз перед созданием поповера отличного от предыдущего. Короче, гибкость…
// синий наследник
@interface MBPopoverBackgroundViewBlue : MBPopoverBackgroundView
@end

// при запуске приложения
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{

// инициализация
[MBPopoverBackgroundView initialize];

// красный поповер со стрелкой
[MBPopoverBackgroundView setArrowImageName:@"popover-arrow-red.png"];
[MBPopoverBackgroundView setBackgroundImageName:@"popover-background-red.png"];
[MBPopoverBackgroundView setBackgroundImageCapInsets:UIEdgeInsetsMake(12, 12, 12, 12)];
[MBPopoverBackgroundView setContentViewInsets:UIEdgeInsetsMake(10, 10, 10, 10)];

// синий поповер с нестандартной "стрелкой"
[MBPopoverBackgroundViewBlue setArrowImageName:@"popover-callout-dotted-blue.png"];
[MBPopoverBackgroundViewBlue setBackgroundImageName:@"popover-background-blue.png"];
[MBPopoverBackgroundViewBlue setBackgroundImageCapInsets:UIEdgeInsetsMake(15, 15, 15, 15)];
[MBPopoverBackgroundViewBlue setContentViewInsets:UIEdgeInsetsMake(20, 20, 20, 20)];

// ***
}

// при создании поповера
{
UIPopoverController *popoverCtl = ...;
popoverCtl.popoverBackgroundViewClass = [MBPopoverBackgroundView class]; // красный
popoverCtl.popoverBackgroundViewClass = [MBPopoverBackgroundViewBlue class]; // или синий
// ***
}

Наглядный результат

Исходники MBPopoverBackgroundView и примеры использования лежат на github.
Реализация не использует ARC, так что не забудьте навесить флаг -fno-objc-arc если будете использовать в проекте с включенным ARC, или уберите те несколько вызовов autorelease, retain, release и dealloc, которые есть в коде. В последнем случае я понятия не имею как долго будет жить статический словарь s_customValuesDic ведь явным образом retain ему не посылается, хотя по логике ARC не будет трогать статический объект до завершения приложения. Да и вообще не думаю что хранение значений таким способом — самое лучшее решение, хоть оно и работает стабильно и надежно.
Использованные материалы

UIPopover Class Reference

UIPopoverBackgroundView Class Reference

View Controller Catalog for iOS — Popovers

iPad-Specific Controllers

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js