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

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


    Share post

    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 13

      0
      Поповер получается одноцветный. Можно замутить градиент?
        0
        Неплохо было бы указать, что это все справедливо только для iOS 5.
          +1
          Странно как-то выглядит компонент минимум для iOS 5 и без поддержки ARC.
          З.Ы. А со статическим словарём ничего плохого не случится, поскольку ему по дефолту ставится модификатор __strong и жить он подуть вплоть до вызова cleanup или завершения работы приложения.
            0
            В iOS 5 ARC обязателен? Ведь можно хорошо обходиться и без него.
              0
              Можно, а зачем? Производительность с ARC на iOS 5 выше, вероятность ошибки меньше. Если 4.x не поддерживается, то отказ от ARC ничего, не дает, разве что пафосности программисту добавляет
            –1
            «А хорошо жить еще лучше!» ©
            0
            Мне кажется, уместной была бы ветка «ARC» на github'е, чтобы всем было удобно.
              0
              Спасибо вам. Достаточно неплохую работу проделали.
                0
                Выложите свое творение вот сюда. Думаю многие спасибо скажут.
                  –4
                  После всего этого понимаешь, на сколько же сладок и близок python… =D
                  p.s. понимаю, что на питоне не сделать приложения для ифон, ипад и т.д., но такой дикий код по сравнению с питоновской «сказкой на ночь» тяжело воспринимать :)
                    +2
                    Вы сравниваете тёплое с мягким…
                      +1
                      У Obj-С синтаксис нормальный и красивый, и фундаментальные концепции типа протоколов, объектов и сообщений тоже нормальные. Разница в том, что Obj-С оперирует такими понятями, как указатели, Memory Management, ARC, heap, stack. Поэтому там впринципе не получатся такие изящные конструкции, как например, со строками, как в Пай.

                      Вы действительно сравниваете тёплое с мягким…

                    Only users with full accounts can post comments. Log in, please.