UITextField
, UIButton
and UISwitch
. Using all the power of standard controls, you can create many different interface options. Be that as it may, sometimes there is a need for something that cannot be realized by standard components.UISlider
, one of which sets the minimum price, the other - the maximum, as shown in the screenshot:UIView
. And if in the context of one particular application, such a solution is justified, then its reuse in other developments will require some effort. A much better idea would be to make this component universal, available for use in any application. This is the meaning of custom controls.UIKit Framework
. As well as the standard components, they must be fully versatile and customizable for any needs. Recently, a whole community of developers has been formed, who spread their components to open access.RangeSlider
control, which solves the above problem. Such issues as extending existing components, developing an API, and even putting your creation into public access will be affected.UIView
.Apple UIKit
, you will notice that many elements such as UILabel
and UIWebView
directly inherit UIView
. But be that as it may, there are such elements that inherit UIControl
, as shown in this figure:Note: A detailed hierarchy of interface elements can be found here: UIKit Framework Reference .
UIControl
class implements the Target-Action pattern, which is essentially a way of notifying component changes. Also, UIControl
has several properties related to controlling the state of an object. To create our component you will use just such a template, so that UIControl
will serve as an excellent start. #import "CERangeSlider.h"
@implementation CEViewController { CERangeSlider* _rangeSlider; }
viewDidLoad
following code block: - (void)viewDidLoad { [super viewDidLoad]; // Do any additional setup after loading the view, typically from a nib. NSUInteger margin = 20; CGRect sliderFrame = CGRectMake(margin, margin, self.view.frame.size.width - margin * 2, 30); _rangeSlider = [[CERangeSlider alloc] initWithFrame:sliderFrame]; _rangeSlider.backgroundColor = [UIColor redColor]; [self.view addSubview:_rangeSlider]; }
Note: Your component's API defines the methods and properties that you intend to expose to other developers. Further in the article you will read about the structure of the API - for now stay in touch!
@interface
and @end:
@property (nonatomic) float maximumValue; @property (nonatomic) float minimumValue; @property (nonatomic) float upperValue; @property (nonatomic) float lowerValue;
initWithFrame:
method generated by Xcode , and replace it with the following code: - (id)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) { // Initialization code _maximumValue = 10.0; _minimumValue = 0.0; _upperValue = 8.0; _lowerValue = 2.0; } return self; }
UIImage
properties.Note: Curiously, Apple prefers using graphic resources in its components. Most likely, this is due to the fact that they have set the standard dimensions for each element and do not allow its complete customization.
#import <QuartzCore/QuartzCore.h>
@implementation
: @implementation CERangeSlider { CALayer* _trackLayer; CALayer* _upperKnobLayer; CALayer* _lowerKnobLayer; float _knobWidth; float _useableTrackLength; }
_trackLayer
, _upperKnobLayer
and _lowerKnobLayer
will be used to display the various elements of your component. The two variables _knobWidth
and _useableTrackLength
are used to set the parameters of these elements.initWithFrame:
and add the following code to the if (self) { }
block: _trackLayer = [CALayer layer]; _trackLayer.backgroundColor = [UIColor blueColor].CGColor; [self.layer addSublayer:_trackLayer]; _upperKnobLayer = [CALayer layer]; _upperKnobLayer.backgroundColor = [UIColor greenColor].CGColor; [self.layer addSublayer:_upperKnobLayer]; _lowerKnobLayer = [CALayer layer]; _lowerKnobLayer.backgroundColor = [UIColor greenColor].CGColor; [self.layer addSublayer:_lowerKnobLayer]; [self setLayerFrames];
- (void) setLayerFrames { _trackLayer.frame = CGRectInset(self.bounds, 0, self.bounds.size.height / 3.5); [_trackLayer setNeedsDisplay]; _knobWidth = self.bounds.size.height; _useableTrackLength = self.bounds.size.width - _knobWidth; float upperKnobCentre = [self positionForValue:_upperValue]; _upperKnobLayer.frame = CGRectMake(upperKnobCentre - _knobWidth / 2, 0, _knobWidth, _knobWidth); float lowerKnobCentre = [self positionForValue:_lowerValue]; _lowerKnobLayer.frame = CGRectMake(lowerKnobCentre - _knobWidth / 2, 0, _knobWidth, _knobWidth); [_upperKnobLayer setNeedsDisplay]; [_lowerKnobLayer setNeedsDisplay]; } - (float) positionForValue:(float)value { return _useableTrackLength * (value - _minimumValue) / (_maximumValue - _minimumValue) + (_knobWidth / 2); }
setLayerFrames
sets sizes for both sliders and progress bars based on the current values of the slider. positionForValue
binds the value to screen coordinates, using a simple proportion to scale the distance between the maximum and minimum values of the component.CALayer
and name it CERangeSliderKnobLayer . #import <QuartzCore/QuartzCore.h> @class CERangeSlider; @interface CERangeSliderKnobLayer : CALayer @property BOOL highlighted; @property (weak) CERangeSlider* slider; @end
#import
: #import "CERangeSliderKnobLayer.h"
_upperKnobLayer
and _lowerKnobLayer
in the @implementation
block: CERangeSliderKnobLayer* _upperKnobLayer; CERangeSliderKnobLayer* _lowerKnobLayer;
initWithFrame:
and replace the upperKnobLayer
and lowerKnobLayer
initialization code with the following code block: _upperKnobLayer = [CERangeSliderKnobLayer layer]; _upperKnobLayer.slider = self; _upperKnobLayer.backgroundColor = [UIColor greenColor].CGColor; [self.layer addSublayer:_upperKnobLayer]; _lowerKnobLayer = [CERangeSliderKnobLayer layer]; _lowerKnobLayer.slider = self; _lowerKnobLayer.backgroundColor = [UIColor greenColor].CGColor; [self.layer addSublayer:_lowerKnobLayer];
slider
property to self
. Run your project and make sure that everything looks the same as in the screenshot: CGPoint _previousTouchPoint;
UIControl
provides several methods for tracking clicks. UIControl UIControl
can override these methods to implement their own logic. In your control, you override the following three methods: beginTrackingWithTouch
, continueTrackingWithTouch
and endTrackingWithTouch
. - (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event { _previousTouchPoint = [touch locationInView:self]; // hit test the knob layers if(CGRectContainsPoint(_lowerKnobLayer.frame, _previousTouchPoint)) { _lowerKnobLayer.highlighted = YES; [_lowerKnobLayer setNeedsDisplay]; } else if(CGRectContainsPoint(_upperKnobLayer.frame, _previousTouchPoint)) { _upperKnobLayer.highlighted = YES; [_upperKnobLayer setNeedsDisplay]; } return _upperKnobLayer.highlighted || _lowerKnobLayer.highlighted; }
setNeedsDisplay
method allows setNeedsDisplay
to make sure that the layers have been updated - then you will understand why this is important. #define BOUND(VALUE, UPPER, LOWER) MIN(MAX(VALUE, LOWER), UPPER) - (BOOL)continueTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event { CGPoint touchPoint = [touch locationInView:self]; // 1. determine by how much the user has dragged float delta = touchPoint.x - _previousTouchPoint.x; float valueDelta = (_maximumValue - _minimumValue) * delta / _useableTrackLength; _previousTouchPoint = touchPoint; // 2. update the values if (_lowerKnobLayer.highlighted) { _lowerValue += valueDelta; _lowerValue = BOUND(_lowerValue, _upperValue, _minimumValue); } if (_upperKnobLayer.highlighted) { _upperValue += valueDelta; _upperValue = BOUND(_upperValue, _maximumValue, _lowerValue); } // 3. Update the UI state [CATransaction begin]; [CATransaction setDisableActions:YES] ; [self setLayerFrames]; [CATransaction commit]; return YES; }
delta
- the number of pixels on which the finger was moved. Then you convert them depending on the minimum and maximum values of the component.BOUND
macro, which is more readable than the MIN/MAX
.disabledActions
flag in CATransaction
. This allows you to make sure that changes to the borders of each layer are applied immediately and are not animated. At the end, the setLayerFrames
method is setLayerFrames
, moving the slider to the right place. - (void)endTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event { _lowerKnobLayer.highlighted = _upperKnobLayer.highlighted = NO; [_lowerKnobLayer setNeedsDisplay]; [_upperKnobLayer setNeedsDisplay]; }
UIKit
components, you will see that they do not use NSNotification or KVO , so for compatibility with UIKit
we have to abandon these two options. The other two templates - delegation and Target-Action - are very often used in UIKit
.UIControl
base class. When the state of the component changes, the target (target) is notified of the action (action) , which is described by one of the enum values of UIControlEvents
. You can provide multiple targets to control all actions, and although it is possible to add your own events ( UIControlEventApplicationReserver
), their number is limited to four. These actions cannot transmit any information along with the event. Therefore, they cannot be used to transmit additional data.UIControl
at the very beginning of the lesson.continueTrackingWithTouch:withEvent:
, so this is where the notification mechanism should be implemented. Open CERangeSlider.m , find the method continueTrackingWithTouch:withEvent
and add the following code before return YES
: [self sendActionsForControlEvents:UIControlEventValueChanged];
viewDidLoad
: [_rangeSlider addTarget:self action:@selector(slideValueChanged:) forControlEvents:UIControlEventValueChanged];
slideValueChanged
each time the slider sends an event UIControlEventValueChanged
. - (void)slideValueChanged:(id)control { NSLog(@"Slider value changed: (%.2f,%.2f)", _rangeSlider.lowerValue, _rangeSlider.upperValue); }
#import <QuartzCore/QuartzCore.h> @class CERangeSlider; @interface CERangeSliderTrackLayer : CALayer @property (weak) CERangeSlider* slider; @end
#import
: #import "CERangeSliderTrackLayer.h"
_trackLayer
and change its type to the class you just created: CERangeSliderTrackLayer* _trackLayer;
initWithFrame:
and update the layer creation code: _trackLayer = [CERangeSliderTrackLayer layer]; _trackLayer.slider = self; [self.layer addSublayer:_trackLayer]; _upperKnobLayer = [CERangeSliderKnobLayer layer]; _upperKnobLayer.slider = self; [self.layer addSublayer:_upperKnobLayer]; _lowerKnobLayer = [CERangeSliderKnobLayer layer]; _lowerKnobLayer.slider = self; [self.layer addSublayer:_lowerKnobLayer];
viewDidLoad
and delete it: _rangeSlider.backgroundColor = [UIColor redColor];
@property (nonatomic) UIColor* trackColour; @property (nonatomic) UIColor* trackHighlightColour; @property (nonatomic) UIColor* knobColour; @property (nonatomic) float curvaceousness; - (float) positionForValue:(float)value;
curvaceousness
- learn a little later. And finally positionForValue:
. We have already managed to implement this method, and now we are making it available to various layers.initWithFrame:,
under the code block responsible for initializing the remaining variables: _trackHighlightColour = [UIColor colorWithRed:0.0 green:0.45 blue:0.94 alpha:1.0]; _trackColour = [UIColor colorWithWhite:0.9 alpha:1.0]; _knobColour = [UIColor whiteColor]; _curvaceousness = 1.0; _maximumValue = 10.0; _minimumValue = 0.0;
#import
: #import "CERangeSlider.h"
CALayer
that makes it possible to use only solid color.drawInContext:
and use the CoreGraphics API for rendering.Note: If you want to learn more about Core Graphics, then the Core Graphics 101 tutorial series course is recommended , since a detailed review of Core Graphics is beyond the scope of this lesson.
@implementation
: - (void)drawInContext:(CGContextRef)ctx { // clip float cornerRadius = self.bounds.size.height * self.slider.curvaceousness / 2.0; UIBezierPath *switchOutline = [UIBezierPath bezierPathWithRoundedRect:self.bounds cornerRadius:cornerRadius]; CGContextAddPath(ctx, switchOutline.CGPath); CGContextClip(ctx); // 1) fill the track CGContextSetFillColorWithColor(ctx, self.slider.trackColour.CGColor); CGContextAddPath(ctx, switchOutline.CGPath); CGContextFillPath(ctx); // 2) fill the highlighed range CGContextSetFillColorWithColor(ctx, self.slider.trackHighlightColour.CGColor); float lower = [self.slider positionForValue:self.slider.lowerValue]; float upper = [self.slider positionForValue:self.slider.upperValue]; CGContextFillRect(ctx, CGRectMake(lower, 0, upper - lower, self.bounds.size.height)); // 3) add a highlight over the track CGRect highlight = CGRectMake(cornerRadius/2, self.bounds.size.height/2, self.bounds.size.width - cornerRadius, self.bounds.size.height/2); UIBezierPath *highlightPath = [UIBezierPath bezierPathWithRoundedRect:highlight cornerRadius:highlight.size.height * self.slider.curvaceousness / 2.0]; CGContextAddPath(ctx, highlightPath.CGPath); CGContextSetFillColorWithColor(ctx, [UIColor colorWithWhite:1.0 alpha:0.4].CGColor); CGContextFillPath(ctx); // 4) inner shadow CGContextSetShadowWithColor(ctx, CGSizeMake(0, 2.0), 3.0, [UIColor grayColor].CGColor); CGContextAddPath(ctx, switchOutline.CGPath); CGContextSetStrokeColorWithColor(ctx, [UIColor grayColor].CGColor); CGContextStrokePath(ctx); // 5) outline the track CGContextAddPath(ctx, switchOutline.CGPath); CGContextSetStrokeColorWithColor(ctx, [UIColor blackColor].CGColor); CGContextSetLineWidth(ctx, 0.5); CGContextStrokePath(ctx); }
curvaceousness
, now is the time to try it!#import
: #import "CERangeSlider.h"
- (void)drawInContext:(CGContextRef)ctx { CGRect knobFrame = CGRectInset(self.bounds, 2.0, 2.0); UIBezierPath *knobPath = [UIBezierPath bezierPathWithRoundedRect:knobFrame cornerRadius:knobFrame.size.height * self.slider.curvaceousness / 2.0]; // 1) fill - with a subtle shadow CGContextSetShadowWithColor(ctx, CGSizeMake(0, 1), 1.0, [UIColor grayColor].CGColor); CGContextSetFillColorWithColor(ctx, self.slider.knobColour.CGColor); CGContextAddPath(ctx, knobPath.CGPath); CGContextFillPath(ctx); // 2) outline CGContextSetStrokeColorWithColor(ctx, [UIColor grayColor].CGColor); CGContextSetLineWidth(ctx, 0.5); CGContextAddPath(ctx, knobPath.CGPath); CGContextStrokePath(ctx); // 3) inner gradient CGRect rect = CGRectInset(knobFrame, 2.0, 2.0); UIBezierPath *clipPath = [UIBezierPath bezierPathWithRoundedRect:rect cornerRadius:rect.size.height * self.slider.curvaceousness / 2.0]; CGGradientRef myGradient; CGColorSpaceRef myColorspace; size_t num_locations = 2; CGFloat locations[2] = { 0.0, 1.0 }; CGFloat components[8] = { 0.0, 0.0, 0.0 , 0.15, // Start color 0.0, 0.0, 0.0, 0.05 }; // End color myColorspace = CGColorSpaceCreateDeviceRGB(); myGradient = CGGradientCreateWithColorComponents (myColorspace, components, locations, num_locations); CGPoint startPoint = CGPointMake(CGRectGetMidX(rect), CGRectGetMinY(rect)); CGPoint endPoint = CGPointMake(CGRectGetMidX(rect), CGRectGetMaxY(rect)); CGContextSaveGState(ctx); CGContextAddPath(ctx, clipPath .CGPath); CGContextClip(ctx); CGContextDrawLinearGradient(ctx, myGradient, startPoint, endPoint, 0); CGGradientRelease(myGradient); CGColorSpaceRelease(myColorspace); CGContextRestoreGState(ctx); // 4) highlight if (self.highlighted) { // fill CGContextSetFillColorWithColor(ctx, [UIColor colorWithWhite:0.0 alpha:0.1].CGColor); CGContextAddPath(ctx, knobPath.CGPath); CGContextFillPath(ctx); } }
- (void)setTrackColour:(UIColor *)trackColour { if (_trackColour != trackColour) { _trackColour = trackColour; [_trackLayer setNeedsDisplay]; } }
trackColor
, this block of code notifies the slider bar layer that it needs to be updated. But, given the fact that eight variables are used in the slider API, rewriting the same code so many times is not the best solution. #define GENERATE_SETTER(PROPERTY, TYPE, SETTER, UPDATER) \ - (void)SETTER:(TYPE)PROPERTY { \ if (_##PROPERTY != PROPERTY) { \ _##PROPERTY = PROPERTY; \ [self UPDATER]; \ } \ }
GENERATE_SETTER(trackHighlightColour, UIColor*, setTrackHighlightColour, redrawLayers) GENERATE_SETTER(trackColour, UIColor*, setTrackColour, redrawLayers) GENERATE_SETTER(curvaceousness, float, setCurvaceousness, redrawLayers) GENERATE_SETTER(knobColour, UIColor*, setKnobColour, redrawLayers) GENERATE_SETTER(maximumValue, float, setMaximumValue, setLayerFrames) GENERATE_SETTER(minimumValue, float, setMinimumValue, setLayerFrames) GENERATE_SETTER(lowerValue, float, setLowerValue, setLayerFrames) GENERATE_SETTER(upperValue, float, setUpperValue, setLayerFrames) - (void) redrawLayers { [_upperKnobLayer setNeedsDisplay]; [_lowerKnobLayer setNeedsDisplay]; [_trackLayer setNeedsDisplay]; }
redrawLayers
is called for the variables associated with the appearance of the component, and setLayerFrames
for those responsible for the markup.viewDidLoad
: [self performSelector:@selector(updateState) withObject:nil afterDelay:1.0f];
updateState
after the second delay. Add this method to CEViewController.m : - (void)updateState { _rangeSlider.trackHighlightColour = [UIColor redColor]; _rangeSlider.curvaceousness = 0.0; }
Note: The code that you just added clearly illustrates one of the most interesting (and, by the way, often forgotten by developers) sides of the development of custom components - their testing.
When you develop your control, check all possible values of its properties and their influence on the appearance of the component - your responsibility. A good way would be to add several buttons and sliders, each of which is responsible for some property of the component. This way, it will be possible to test the control without being distracted by changing the code.
max
, min
, upper
, lower
.upperValue
greater thanmaximumValue
? Of course, you yourself will never do this - it is at least silly. But you can not guarantee that someone else will try! You need to make sure that the component is always working properly - regardless of the level of developer nonsense.Source: https://habr.com/ru/post/212111/
All Articles