Pull to refresh

Анимация UIView: перемещение по произвольной траектории на примере окружности

Reading time 8 min
Views 7K
Пожалуй, большинство iOs разработчиков знают, что для реализации различных визуальных эффектов, обычно, достаточно нескольких строчек кода. Фреймворк UIKit, отвечающий за стандартный интерфейс, имеет встроенные средства, позволяющие делать довольно изощрённые виды анимации — от перемещения по прямой, до эффекта переворачивания страницы. Однако, для перемещения наследников UIView по более сложной траектории, приходится спускаться ниже и переходить на уровень фреймворка Core Graphics. При этом, количество примеров в сети снижается и бывает сложно найти необходимое. А если и находится, то качество реализации, зачастую, оставляет желать лучшего. С такой ситуацией я и столкнулся, когда возникла необходимость сделать анимацию интерактивной книги для детей.


Механизм анимации



Для реализации движения по произвольной траектории используется следующий подход:

  1. строится путь, состоящий из фигур (прямые, кривые, окружности и прочее). Для этого используется структура CGPath и вспомогательные функуции для работы с ней. Кстати, эту структуру можно использовать и для отрисовки полученой фигуры.
  2. Создаётся анимация CAKeyframeAnimation, которая описывает поведение — длительность, тип аппроксимации, смещение по времени и т.д. К этому объекту также “цепляется” созданый ранее путь.
  3. Объекту CGLayer отдаётся команда выполнить полученую анимацию.


Построение пути


Пути бывают двух типов: статичный CGPathRef и изменяемый CGMutablePathRef. Первый создаётся с помощью одной из функций, после создания изменить его нельзя. Например, CGPathCreateWithEllipseInRect( CGRect rect, const CGAffineTransform *transform) создаёт эллипс, вписаный в прямоугольник из первого параметра и накладывает на него матрицу трансформации из второго параметра. Это самый простой и быстрый способ создать путь, но у него есть недостаток – начало такого пути будет находится между 1-й и 4-й четвертями, в 0 (360) градусах и иметь почасовое направление. Если мы хотим просто отрисовать полученый путь, такой подход вполне может пригодиться. Но в случае с анимацией, это будет неудобно – начало и направление имеет значение.

Второй тип путей, CGMutablePathRef, создаётся либо пустым и дополняется отдельными функциями, либо с помощью создания изменяемой копии существующего пути. Для примера, рассмотрим создание окружности с центром в произвольной точке:

CGPoint center = CGPointMake(200.0, 200.0);
CGFloat radius = 100.0;
CGMutablePathRef path = CGPathCreateMutable(); 
CGPathAddArc(path, NULL, center.x, center.y, radius, M_PI, 0, NO);       //А
CGPathAddArc(path, NULL, center.x, center.y, radius, 0, M_PI, NO);
CGPathRelease(path);                                                     //Б



  • Функция CGPathAddArc добавляет дугу к пути и принимает следующие параметры:
    1. изменяемый путь
    2. матрица трансформации
    3. Х координата центра окружности
    4. У координата центра окружности
    5. радиус дуги
    6. угол от оси Х к началу дуги, в радианах
    7. угол к концу дуги
    8. направление, в данном случае против часовой стрелки

  • Ответственность за освобождение созданного ресурса лежит на программисте. Программист, помни: утечки это плохо. Приложение будет жрать память, эппл – негодовать, а пользователь – расстраиваться.


Значение некоторых параметров функции CGPathAddArc может быть не очевидно и для лучшего понимания посмотрим на приведённую ниже картинку:



А – центр воображаемой окружности, по которой будет пролегать наша дуга. Координаты задают параметры 3 и 4.
Б – начало дуги, задаётся углом, параметр 6.
В – конец дуги, аналогично, параметр 7.

Создание и запуск анимации


Тут всё проще:

CAKeyframeAnimation *pathAnimation = [CAKeyframeAnimation animationWithKeyPath:@"position"];
pathAnimation.path = path;
pathAnimation.duration = 2.0f;
 [view.layer addAnimation:pathAnimation forKey:nil];


Создаём экземпляр CAKeyframeAnimation и передаём конструктору Key-Value path до свойства, которое хотим анимировать. В нашем случае это „position”.
Присваиваем анимации ранее созданый CGPathRef.
Устанавливаем длительность анимации.
Берём нужный нам UIView, находим его CGLayer и вызываем проигрывание анимации.

Всё, после этого анимация начнёт проигрываться. Вторым параметром передаётся nil и наша анимация останется безимянной. К ней невозможно будет обращаться, но нам пока это и не требуется.
Вроде бы всё просто, но есть ньюанс. Как совместить начало пути с UIView? Ведь если этого не сделать, картинка при начале анимации будет просто перепрыгивать в начало первой дуги. Для того, чтобы всё работало как надо, придётся усложнять – чем мы и займёмся дальше.

От теории к практике



В вышепреведённом примере всё просто и хорошо, но скучно и коряво. Чтобы было веселее, напишем небольшое приложение, в котором картинка будет двигаться по дуге к указанной точке. Вот ролик того, что должно получиться в итоге:



Для начала, создаём Single View проект и добавляем в него QuartzCore framework. Затем меняем заголовок ViewController:

@class PathDrawingView; // 1

@interface CMViewController : UIViewController
{
    UIImageView     *_image;	//2
    BOOL            _isAnimating;	//3
    BOOL            _drawPath;	//4
}

@property (retain, nonatomic) PathDrawingView *pathView; //5

@end


  1. Объявляем класс-помошник, который будет отвечать за отрисовку нашего пути. Это сильно облегчает отладку.
  2. Простая картинка, которую мы будет двигать.
  3. Флаг проигрывания анимации.
  4. Флаг для отрисовки пути, если вдруг захотим посмотреть как будет двигаться наша картинка.
  5. Механика работы помошника подразумевает многократное создание и удаление. Объявим его как свойство, для упрощения этого процесса.


Теперь к реализации. И начнём с начала, то есть, с добавления нужных заголовков и объявления константы:

#import <QuartzCore/QuartzCore.h>
#import "PathDrawingView.h"

static NSString *cAnimationKey = @"pathAnimation";


С первым заголовком понятно, а второй это класс-помошник. Константа нам пригодится для именования анимации.

Теперь меняем метод viewDidLoad:

- (void) viewDidLoad
{
    [super viewDidLoad];
    _drawPath = NO;
    _isAnimating = NO;
    _image = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"image.png"]];
    _image.center = CGPointMake(160, 240);
    [self.view addSubview:_image];
}


Устанавливаем флаги. Если вдруг захотим посмотреть как выглядит наш путь, надо будет активировать _drawPath. Понятно, _isAnimating у нас пока не установлен – анимация ещё не проигрывается. Далее, создаём изображение и показываем его.

Надо создать путь, выделим это в отдельный метод:

- (CGPathRef) pathToPoint:(CGPoint) point
{
    CGPoint imagePos = _image.center;
    CGFloat xDist = (point.x - imagePos.x);
    CGFloat yDist = (point.y - imagePos.y);
    CGFloat radius = sqrt((xDist * xDist) + (yDist * yDist)) / 2;	// 1
    
    CGPoint center = CGPointMake(imagePos.x + radius, imagePos.y); //2
    CGFloat angle = atan2f(yDist, xDist);		// 3
    
    CGAffineTransform transform = CGAffineTransformIdentity;
    transform = CGAffineTransformTranslate(transform, imagePos.x, imagePos.y);
    transform = CGAffineTransformRotate(transform, angle);
    transform = CGAffineTransformTranslate(transform, -imagePos.x, -imagePos.y);	//4
    
    CGMutablePathRef path = CGPathCreateMutable();
    CGPathAddArc(path, &transform, center.x, center.y, radius, M_PI, 0, YES);
    //CGPathAddArc(path, &transform, center.x, center.y, radius, 0, M_PI, YES);		//5
    return path;
}


Методу передаётся точка назначения (далее Т) и он условно разбит на 4 блока:

  1. По теореме Пифагора вычисляем расстояние между картинкой и Т. Делим на два и получаем радиус дуги, начало которой будет в картинке, а конец – в нужной точке.
  2. Сначала будем работать в системе координат, где центр картинки и Т находятся на одной прямой, проходящей по оси Y. В этой системе координат, центр искомой окружности будет смещён на расстояние радиуса по оси X.
  3. Находим угол между центром картинки и Т. Конечно, в исходной системе координат. Для этого используем найденый ранее вектор от Т к центру картинки.
  4. Создаём матрицу поворота для перехода из произвольной системы координат к «настоящей».
  5. Создаём путь. К этому моменту у нас есть все необходимые данные. Обратите внимание, что одна строчка закоментирована. Создаётся только одна дуга – мы хотим чтобы картинка остановилась в указаной точке, а не прошла через неё и вернулась обратно.


Перейдём к самой анимации:

- (void) followThePath:(CGPathRef) path
{
    CAKeyframeAnimation *pathAnimation = [CAKeyframeAnimation animationWithKeyPath:@"position"];
    pathAnimation.path = path;
    pathAnimation.removedOnCompletion = NO;  	// 1
    pathAnimation.fillMode = kCAFillModeForwards;	//2
    pathAnimation.duration = 2.0f;
    pathAnimation.calculationMode = kCAAnimationPaced;	//3
    pathAnimation.delegate = self; //4
    [_image.layer addAnimation:pathAnimation forKey:cAnimationKey]; 		//5
}


Что тут нового?

  1. Указывает, что анимация должна остаться после окончания. Это нужно, чтобы мы могли прочитать последнее значение. А вот зачем нужно это – будет понятно позже.
  2. Указывает, что объект анимации (т.е. картинка, которую мы будем двигать) должна оставаться в том состоянии, в котором закончилась анимация. Если убрать, картинка будет перепрыгивать туда, откуда начинала движение.
  3. Устанавливает способ расчёта промежуточных кадров анимации. Если хотим (а мы хотим!) останавливать анимацию в произвольный момент, надо указывать именно такой вид. В противном случае, картинка будет прыгать, а не останавливаться точно в текущем положении.
  4. Назначаем себя делегатом анимации, чтобы ловить момент её окончания.
  5. Запускаем анимацию. На этот раз, присваеваем ей имя.


Теперь надо обработать окончание анимации:

- (void) stop
{
    CALayer *pLayer = _image.layer.presentationLayer;		// 1
    CGPoint currentPos = pLayer.position;
    [_image.layer removeAnimationForKey:cAnimationKey];	// 2
    [_image setCenter:currentPos];
    _isAnimating = NO;
}


  1. Берём presentation layer, именно там крутится анимация и содержится актуальная информация о состоянии объекта во время её проигрывания – это особенность работы фреймворка Core Graphics. Если этого не сделать, то картинка будет прыгать туда, откуда начиналась анимация.
  2. Убираем нашу анимацию.


Добавляем метод делегата анимации:

- (void) animationDidStop:(CAAnimation *)anim finished:(BOOL)flag
{
    if (flag)
        [self stop];
}


Здесь всё просто: если анимация закончилась сама, мы её останавливаем и делаем необходимые действия. В случае принудительного прерывания, остановим её в другом месте. Вот тут, в обработчике касания:

- (void) touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    if (_isAnimating)
        [self stop];
    _isAnimating = YES;
    UITouch *touch = [touches anyObject];
    CGPoint touchPoint = [touch locationInView:self.view];
    
    CGPathRef path = [self pathToPoint:touchPoint];
    [self followThePath:path];
    if (_drawPath)
        [self drawPath:path];
    
    CGPathRelease(path);
}


Тут мы просто соединяем всё написаное ранее и освобождаем созданый путь.
Осталось добавить отладочный метод для отрисовки пути:

- (void) drawPath:(CGPathRef) path
{
    [self.pathView removeFromSuperview];			// 1
    self.pathView = [[PathDrawingView alloc] init];		// 2
    self.pathView.path = path;
    self.pathView.frame = self.view.frame;
    [self.view addSubview:self.pathView];
}


  1. Убираем предидущий путь с экрана, иначе будет каша
  2. Создаём специальный объект для отрисовки пути. Его код будет ниже.


Наконец, освобождаем ресурсы:

- (void) viewDidUnload
{
    [_image release];
    self.pathView = nil;
}


Вот и всё, теперь можно запускать.

Приложение


PathDrawingView.h
#import <UIKit/UIKit.h>

@interface PathDrawingView : UIView
{
    CGPathRef   _path;
}

@property (retain, nonatomic) UIColor *strokeColor;
@property (retain, nonatomic) UIColor *fillColor;
@property (assign, nonatomic) CGPathRef path;

@end

PathDrawingView.m
#import "PathDrawingView.h"
#import <QuartzCore/QuartzCore.h>

@implementation PathDrawingView

@synthesize strokeColor, fillColor;

- (CGPathRef) path
{
    return _path;
}

- (void) setPath:(CGPathRef)path
{
    CGPathRelease(_path);
    _path = CGPathRetain(path);
}

- (void)drawRect:(CGRect)rect
{
    CGContextRef ctx = UIGraphicsGetCurrentContext();
    CGContextSetStrokeColorWithColor(ctx, strokeColor.CGColor);
    CGContextSetFillColorWithColor(ctx, fillColor.CGColor);
    CGContextAddPath(ctx, _path);
    CGContextStrokePath(ctx);
}

- (id) init
{
    if (self = [super init])
    {
        self.fillColor = [UIColor clearColor];
        self.strokeColor = [UIColor redColor];
        self.backgroundColor = [UIColor clearColor];
    }
    return self;
}

- (void) dealloc
{
    self.fillColor = nil;
    self.strokeColor = nil;
    CGPathRelease(_path);
    [super dealloc];
}

@end


GitHub Код проекта
Core Animation Programming Guide — Описание тонкостей работы фреймворка.
CGPathRef reference — А также, функций для работы с этой структурой.
Tags:
Hubs:
+21
Comments 7
Comments Comments 7

Articles