Реализация кастомного UIControl компонента

Реализация кастомного UIControl компонента
Photo by Gabriel / Unsplash

Очень часто приходится сталкиваться с различными нестандартными элементами интерфейса, когда имплементация от уже готовых элементов не удобна да и затратила бы много времени и сил, нагромождая проект огромным количеством костылей, приходится прибегать к реализации собственных компонентов.

Разберем задачу на примере радио-кнопки. В изначальной поставке у нас есть похожий компонент 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: Ссылка