Реализация кастомного UIControl компонента
Очень часто приходится сталкиваться с различными нестандартными элементами интерфейса, когда имплементация от уже готовых элементов не удобна да и затратила бы много времени и сил, нагромождая проект огромным количеством костылей, приходится прибегать к реализации собственных компонентов.
Разберем задачу на примере радио-кнопки. В изначальной поставке у нас есть похожий компонент UISwitch, но давайте создадим свой собственный компонент. Для этого необходимо создать новый класс от UIControl с поддержкой использования его в Interface Builder.
Анимацию нашей радио-кнопки мы будем рисовать в QuartzCode. Этот инструмент позволяет с помощью примитивов и изображений быстро набросать картинку и анимаировать ее средствами CoreAnimation, а после экспортировать все это в Objective-C или Swift код уже готовый для внедрения в проект. Не будем углубляться в тонкости работы с этим инструментом, а уже перейдем к самому коду.
#import <UIKit/UIKit.h>
IB_DESIGNABLE
@interface TLCheckBox : UIView
- (void)addCheckAnimation;
- (void)addCheckAnimationCompletionBlock:(void (^)(BOOL finished))completionBlock;
- (void)addCheckAnimationReverse:(BOOL)reverseAnimation totalDuration:(CFTimeInterval)totalDuration completionBlock:(void (^)(BOOL finished))completionBlock;
- (void)removeAnimationsForAnimationId:(NSString *)identifier;
- (void)removeAllAnimations;
@end
Мы получили код в виде класса UIView с методами для анимаций. Если вы поставили галочку на Reverse Animation при экспортировании проекта в код, то у вас будут доступна возможность обратить анимацию, которую мы используем при переходе радио-кнопки в состояние unchecked.
Макрос IB_DESIGNABLE говорит о том, что этот объект будет отрисовываться в Interface Builder. Метод отрисовки вызываемый Interface Builder называется drawRect:
. Перенесем код инициализации из initWithFrame:
и initWithCoder:
в этот метод.
- (void)drawRect:(CGRect)rect {
[self setupProperties];
[self setupLayers];
};
Запустив проект и добавив UIView с классом нашего компонента, то мы сможем увидеть начальное состояние картинки. Теперь необходимо поле, которое будет отвечать за сохранение состояния кнопки и реализуем setter для нее.
@property (nonatomic) IBInspectable BOOL isChecked;
- (void) setIsChecked:(BOOL)isChecked {
_isChecked = isChecked;
if (isChecked) {
[self addCheckAnimationReverse:NO
totalDuration:0.5f
completionBlock:nil];
} else {
[self addCheckAnimationReverse:YES
totalDuration:0.5f
completionBlock:nil];
}
}
IBInspectable говорит о том, что это поле может редактироваться из Interface Builder. Но проблема вот проблема, как бы мы не тыкали это свойство в настройках UIView, ничего не изменяется. Для этого необходимо добавить небольшой код в drawRect:
:
- (void)drawRect:(CGRect)rect {
[self setupProperties];
[self setupLayers];
#if TARGET_INTERFACE_BUILDER
if (!self.isChecked) {
((CAShapeLayer *) self.layers[@"oval2"]).fillColor = [UIColor clearColor].CGColor;
}
#else
self.isChecked = _isChecked;
#endif
};
Такая конструкция нам позволяет сделать выполнить отдельный код, если исполнение кода происходит для Interface Builder. Здесь мы просто заливаем центральный кружок прозрачным цветом.
Теперь с отображением все хорошо, давайте чутка улучшим сгенерированный QuartzCode код для большей гибкости. Создадим переменные для определения размеров внутреннего и внешнего круга и напишем для них getter-ы:
@property (nonatomic) CGFloat radioSize;
@property (nonatomic) CGFloat centerSize;
@property (nonatomic) CGFloat strokeWidth;
- (CGFloat)radioSize {
return self.frame.size.width;
}
- (CGFloat)centerSize {
return self.radioSize * 7/11;
}
- (CGFloat)strokeWidth {
return 2.0f;
}
Далее заменим цифры в setupLayers
:
- (void)setupLayers{
CAShapeLayer * oval = [CAShapeLayer layer];
oval.frame = CGRectMake(0, 0, self.radioSize-self.strokeWidth, self.radioSize-self.strokeWidth);
oval.path = [self ovalPath].CGPath;
[self.layer addSublayer:oval];
self.layers[@"oval"] = oval;
CAShapeLayer * oval2 = [CAShapeLayer layer];
CGFloat frameX = (self.radioSize - self.centerSize)/2;
oval2.frame = CGRectMake(frameX, frameX, self.centerSize, self.centerSize);
oval2.path = [self oval2Path].CGPath;
[self.layer addSublayer:oval2];
self.layers[@"oval2"] = oval2;
[self resetLayerPropertiesForLayerIdentifiers:nil];
}
Теперь добавим возможность изменять цвет радио-кнопки. Для этого необязательно добавлять еще одно поле с цветом. Тут мы можем использовать tint у UIView. Добавим это в resetLayerPropertiesForLayerIdentifiers:
- (void)resetLayerPropertiesForLayerIdentifiers:(NSArray *)layerIds{
[CATransaction begin];
[CATransaction setDisableActions:YES];
if(!layerIds || [layerIds containsObject:@"oval"]){
CAShapeLayer * oval = self.layers[@"oval"];
oval.fillColor = nil;
oval.strokeColor = self.tintColor.CGColor;
oval.lineWidth = self.strokeWidth;
}
if(!layerIds || [layerIds containsObject:@"oval2"]){
CAShapeLayer * oval2 = self.layers[@"oval2"];
oval2.fillColor = self.tintColor.CGColor;
oval2.lineWidth = 0;
}
[CATransaction commit];
}
Не забываем, что анимации все еще привязаны к старым размерам и их необходимо также отредактировать. Как и методы Bezier Path в самом низу кода.
Так же давайте добавим возможность изменять скорость работы анимации. Добавим поле и интегрируем в проект.
@property (nonatomic) IBInspectable CGFloat animationDuration;
- (void) setIsChecked:(BOOL)isChecked {
_isChecked = isChecked;
if (isChecked) {
[self addCheckAnimationReverse:NO
totalDuration:self.animationDuration/1000.0f
completionBlock:nil];
} else {
[self addCheckAnimationReverse:YES
totalDuration:self.animationDuration/1000.0f
completionBlock:nil];
}
}
Изменение скорости задается в миллисекундах. Теперь необхоимо задать значения по умолчанию. Хорошей практикой было бы вынести все задания этих значений в метод inspectableDefaults
:
- (void) inspectableDefaults {
self.animationDuration = 500.0f;
self.isChecked = false;
}
Необходимо вставить в этот метод в блоки инициализации UIView.
- (instancetype)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self) {
[self inspectableDefaults];
}
return self;
}
- (instancetype)initWithCoder:(NSCoder *)coder
{
self = [super initWithCoder:coder];
if (self) {
[self inspectableDefaults];
}
return self;
}
Теперь давайте изменим родительский класс с UIView на UIControl и реализуем функционал нажатия. Основные методы которые советуют перепределить это:
- (void)sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event
- (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event
- (void)endTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event
- (void)cancelTrackingWithEvent:(UIEvent *)event
Так как радио-кнопка не имеет каких то сложных обработок нажатий то мы наделим логикой только endTrackingWithTouch:withEvent:
:
- (void)endTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event {
[super endTrackingWithTouch:touch withEvent:event];
[self toggleCheckBox];
[self sendActionsForControlEvents:UIControlEventValueChanged];
}
Здесь у нас используется метод toggleCheckBox
для того чтобы инвентировать состояние кнопки и отправку евента о том, что произошло изменение состояния кнопки.
Весь код вместе с тестовым проектом можно посмотреть на Github: Ссылка