📜 ⬆️ ⬇️

Implement a custom UI element for timing. Part 1

On November 17, in Moscow, in the framework of the MBLTdev International Conference of Mobile Developers , Alexander Zimin made a presentation on the topic “Visualize beyond the standard UIKit components”. First of all, this report will interest iOS developers who want to learn more about the development of custom UI elements. He was interested in me by the example of custom control, which I decided to implement and refine, taking into account the theses voiced in the report. The example was implemented in Swift , I implement it in Objective-C .

How to develop custom UI-elements:



What will be implemented


The report was an example of a custom UIView , which resembles a UIPickerView . It was meant for timing.



This component is similar to UIPickerView . Accordingly, we need to implement:
')

How to implement?


Take a UIView , make it round and hang a UILabel with numbers on it. To rotate, add a UIScrollView with infinite contentSize and based on the shift we will calculate the angle of rotation.



It is necessary:


Hierarchy preparation


Create an AYNCircleView . This will be the class that contains our entire custom element. At this stage, he has nothing public, we make everything private. Next, we begin to create a hierarchy. First we build our view in Interface Builder . Let's make AYNCircleView.xib and deal with the hierarchy.



The hierarchy consists of the following elements:


We will place constraints . We are most interested in the height of the contentView and the bottom space . They will ensure the size and position of our circle. The remaining constraints do not allow the contentView to get out of the superview . For convenience, we denote the side by contentSize the scrollView . This does not greatly affect the performance, but simulates the "infinity" of rotation. If you are attentive to the details, you can implement a "jump" system to significantly reduce the contentSize the scrollView .

Create a class 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 

Let's redefine initializers for cases when our view is initialized from Interface Builder and in code.

 @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; } 

Place our hierarchy. You cannot do this in initializers, because we do not know the actual size of the views at the moment. We can recognize them in the method - (void)layoutSubviews , so we adjust the dimensions there. To do this, enter the radius of the circle, which depends on the minimum width and height.

 @property (assign, nonatomic) CGFloat circleRadius; 

Enter a flag indicating that initialization is done.

 @property (assign, nonatomic) BOOL isInitialized; 

Since scrolling leads to a call - (void)layoutSubviews , it would be wrong to constantly calculate the position of our hierarchy. Update constraints to set the correct size of our 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]; } 

Is done. We look at the result of building the hierarchy. Let's create a view controller on which our control will be located.



Now look at the live hierarchy.



The hierarchy is built right, we continue.

Background UIView


Next step: make backgroundView support. Our custom control thinks so that you can put any view on the background, and the user of this control does not think about implementation.

We make a public property that contains information about backgroundView :

 @property (strong, nonatomic) UIView *backgroundView; 

Now we define how it will be added to the hierarchy. Override the setter .

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

What is the logic here? Remove the previous view from the hierarchy, add a new backgroundView to the lowest level of the hierarchy and change its size in the method.

 - (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; } 

Also consider the case when the view only created. To resize correctly, add a call to this method in - (void)layoutSubviews .

Consider a new hierarchy. Add a red UIView and look at the hierarchy.

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



Everything is good!

Dial realization


To implement the dial use UILabel . If you need to improve performance, CoreGraphics down to the level of CoreGraphics and add signatures already there. Our solution is a category over UILabel , where we define a “rotated” label . I added a little customization to the method: text color and font.

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

The method allows you to place a label on a circle. circleRadius defines the radius of this circle, offset defines the offset relative to this circle, angle is the central angle. Create a rotated label in the center of this circle, and then use xOffset and yOffset shift the center of this label to the right place.

 #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 

Is done. Now we need to add a method - (void)addLabelsWithNumber: to our contentView labels. For this, it is convenient to store the step of the angle along which the captions are located. If we take a circle of 360 degrees, and signatures 12, then the step will be 360/12 = 30 degrees. Create a property, it is useful to us to normalize the angle of rotation.

 @property (assign, nonatomic) CGFloat angleStep;   offset  ,    . static CGFloat const kAYNCircleViewLabelOffset = 10; 

We make constant offset for the labels, which will also be needed later.

 - (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]; } } } 

The step will be calculated when placing the numbers on the dial.

 @property (assign, nonatomic) NSUInteger numberOfLabels; 

Now we add a public property to set the number of digits on the dial.

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

And we define setter for it by analogy with backgroundView .
Is done. When the view already created, set the number of digits on the dial. Do not forget about the method - (void)layoutSubviews and initialization of AYNCircleView . There should also be signed.

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

Now - (void)viewDidLoad controller, on the view which our control is shown, has the following form:

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

Look at the hierarchy of views and the arrangement of numbers.



The hierarchy turned out to be true - all the labels are located on the contentView .

Interface rotation support


Please note that some applications use the horizontal orientation of the screen. To handle this situation, let's track the notification ( NSNotification class) about changing the orientation of the interface. We are interested in UIDeviceOrientationDidChangeNotification .

Let's add observer this notification in the initializer of our control and process it in the same block.

 __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]; }]; 

Since blocks implicitly capture self , this can lead to a retain cycle , so loosening the self reference. When the orientation changes, we kind of re-initialize the controls to recalculate the radius of the circle, the new center, etc.

Do not forget to unsubscribe from the alerts in the method - (void)dealloc .

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

Dial is realized. Read about the mathematics of rotation and the next steps in creating custom controls in the second part of the article .

The whole project is available on the gita .

Source: https://habr.com/ru/post/326324/


All Articles