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];
}
Циферблат реализован. О математике вращения и дальнейших шагах создания кастомного контрола читайте во второй части статьи.
Весь проект доступен на гите.
Автор: e-Legion Ltd.