오늘은 이 우주비행사를 원하는 경로로 움직여보려고 합니다!
먼저 지난번에 알아본 CAKeyframeAnimation와 UIBezierPath의 개념에 대한 이해가 필요해요.
아래 두곳에 정리해두었는데 참고하시면 좋겠습니다!
https://github.com/wody-d/woody-iOS-tip/blob/main/TIL_2022:10:19_uibezierpath.md
https://github.com/wody-d/woody-iOS-tip/blob/main/TIL_2022:10:20_core_animation.md
경로 그리기
가장 먼저 view 하나를 준비합니다. 위 view위에 layer를 추가해줄려고 합니다.
final class ViewController: UIViewController {
lazy var animationView = AnimationView(
frame: .init(
origin: .init(x: 50, y: 100),
size: .init(width: view.frame.width-100, height: 300)
)
)
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
view.addSubview(animationView)
}
}
final class AnimationView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = .black
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
이제 시작해볼게요.
물체를 원하는 경로로 날리기 위해서는 경로를 그려줘야 합니다.
경로는 UIBezierPath를 이용해서 간단한 곡선을 그려볼게요.
let path = UIBezierPath()
path.move(to: .init(x: 0, y: 0))
path.addQuadCurve(
to: .init(x: bounds.width/2, y: bounds.height/2),
controlPoint: .init(x: 0, y: bounds.height/2)
)
path.addQuadCurve(
to: .init(x: bounds.width, y: bounds.height),
controlPoint: .init(x: bounds.width, y: bounds.height/2)
)
path.move(to: .init(x: bounds.width, y: bounds.height))
경로를 만들어주었으면 이제 레이어를 추가해주어야해요.
override func draw(_ rect: CGRect) {
let lineLayer = CAShapeLayer()
lineLayer.frame = bounds
lineLayer.path = path.cgPath
lineLayer.strokeColor = UIColor.white.cgColor
lineLayer.fillColor = UIColor.clear.cgColor
self.layer.addSublayer(lineLayer)
}
의도한 대로 결과가 나온걸 볼 수 있어요!
Shake 버튼과 아래 슬라이더는 일단 무시해주세요. 여러가지 실험을 위한 재료들입니다.
애니메이션 입히기
그럼 이제 이 선에 애니메이션을 주겠습니다.
아무데서나 우주비행사 아이콘을 하나 준비하고
뷰에 추가합니다.
final class AnimationView: UIView {
let imageView: UIImageView = UIImageView(image: .init(named: "astronaut"))
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = .black
addSubview(imageView)
imageView.frame = CGRect(
origin: .init(x: -20, y: -20),
size: .init(width: 40, height: 40)
)
// path 코드 생략...
}
네,
저는 우주비행사가 왼쪽 상단 모서리에 오도록 배치했습니다.
이제 우주비행사한테 애니메이션을 주려면 애니메이션을 실행해줄 CAKeyframeAnimation 객체가 필요합니다,
또 움직일 경로가 필요한데 이미 위에서 path는 준비해두었네요!
이 때, CAKeyframeAnimation의 keypath는 우주비행사 이미지를 움직이는 것이므로 position이 됩니다.
position은 해당 객체의 위치를 나타냅니다.
position은 객체의 위치를 항상 나타내지는 않습니다. 위의 경우 우주비행사 이미지에 새로 추가한 레이어가 없고 anchorPoint 또한 (0.5, 0.5) 이기 때문에 우주비행사 이미지의 중앙을 나타냅니다. 자세한 내용은 아래 내용을 참고해주세요!
https://wodyios.tistory.com/71
override func draw(_ rect: CGRect) {
let lineLayer = CAShapeLayer()
lineLayer.frame = bounds
lineLayer.path = path.cgPath
lineLayer.strokeColor = UIColor.white.cgColor
lineLayer.fillColor = UIColor.clear.cgColor
self.layer.addSublayer(lineLayer)
// 아래 코드 추가
let astronautAnimation = CAKeyframeAnimation(keyPath: "position")
astronautAnimation.repeatCount = 0
astronautAnimation.path = path.cgPath
astronautAnimation.duration = 1
imageView.layer.add(astronautAnimation, forKey: nil)
imageView.layer.position = path.currentPoint
}
여기서 선도 함께 애니메이션을 줄 수 있습니다.
override func draw(_ rect: CGRect) {
let lineLayer = CAShapeLayer()
lineLayer.frame = bounds
lineLayer.path = path.cgPath
lineLayer.strokeColor = UIColor.white.cgColor
lineLayer.fillColor = UIColor.clear.cgColor
self.layer.addSublayer(lineLayer)
let astronautAnimation = CAKeyframeAnimation(keyPath: "position")
astronautAnimation.path = path.cgPath
astronautAnimation.duration = 2
let lineAnimation = CABasicAnimation(keyPath: "strokeEnd")
lineAnimation.fromValue = 0
lineAnimation.toValue = 1
lineAnimation.duration = 2
CATransaction.begin()
lineLayer.add(lineAnimation, forKey: nil)
imageView.layer.add(astronautAnimation, forKey: nil)
imageView.layer.position = path.currentPoint
CATransaction.commit()
}
위 코드에서 CATransaction을 사용한 이유는 2개의 애니메이션을 함께 실행하는 것을 보장하기 위해서입니다. CATransaction은 타입메소드로 되어 있기 때문에 CATransaction.begin()과 commit()과 같이 사용할 수 있어요.
원래 Core Animation을 레이어에 추가하면 스레드의 다음 런루프에 애니메이션이 실행이 돼요. 애니메이션이 실행될 때, 부분적으로 실행되다가 중단되지 않을 것을 보장하기 위해 transaction을 만드는데, 스레드의 런루프마다 자동으로 생성되는 transaction을 implicit transaction이라 해요. 반대로, CATransaction와 같이 명시적으로 transaction을 만들어줄 수가 있는데 이를 explicit transaction이라고 합니다! 저는 지금 explicit transaction을 만들었습니다!
transaction은 inner와 outer로 구분될 수도 있습니다. CATransaction안에 또다른 CATransaction을 만들어줄 수가 있기 때문에 2번의 begin()을 하면, 2번의 commit()이 필요합니다. 자세한 내용은 아래 문서를 확인해주세요!
https://developer.apple.com/documentation/quartzcore/catransaction
실행해보면 아래와 같은 애니메이션을 볼 수 있어요!
시간 조절하기 (timeOffset, speed)
위 애니메이션은 view가 로드되는 순간 실행돼요.
하지만 저는 직접 제 손으로 우주비행사를 원하는 경로로 움직이고 싶습니다!
슬라이더의 값을 통해 우주비행사의 위치를 저 경로에 맞춰서 이동시키도록 시도해봤어요.
먼저 슬라이더를 추가하고 슬라이더의 값이 변할 때마다 CoreAnimation을 주면 되겠다고 생각했습니다.
그런데,,, 애니메이션을 주면 처음부터 같은 애니메이션이 반복해서 실행되자나요?
그래서, 슬라이더의 값에 따라 원하는 위치에 있기 위해서는 timeOffset이라는 프로퍼티를 사용해야 했어요.
timeOffset 프로퍼티는 애니메이션이 실행될 때 몇 초 후 애니메이션이 그리고 있는 레이어에서부터 시작할 수 있도록 도와주는 프로퍼티입니다! (여기서 이해가 안될 수도 있는데 아래에서 더 자세하게 설명할게요)
그리고 CAMediaTiming 문서를 좀만 읽어가다 보면,
레이어에 speed 프로퍼티가 있고 이 프로퍼티는 애니메이션의 속도를 조절하는 것이라는 사실을 알 수 있었어요.
그래서 speed = 0으로 하고 timeoffset에 슬라이더의 값 * duration을 할당했습니다. duration을 곱해준 이유는 duration동안 애니메이션이 실행되기 때문입니다! 생각한 것을 코드로 구현했습니다.
// 슬라이더 추가 코드 생략
func changeValue(_ value: Float) {
let pathAnimation = CAKeyframeAnimation(keyPath: "position")
pathAnimation.path = path.cgPath
let duration: CGFloat = 1
pathAnimation.duration = duration
pathAnimation.speed = 0
pathAnimation.timeOffset = duration * Double(value)
imageView.layer.add(pathAnimation, forKey: nil)
imageView.layer.position = path.currentPoint
}
슬라이더의 값이 변경됨에 따라, 우주비행의 위치도 정해준 경로를 이동하는 것을 볼 수 있어요!
다른 방법? (beginTime, isRemovedOnCompletion, fillMode)
이 방법이 최선일까 더 찾아보다가 신기한 방법을 찾아냈어요.
speed = 0 을 하지 않아도, beginTime isRemovedOnCompletion, fillMode 3가지 프로퍼티만으로 작동하더라구요.
func changeValue(_ value: Float) {
let pathAnimation = CAKeyframeAnimation(keyPath: "position")
pathAnimation.path = path.cgPath
let duration: CGFloat = 1
pathAnimation.duration = duration
pathAnimation.timeOffset = duration * Double(value)
pathAnimation.beginTime = 1 // ✅
pathAnimation.isRemovedOnCompletion = false // ✅
pathAnimation.fillMode = .forwards // ✅
imageView.layer.add(pathAnimation, forKey: nil)
imageView.layer.position = path.currentPoint
}
근데 이 코드가 왜 되지? 싶었어요.
isRemovedOnCompletion 프로퍼티는
애니메이션이 끝났을 때 해당 애니메이션을 지울까말까 여부에 관한 프로퍼티예요.
fillMode 프로퍼티는
애니메이션이 끝났을 때, 다시 원래대로 돌아갈까 아니면 끝난 시점 레이어로 보여줄까에 관한 프로퍼티예요.
즉, 처음에 실행되는 애니메이션에서 우주비행사가 오른쪽아래로 이동했을 때 다시 왼쪽위모서리로 되돌릴까 아니면 그대로 남길까에 관한 프로퍼티인거죠. 이 프로퍼티는 isRemovedOnCompletion = false일 때에만 실행돼요. 애니메이션이 레이어에서 제거되면 애니메이션 에 관한 정보가 남아있지 않기 때문에 유효하지 않아요.
beginTime 프로퍼티는 애니메이션이 시작되는 시간을 의미해요.
프로퍼티들의 정의를 다 읽어보고 fillMode와 isRemovedOnCompletion프로퍼티는 납득이 갔어요.
"애니메이션을 남겨서 이동된 위치정보를 가지고 있게하기 위해 설정한 것이구나"
그런데, 도저히 beginTime은 이해가 가지 않았습니다..
beginTime = 0 으로 설정하면 애니메이션이 의도대로 동작하지 않고 0이 아닌 다른 값으로 설정하면 의도대로 동작하더라구요.
Customizing the Timing of an Animation 문서를 더 뒤져봤어요.
그리고 어느정도 유추해보았는데요.
먼저 알아야할 개념은 레이어마다 각자의 시간을 가지고 있다는 사실이에요. 레이어 각각의 시간차이는 유저가 느끼지 못할 정도로 작기 때문에 시간차를 준 애니메이션이 아니고선 시간차이에 관해서는 신경쓰지말라고 합니다.
두번째로는 부모와의 상대적인 시간 개념도 있어요. 즉, frame과 bound의 관계처럼 말이에요.
그래서 결국 부모와 local 시간 중에 무엇이 더 중요하냐면, 레이어 각각이 가지고 있는 local 시간이 중요합니다!
그래서 각 레이어의 현재 시간(local time)을 구하기 위해 부모의 시간을 자신의 시간(local time)으로 변환하는 과정이 필요해요.
여기까지 읽어보았을 때, beginTime을 0으로 설정하면 자동으로 부모의 시간을 자신의 시간으로 변환해서 애니메이션을 실행합니다. 하지만, 0이 아닌 0.1초, 1초, 5초 등의 시간으로 설정하면 beginTime은 레이어의 절대적인 시간을 설정하는 것이므로 부모 레이어의 시간과 비교해보았을 때, 너어무 비슷해서 실행하고 바로 멈추는 것 같아요. (사실 이게 오작동 아닐까요?)
애니메이션이 아래와 같이 동작하는게 정삭적일 텐데,
Normal : | A->B->C->D->E
beginTime : | A->B->C->D->E
timeOffset : | C->D->E->A->B
beginTime이 부모 레이어의 시간과 너무 맞지 않아,
Normal : | A->B->C->D->E
beginTime+timeOffset : | C
이렇게 끝나버린겨죠..
조금 더 찾아봐야겠지만 일단 이렇게 결론을 내려봤어요. 더 찾아보고 놀라운 내용이 있다면 글을 다시 작성하도록 하겠습니다!
아무튼 이걸 통해서 멋진 애니메이션을 구현할 수 있으면 좋곘네요. 읽어주셔서 감사합니다!
Ref
https://stackoverflow.com/questions/27643002/ios-swift-animation-controlled-by-gesture
https://developer.apple.com/documentation/quartzcore/camediatiming/1427654-begintime
https://developer.apple.com/documentation/quartzcore/catransaction
https://developer.apple.com/documentation/quartzcore/camediatiming
'iOS' 카테고리의 다른 글
ViewController 생명주기의 몰랐던 점 (feat. 트러블 슈팅) (0) | 2022.10.24 |
---|---|
[CoreAnimation] Layer에 CoreAnimation을 무한으로 체이닝할 수 있을까? (2) | 2022.10.23 |
[CoreAnimation] AnchorPoint, position의 관계 (feat. SwiftUI에선 anchortPoint가 없다..?) (0) | 2022.10.22 |
[CoreAnimation] Layer Masking (feat. 키 모양 만들기) (0) | 2022.10.20 |
[CoreAnimation] CAReplicatorLayer 알아보기 (0) | 2022.10.20 |