[swift] NSTimer 기반의 애니메이션

개요top

NSTimer는 런루프와 연동하여 UIView를 갱신할 수 있는 인터벌을 제공합니다. 이벤트감시 등을 위한 내부용 메인쓰레드처리기가 런루프라면 이 런루프가 감시하는 큐에 실행할 코드를 스케쥴링하여 걸어두는 역할이 NSTimer라고 할 수 있죠.
이는 일반적으로 빈자(가난한 사람)의 쓰레드라고 불리는 굉장히 오래된 기법입니다. 멀티쓰레드에 제약이 있었던 초창기 iOS정책을 생각해보면 이상할 것도 없습니다만, 싱글쓰레드 내에서 최대한 동시성을 확보하는 정책은 여러가지 유리한 점이 있습니다. 일단 이 빈자의 쓰레드모델에서는 하나의 쓰레드가 큐에 쌓인 명령을 루프돌면서 해소하는 식이니 실제 코드를 실행하는 실행쓰레드는 단 하나인 셈입니다. 따라서 멀티쓰레드가 동기화가 필요없습니다. 또한 한 번에 쓰레드가 실행하는 코드는 이번 루프타이밍의 큐에 있는 명령으로 한정되므로 동기화명령을 분산하여 실행할 수 있어 전체적인 블록킹현상을 완화하는 효과도 갖게 됩니다. 현재도 이 모델은 자바스크립트의 기본적인 명령수행방식 등에서 채택되고 있습니다.
이런 면에서 NSTimer를 이용하면 동시성에 대한 고민없이 메인쓰레드와 마찬가지로 UIView를 갱신할 수 있어 굉장히 편리하고 개발하기도 간단합니다. 단 애니메이션을 위한 프레임웍으로 바라보는 관점에서 다음의 두 가지를 베이스에 깔고 전개합니다.

  1. 싱글타이머 – 타이머는 결국 큐에 실행할 명령을 적재하는 역할이 타이머인데 다수의 타이머가 있어봐야 큐에 넣으려는 작업자가 많아질 뿐입니다. 하나의 타이머를 이용하여 모든 작업을 처리합니다.
  2. CALayer공략 – 메인쓰레드를 지연시키게 되므로 보다 부하를 낮추기 위해 CALayer계층으로 애니메이션을 공략합니다(머 이미 코어 애니메이션에 내려갔다 온지라 UIView에서 놀 필요도 못 느끼는 상태입니다)

전역 타이머와 Tween클래스top

Tween클래스의 인스턴스 하나는 애니메이션을 시킬 하나의 작업을 의미합니다. 그 작업 안에는 한 개의 UIView와 다수의 속성변화를 내포하고 있습니다.
필요한 속성은 다음과 같습니다.

class Tween{
	//이징함수 클로저 정의 (경과시간, 최초값, 변화량, 총시간)를 인자로 받아서 변화된 값을 계산함
	typealias easing = (Double, Double, Double, Double)->Double


	//애니메이션 할 대상
	private var view:UIView?
	private var layer:CALayer?

	//애니메이션 할 속성들 (속성키패스, 시작값, 변화량)
	private var props:[(String, Double, Double)] = []

	//이징함수
	private var ease:easing?

	//시작시간, 총시간
	private var start:Double = 0
	private var duration:Double = 0

	//반복횟수, 요요여부
	private var loop:Int = 0
	private var yoyo:Bool = false

	//완료보고
	private var end:(UIView->Void)?
}

대부분 애니메이션을 위한 기본 정보로 CABasicAnimation의 요구사항과도 대동소이합니다. 단지 이징함수가 직접 수학처리하는 함수를 받을 수 있다는 점이 다릅니다.

시간 보간 함수의 기본top

애니메이션 기초를 위해 다시금 보간함수를 복습해 보겠습니다. 예를 들어 1초간 50에서 100으로 움직이는 애니메이션을 생각해보겠습니다. 그러면 다음과 같이 수치가 정리될 것입니다.

  • duration – 총시간은 1초입니다.
  • start – 시작시간은 애니메이션이 시작되는 시점의 NSDate.timeIntervalSinceReferenceDate()로 정합니다
  • beginPoint – 50에서 시작하므로 시작점은 50입니다
  • term – 변화량은 50에서 100까지 움직이므로 100 – 50을 통해 50을 구할 수 있습니다. 즉 변화량이란 (종료점 – 시작점)이라 할 수 있습니다.

이를 바탕으로 매 타이머 호출시마다 현재의 위치를 계산하는 이징함수를 생각해보죠. 가장 간단한 이징함수는 선형보간함수로 흔히 linear라고 알려져있습니다.

let linear:Tween.easing = {(elapse, begin, term, duration)->Double in
	let rate = elapse / duration
	return begin + term * rate 
}

위의 함수에서 경과시간을 총시간으로 나누면 0~1사이의 경과율을 우선 구합니다. 그 경과율에 변화량을 곱하여 시작값에 더해주면 선형보간이 완성됩니다.

이제 타이머를 이용하여 매번 이 값을 계산하다고 생각해보죠.

//선형보간함수
let linear:Tween.easing = {(elapse, begin, term, duration)->Double in
	let rate = elapse / duration
	return begin + term * rate 
}

//위에서 정리한 값
let duration:Double = 1
let begin:Double = 50.0
let term:Double = 50.0

//애니메이션 시작시간
var startTime:Double = NSDate.timeIntervalSinceReferenceDate()

//타이머 틱보고용 클래스
class Tick:NSObject{
	func onTick(){
		//현재시간을 구함
		let currTime = NSDate.timeIntervalSinceReferenceDate()

		//경과시간을 구함
		let elapsedTime = currTime - startTime

		//보간함수를 이용해 현재값을 계산해보자
		print("\(linear(elapsedTime, begin, term, duration))")
	}
}

//타이머를 건다
NSTimer.scheduledTimerWithTimeInterval(
	1.0/60, //60ftp면 충분하다! 
	target:Tick(), selector:#selector(Tick.onTick), userInfo:nil, repeats:true
)

간단한 선형보간함수를 통해 타이머와 tick, 보간함수를 통한 값의 보간을 알아봤습니다. 로그는 50, …65..80…100 이런식으로 찍하게 되죠.

실제 단일 타이머를 이용하는 아이디어top

하나의 타이머를 이용하는 아이디어는 결국 앞서 설명한 예제에서 Tick()객체 하나만 사용하겠다는 뜻입니다. 따라서 onTick에서 하나의 애니메이션만 처리하는 것이 아니라 현재 쌓여있는 모든 애니메이션을 처리해야합니다. 각각의 애니메이션은 Tween인스턴스로 나타낼 수 있으므로 다음과 같이 정리할 수 있습니다.

class Tween{

	//현재 재생 중인 애니메이션 집합
	static var tweens = Set<Tween>() 

	class Tick:NSObject{
		func onTick(){
			//현재 재생 중인 애니메이션을 순회하면서
			Tween.tweens.forEach{
				//개별 애니메이션의 tick메소드가 true를 반환하면 애니메이션 종료로 판단한다.
				if $0.tick(){ 
					Tween.tweens.remove($0)
				}
			}
		}
	}

	func tick()->Bool{
		if 애니메이션 지속 {
			return false
		}else{
			return true //종료
		}
	}
}

위의 구조에서 Tick은 그저 tweens루프를 돌릴 뿐이고 실제 재생목록에서 빠질지 말지는 개별 Tween인스턴스가 자율적으로 정할 수 있게 됩니다.

Set에 들어갈 수 있게 개조하기top

애니메이션은 특성상 잦은 삽입삭제가 일어나므로 Set을 이용하고 있습니다. 하지만 Set에 Tween클래스가 들어가려면 Equatable, Hashable 두개의 프로토콜을 구현해야합니다. NSObject를 상속받으면 손쉽게 해결되지만 공부삼아 페친이 가르쳐준 방법으로 스위프트 클래스를 개조해봅니다.

class Tween:Equatable, Hashable{
	//Hashable구현
	var hashValue:Int{return ObjectIdentifier(self).hashValue}
}
//Equatable 구현
func == (l:Tween, r:Tween)->Bool{
	return l === r
}

머 간단하여 설명이 필요치 않다고 봅니다. 이제 Tween클래스는 Set에 잘 들어갈 수 있는 준비를 마쳤습니다.

Tween인스턴스 생성 및 애니메이션 시작top

애니메이션은 굉장히 많아질 수 있으므로 Tween객체는 풀링하는 편이 유리합니다. 또한 풀링되면 초기화가 복잡해지므로 외부에 직접적인 생성을 막고 팩토리가 빌더역할을 대신하도록 해야합니다.

class Tween:Equatable, Hashable{
	//외부 생성을 막는다
	private init(){}

	//풀링스택
	static private var pool = [Tween]()

	//전역타이머 시작여부
	static private var isInterval = false

	//팩토리함수
	static func tween( //인자 개떼
		view:UIView, vals:[(String, Double)], //뷰와 속성배열
		time:Double, delay:Double, ease:easing, loop:Int, yoyo:Bool, //시간, 반복, 이징
		end:(UIView->Void)?){ //완료리스너

		//풀에서 꺼내오거나 새로 생성
		let tw = pool.isEmpty ? Tween() : pool.removeLast()
		let layer = view.layer
		//vals에는 (키패스, 원하는 최종값) 형태로 값이 들어있으므로 
		//이를 (키패스, 시작값, 변화량)형태로 바꿔준다
		var props:[(String, Double, Double)] = []
		val.forEach{
			//시작값은 키밸류로 얻어오고
			let v = layer.valueForKeyPath($0.0.rawValue) as! Double
			//변화량은 결과값에서 시작값을 빼서 얻는다
			props.append(($0.0, v, $0.1 - v))
		}

		//풀에서 얻어온 인스턴스의 속성을 정해준다.
		tw.view = view
		tw.layer = layer
		tw.ease = ease
		tw.props = props

		//delay인자는 시작시간을 늦추는것에 반영하는 것으로 충분하다!
		tw.start = NSDate.timeIntervalSinceReferenceDate() + delay

		tw.duration = time
		tw.loop = loop
		tw.yoyo = yoyo
		tw.end = end

		//현재 애니메이션목록에 넣어준다.
		tweens.insert(tw)

		//전역타이머가 아직 시작하지 않았다면 시작시킨다
		if !isInterval{
			isInterval = true
			NSTimer.scheduledTimerWithTimeInterval(
				1.0/60, target:Tick(), selector:#selector(Tick.onTick),
				userInfo:nil, repeats:true
			)
		}
	}

	private var view:UIView?
	private var layer:CALayer?
	private var props:[(String, Double, Double)] = []
	private var ease:easing?
	private var start:Double = 0
	private var duration:Double = 0
	private var loop:Int = 0
	private var yoyo:Bool = false
	private var end:(UIView->Void)?

	func tick()->Bool{...}
}

이제 재료가 다 모였으니 UIView의 tween함수를 구현할 수 있습니다

extension UIView{
	//(키, 값),(키, 값)...{UIView in ...} 형태의 인자를 받는다 
	func tween(vans:(String, Any)..., _ complete:(UIView->Void)?){
		//속성만 추릴 배열
		var prop:[(String, Any)] = []

		//Tween의 기본값
		var time:Double = 0, delay:Double = 0, loop = 0, yoyo = false
		var ease = Tween.easingFunction["linear"], end = complete

		//전체인자를 순회하며 Tween 자체의 속성과 애니메이션시킬 View의 속성을 분리한다
		vals.forEach{
			switch $0{
			case "TIME":time = $1 as! Double
			case "DELAY":delay = $1 as! Double
			case "EASE":
				if $1 is String{ //이미 있는 함수에서 고르는 경우
					ease = Tween.easingfunction[$1 as! String]
				}else if $1 is Tween.easing{ //직접 함수를 보낸 경우
					ease = $1 as? Tween.easing
				}
			case "LOOP":loop = $1 as! Int
			case "YOYO":yoyo = $1 as! Bool
			default:prop.append(($0, $1))
			}
		}
		//팩토리 함수를 불러서 애니메이션 발동
		Tween.tween(self, arr, time, delay, ease!, loop, yoyo, end)
	}
}

이징함수의 구현top

이미 위 코드에서 등장했지만 Tween.easingfunction 에 이징함수 딕셔너리를 넣어줍니다. 이미 살펴봤던 linear를 비롯해 간단히 몇 가지만 살펴봅니다.

class Tween{

	typealias easing = (Double, Double, Double, Double)->Double

	static let easingfunction:[String:easing] = {
		var dic:[String:easing] = []
		dic['linear'] = {
			let v = $0 / $3
			return $1 + $2 * v 
		}
		dic["quartIn"] = {
			let v = $0 / $3
			return $1 + $2 * v * v * v * v
		}
		dic["backInOut"] = {
			let s = 1.70158 * 1.525
			var v = $0 / ($3 / 2)
			if v < 1{
				return $1 + $2 / 2 * (v * v * ((s + 1) * v - s))
			}
			v -= 2
			return $1 + $2 / 2 * (v * v * ((s + 1) * v + s) + 2)
		}
		//...
	}()
}

검색해보면 수많은 이징함수가 있습니다. 이징함수는 반드시 선형적인 것은 아닙니다. 비선형적으로 가만히 있다가 끝에 가서 확 점프해버리는 애니메이션이 있다고 가정한다면 그 이징함수는 다음과 같겠죠.

dic['jumpIn'] = {
	//80% 경과까지는 시작값이 계속 감
	if $0 / $3 < 0.8{ 
		return $1
	}
	
	//그 이후는 갑자기 목표값이 됨
	return $1 + $2
}

짜피 모든 로직을 자유롭게 기술할 수 있으므로 굉장히 다양한 로직적 움직임과 물리학적 표현을 시간에 대한 적분함수 만들어서 포물선이나 중력운동을 나타낼 수도 있습니다(싫은 기억들이 새록새록 나네요 ^^)

Tween.tick메소드와 Tick.onTicktop

실제 애니메이션은 Tick.onTick을 타이머가 호출하고 다시 tween를 루프돌면서 각각의 Tweeen.tick을 호출하여 진행됩니다. 따라서 Tweend의 tick메소드가 애니메이션을 관장하고 있다고 볼 수 있죠. 가장 중요한 메소드인 셈입니다. 이 메소드는 호출될 때마다

  1. 경과시간을 계산하고
  2. 이징함수를 통해 값을 보간한 뒤 그 값을 실제 Layer에 반영하는 책임을 지고 있습니다.
  3. 또한 애니메이션이 완료되면 깔끔한 완료처리를 해야할 뿐 아니라 풀에 반환되어야합니다.
  4. 반복이나 요요가 설정된 경우는 이에 따른 처리도 감당해야합니다.

지금까지 언급된 내용을 보면 결국 매번 현재 시간을 구해서 애니메이션이 시작되고 얼마나 경과되었는지를 구하는 것으로부터 시작됩니다. 모든 Tween의 인스턴스가 매 tick마다 현재 시간을 구하려 들면 IO비용이 굉장히 커지므로(시간을 얻는건 인메모리연산이 아니라 IO연산임) Tick.onTick레벨에서 한번만 계산하고 다소간의 나노오차가 있겠지만 그걸 모든 tweens에게 배포하는 전략을 사용하겠습니다. 이제 코드로 보죠.

class Tween{
	class Tick:NSObject{
		func onTick(){

			//onTick레벨에서 현재시간을 한 번만 구하자!
			let currTime = NSDate.timeIntervalSinceReferenceDate()
			Tween.tweens.forEach{
				//각 Tween에게는 인자로 넘겨줌
				if $0.tick(currTime){Tween.tweens.remove($0)}
			}
		}
	}

	private var view:UIView?
	private var layer:CALayer?
	private var props:[(String, Double, Double)] = []
	private var ease:easing?
	private var start:Double = 0
	private var duration:Double = 0
	private var loop:Int = 0
	private var yoyo:Bool = false
	private var end:(UIView->Void)?

	func tick(currTime:Double)->Bool{
		//현재시간이 시작전이면 대기처리
		if currTime < start{
			return false
		}

		//경과시간을 구한다
		let elapse = currTime - start

		//경과시간이 지났다면 완료된 것임
		if elapse > duration{

			//모든 속성을 최종값에 정확히 수렴시킨다
			for v in props{
				layer!.setValue(v.1 + v.2, forKeyPath:v.0)
			}

			//만약 반복이 있다면
			if loop > 0{
				loop -= 1
				if yoyo{ //요요인 경우는 각 속성값을 역전시킨다
					props = props.map{($0.0, $0.1 + $0.2, -$0.1)}
				}
				//시작시간을 초기화하여 다시 반복을 한다
				start = currTime

			}else{//진짜 종료
				//완료리스너가 있다면 불러준다
				end?(view!)

				//객체참조를 해지한다.
				self.view = nil
				self.layer = nil
				self.end = nil

				//풀로 돌아간다
				Tween.pool.append(self)

				//현재 애니메이션목록에서 빠져나온다
				return true
			}
		}else{ //진행중
			let e = ease!
			props.forEach{ //속성을 돌면서 이징함수의 결과를 KV로 반영해준다
				layer!.setValue(e(elapse, $0.1, $0.2, duration), forKeyPath:$0.0)
			}
		}
		return false //계속 진행
	}
}

설명한 그대로의 로직이므로 구현시 큰 난제는 없네요.

Tween, Tick, UIView확장 전체 코드top

여태 언급했던 모든 코드를 한 번에 모아보면 아래와 같습니다.

class Tween:Equatable, Hashable{
	typealias easing = (Double, Double, Double, Double)->Double
	static var tweens = Set<Tween>() 
	class Tick:NSObject{
		func onTick(){
			let currTime = NSDate.timeIntervalSinceReferenceDate()
			Tween.tweens.forEach{if $0.tick(currTime){Tween.tweens.remove($0)}}
		}
	}
	var hashValue:Int{return ObjectIdentifier(self).hashValue}
	private init(){}
	static private var pool = [Tween]()
	static private var isInterval = false
	static let easingfunction:[String:easing] = {
		var dic:[String:easing] = []
		dic['linear'] = {
			let v = $0 / $3
			return $1 + $2 * v 
		}
		//이하생략
	}()
	static func tween(
		view:UIView, vals:[(String, Double)],
		time:Double, delay:Double, ease:easing, loop:Int, yoyo:Bool,
		end:(UIView->Void)?){
		let tw = pool.isEmpty ? Tween() : pool.removeLast()
		let layer = view.layer
		var props:[(String, Double, Double)] = []
		val.forEach{
			let v = layer.valueForKeyPath($0.0.rawValue) as! Double
			props.append(($0.0, v, $0.1 - v))
		}
		tw.view = view
		tw.layer = layer
		tw.ease = ease
		tw.props = props
		tw.start = NSDate.timeIntervalSinceReferenceDate() + delay
		tw.duration = time
		tw.loop = loop
		tw.yoyo = yoyo
		tw.end = end
		tweens.insert(tw)
		if !isInterval{
			isInterval = true
			NSTimer.scheduledTimerWithTimeInterval(
				1.0/60, target:Tick(), selector:#selector(Tick.onTick),
				userInfo:nil, repeats:true
			)
		}
	}

	private var view:UIView?
	private var layer:CALayer?
	private var props:[(String, Double, Double)] = []
	private var ease:easing?
	private var start:Double = 0
	private var duration:Double = 0
	private var loop:Int = 0
	private var yoyo:Bool = false
	private var end:(UIView->Void)?

	func tick(currTime:Double)->Bool{
		if currTime < start{return false}
		let elapse = currTime - start
		if elapse > duration{
			for v in props{layer!.setValue(v.1 + v.2, forKeyPath:v.0)}
			if loop > 0{
				loop -= 1
				if yoyo{props = props.map{($0.0, $0.1 + $0.2, -$0.1)}}
				start = currTime
			}else{
				end?(view!)
				self.view = nil
				self.layer = nil
				self.end = nil
				Tween.pool.append(self)
				return true
			}
		}else{
			let e = ease!
			props.forEach{layer!.setValue(e(elapse, $0.1, $0.2, duration), forKeyPath:$0.0)}
		}
		return false
	}

}
func == (l:Tween, r:Tween)->Bool{
	return l === r
}
extension UIView{
	func tween(vans:(String, Any)..., _ complete:(UIView->Void)?){
		var prop:[(String, Any)] = []
		var time:Double = 0, delay:Double = 0, loop = 0, yoyo = false
		var ease = Tween.easingFunction["linear"], end = complete
		vals.forEach{
			switch $0{
			case "TIME":time = $1 as! Double
			case "DELAY":delay = $1 as! Double
			case "EASE":
				if $1 is String{
					ease = Tween.easingfunction[$1 as! String]
				}else if $1 is Tween.easing{
					ease = $1 as? Tween.easing
				}
			case "LOOP":loop = $1 as! Int
			case "YOYO":yoyo = $1 as! Bool
			default:prop.append(($0, $1))
			}
		}
		Tween.tween(self, arr, time, delay, ease!, loop, yoyo, end)
	}
}

이제 사용해볼 수 있습니다.

let box = self.subviews[0]!

box.tween(("position.x", 100), ("position.y", 200), ("TIME", 1.2), ("EASE", "linear")){
	print("\($0)")
}

위의 예에서 box는 100, 200으로 중앙점이 올 때까지 1.2초간 이동할 것이며 이동이 완료되면 box가 로그에 보이게 됩니다.

결론top

고도로 추상화되어있는 UIView의 애니메이션이나, 저수준의 CABasicAnimation 등에서 걸려있는 제약을 완전히 제거하고 NSTimer기반으로 이전하여 애니메이션을 구현해봤습니다. 완전한 수학적 이징함수를 쓸 수 있다는 점도 좋지만 기능 추가를 자유롭게 해갈 수 있다는 점도 편리합니다.
다음과 같은 확장을 생각할 수 있습니다.

  • 일시정지, 완전정지 등의 기능
  • 다양한 움직임을 나타내는 기능
  • 병렬화하거나 그룹화하는 움직임
  • UIView 뿐만 아니라 다양한 경우에 사용할 수 있는 범용타이머

실은 이러한 트위너는 어떤 언어에서나 빈자의 쓰레드 패턴으로 만들면 편리한 것입니다.