Реализация кастомного UI-элемента для выбора времени. Часть 1
17 ноября в Москве в рамках Международной конференции мобильных разработчиков MBLTdev Александр Зимин выступил с докладом на тему «Визуализируем за рамками стандартных компонентов UIKit». В первую очередь, этот доклад заинтересует iOS-разработчиков, которые хотят узнать больше о разработке кастомных UI-элементов. Меня он заинтересовал примером кастомного контрола, который я решил реализовать и доработать с учетом тезисов, озвученных в докладе. Пример был реализован на
В докладе был пример кастомной
Возьмём
Создаём
Расставим
Переопределим инициализаторы для случаев, когда наша
Размещаем нашу иерархию. Нельзя это делать в инициализаторах, потому что мы не знаем реальных размеров views в данный момент. Мы можем узнать их в методе
Вводим флаг, указывающий, что инициализация проведена.
Так как скролл приводит к вызову
Готово. Смотрим на результат построения иерархии. СоздадимФоновая
Следующий шаг: сделать поддержку
Теперь определим, как она будет добавляться в иерархию. Переопределим
Какая тут логика? Удаляем предыдущую
Также рассмотрим случай, когда
Для реализации циферблата используем
Метод позволяет разместить
Готово. Теперь нужно добавить метод
Делаем константый
Шаг будет рассчитываться при выставлении цифр на циферблат.
Теперь добавляем публичное свойство для выставления количества цифр на циферблате.
И определяем для него
Готово. Когда
Теперь
Посмотрим на иерархию
Необходимо учитывать, что некоторые приложения используют горизонтальную ориентацию экрана. Чтобы обработать эту ситуацию, отследим нотификацию (класс
Так как блоки неявно захватывают
Циферблат реализован. О математике вращения и дальнейших шагах создания кастомного контрола читайте во второй части статьи.
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];
}
Циферблат реализован. О математике вращения и дальнейших шагах создания кастомного контрола читайте во второй части статьи.
Весь проект доступен на гите.