Pull to refresh

Настройка внешнего вида UIPopoverController

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

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


Tags:
Hubs:
+29
Comments 13
Comments Comments 13

Articles