Создание view с анимацией изменения свойства

    Одной из типовых задач при разработке приложений под iOS является создание кастомных элементов UI, в том числе иногда может потребоваться анимировать изменения значения какого-либо из свойств. В статье рассматривается процесс создания подкласса UIView, имеющего свойства, значения которых можно изменять с анимацией. Простой пример: необходимо отрисовывать круговой прогресс с возможностью анимировать изменение цвета и значения в пределах от 0 до 1.



    Для создания кастомных анимаций в интерфейсах используются средства Quartz Core и Core Animation. Основная работа происходит в классах слоёв, но в моей практике интерфейсы пользователя, как правило, строятся из иерархии view, так что рассматривается создание отдельного подкласса UIView. По той же причине будем использовать ARC. Начнём.

    Фреймворки


    В первую очередь, нужно подключить фреймворк Quartz Core, если его нет в проекте.

    Слой


    Затем нужно создать класс слоя. Назовём его TSTRoundProgressLayer и наследуемся от CALayer. Для взаимодействия с внешним миром ему понадобятся интерфейсы. Сделаем их на манер стандартных контролов вроде UIProgressView:

    @interface TSTRoundProgressLayer : 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
    

    Стоит обратить внимание на хранение цвета в свойстве. Core Animation умеет анимировать изменение цвета, но только если это CGColorRef. ARC изначально не понимает, как хранить объекты из мира CG, поэтому нужно задать дополнительные атрибуты управления памятью.

    Возможные значения прогресса имеет смысл ограничить. Для этого нам понадобится продублировать это свойство в расширении класса (позже подробнее объясню, почему). Свойство, доступное снаружи класса, будет нужно только для добавления логики изменения значений, а вся работа, касающаяся непосредственно анимаций, будет происходить через его пару из расширения. Назовём её, например, animatableProgress:

    @interface TSTRoundProgressLayer ()
    
    @property (assign, nonatomic) CGFloat animatableProgress;
    
    @end
    

    Чтобы анимации заработали, нужно выполнить несколько шагов:

    Код отрисовки состояния


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

    Первым делом, ограничиваем некорректные условия. Затем немного сужаем область рисования, чтобы линии не обрезались краями слоя (издержки рисования линий в Core Graphics). Далее выполняем вычисления, настройку контекста и непосредственно рисование. Обращаю внимание, что в коде используется значение внутреннего свойства animatableProgress. Также стоит отметить, что в случае, если не присвоено значение progress, оно будет нулевым, и код сработает корректно. В целом, если чёрный цвет устраивает как цвет прогресса по умолчанию, progressColor тоже может быть пустым. Однако, если хочется задать другой цвет по умолчанию, можно использовать метод +defaultValueForKey:

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

    Когда дело касается названий полей, всегда рекомендую страховаться комбинацией:

    NSStringFromSelector(@selector())
    

    Так при изменении названия поля компилятор в случае чего подскажет, что его нужно подправить.
    Важным моментом является то, что код рисования заполняет только маленькую окружность на поверхности слоя, а не всё отведённое место целиком, поэтому для корректной отрисовки необходимо, чтобы свойство opaque было выставлено в NO, либо чтобы backgroundColor имел альфу, отличную от 1. Чтобы подстраховаться, можно перегрузить геттер isOpaue:

    - (BOOL)isOpaque
    {
        return NO;
    }
    

    Кроме того, сам код рисования в примере написан так, что необходимо иметь у view clearsContextBeforeDrawing == YES (это значение по умолчанию).

    Указание необходимости перерисовки при изменении свойств


    Чтобы слой знал, что ему необходимо отрисоваться заново при изменении значений свойств, нужно перегрузить метод +needsDisplayForKey:

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

    Динамические свойства


    А чтобы при изменении значений свойств срабатывала магия CALayer, нужно сделать их динамическими:

    @dynamic progressColor, progress;
    

    Как это работает? Для управления анимациями в CALayer есть функционал, обеспечивающий работу со значениями по ключам, не имеющим ivar'ов и имплементации аксессоров. Прежде всего, у динамических свойств не синтезируются аксессоры. Таким образом, при вызове аксессора от динамического свойства (например, -setAnimatableProgress:) селектор не опознаётся, и включаются механизмы рантайма для разрешения ситуации. Срабатывает метод класса +(BOOL)resolveInstanceMethod:(SEL)sel, в котором, если метод соответствует существующему динамическому свойству данного класса, он добавляется с имплементацией, запускающей механизмы анимаций.

    Создание анимаций


    Наконец, нужно создать сам объект анимации, который будет добавляться в слой. Для этого используется метод -actionForKey:

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

    Здесь для нужного ключа необходимо возвращать соответствующую анимацию. В общем случае возвращать нужно объект, удовлетворяющий протоколу CAAction, позволяющему «объекту отвечать на действие, запущенное CALayer» (вольный перевод). Из классов iOS SDK его реализует только CAAnimation. Описание протокола довольно размытое, и, судя по обсуждениям, особого смысла реализовывать протокол своими руками нет. Тем более, что CAAnimation имеет достаточную гибкость, чтобы позволять решать подавляющее большинство задач, связанных с анимациями в интерфейсах обычных приложений.

    Для выбранного примера так и вовсе подойдёт самый простой и высокоуровневый вариант — CABasicAnimation. Здесь нам достаточно указать, значения по какому ключу анимировать, с какой длительностью это делать и откуда начинать. Можно использовать более гибкие виды анимаций и проводить более тонкую их настройку, в зависимости от потребностей. Так, например, я добавил изинг, указав соответствующую временную функцию.

    Стоит отметить, что анимация создаётся раньше изменения значения в свойстве, а начинает работать уже после него, поэтому указание конечного значения опускается — на момент создания анимации его просто негде взять. В свою очередь, CABasicAnimation использует значение по keyPath на момент старта как начальное и конечное по умолчанию, так что в данном случае всё срабатывает корректно.

    Анимации добавляются к слою с помощью вызова -addAnimation:forKey:. Не стоит путать key с keyPath от CABasicAnimation, так как они не связаны напрямую. Например, добавление анимации свойства «foo» можно добавить в слой по ключу «bar»:

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

    Когда анимацию добавляет сам layer с помощью -actionForKey:, она добавляется по тому же ключу, который изменяется (и передаётся в этот метод). В данном случае код создания объекта анимации написан так, что ключ -actionForKey: совпадает с её keyPath. Важно, что одна анимация может быть инициирована в момент, пока отображается другая, а при добавлении новой анимации по ключу текущая анимация по тому же ключу удаляется. Это значит, что необходимо задуматься о том, чтобы сглаживать стыки, когда одна анимация заменяет другую.

    Для отображения слоя на экране системой используется не тот объект, свойство которого мы анимируем, а его «слой презентации» (presentationLayer) — копия, создаваемая системой. Из него можно получать актуальные значения анимируемых свойств. Поэтому он используется для получения начального значения анимации: мы начинаем новую анимацию с того значения, которое отображалось на экране на момент её создания.

    Таким образом создаётся сам объект анимации для текущего свойства. Стоит отметить, что поведение слоя при изменении значений будет таким же, как при изменении свойств вроде backgroundColor — по умолчанию изменение анимируется. На уровне view это можно изменить.

    Внешние интерфейсы


    Основа есть, теперь нужно наладить работу внешних интерфейсов:

    - (void)setProgressColor:(CGColorRef)progressColor animated:(BOOL)animated
    {
        self.progressColor = progressColor;
        if (!animated) {
            [self removeAnimationForKey:NSStringFromSelector(@selector(progressColor))];
        }
    }
    
    - (CGFloat)progress
    {
        return self.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))];
        }
    }
    

    Как уже говорилось, слой добавляет анимации по тем ключам, которые изменяются. Соответственно, чтобы отобразить изменения мгновенно, достаточно после изменения значения снять анимацию по ключу.

    Здесь же мы видим, как использованы аксессоры внешнего свойства progress для ограничения диапазона значений. Зачем нам нужно именно добавлять в расширение его скрытую пару? Если у нас будет только свойство progress, и мы перегрузим -setProgress:, CALayer не добавит в рантайме свою имплементацию метода, запускающую анимации. У меня была наивная идея перегрузить -setValue:forKey: и добавить проверку с изменением value, но изменение значений идёт в обход этого метода, хотя он и вызывается у presentationLayer в процессе отображения анимаций. Была мысль ограничить значения путём указания значений при создании объекта анимации, но на этот момент ещё не известно конечное значение. Таким образом, остаётся только продублировать наружное свойство и использовать его скрытую пару для работы с анимациями, а внешние аксессоры для добавления логики.

    View


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

    @interface TSTRoundProgressBar : 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
    

    Подключаем Quartz Core и задаём классу view класс слоя.

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

    Все интерфейсы нужны исключительно, чтобы пробросить вызовы в слой:

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

    Здесь мы приводим поведение к виду, более привычному для view — по умолчанию изменения значений не анимируются.

    Использование


    Собственно, классы готовы к использованию. Теперь можно положить view в иерархию и наблюдать за поведением при изменении значений. Например, можно сделать класс контроллера, в иерархию view которого будет добавлен наш прогресс. Не привожу деталей добавления view, можно сделать это любым удобным способом. Я использовал storyboard. Итак, мы имеем:

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

    Понаблюдать за поведением можно, например, так:

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

    Так как контент у view создаётся с помощью рисования, будет удобно поставить contentMode == UIViewContentModeRedraw, чтобы при изменении frame снова происходила отрисовка контента. Сделать это можно в коде снаружи view или внутри при инициализации, либо в interface builder. Чистоты кода ради выбрал последний вариант.

    Готовый проект с примером можно найти здесь.
    e-Legion
    Делаем приложения, которыми пользуются миллионы

    Similar posts

    Comments 3

      0
      Здорово, когда довольно простая по цели задача позволяет продемонстрировать разные стороны языка, фреймворков и прочего.
      Спасибо, очень качественно!
        +1
        Отличный пример по работе со слоями, но всё же подобные круговые анимации лучше делать через уже готовый CAShapeLayer.
          0
          Да, уже после публикации узнал, что в таких случаях проще и правильнее использовать CAShapeLayer и анимировать изменения strokeStart/strokeEnd. Вообще в drawInContext можно нарисовать что угодно, в том числе разными цветами, поэтому у приведённого подхода возможности пошире.

        Only users with full accounts can post comments. Log in, please.