Анимированные Линии в iOS

Доброго времени суток iOS-разработчики и им сочувствующие! Хочу поделиться с вами одной простой, но в то же время довольно симпатичной анимацией для текстовых полей и прочих вьюх на iOS. Думаю, каждый, кто хотя бы мельком сталкивался с CALayer и Core Animation вообще, знает об этих возможностях, а вот новичкам может быть интересно и натолкнет на изучение более глубоко Core Animation.

Картинка для затравки:


Для тех кто не любит читать, а испытывать в действии — ссылка на тестовый проект. Для всех остальных же — Начнем!

Для тестов создаем новый проект Single View Application. Добавляем на основной View Controller новую View.

Заголовок спойлера


Создаем Referencing Outlet с именем 'panel' в класс ViewController. В viewDidLoad ViewController'а добавляем строчку:

_panel.layer.cornerRadius = 5;

Чтобы скруглить углы у прямоугольника. Запускаем — сейчас приложение выглядит так:


На этом с Interface Builder мы закончили. Начинается собственно то ради чего мы здесь — анимация!

Небольшой экскурс в Core Animation. Базовый класс отрисовки в iOS это CALayer, который предоставляет базовые возможности для анимации и отрисовки — как то перемещения, трансформации. В общем это что-то среднее между низкоуровневой отрисовкой через Core Graphics и более высокой в виде UIView. В нашем случаем нам интересен наследник CALayer — CAShapeLayer, в котором добавляется поддержка CGPath, а также сопутствующие методы для этого, как то заливка и работа со stroke (черта?).

Итак. Создадим категорию, расширяющую класс UIView — UIView+AnimatedLines. Для начала добавим простой метод добавления анимированой обводки для VIew с использования CAShapeLayer.

-(void)animateLinesWithColor:(CGColorRef)lineColor andLineWidth:(CGFloat)lineWidth animationDuration:(CGFloat)duration
{
	
}

Создадим CAShapeLayer:

CAShapeLayer* animateLayer = [CAShapeLayer layer];
	animateLayer.lineCap = kCALineCapRound;// Конец и начало линии будут заокругленными
	animateLayer.lineJoin = kCALineJoinBevel;//Переход между линиями будет заоккругленный
	animateLayer.fillColor   = [[UIColor clearColor] CGColor];//сам слой будет прозрачный
	animateLayer.lineWidth   = lineWidth;
	animateLayer.strokeEnd   = 0.0;

Создадим UIBezierPath, в котором и будем рисовать обводку.

UIBezierPath* path = [UIBezierPath new];
	[path setLineWidth:1.0];
	[path setLineCapStyle:kCGLineCapRound];
	[path setLineJoinStyle:kCGLineJoinRound];

Дальше простая геометрия — рисуем линии вдоль границы нашей вьюшки (много кода, бессмысленного и беспощадного):

CGRect bounds = self.layer.bounds;//Границы нашей вью
	CGFloat radius = self.layer.cornerRadius;// определяем есть ли у вьюшки скругленные края
	CGPoint zeroPoint = bounds.origin; //Начальная точка
	
	BOOL isRounded = radius>0;
	
	if(isRounded)
	{
		zeroPoint.x = bounds.origin.x+radius; //Есть края скругленные -  начинаем не с самого угла, а с места, где заканчивается скругленный угол.
	}
	
	[path moveToPoint:zeroPoint];//Передвигаем курсор в начальную позицию
	//Далее проходимся по всем 4 сторонам. Начинаем сверху
	CGPoint nextPoint = CGPointMake(bounds.size.width, 0);
	if(isRounded)
	{
		nextPoint.x-=radius;
	}
	[path addLineToPoint:nextPoint];
	if(isRounded)
	{
		[path addArcWithCenter:CGPointMake(nextPoint.x, nextPoint.y+radius) radius:radius startAngle:-M_PI_2 endAngle:0 clockwise:YES];//Если есть скругления - рисуем дугу.
	}
	//Правая грань
	nextPoint = CGPointMake(bounds.size.width, bounds.size.height);
	if(isRounded)
	{
		nextPoint.y-=radius;
	}
	[path addLineToPoint:nextPoint];
	if (isRounded)
	{
		[path addArcWithCenter:CGPointMake(nextPoint.x-radius, nextPoint.y) radius:radius startAngle:0 endAngle:M_PI_2 clockwise:YES];
	}
	//Нижняя грань
	nextPoint = CGPointMake(0, bounds.size.height);
	if(isRounded)
	{
		nextPoint.x +=radius;
	}
	[path addLineToPoint:nextPoint];
	if (isRounded)
	{
		[path addArcWithCenter:CGPointMake(nextPoint.x, nextPoint.y-radius) radius:radius startAngle:M_PI_2 endAngle:M_PI clockwise:YES];
	}
	//Левая грань
	nextPoint = CGPointMake(0, 0);
	if(isRounded)
	{
		nextPoint.y +=radius;
	}
	[path addLineToPoint:nextPoint];
	if (isRounded)
	{
		[path addArcWithCenter:CGPointMake(nextPoint.x+radius, nextPoint.y) radius:radius startAngle:M_PI endAngle:-M_PI_2 clockwise:YES];
	}

Рисование линий мы закончили. Добавляем Path в CAShapeLayer:


animateLayer.path = path.CGPath;
animateLayer.strokeColor = lineColor;

А сам слой на нашу вью:

[self.layer addSublayer:animateLayer];

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


_panel.layer.cornerRadius = 5;
[_panel animateLinesWithColor:[UIColor redColor].CGColor andLineWidth:2 animationDuration:5];

И можем запускать:


Ну, на самом деле так себе, скажете вы? И будете правы, ведь такого же результата можно добиться просто сделав layer.borderWidth=2.

Тут нужно небольшое отступление.

Когда вы рисуете в Path (UIPath, CGPath) отрезки, окружности и прочии примитивы — они все имеют начало и конец. StrokeEnd у CAShapeLayer означает до какого места стоит рисовать эту линию.

StrokeStart же в свою очередь указывает с какого места нужно начинать рисовать линию. Значение должны лежать в пределах 0.0 — 1.0

Например:


Итак, что можно сделать с этой информацией? Все что нам нужно — добавить несколько строк кода. В месте где мы создаем CAShapeLayer добавим еще одну строчку:


animateLayer.strokeEnd   = 0.0;

Далее после добавления слоя создаем анимацию для проперти strokeEnd:


	CABasicAnimation *pathAnimation = [CABasicAnimation animationWithKeyPath:@"strokeEnd"];
	pathAnimation.duration = duration;
	pathAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear];
	pathAnimation.fromValue = [NSNumber numberWithFloat:0.0f];
	pathAnimation.toValue = [NSNumber numberWithFloat:1.0f];
	pathAnimation.autoreverses = NO;
	[animateLayer addAnimation:pathAnimation forKey:@"strokeEndAnimation"];
	
	animateLayer.strokeEnd = 1.0;

(Как работают CABasicAnimation вы можете почитать на официальном сайте эпл)

3. Запускаем!


Как видите линия красиво огибает наш UIView. Теперь давайте сделаем чтобы было как на КДПВ.

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

[path addCurveToPoint:controlPoint1:controlPoint2:];

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

Добавим новый класс который будет содержать контрольные точки для кривых Безье:

@interface LinesCurvePoints : NSObject
@property(nonatomic,assign)CGPoint controlPoint1;
@property(nonatomic,assign)CGPoint controlPoint2;
+(instancetype)curvePoints:(CGPoint)point1 point2:(CGPoint)point2;
@end
@implementation LinesCurvePoints

+(instancetype)curvePoints:(CGPoint)point1 point2:(CGPoint)point2
{
	LinesCurvePoints* point = [LinesCurvePoints new];
	point.controlPoint1 = point1;
	point.controlPoint1 = point2;
	return point;
}

@end

Добавим новые поля в метод:

-(void)animateLinesWithColor:(CGColorRef)lineColor andLineWidth:(CGFloat)lineWidth startPoint:(CGPoint)startFromPoint rollToStroke:(CGFloat)rollToStroke curveControlPoints:(NSArray<LinesCurvePoints*>*)curvePoints animationDuration:(CGFloat)duration

В методе, после определения zeroPoint добавляем следующий код:


[path moveToPoint:startFromPoint];
long c = curvePoints.count;
	for (long i =1; i<=c; i++)
	{
		float nX = startFromPoint.x + (zeroPoint.x - startFromPoint.x)/(c)*i;
		float nY = startFromPoint.y +(zeroPoint.y - startFromPoint.y)/(c)*i;
		
		
		LinesCurvePoints* point = curvePoints[i-1];
		
		
		[path addCurveToPoint:CGPointMake(nX, nY) controlPoint1:CGPointMake(nX+point.controlPoint1.x,nY+point.controlPoint1.y) controlPoint2:CGPointMake(nX+ point.controlPoint2.y,nY+ point.controlPoint2.y)];
		
	}

Он Разделит участок от стартовой точки до начала периметра на равные участки и нарисует их с помощью кривых, с котрольными точками которые мы указали в curveControlPoints. И вторая часть которую нам нужно добавить — движение strokeStart:


	pathAnimation = [CABasicAnimation animationWithKeyPath:@"strokeStart"];
	pathAnimation.duration = duration*1.2;

	pathAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseIn];
	pathAnimation.fromValue = [NSNumber numberWithFloat:0.0f];
	pathAnimation.toValue = [NSNumber numberWithFloat:rollToStroke];
	pathAnimation.autoreverses = NO;

	[animateLayer  addAnimation:pathAnimation forKey:@"strokeStartAnimation"];
animateLayer.strokeStart = rollToStroke;

Добавляем после анимации strokeEnd. Значение для strokeStart к сожалению придется подбирать эмпирически, мне так и не получилось высчитать правильную длинну участка если рисовать его c кривыми Безье.

Финальный код метода должен выглядеть так:


-(void)animateLinesWithColor:(CGColorRef)lineColor andLineWidth:(CGFloat)lineWidth startPoint:(CGPoint)startFromPoint rollToStroke:(CGFloat)rollToStroke curveControlPoints:(NSArray<LinesCurvePoints*>*)curvePoints animationDuration:(CGFloat)duration

{
	
	CAShapeLayer* animateLayer = [CAShapeLayer layer];
	animateLayer.lineCap = kCALineCapRound;
	animateLayer.lineJoin = kCALineJoinBevel;
	animateLayer.fillColor   = [[UIColor clearColor] CGColor];
	animateLayer.lineWidth   = lineWidth;
	animateLayer.strokeEnd   = 0.0;
	
	UIBezierPath* path = [UIBezierPath new];
	[path setLineWidth:1.0];
	[path setLineCapStyle:kCGLineCapRound];
	[path setLineJoinStyle:kCGLineJoinRound];
	
	
	
	CGRect bounds = self.layer.bounds;
	CGFloat radius = self.layer.cornerRadius;
	CGPoint zeroPoint = bounds.origin;
	
	
	
	
	BOOL isRounded = radius>0;
	
	if(isRounded)
	{
		zeroPoint.x = bounds.origin.x+radius;
	}
	
	[path moveToPoint:startFromPoint];
	
	long c = curvePoints.count;
	for (long i =1; i<=c; i++)
	{
		float nX = startFromPoint.x + (zeroPoint.x - startFromPoint.x)/(c)*i;
		float nY = startFromPoint.y +(zeroPoint.y - startFromPoint.y)/(c)*i;
		
		
		LinesCurvePoints* point = curvePoints[i-1];
		
		
		[path addCurveToPoint:CGPointMake(nX, nY) controlPoint1:CGPointMake(nX+point.controlPoint1.x,nY+point.controlPoint1.y) controlPoint2:CGPointMake(nX+ point.controlPoint2.y,nY+ point.controlPoint2.y)];
		
	}
	
	[path moveToPoint:zeroPoint];
	
	CGPoint nextPoint = CGPointMake(bounds.size.width, 0);
	if(isRounded)
	{
		nextPoint.x-=radius;
	}
	[path addLineToPoint:nextPoint];
	if(isRounded)
	{
		[path addArcWithCenter:CGPointMake(nextPoint.x, nextPoint.y+radius) radius:radius startAngle:-M_PI_2 endAngle:0 clockwise:YES];
	}
	
	nextPoint = CGPointMake(bounds.size.width, bounds.size.height);
	if(isRounded)
	{
		nextPoint.y-=radius;
	}
	[path addLineToPoint:nextPoint];
	if (isRounded)
	{
		[path addArcWithCenter:CGPointMake(nextPoint.x-radius, nextPoint.y) radius:radius startAngle:0 endAngle:M_PI_2 clockwise:YES];
	}
	
	nextPoint = CGPointMake(0, bounds.size.height);
	if(isRounded)
	{
		nextPoint.x +=radius;
	}
	[path addLineToPoint:nextPoint];
	if (isRounded)
	{
		[path addArcWithCenter:CGPointMake(nextPoint.x, nextPoint.y-radius) radius:radius startAngle:M_PI_2 endAngle:M_PI clockwise:YES];
	}
	
	nextPoint = CGPointMake(0, 0);
	if(isRounded)
	{
		nextPoint.y +=radius;
	}
	[path addLineToPoint:nextPoint];
	if (isRounded)
	{
		[path addArcWithCenter:CGPointMake(nextPoint.x+radius, nextPoint.y) radius:radius startAngle:M_PI endAngle:-M_PI_2 clockwise:YES];
	}
	
	animateLayer.path = path.CGPath;
	animateLayer.strokeColor = lineColor;
	
	[self.layer addSublayer:animateLayer];
	
	
	
	CABasicAnimation *pathAnimation = [CABasicAnimation animationWithKeyPath:@"strokeEnd"];
	pathAnimation.duration = duration;
	pathAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear];
	pathAnimation.fromValue = [NSNumber numberWithFloat:0.0f];
	pathAnimation.toValue = [NSNumber numberWithFloat:1.0f];
	pathAnimation.autoreverses = NO;
	[animateLayer addAnimation:pathAnimation forKey:@"strokeEndAnimation"];
	
	animateLayer.strokeEnd = 1.0;
	
	pathAnimation = [CABasicAnimation animationWithKeyPath:@"strokeStart"];
	pathAnimation.duration = duration*1.2;
	
	
	
	pathAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseIn];
	pathAnimation.fromValue = [NSNumber numberWithFloat:0.0f];
	pathAnimation.toValue = [NSNumber numberWithFloat:rollToStroke];
	pathAnimation.autoreverses = NO;
	
	
	[animateLayer addAnimation:pathAnimation forKey:@"strokeStartAnimation"];
	animateLayer.strokeStart = rollToStroke;

	
}

Вызов метода в ViewController:


[_panel	animateLinesWithColor:[UIColor redColor].CGColor
andLineWidth:2
startPoint:CGPointMake(100, -200)
rollToStroke:0.25
	 curveControlPoints:@[
[LinesCurvePoints curvePoints:CGPointMake(-50, -2) point2:CGPointMake(60, 5)],
[LinesCurvePoints curvePoints:CGPointMake(-60, 10) point2:CGPointMake(100, 5)]
] 
animationDuration:2 ];

rollToStroke значение подходит для если _panel размером 240 на 128 пикселей:


Еще один из примеров использования этой анимации:


Есть много игр основанных на этой анимации, моя любимая:



В общем вот таким нехитрым способом можно сделать довольно интересные анимации в приложении. Буду рад если кому-то показалось полезным.
Поделиться публикацией
Комментарии 2
    0
    Про симпатичность анимации это шутка, надеюсь.
      0
      Класс, спасибо

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое