[swift] 코어 애니메이션 래핑

UIView레벨의 애니메이션

굉장히 편리한 인터페이스로 제공되는 UIView애니메이션은 다음과 같은 코드로 사용할 수 있습니다.

//box를 하나 준비한다.
let box = UIView(frame: CGRectMake(0, 0, 50, 50))
box.backgroundColor = UIColor.red
self.view.addSubview(box)

//100,100으로 이동시키자
UIView.animateWithDuration(
	1, delay:0, options:UIViewAnimationOptions.CurveEaseIn,
	animations: {
		v.transform = CGAffineTransformMakeTranslation(100, 100)
	},
	completion: {print("\($0)"}
)

굉장히 깔끔하고 편리한 인터페이스로 되어있습니다. 심지어 거의 사용법을 공부할 것도 없이 보자마자 쓸 수 있는 레벨입니다.
특히 animations인자에 들어가는 클로저에서 굉장히 자유롭게 최종 결과에 대한 정의를 할 수 있습니다. 자동레이아웃을 비롯하여 UIView의 모든 변화를 기술하는 것만으로도 현재 상태에서 어떻게 변화를 시킬지 자동으로 계산하여 애니메이션을 만들어줍니다.

UIView.animateWithDuration 는 굉장한 기능이지만 반대로 제공하는 기능 외엔 전혀 확장할 수 없습니다.
예를들어 이징함수의 경우 linear, easeIn, easeOut, easeInOut 딱 네 종류만 제공됩니다. 반복을 정의하거나 요요(왔다갔다하면서 반복하는 기능)를 쓸 수도 없죠. 뭔가 이 상태에서 추가기능을 바라면 UIView.animateWithDuration 을 사용하는 패턴에서는 답을 구할 수 없다는 것입니다.

애니메이션시에 더 바라는 기능

UIView.animateWithDuration 에서 지원하지 않는 하지만 자주 애니메이션을 사용할 때 필요한 기능을 목록화해보죠.

  1. 반복설정 – 당연히 반복은 필요합니다
  2. 요요반복 – 좌우로 움직임을 반복하려면 시작에서 끝으로 이동한뒤 다시 끝에서 시작으로 이동하는 식으로 반복이 일어나야하는데 이를 요요라는 용어로 보통 부릅니다
  3. 보간함수 – 네가지 이징함수외에도 다양한 보간함수는 반드시 필요합니다. 각각 나타내려는 움직임마다의 느낌이 있기 때문이죠.
  4. 완료보고 – completion에 인자로 bool 따위를 받고 싶지 않습니다. 차라리 완료가 제대로 된 시점에 해당 UIView를 받고 싶죠

차근차근 살펴보면 반복설정과 요요반복은 정적함수에서는 안되지만 다음의 형태로 코드를 바꾸면 됩니다.

UIView.beginAnimations(nil, context: nil)

//1. 반복설정
UIView.setAnimationRepeatCount(repeatCount:1.0)

//2. 요요반복
UIView.setAnimationRepeatAutoreverses(repeatAutoreverses: true)

box.center = newCenter

UIView.commitAnimations()

하지만 이 경우에도 3번에 해당되는 보간함수 설정은 네가지 밖에 안됩니다. 마찬가지로 setAnimationDidStopSelector 를 설정할 수는 있지만 애니메이션객체와 완료여부만 인자로 들어오죠.
1, 2번은 해결되지만 3, 4번은 해결이 안되는 상황이라고 할 수 있습니다(4번의 경우야 미리 정의된 객체를 만들고 그 객체의 속성으로 UIView를 설정하면야 보완은 되겠죠)
그런데… UIView.animateWithDuration 에서 밑으로 내려가면 곧장 레이어의 코어애니메이션까지 떨어져버립니다.
레이어에 코어애니메이션을 사용하면 UIView.layer에게 애니메이션을 키하나당 하나씩 걸어야하는 굉장히 저수준 개발이 됩니다. 근데 이것 외에 대안이 되는 중간 레벨의 무언가가 없습니다.

기초 API 설계

CABasicAnimation은 CAPropertyAnimation 을 상속하여 fromValue, toValue 등을 살짝쿵 지원해주는 굉장히 얇은 속성별 애니메이션 객체입니다. 이 애니메이션 객체는 하나의 속성당 하나의 애니메이션객체가 필요하여 굉장히 귀찮고 개별 속성마다 from, to를 비롯하여 꼼꼼한 설정해야하기 때문에 짜증이 장난 아닙니다. 해서 자유로운 클로저를 통해 애니메이션의 목표값을 설정하던 세상에서 벗어나 (키, 값) 형태의 튜플셋트를 통해 값을 설정하는 세계로 내려가야합니다.

이를 위해 호스트에서 사용할 기본적인 구조를 잡아봅니다.

//키로 사용할 것들
enum tween{
	...
}

//UIView의 메소드로 정의
extension UIView{ 

	//(키,값)을 받아들이고 최종적으로 완료보고용 클로저를 받아들임(안줄수도 있고)
	func tween(vals:(tweenKey, Any)..., _ complete:(UIView->Void)?){
	}
}

대충 이런 인터페이스라면 실제 사용할 때는 다음과 같은 느낌이 될 것입니다.

let box = self.subviews[0]!
box.tween( (tween.TIME, 1), (tween.LOOP, 3), (tween.x, 30), (tween.y, 50) ){
	print("target View = \($0)")
}

애니메이션 걸 속성을 일일히 지정해야하는건 귀찮지만 저정도가 한계겠죠. 임의의 문자열을 키로 받기는 위험하니 enum을 통해 받기로 했습니다. enum의 경우 대문자로 지정된 속성은 특별히 애니메이션 자체의 속성을 지정할 수 있게 하는걸로 하여 다음과 같은 키를 정의합니다.

  • tween.TIME – 애니메이션지속시간
  • tween.DELAY – 시작하기 전에 대기시간
  • tween.LOOP – 반복횟수
  • tween.YOYO – 요요여부
  • tween.EASE – 이징함수

그외에는 CALayer에서 지정할 수 있는 다양한 속성이 키가 됩니다. 예를들어 tween.x는 내부적으로는 “position.x” 키패스에 매칭되겠죠. 중요한건 결국 외부에 노출될 api의 형태이므로 우선적으로 이 모양을 결정하고 이를 실현할 방법을 찾아보기로 했습니다.

Tween 클래스의 정의

다시 원래 계획을 되짚어 보면 하나의 속성마다 CABasicAnimation가 필요합니다. 하지만 tween API는 동시에 여러개의 속성을 애니메이션시킵니다. 따라서 관계구조로보면 tween호출시 Tween클래스 하나가 대응하되 여러개의 CABasicAnimation를 소유하는 식이 될 것입니다.
또한 애니메이션은 굉장히 자주 사용되는 기능이므로 간단하게 풀링하는게 좋을 것입니다. UIView수준에서는 Tween의 인스턴스를 재활용하고 Tween수준에서는 CABasicAnimation를 재활용하는 식이죠. 여기까지를 작성해봅니다.

extension UIView{

	class Tween{
		static private var aniPool = [CABasicAnimation]()
		static private var tweenPool = [Tween]()
	}

	func tween(vals:(tweenKey, Any)..., _ complete:(UIView->Void)?){
	}
}

모양은 어느 정도 나왔으니 Tween클래스에 집중해보죠. 내부에 정적 풀링상태이므로 직접 인스턴스를 불출하지 않고 팩토리를 통해서만 인스턴스를 얻게 할 것입니다. 또한 init에서 초기화하지 않고 별도의 초기화를 팩토리에서 하게 됩니다. 재활용되는 객체이므로 그러한 순환과정을 통해 초기화를 해줘야합니다.

팩토리함수를 생성하기 앞서 Tween클래스 레벨에서 기억해야하는 속성도 생각해보죠. 각 속성을 실제로 애니메이션시키는 것은 각각의 CABasicAnimation이지만 하나의 Tween내에서 이들이 공통으로 공유하는 내용이 바로 속성의 후보입니다. 정리하면 다음과 같습니다.

class Tween{

	//애니메이션 시킬 속성의 수
	var count = 0

	//대상 객체
	var target:UIView!

	//이징함수 - 구조는 다음 장에서 설명
	var ease:CAMediaTimingFunction = Tween.easing[ease.linear]!

	//지속시간과 최초 지연시간
	var time:Double = 1, delay:Double = 0

	//완료시 호출될 클로저
	var complete:(UIView->Void)?

	//반복횟수와 요요여부
	var loop = 0, yoyo = false
}

이 속성들은 Tween레벨에서 기억하고 각 속성별 CABasicAnimation에 전달해줘야할 것입니다.
이제 팩토리 함수를 만들 수 있습니다.

class Tween{

	// Tween을 풀링하여 재활용한다
	static private var tweenPool = [Tween]()

	//속성초기화에 필요한 인자를 전부 받는다..많네..
	static func tween(target:UIView, time:Double, delay:Double, ease:ease, loop:Int, yoyo:Bool, end:(UIView->Void)?)->Tween{

		//pool에서 꺼내오거나 새로 생성함
		let tw = tweenPool.isEmpty ? Tween() : Tween.popLast()!

		//속성을 리셋한다
		tw.target = target
		tw.time = time
		tw.delay = delay
		tw.ease = Tween.easing[ease]!
		tw.complete = end
		tw.loop = loop
		tw.yoyo = yoyo

		return tw
	}

	var count = 0
	var target:UIView!
	var ease:CAMediaTimingFunction = Tween.easing[ease.linear]!
	var time:Double = 1, delay:Double = 0, loop = 0, yoyo = false
	var complete:(UIView->Void)?
}

일단 인스턴스가 만들어지고 나면 개별 속성에 따라 CABasicAnimation을 생성하고 설정을 해줘야 할 것입니다. 이를 위해 ani라는 메소드를 구현해보죠

class Tween{
	static private var tweenPool = [Tween]()
	static func tween(target:UIView, ...)->Tween{..}
	
	// CABasicAnimation을 풀링하여 재활용한다
	static private var aniPool = [CABasicAnimation]()

	//키, 값 쌍을 받아들여 각각 _ani함수에게 위임한다
	func ani(val:[(tween, Any)]){

		//이 시점에 속성갯수를 업데이트한다.
		count = val.count

		//나머진 _ani메소드에게 맡기자
		val.forEach{_ani($0.rawValue, $1 as! AnyObject)}
	}

	private func _ani(key:String, _ val:AnyObject){

		//pool에서 꺼내오거나 새로 생성함
		let a = Tween.aniPool.isEmpty ? CABasicAnimation() : Tween.aniPool.popLast()!

		//완료보고를 받을 델리게이터를 Tween으로 지정
		a.delegate = self

		//애니메이션 완료시 원복되지 않고 상태를 유지하도록 설정
		a.fillMode = kCAFillModeForwards
		a.removedOnCompletion = false

		//Tween의 속성을 이용해 공통내용을 셋팅함
		a.duration = time
		a.timeOffset = delay
		a.timingFunction = ease

		//넘겨받은 키, 값을 적용함.
		a.keyPath = key
		a.fromValue = target.layer.valueForKey(key)
		a.toValue = val

		//반복과 요요를 설정
		a.repeatCount = loop
		a.autoreverses = yoyo

		//최종적으로 애니메이션을 해당 속성에 적용
		target.layer.addAnimation(a, forKey:key)

	}

	var count = 0...
}

자 이제 어려운건 다 되었습니다…만 귀찮은게 아직도 많이 남아있습니다.

애니메이션이 완료된 시점의 처리

CABasicAnimation의 델리게이터로 Tween을 지정했는데 Tween이 델리게이터가 되려면 다음의 두가지 메소드를 구현해야합니다.

func animationDidStop(anim: CAAnimation, finished flag: Bool)
func animationDidStart(anim: CAAnimation)

근데 공식 인터페이스를 확인해보면 CABasicAnimation의 델리게이션 자리에는 AnyObject?가 들어가도록 되어있습니다.

1

하지만 실제 animationDidStop의 구현은 NSObject레벨에서 정의되어있고 실제 NSObject를 원하게 됩니다.

2

결국 연결해서 생각해보면 delegate의 형은 AnyObject?가 아니라 NSObject?가 되어야할 것입니다. 사실 이 시점에서는 명백히 정의상 오류라고 할 수 있습니다. 미래를 위한 포석일 수는 있지만…

결과적으로 Tween클래스는 NSObject를 상속해야하고 상속한 경우 위의 두 개 메소드를 override를 통해 구상해야한다는 점입니다.

//NSObject를 상속하도록 하고..
class Tween:NSObject{
	static private var tweenPool = [Tween]()
	static func tween(target:UIView, ...)->Tween{..}

	static private var aniPool = [CABasicAnimation]()
	func ani(val:[(tween, Any)]){...}
	private func _ani(key:String, _ val:AnyObject){...}

	var count = 0...

	//override를 통해 구현해야한다.
	override func animationDidStart(anim: CAAnimation){}
	override func animationDidStop(anim: CAAnimation, finished flag: Bool){}
}

이런 느낌이죠. animationDidStart는 현시점에 따로 할 일이 없으므로 그냥 비워두는 것으로 하고 animationDidStop 할 일만 집중해보죠. 아마 다음과 같은 일을 해야합니다.
* 3,4번은 그냥 repeatCount와 autoreverses로 대체해버렸습니다

  1. 완료시점마다 count를 하나씩 까나가서 완료된 속성수를 알게 한다.
  2. removedOnCompletion = false 이므로 완료시점에 layer에서 자동으로 사라지지 않기 때문에 직접 remove해야한다.
  3. 애니메이션을 remove하면 layer는 이전 값으로 돌아가기 때문에 명시적으로 최종값을 설정해주고 삭제함.
  4. 반복인 경우 다시 layer에 삽입하여 애니메이션을 일으킨다. repeatCount로 대체
  5. 요요인 경우 시작값과 끝값을 교환해준다. autoreverses로 대체
  6. 반복이 없는 경우 CABasicAnimation는 풀로 돌아간다.
  7. 최종 완료시점에 완료 클로저를 호출해준다.
  8. 최종 완료시점에 Tween은 풀로 돌아간다.

이중 핵심기능은 ani에서 count를 설정하고 animationDidStop에서 하나씩 까나감으로 최종 완료시점을 한번으로 안전하게 인식할 수 있다는 것입니다. 만약 이 장치가 없으면 속성하나 하나가 끝날 때마다 완료 클로저가 호출될 것입니다.
이제 위의 전략 그대로 구현하면 됩니다.

override func animationDidStop(anim: CAAnimation, finished flag: Bool){

	//제대로 완료되지 않은 시점의 처리
	if !flag {
		return
	}

	//카운트 감소
	count -= 1

	//CABasicAnimation로 캐스팅
	let a:CABasicAnimation = anim as! CABasicAnimation

	//애니메이션 삭제전 최종값으로 설정
	target.layer.setValue(a.toValue, forKeyPath: a.keyPath!)

	//수동으로 명시적 삭제함
	target.layer.removeAnimationForKey(a.keyPath!)

	//이 시점에 무조건 애니메이션을 풀로 환원시킨다
	Tween.aniPool.append(a)

	//마지막으로 완료되고 있는 속성이라면 complete클로저를 실행한다
	if count == 0{
		complete?(target)
	}

	if count == 0{ //마지막 완료되는 속성이라면 Tween을 풀로 되돌림
		target = nil //UIView의 참조를 미리 해결하자!
		Tween.pool.append(self)
	}
}

이를 통해 개별 CABasicAnimation를 안전하게 처리하면서도 Tween 수준의 완료리스너를 처리하고 UIView를 인자로 보낼 수 있게 되었습니다. 이제 이징함수만 정의해주면 되겠죠.

다양한 이징함수

조절점 두개를 갖는 베지어 곡선을 통해 이징함수를 정의하는 것은 사실 많은 시스템에서 표준처럼 사용됩니다. css의 transition에 사용되는 형식도 동일합니다. 따라서 그걸 위한 다양한 웹사이트 툴을 그대로 이용할 수 있겠죠.

  • http://easings.net 여기에서는 다양한 이징 함수를 베지어 곡선으로 나타낸 값을 알 수 있습니다.
  • https://matthewlein.com/ceaser/ 이 사이트에서는 시각적으로 직접 조작하여 베지어 값을 얻을 수 있는 툴을 제공하고 있습니다.

다양한 이징함수가 필요한 이유는 여러가지지만 똑같이 out계열(처음에 빨리 진행되고 뒤로 갈수록 느려지는)이라고 해도 expo의 경우 굉장히 극초반에 진행이 많이 되는데 비해 sine은 전반적으로 부드럽게 증가하다가 끝에가서 천천히 수렴하는 느낌이죠. 처리되는 타이밍이 다르므로 애니메이션이 주는 느낌도 다릅니다. 기본적으로 각 이징함수가 의미하는 것은 물리적이거나 수학적인 내용이 많기 때문에 적절히 사용하면 좋은 효과를 볼 수 있습니다.
해서 결국 여러 케이스를 다 쓰다보면 이러한 이징함수 셋트를 전반적으로 사용하게 됩니다. 따라서 전부 정의합니다.

//외부에 노출된 이징키
enum ease{
	case linear
	case sineIn, sineOut, sineInOut
	case circleIn, circleOut, circleInOut
	case cubicIn, cubicOut, cubicInOut
	case quintIn, quintOut, quintInOut
	case quadIn, quadOut, quadInOut
	case quartIn, quartOut, quartInOut
	case expoIn, expoOut, expoInOut
	case backIn, backOut, backInOut
}

//실제 이징함수
class Tween{

	//네.. 저 게으른 인간입니다.
	typealias F = CAMediaTimingFunction
	typealias E = ease

	static private let easing = [
		E.linear:F(controlPoints: 0.250, 0.250, 0.750, 0.750),
			
		E.sineIn:F(controlPoints: 0.47, 0, 0.745, 0.715),
		E.sineOut:F(controlPoints: 0.39, 0.575, 0.565, 1.0),
		E.sineInOut:F(controlPoints: 0.445, 0.05, 0.55, 0.95),
		
		E.circleIn:F(controlPoints: 0.6, 0.04, 0.98, 0.335),
		E.circleOut:F(controlPoints: 0.075, 0.82, 0.165, 1),
		E.circleInOut:F(controlPoints: 0.785, 0.135, 0.15, 0.86),
		
		E.cubicIn:F(controlPoints: 0.55, 0.055, 0.675, 0.19),
		E.cubicOut:F(controlPoints: 0.215, 0.61, 0.355, 1),
		E.cubicInOut:F(controlPoints: 0.645, 0.045, 0.355, 1),
		
		E.quintIn:F(controlPoints: 0.755, 0.05, 0.855, 0.06),
		E.quintOut:F(controlPoints: 0.25, 0.46, 0.45, 0.94),
		E.quintInOut:F(controlPoints: 0.86, 0, 0.07, 1),
		
		E.quadIn:F(controlPoints: 0.55, 0.085, 0.68, 0.53),
		E.quadOut:F(controlPoints: 0.39, 0.575, 0.565, 1.0),
		E.quadInOut:F(controlPoints: 0.455, 0.03, 0.515, 0.955),
		
		E.quartIn:F(controlPoints: 0.895, 0.03, 0.685, 0.22),
		E.quartOut:F(controlPoints: 0.165, 0.84, 0.44, 1),
		E.quartInOut:F(controlPoints: 0.77, 0, 0.175, 1),
		
		E.expoIn:F(controlPoints: 0.95, 0.05, 0.795, 0.035),
		E.expoOut:F(controlPoints: 0.19, 1, 0.22, 1),
		E.expoInOut:F(controlPoints: 1, 0, 0, 1),
		
		E.backIn:F(controlPoints: 0.6, -0.28, 0.735, 0.045),
		E.backOut:F(controlPoints: 0.175, 0.885, 0.32, 1.275),
		E.backInOut:F(controlPoints: 0.68, -0.55, 0.265, 1.55)
	]
}

머 간단히 이징함수를 정의했으니 var ease:CAMediaTimingFunction = Tween.easing[ease.linear]! 같은 코드가 성립하게 되는거죠.

Tween클래스 전체 코드

위에서 언급된 모든 내용을 포함한 코드입니다. 두 개의 enum과 Tween클래스죠.

//이징함수 선택용 enum
enum ease{
	case linear
	case sineIn, sineOut, sineInOut
	case circleIn, circleOut, circleInOut
	case cubicIn, cubicOut, cubicInOut
	case quintIn, quintOut, quintInOut
	case quadIn, quadOut, quadInOut
	case quartIn, quartOut, quartInOut
	case expoIn, expoOut, expoInOut
	case backIn, backOut, backInOut
}
//tween함수에 넘길 수 있는 키
enum tween:String{
	case TIME
	case DELAY
	case EASE
	case LOOP
	case YOYO
	case x = "position.x"
	case y = "position.y"
	//..더 많은 속성정의
}

extension UIView{
	class Tween:NSObject{
		typealias F = CAMediaTimingFunction
		typealias E = ease
		static private let easing = [
			E.linear:F(controlPoints: 0.250, 0.250, 0.750, 0.750),
			E.sineIn:F(controlPoints: 0.47, 0, 0.745, 0.715),
			E.sineOut:F(controlPoints: 0.39, 0.575, 0.565, 1.0),
			E.sineInOut:F(controlPoints: 0.445, 0.05, 0.55, 0.95),
			E.circleIn:F(controlPoints: 0.6, 0.04, 0.98, 0.335),
			E.circleOut:F(controlPoints: 0.075, 0.82, 0.165, 1),
			E.circleInOut:F(controlPoints: 0.785, 0.135, 0.15, 0.86),
			E.cubicIn:F(controlPoints: 0.55, 0.055, 0.675, 0.19),
			E.cubicOut:F(controlPoints: 0.215, 0.61, 0.355, 1),
			E.cubicInOut:F(controlPoints: 0.645, 0.045, 0.355, 1),
			E.quintIn:F(controlPoints: 0.755, 0.05, 0.855, 0.06),
			E.quintOut:F(controlPoints: 0.25, 0.46, 0.45, 0.94),
			E.quintInOut:F(controlPoints: 0.86, 0, 0.07, 1),
			E.quadIn:F(controlPoints: 0.55, 0.085, 0.68, 0.53),
			E.quadOut:F(controlPoints: 0.39, 0.575, 0.565, 1.0),
			E.quadInOut:F(controlPoints: 0.455, 0.03, 0.515, 0.955),
			E.quartIn:F(controlPoints: 0.895, 0.03, 0.685, 0.22),
			E.quartOut:F(controlPoints: 0.165, 0.84, 0.44, 1),
			E.quartInOut:F(controlPoints: 0.77, 0, 0.175, 1),
			E.expoIn:F(controlPoints: 0.95, 0.05, 0.795, 0.035),
			E.expoOut:F(controlPoints: 0.19, 1, 0.22, 1),
			E.expoInOut:F(controlPoints: 1, 0, 0, 1),
			E.backIn:F(controlPoints: 0.6, -0.28, 0.735, 0.045),
			E.backOut:F(controlPoints: 0.175, 0.885, 0.32, 1.275),
			E.backInOut:F(controlPoints: 0.68, -0.55, 0.265, 1.55)
		]
		static private var tweenPool = [Tween]()
		static func tween(target:UIView, time:Double, delay:Double, ease:ease, loop:Int, yoyo:Bool, end:(UIView->Void)?)->Tween{
			let tw = tweenPool.isEmpty ? Tween() : Tween.popLast()!
			tw.target = target
			tw.time = time
			tw.delay = delay
			tw.ease = Tween.easing[ease]!
			tw.complete = end
			tw.loop = loop
			tw.yoyo = yoyo
			return tw
		}
		static private var aniPool = [CABasicAnimation]()
		func ani(val:[(tween, Any)]){
			count = val.count
			val.forEach{_ani($0.rawValue, $1 as! AnyObject)}
		}
		private func _ani(key:String, _ val:AnyObject){
			let a = Tween.aniPool.isEmpty ? CABasicAnimation() : Tween.aniPool.popLast()!
			a.delegate = self
			a.fillMode = kCAFillModeForwards
			a.removedOnCompletion = false
			a.duration = time
			a.timeOffset = delay
			a.timingFunction = ease
			a.keyPath = key
			a.fromValue = target.layer.valueForKey(key)
			a.toValue = val
			target.layer.addAnimation(a, forKey:key)
		}

		override func animationDidStop(anim: CAAnimation, finished flag: Bool){
			if !flag {
				return
			}
			count -= 1
			let a:CABasicAnimation = anim as! CABasicAnimation
			target.layer.setValue(a.toValue, forKeyPath: a.keyPath!)
			target.layer.removeAnimationForKey(a.keyPath!)
			if loop > 0 {
				if count == 0 {
					loop -= 1
				}
				if yoyo{
					let from = a.fromValue
					a.fromValue = a.toValue
					a.toValue = from
				}
				target.layer.addAnimation(a, forKey:a.keyPath)
			}else{ 
				Tween.aniPool.append(a)
				if count == 0{
					complete?(target)
				}
			}
			if count == 0{
				target = nil
				Tween.pool.append(self)
			}
		}


		var count = 0
		var target:UIView!
		var ease:CAMediaTimingFunction = Tween.easing[ease.linear]!
		var time:Double = 1, delay:Double = 0, loop = 0, yoyo = false
		var complete:(UIView->Void)?
	}
}

UIView의 tween함수 완성

모든 재료가 다 모였습니다. 이제 tween함수를 완성시킬 수 있습니다. 인터페이스가 단순히 (tween, Any)… 형태의 인자를 받기 때문에 어디까지가 Tween용이고 어디까지가 속성인지 구분하기 힘듭니다. 호스트코드의 자유도를 고려하여 인자를 두번 루프돌면서 Tween용과 보통의 속성을 처리하도록 구현합니다.

extension UIView{
	class Tween:NSObject{...}

	func tween(vals:(tween, Any)..., _ complete:(UIView->Void)?){
		//속성만 담을 배열
		var prop:[(tween, Any)] = []

		//Tween 기본값
		var time:Double = 0, delay:Double = 0, loop = 0, yoyo = false, ease = ease.linear, end = complete
		vals.forEach{
			//Tween용 설정값을 처리하고 나머지는 속성으로 처리
			switch $0{
			case .TIME:time = $1 as! Double
			case .DELAY:delay = $1 as! Double
			case .EASE:ease = $1 as! ease
			case .LOOP:loop = $1 as! Int
			case .YOYO:yoyo = $1 as! Bool
			default:prop.append(($0, $1))
			}
		}

		//Tween을 팩토리로부터 만들어낸 후 속성배열을 넘겨준다.
		Tween.tween(self, time, delay, ease, loop, yoyo, end).ani(prop)
	}
}

거의 모든 일은 Tween에게 위임했으므로 UIView의 tween메소드가 해야할 일은 별거 없습니다. 이제 원래 계획대로 애니메이션을 편리하게 사용할 수 있습니다.

box.tween(
  (tween.TIME, 1), (tween.LOOP, 2), (tween.EASE, ease.sineOut),
  (tween.x, 0), (tween.y, 2000)
){print("end\($0)")}

결론

UIView에 작동하는 고수준 UIView.animateWithDuration을 확장하려고하자마자 굉장한 대모험이 되어버렸습니다. 덕분에 코코아선언버그도 알게 되고 CALayer와 코어 애니메이션에 대한 공부도 해보게 되었습니다.
UIView애니메이션은 수천개를 걸어도 부드럽게 자동하는 굉장히 성능이 잘나오는 그래픽스 시스템입니다. 게임을 만들던, 유틸리티를 만들던 사용하기 나름이겠죠. 고도화된 애니메이션이 필요하여 제작해버렸습니다.
아직도 다음과 같은 기능은 위의 코드에 반영되어 있지 않습니다.

  • 강제중지시의 처리
  • 직렬, 병렬 애니메이션의 연결
  • 이징함수 기반을 벗어난 애니메이션(원운동, 패스운동등)
  • 완료시점외에도 매프레임에 update상황을 보고하는 리스너 시스템

이는 개발중이므로 또 다른 기회에 포스팅하도록 하죠. 도와주신 페친분들께 감사드립니다.