
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 <UIKit/UIPopoverBackgroundView.h> @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 не будет трогать статический объект до завершения приложения. Да и вообще не думаю что хранение значений таким способом — самое лучшее решение, хоть оно и работает стабильно и надежно.