13 апреля в 16:46

Реализация кастомного UI-элемента для выбора времени. Часть 1 tutorial

17 ноября в Москве в рамках Международной конференции мобильных разработчиков MBLTdev Александр Зимин выступил с докладом на тему «Визуализируем за рамками стандартных компонентов UIKit». В первую очередь, этот доклад заинтересует iOS-разработчиков, которые хотят узнать больше о разработке кастомных UI-элементов. Меня он заинтересовал примером кастомного контрола, который я решил реализовать и доработать с учетом тезисов, озвученных в докладе. Пример был реализован на Swift, я реализую его на Objective-C.

Как правильно разрабатывать кастомные UI-элементы:


  • Необходимо разобраться, как работает базовый элемент: изучить все его свойства, методы, методы delegate и dataSource.
  • Спроектировать зависимые от UIView+ элементы. Нужно сделать универсальное решение, которое будет отображать любой UIView. Например, у нашего элемента есть contentView. Следует спроектировать так, чтобы пользователь мог присвоить туда любую UIView, не задумываясь о реализации нашего UI-элемента.
  • Не забывайте про UIControl. Если вам нужна какая-либо кастомная кнопка или другой контрол, лучше наследоваться от UIControl, нежели от UIView. У UIControl есть Target-Action система, которая позволяет «протягивать» IBAction из Interface Builder от кнопки сразу в код. Его преимуществом над UIView является наличие состояний и лучшее отслеживание касаний.
  • Следует изучить близкие к вашему компоненты.
  • Не забывайте про особенности разных девайсов, в частности, про тактильную вибрацию iPhone 7 (класс UIImpactFeedbackGenerator) при работе с экшен-компонентами.

Что будет реализовано


В докладе был пример кастомной UIView, которая напоминает UIPickerView. Она предназначалась для выбора времени.



Этот компонент похож на UIPickerView. Соответственно, нам нужно реализовать:

  • автоматическую докрутку;
  • барабан останавливается на элементе;
  • для iPhone 7 нужна feedback вибрация (мной не реализовано).

Как нужно реализовать?


Возьмём UIView, сделаем ее круглой и навесим на нее UILabel с числами. Для вращения добавим UIScrollView с бесконечным contentSize и на основе сдвига будем считать угол поворота.



Необходимо:

  • высчитать сдвиг x, y на UIScrollView,
  • распознать направление,
  • крутить contentView,
  • докручивать до нужного элемента,
  • дать возможность подставить любой UIView.

Подготовка иерархии


Создаём AYNCircleView. Это будет класс, который содержит весь наш кастомный элемент. На данном этапе ничего публичного у него нет, делаем всё приватным. Далее начинаем создавать иерархию. Сначала построим нашу view в Interface Builder. Сделаем AYNCircleView.xib и разберёмся с иерархией.



Иерархия состоит из таких элементов:

  • contentView — круг, на котором будут все остальные subviews,
  • scrollView обеспечит вращение.

Расставим constraints. Больше всего нас интересует высота contentView и bottom space. Они будут обеспечивать размер и положение нашего круга. Остальные constraints не позволяют вылезти contentView за пределы superview. Для удобства обозначим константой сторону contentSize у scrollView. Это не сильно повлияет на производительность, зато симулирует «бесконечность» вращения. Если вы внимательны к мелочам, можно реализовать систему «прыжка», чтобы значительно уменьшить contentSize у scrollView.

Создаем класс AYNCircleView.

@interface AYNCircleView : UIView

@end


static CGFloat const kAYNCircleViewScrollViewContentSizeLength = 1000000000;

@interface AYNCircleView ()

@property (assign, nonatomic) BOOL isInitialized;

@property (assign, nonatomic) CGFloat circleRadius;

@property (weak, nonatomic) IBOutlet UIView *contentView;
@property (weak, nonatomic) IBOutlet UIScrollView *scrollView;

@property (weak, nonatomic) IBOutlet NSLayoutConstraint *contentViewDimension;
@property (weak, nonatomic) IBOutlet NSLayoutConstraint *contentViewOffset;

@end

Переопределим инициализаторы для случаев, когда наша view будет инициализирована из Interface Builder и в коде.

@implementation AYNCircleView

#pragma mark - Initializers

- (instancetype)initWithFrame:(CGRect)frame {
    self = [super initWithFrame:frame];
    
    if (self) {
        [self commonInit];
    }
    
    return self;
}

- (instancetype)initWithCoder:(NSCoder *)aDecoder {
    self = [super initWithCoder:aDecoder];
    
    if (self) {
        [self commonInit];
    }
    
    return self;
}

#pragma mark - Private

- (void)commonInit {
    UIView *nibView = [[NSBundle mainBundle] loadNibNamed:NSStringFromClass([self class]) owner:self options:nil].firstObject;
    [self addSubview:nibView];
    
    self.scrollView.contentSize = CGSizeMake(kAYNCircleViewScrollViewContentSizeLength, kAYNCircleViewScrollViewContentSizeLength);
    self.scrollView.contentOffset = CGPointMake(kAYNCircleViewScrollViewContentSizeLength / 2.0, kAYNCircleViewScrollViewContentSizeLength / 2.0);
    
    self.scrollView.delegate = self;
}

Размещаем нашу иерархию. Нельзя это делать в инициализаторах, потому что мы не знаем реальных размеров views в данный момент. Мы можем узнать их в методе - (void)layoutSubviews, поэтому настраиваем размеры там. Для этого вводим радиус окружности, который зависит от минимума ширины и высоты.

@property (assign, nonatomic) CGFloat circleRadius;

Вводим флаг, указывающий, что инициализация проведена.

@property (assign, nonatomic) BOOL isInitialized;

Так как скролл приводит к вызову - (void)layoutSubviews, было бы неправильно постоянно рассчитывать положение нашей иерархии. Обновляем constraints, чтобы выставить правильные размеры наших views.

#pragma mark - Layout

- (void)layoutSubviews {
    [super layoutSubviews];
    
    if (!self.isInitialized) {
        self.isInitialized = YES;
        
        self.subviews.firstObject.frame = self.bounds;
        self.circleRadius = MIN(CGRectGetWidth(self.bounds), CGRectGetHeight(self.bounds)) / 2;
        
        self.contentView.layer.cornerRadius = self.circleRadius;
        self.contentView.layer.masksToBounds = YES;
        
        [self setNeedsUpdateConstraints];
    }
}

- (void)updateConstraints {
    self.contentViewDimension.constant = self.circleRadius * 2;
    self.contentViewOffset.constant = self.circleRadius;

    [super updateConstraints];
}

Готово. Смотрим на результат построения иерархии. Создадим view controller, на котором будет расположен наш контрол.



Теперь смотрим живую иерархию.



Иерархия построена верно, продолжаем.

Фоновая UIView


Следующий шаг: сделать поддержку backgroundView. Наш кастомный контрол задумывается так, что на фон можно ставить любую view, и пользователь этого контрола не думает о реализации.

Делаем публичное свойство, которое содержит информацию о backgroundView:

@property (strong, nonatomic) UIView *backgroundView;

Теперь определим, как она будет добавляться в иерархию. Переопределим setter.

- (void)setBackgroundView:(UIView *)backgroundView {
    [_backgroundView removeFromSuperview];
    
    _backgroundView = backgroundView;
    
    [_contentView insertSubview:_backgroundView atIndex:0];
    
    if (_isInitialized) {
        [self layoutBackgroundView];
    }
}

Какая тут логика? Удаляем предыдущую view из иерархии, добавляем новую backgroundView в самый нижний уровень иерархии и изменяем её размер в методе.

- (void)layoutBackgroundView {
    self.backgroundView.frame = CGRectMake(0, 0, self.circleRadius * 2, self.circleRadius * 2);
    self.backgroundView.layer.masksToBounds = YES;
    self.backgroundView.layer.cornerRadius = self.circleRadius;
}

Также рассмотрим случай, когда view только создается. Чтобы изменение размера прошло корректно, добавим вызов этого метода в - (void)layoutSubviews.

Рассмотрим новую иерархию. Добавим UIView красного цвета и посмотрим на иерархию.

UIView *redView = [UIView new];
    redView.backgroundColor = [UIColor redColor];
    
    self.circleView.backgroundView = redView;



Все в порядке!

Реализация циферблата


Для реализации циферблата используем UILabel. При необходимости повысить производительность спускаемся до уровня CoreGraphics и добавляем подписи уже там. Наше решение — категория над UILabel, где мы определим «повернутую» label. К методу я добавил немного кастомизации: цвет текста и шрифт.

@interface UILabel (AYNHelpers)

+ (UILabel *)ayn_rotatedLabelWithText:(NSString *)text angle:(CGFloat)angle circleRadius:(CGFloat)circleRadius offset:(CGFloat)offset font:(UIFont *)font textColor:(UIColor *)textColor;

@end

Метод позволяет разместить label на окружности. circleRadius определяет радиус этой окружности, offset определяет смещение относительно этой окружности, angle — центральный угол. Создаем повёрнутую label в центре этой окружности, а потом с помощью xOffset и yOffset сдвигаем центр этой label в нужное место.

#import "UILabel+AYNHelpers.h"

@implementation UILabel (AYNHelpers)

+ (UILabel *)ayn_rotatedLabelWithText:(NSString *)text angle:(CGFloat)angle circleRadius:(CGFloat)circleRadius offset:(CGFloat)offset font:(UIFont *)font textColor:(UIColor *)textColor {
    UILabel *rotatedLabel = [[UILabel alloc] initWithFrame:CGRectZero];
    rotatedLabel.text = text;
    
    rotatedLabel.font = font ?: [UIFont boldSystemFontOfSize:22.0];
    rotatedLabel.textColor = textColor ?: [UIColor blackColor];
    
    [rotatedLabel sizeToFit];
    
    rotatedLabel.transform = CGAffineTransformMakeRotation(angle);
    
    CGFloat angleForPoint = M_PI - angle;
    
    CGFloat xOffset = sin(angleForPoint) * (circleRadius - offset);
    CGFloat yOffset = cos(angleForPoint) * (circleRadius - offset);
    
    rotatedLabel.center = CGPointMake(circleRadius + xOffset, circleRadius + yOffset);
    
    return rotatedLabel;
}

@end

Готово. Теперь нужно добавить метод - (void)addLabelsWithNumber: на наш contentView лейблов. Для этого удобно хранить шаг угла, по которым расположены подписи. Если взять окружность в 360 градусов, а подписей 12, то шаг будет 360 / 12 = 30 градусов. Создаем свойство, оно нам пригодится для нормализации угла поворота.

@property (assign, nonatomic) CGFloat angleStep;

Делаем  константый offset для лейблов, который тоже понадобится позже.
static CGFloat const kAYNCircleViewLabelOffset = 10;

Делаем константый offset для лейблов, который тоже понадобится позже.

- (void)addLabelsWithNumber:(NSInteger)numberOfLabels {
    if (numberOfLabels > 0) {
        [self.contentView.subviews enumerateObjectsUsingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
            if ([obj isKindOfClass:[UILabel class]]) {
                [obj removeFromSuperview];
            }
        }];
        
        self.angleStep = 2 * M_PI / numberOfLabels;
        for (NSInteger i = 0; i < numberOfLabels; i++) {
            UILabel *rotatedLabel = [UILabel ayn_rotatedLabelWithText:[NSString stringWithFormat:@"%ld", i]
                                                                angle:self.angleStep * i
                                                         circleRadius:self.circleRadius
                                                               offset:kAYNCircleViewLabelOffset
                                                                 font:self.labelFont
                                                            textColor:self.labelTextColor];
            
            [self.contentView addSubview:rotatedLabel];
        }
    }
}

Шаг будет рассчитываться при выставлении цифр на циферблат.

@property (assign, nonatomic) NSUInteger numberOfLabels;

Теперь добавляем публичное свойство для выставления количества цифр на циферблате.

- (void)setNumberOfLabels:(NSUInteger)numberOfLabels {
    _numberOfLabels = numberOfLabels;
    
    if (_isInitialized) {
        [self addLabelsWithNumber:_numberOfLabels];
    }
}

И определяем для него setter по аналогии с backgroundView.
Готово. Когда view уже создана, выставляем количество цифр на циферблате. Не забываем про метод - (void)layoutSubviews и инициализацию AYNCircleView. Там тоже следует выставить подписи.

- (void)layoutSubviews {
    [super layoutSubviews];
    
    if (!self.isInitialized) {
        self.isInitialized = YES;
        
        ….
        
        [self addLabelsWithNumber:self.numberOfLabels];
        
        ...
    }
}

Теперь - (void)viewDidLoad контроллера, на view которого изображен наш контрол, имеет такой вид:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    UIView *redView = [UIView new];
    redView.backgroundColor = [UIColor redColor];
    
    self.circleView.backgroundView = redView;
    self.circleView.numberOfLabels = 12;
    
    self.circleView.delegate = self;
}

Посмотрим на иерархию views и расположение цифр.



Иерархия получилась верной — все надписи расположены на contentView.

Поддержка вращения интерфейса


Необходимо учитывать, что некоторые приложения используют горизонтальную ориентацию экрана. Чтобы обработать эту ситуацию, отследим нотификацию (класс NSNotification) об изменении ориентации интерфейса. Нас интересует UIDeviceOrientationDidChangeNotification.

Добавим observer этой нотификации в инициализаторе нашего контрола и обработаем там же в блоке.

__weak __typeof(self) weakSelf = self;
    [[NSNotificationCenter defaultCenter] addObserverForName:UIDeviceOrientationDidChangeNotification object:nil queue:nil usingBlock:^(NSNotification * _Nonnull note) {
        __strong __typeof(weakSelf) strongSelf = weakSelf;
        
        strongSelf.isInitialized = NO;
        
        [strongSelf setNeedsLayout];
    }];

Так как блоки неявно захватывают self, это может привести к retain cycle, поэтому ослабляем ссылку на self. При изменении ориентации мы как бы заново инициализируем контрол, чтобы пересчитать радиус окружности, новый центр и т.д.

Не забываем отписаться от оповещений в методе - (void)dealloc.

- (void)dealloc {
    [[NSNotificationCenter defaultCenter] removeObserver:self name:UIDeviceOrientationDidChangeNotification object:nil];
}

Циферблат реализован. О математике вращения и дальнейших шагах создания кастомного контрола читайте во второй части статьи.

Весь проект доступен на гите.
Автор: @haron1020
e-Legion Ltd.
рейтинг 147,17
Лидер мобильной разработки в России
Похожие публикации

Комментарии (5)

  • 0
    * в этом NotificationCenter захват self ни к чему плохому не приводит
    * неправильное отписывание от NotificationCenter. нужно отписывать то, что вернул addObserverForName:
    * «блоки неявно захватывают self» – здесь как раз всё явно. неявно это когда например захватывается ivar
    * addLabelsWithNumber слишком жёсткий. что если кто-то добавит в contentView какую-то более полезную UILabel? она удалится?
    • 0
      > что если кто-то добавит в `contentView` какую-то более полезную `UILabel`? она удалится?

      Нет, не удалится. На самом деле эта проверка избыточна. Этот метод не трогает `subviews` у `contentView`, поэтому ничего не случится с иерархией `contentView`.



      А все, что связано с блоками действительно требует моей доработки, спасибо.
    • 0
      «в этом NotificationCenter захват self ни к чему плохому не приводит»

      Разве? Блок содержит strong reference на объект, который является подписчиком. Он же не отпишится, пока его не освободят -> retain circle
  • 0
    UIScrollView — не лишний? Может лучше было обойтись распознованием жестов?

    Не очень понял, зачем там преобразование weak reference в strong reference и как это вообще должно работать.

    addLabelsWithNumber — не очень. Во-первых, лучше всё-таки деражать массив со своими лейблами и удалять по нему. Во-вторых, он делает больше чем добавление и из названия этого не следует. Лучше разделить на два разных метода или подбрать более точное название
  • 0
    Вообще непонятно зачем отслеживать поворот экрана?
    Если вы предполагаете изменение размера контрола, то отслеживать размер нужно всегда.
    Лейблы надо конечно переиспользовать.

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое Разработка