Development for iOS
11 February 2012

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

Использованные материалы



+29
11.4k 134
Comments 13
Top of the day