Creating a view with property change animation

    One of the typical tasks when developing applications for iOS is to create custom UI elements, including sometimes it may be necessary to animate changes in the value of any of the properties. This article discusses the process of creating a subclass of UIView that has properties whose values ​​can be changed with animation. A simple example: you need to draw a circular progress with the ability to animate a change in color and value in the range from 0 to 1.



    To create custom animations, the Quartz Core and Core Animation tools are used in the interfaces. The main work takes place in layer classes, but in my practice, user interfaces are usually built from the view hierarchy, so the creation of a separate subclass of UIView is considered . For the same reason, we will use ARC. Let's get started.

    Frameworks


    First of all, you need to connect the Quartz Core framework if it is not in the project.

    Layer


    Then you need to create a layer class. Call it TSTRoundProgressLayer and inherit from CALayer . To interact with the outside world, he will need interfaces. Let's make them in the manner of standard controls like UIProgressView :

    @interfaceTSTRoundProgressLayer : CALayer@property (strong, nonatomic) __attribute__((NSObject)) CGColorRef progressColor;
    - (void)setProgressColor:(CGColorRef)progressColor animated:(BOOL)animated;
    @property (readwrite, nonatomic) CGFloat progress;
    - (void)setProgress:(CGFloat)progress animated:(BOOL)animated;
    @end

    It is worth paying attention to the color storage in the property. Core Animation can animate a color change, but only if it's CGColorRef . ARC initially does not understand how to store objects from the CG world, so you need to set additional memory management attributes.

    Possible meaning of progress makes sense to limit. To do this, we need to duplicate this property in the class extension (I will explain why in more detail later). A property accessible outside the class will be needed only to add the logic of changing values, and all work relating directly to animations will occur through its pair from the extension. Let's call it, for example, animatableProgress :

    @interfaceTSTRoundProgressLayer ()@property (assign, nonatomic) CGFloat animatableProgress;
    @end

    For animations to work, you need to follow a few steps:

    Status Rendering Code


    - (void)drawInContext:(CGContextRef)context
    {
        CGFloat lineWidth = [UIScreen mainScreen].scale;
        CGRect rect = self.bounds;
        if (rect.size.height <= lineWidth || rect.size.width <= lineWidth) return;
        rect = CGRectInset(rect, lineWidth, lineWidth);
        CGFloat radius = MIN(rect.size.height, rect.size.width)/2;
        CGContextSetLineWidth(context, lineWidth);
        CGContextSetStrokeColorWithColor(context, self.progressColor);
        CGContextBeginPath(context);
        CGContextAddArc(context,
                        CGRectGetMidX(rect),
                        CGRectGetMidY(rect),
                        radius,
                        -M_PI_2,
                        -M_PI_2 + M_PI*2*self.animatableProgress,
                        NO);
        CGContextStrokePath(context);
    }
    

    First of all, we limit the incorrect conditions. Then we narrow the drawing area a bit so that the lines are not cut off by the edges of the layer (the cost of drawing lines in Core Graphics). Next, we perform calculations, adjust the context, and directly draw. Please note that the code uses the value of the animatableProgress internal property . It is also worth noting that if progress is not assigned , it will be zero and the code will work correctly. In general, if black suits the default progress color, progressColor may also be empty. However, if you want to set a different default color, you can use the + defaultValueForKey method :

    + (id)defaultValueForKey:(NSString *)key
    {
        if ([key isEqualToString:NSStringFromSelector(@selector(progressColor))]) {
            return (id)[UIColor blueColor].CGColor;
        }
        return [super defaultValueForKey:key];
    }
    

    When it comes to field names, I always recommend insurance with a combination of:

    NSStringFromSelector(@selector())
    

    So, when changing the name of the field, the compiler will tell you in the event that it needs to be corrected.
    The important point is that the drawing code fills only a small circle on the surface of the layer, and not the entire space allotted, therefore, for the correct rendering, it is necessary that the opaque property be set to NO , or backgroundColor should have an alpha other than 1. To be safe, can overload the isOpaue getter :

    - (BOOL)isOpaque
    {
        returnNO;
    }
    

    In addition, the drawing code itself in the example is written so that view clearsContextBeforeDrawing == YES is required (this is the default value).

    Specify whether to redraw when changing properties


    In order for the layer to know that it needs to be redrawn when the property values ​​change, you need to overload the + needsDisplayForKey method :

    + (BOOL)needsDisplayForKey:(NSString *)key
    {
        if ([key isEqualToString:NSStringFromSelector(@selector(progressColor))] ||
            [key isEqualToString:NSStringFromSelector(@selector(animatableProgress))])
        {
            returnYES;
        }
        return [super needsDisplayForKey:key];
    }
    

    Dynamic properties


    And in order to trigger the magic of CALayer when changing property values , you need to make them dynamic:

    @dynamic progressColor, progress;
    

    How it works? For managing animations, CALayer has a functional that provides work with values ​​by keys that do not have ivars and implementation of accessors. First of all, accessors are not synthesized for dynamic properties. Thus, when an accessor is called from a dynamic property (for example, -setAnimatableProgress:), the selector is not recognized, and the runtime mechanisms are activated to resolve the situation. The method of class + (BOOL) resolveInstanceMethod: (SEL) sel is triggered , in which, if the method matches the existing dynamic property of this class, it is added with an implementation that starts the animation mechanisms.

    Create animations


    Finally, you need to create the animation object itself, which will be added to the layer. To do this, use the -actionForKey method :

    - (id<CAAction>)actionForKey:(NSString *)key
    {
        if ([key isEqualToString:NSStringFromSelector(@selector(progressColor))] ||
            [key isEqualToString:NSStringFromSelector(@selector(animatableProgress))])
        {
            CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:key];
            animation.duration = 1;
            animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
            animation.fromValue = [self.presentationLayer valueForKey:key];
            return animation;
        }
        return [super actionForKey:key];
    }
    

    Here, for the desired key, you need to return the corresponding animation. In general, you need to return an object that complies with the CAAction protocol , which allows the "object to respond to the action launched by CALayer" (free translation). Of the iOS SDK classes, only CAAnimation implements it . The description of the protocol is rather vague, and, judging by the discussions, it makes little sense to implement the protocol with your own hands. Moreover, CAAnimation has sufficient flexibility to allow solving the vast majority of problems associated with animations in the interfaces of ordinary applications.

    For the selected example, the simplest and highest-level option - CABasicAnimation - will do at all .. Here it’s enough for us to indicate the values ​​for which key to animate, how long it should be done, and where to start. You can use more flexible types of animations and fine-tune them, depending on your needs. So, for example, I added ising, indicating the corresponding temporary function.

    It is worth noting that the animation is created before the value in the property is changed, and starts working after it, so the indication of the final value is omitted - at the time of creating the animation, there is simply nowhere to take it. In turn, CABasicAnimation uses the value of keyPath at the time of the start of the initial and final by default, so that in this case everything works correctly.

    Animations are added to the layer by calling-addAnimation: forKey: . Do not confuse key with keyPath from CABasicAnimation , as they are not directly related. For example, adding animation of the “foo” property can be added to the layer by the key “bar”:

    CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"foo"];
    [layer addAnimation:animation forKey:@"bar"];
    

    When the layer itself adds the animation using -actionForKey:, it is added using the same key, which is changed (and passed to this method). In this case, the code for creating an animation object is written so that the -actionForKey: key matches its keyPath . It is important that one animation can be initiated while another is being displayed, and when a new animation is added by key, the current animation by the same key is deleted. This means that you need to think about smoothing joints when one animation replaces another.

    To display a layer on the screen, the system uses not the object whose property we are animating, but its “presentation layer” ( presentationLayer) Is a copy created by the system. From it one can obtain actual values ​​of animated properties. Therefore, it is used to obtain the initial value of the animation: we start a new animation from the value that was displayed on the screen at the time of its creation.

    This creates the animation object itself for the current property. It is worth noting that the behavior of the layer when changing values ​​will be the same as when changing properties like backgroundColor - by default, the change is animated. At the view level, this can be changed.

    External interfaces


    There is a basis, now we need to organize the work of external interfaces:

    - (void)setProgressColor:(CGColorRef)progressColor animated:(BOOL)animated
    {
        self.progressColor = progressColor;
        if (!animated) {
            [self removeAnimationForKey:NSStringFromSelector(@selector(progressColor))];
        }
    }
    - (CGFloat)progress
    {
        returnself.animatableProgress;
    }
    - (void)setProgress:(CGFloat)progress
    {
        self.animatableProgress = MAX(0, MIN(progress, 1));
    }
    - (void)setProgress:(CGFloat)progress animated:(BOOL)animated
    {
        self.progress = progress;
        if (!animated) {
            [self removeAnimationForKey:NSStringFromSelector(@selector(animatableProgress))];
        }
    }
    

    As already mentioned, a layer adds animations to those keys that change. Accordingly, to display the changes instantly, it is enough to remove the animation by key after changing the value.

    Here we see how the accessors of the external progress property are used to limit the range of values. Why do we need to add a hidden pair to the extension? If we have only the property of progress , and we have overloaded -setProgress: , the CALayer not add runtime in its implementation of the method that triggers the animation. I had a naive idea to overload -setValue: forKey: and add a check with the value changed, but changing the values ​​bypasses this method, although it is called bypresentationLayer in the process of displaying animations. There was an idea to limit values ​​by specifying values ​​when creating an animation object, but at this point the final value is not yet known. Thus, it remains only to duplicate the external property and use its hidden pair to work with animations, and external accessors to add logic.

    View


    Work with the layer is finished, now you need to wrap it in view. To do this, add a new class with similar interfaces:

    @interfaceTSTRoundProgressBar : UIView@property (readwrite, nonatomic) UIColor *progressColor;
    - (void)setProgressColor:(UIColor*)progressColor animated:(BOOL)animated;
    @property (readwrite, nonatomic) CGFloat progress;
    - (void)setProgress:(CGFloat)progress animated:(BOOL)animated;
    @end

    Connect the Quartz Core and set the view class to the layer class.

    + (Class)layerClass
    {
        return [TSTRoundProgressLayer class];
    }
    

    All interfaces are needed exclusively to forward calls to the layer:

    - (UIColor *)progressColor
    {
        return [UIColor colorWithCGColor:[(TSTRoundProgressLayer*)self.layer progressColor]];
    }
    - (void)setProgressColor:(UIColor *)progressColor
    {
        [(TSTRoundProgressLayer*)self.layer setProgressColor:progressColor.CGColor animated:NO];
    }
    - (void)setProgressColor:(UIColor *)progressColor animated:(BOOL)animated
    {
        [(TSTRoundProgressLayer*)self.layer setProgressColor:progressColor.CGColor animated:animated];
    }
    - (CGFloat)progress
    {
        return [(TSTRoundProgressLayer*)self.layer progress];
    }
    - (void)setProgress:(CGFloat)progress
    {
        [(TSTRoundProgressLayer*)self.layer setProgress:progress animated:NO];
    }
    - (void)setProgress:(CGFloat)progress animated:(BOOL)animated
    {
        [(TSTRoundProgressLayer*)self.layer setProgress:progress animated:animated];
    }
    

    Here we bring the behavior to a view more familiar to view - by default, changes to values ​​are not animated.

    Using


    Actually, the classes are ready to use. Now you can put the view in the hierarchy and observe the behavior when the values ​​change. For example, you can make a controller class in the view hierarchy of which our progress will be added. I don’t give details of adding view, you can do it in any convenient way. I used the storyboard. So we have:

    @interfaceViewController ()@property (weak, nonatomic) IBOutlet TSTRoundProgressBar *progressBar;
    @end

    You can observe the behavior, for example, as follows:

    - (void)viewDidLoad
    {
        [super viewDidLoad];
        self.progressBar.progress = 0.2;
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            [self.progressBar setProgressColor:[UIColor magentaColor] animated:YES];
            [self.progressBar setProgress:0.9 animated:YES];
            dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.8 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
                [self.progressBar setProgressColor:[UIColor greenColor] animated:YES];
                [self.progressBar setProgress:0.4 animated:YES];
                dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
                    self.progressBar.progressColor = [UIColor redColor];
                    self.progressBar.progress = 0.9;
                });
            });
        });
    }
    

    Since view content is created using drawing, it will be convenient to set contentMode == UIViewContentModeRedraw so that when the frame changes , the content is drawn again. This can be done in the code outside the view or inside during initialization, or in the interface builder. For the sake of purity of the code, I chose the last option.

    A finished project with an example can be found here .

    Also popular now: