안녕하세요!
오늘은 코어애니메이션을 체이닝할 수 있는지에 대해 알아보려고 합니다. 그냥 체이닝이 아니라 무한 체이닝입니다!
(살짝 스포하자면 실패했습니다.. 스레드문제인 거 같은데...) 일단 구현에 들어가보도록 할게요.
구현할 애니메이션은 아래 밤하늘이에요!
질문이 나오게 된 배경
위 질문이 왜 나오게 되었는 지 설명해볼게요.
먼저, 저는 밤하늘에 별이 반짝이는 애니메이션을 주고 싶었어요. 그래서 먼저 피그마로 별을 디자인했습니다!
왜 굳이 디자인을 해주었냐면, 별이 찍힐 좌표값을 알아야했기 때문입니다!
별을 구성하고 있는 모서리를 알아야 UIBezierPath로 경로를 그려줄 수 있어요.
이미지로 추출해서 layer의 contents로 넣어줘도 가능하지만 정밀한 컨트롤이 불가능할 것 같아서 직접 만들어주었어요.
위에 보이는 모서리를 이용해서 path를 한땀한땀 만들어줬어요. 생각해보면 애니메이션은 개발의 영역이 아닌 것 같기도 합니당.. 이렇게 말한 이유는 나중에도 생각할 수 있지만... 일단 넘어가서!
UIBezierPath의 Extension함수로 만들었습니다!
extension UIBezierPath {
static var starPath: UIBezierPath {
let path = UIBezierPath()
path.move(to: .init(x: 10, y: 0))
path.addLine(to: .init(x: 7.35, y: 6.36))
path.addLine(to: .init(x: 0.49, y: 6.91))
path.addLine(to: .init(x: 5.72, y: 11.39))
path.addLine(to: .init(x: 4.12, y: 18.09))
path.addLine(to: .init(x: 10, y: 14.5))
path.addLine(to: .init(x: 15.88, y: 18.09))
path.addLine(to: .init(x: 14.28, y: 11.39))
path.addLine(to: .init(x: 19.51, y: 6.91))
path.addLine(to: .init(x: 12.65, y: 6.36))
path.close()
return path
}
}
왜 저렇게 작성해야하는 지에 관해서 궁금하시다면 아래 링크를 보고 오시면 도움이 될 것 같습니당
https://github.com/wody-d/woody-iOS-tip/blob/main/TIL_2022:10:19_uibezierpath.md
그럼 이제 별이 빛날 배경을 만들기 위해서 샘플코드를 작성했습니다!
final class ViewController: UIViewController {
lazy var starBackgroundView = StarBackgroundView(frame: view.bounds)
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
view.addSubview(starBackgroundView)
}
}
final class StarBackgroundView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
준비는 완료가 되었기에 별 레이어를 추가해볼게요.
final class StarBackgroundView: UIView {
let starSize = CGSize(width: 10, height: 10)
let starCount: Int = 100
var starLayers = [CALayer]()
override init(frame: CGRect) {
super.init(frame: frame)
for _ in 0..<starCount {
let astar = CAShapeLayer()
astar.frame = .init(origin: generateRandomPosition(), size: starSize)
astar.path = UIBezierPath.starPath.cgPath
astar.fillColor = UIColor.systemYellow.cgColor
astar.shadowPath = UIBezierPath.starPath.cgPath
astar.masksToBounds = false
astar.shadowColor = UIColor.red.cgColor
astar.shadowOpacity = 2
astar.shadowOffset = .zero
astar.shadowRadius = 4
starLayers.append(astar)
}
}
override func draw(_ rect: CGRect) {
starLayers.forEach {
layer.addSublayer($0)
}
// ✅ 랜덤 위치 생성 함수
func generateRandomPosition() -> CGPoint {
let height = frame.height
let width = frame.width
return CGPoint(
x: CGFloat(arc4random()).truncatingRemainder(dividingBy: width),
y: CGFloat(arc4random()).truncatingRemainder(dividingBy: height)
)
}
}
별의 frame이 필요했기에 사이즈를 가로 세로가 10로 정했고, 별의 개수는 100개로 했습니다!
별 레이어의 속성들을 초기화를 해주었는데 여기서 중요한 점은 frame의 origin 속성입니다.! 랜덤한 값을 넣어줘야 매번 다른 위치에 별이 생성되니까요. path는 처음에 만들어준 별 경로를 할당했고 색깔, 그림자 등등을 정의했어요.
그리고 이제 만든 별 레이어를 배경 뷰가 초기화될 때 별 별의 개수만큼 추가해주었어요. 이때 for문을 이용해서 별을 추가해주었지만 처음에는 CAReplicatorLayer로 레이어를 복제하려고 했습니다. 하지만 CAReplicatorLayer는 원하는 위치에 랜덤배치가 되지 않았어요. instanceTransform 프로퍼티는 복제된 이전의 위치를 기반으로 다음 레이어가 복제되기 때문에, 원하는 구현이 아니었습니다.. 그래서 for문을 이용하기로 했어요.
빌드해보면 시뮬레이터에 이렇게 나온답니다.
여기까지가 준비였고, 이제 Core Animation을 주도록 할게요.
opacity, fillColor 2가지에 대한 애니메이션을 줬고 어떻게 줬는지에 대한 설명은 생략하도록 하겠습니다. 궁금하시다면, 다른 글을 읽고 오시면 좋을 것 같아요!
아래 코드를 draw 메소드에 추가해줍니다.
starLayers.forEach {
let opacityAnimation = CABasicAnimation(keyPath: "opacity")
let duration: CGFloat = 1
opacityAnimation.fromValue = 0.2
opacityAnimation.toValue = 0.8
opacityAnimation.timeOffset = duration * drand48()
opacityAnimation.duration = duration
opacityAnimation.repeatCount = .infinity
opacityAnimation.autoreverses = true
opacityAnimation.delegate = self
opacityAnimation.isRemovedOnCompletion = false
$0.add(opacityAnimation, forKey: "opacity")
let colorAnimation = CAKeyframeAnimation(keyPath: "fillColor")
colorAnimation.duration = duration
colorAnimation.values = [UIColor.clear.cgColor, UIColor.systemYellow.cgColor, UIColor.white.cgColor]
colorAnimation.timeOffset = duration * drand48()
colorAnimation.repeatCount = .infinity
colorAnimation.autoreverses = true
colorAnimation.delegate = self
$0.add(colorAnimation, forKey: "color")
}
그럼 이제 별이 반짝이는 애니메이션을 볼 수 있어요.
그런데, 이제부터가 시작이에요.
지금 별들은 매번 같은 duration, 같은 timeOffset, 같은 speed를 가진 채 반짝이고 있습니다. 저는 duration, timeOffset과 speed를 매번 다르게 주고 싶었어요. 별은 매번 일정하게 반짝이는 것이 아니라 매번 랜덤하게 반짝이니까요. (어쩔때는 오랫동안 별의 빛을 유지하기도 하다가 또 어쩔때는 금방 깜박이고..)
Core Animation 효과를 무한으로 체이닝할 수 있을까?
가장 처음 든 생각은 현재 repeatCount 프로퍼티를 infinity로 줬는데 이것을 0으로 준 후, 애니메이션이 끝날 때마다 새로운 애니메이션을 추가하는 방법이었어요. 애니메이션이 끝나는 시점은 2가지 방법으로 알 수가 있었습니다.
먼저, delegate을 채택하고 func animationDidStop(_ anim: CAAnimation, finished flag: Bool) 메소드에서 알 수 있습니다. 따라서 해당 메소드에서 새로운 애니메이션을 추가하면 돼요. 그래서 아래와 같이 추가를 해줬습니다.
// opacity 애니메이션만 일단 적용
// 전체코드 아님 주의!
opacityAnimation.delegate = self
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
if let opacity = anim.value(forKey: "opacity") as? String, opacity == "opacityValue" {
let opacityAnimation = CABasicAnimation(keyPath: "opacity")
let duration: CGFloat = 1
opacityAnimation.fromValue = 0.2
opacityAnimation.toValue = 0.8
opacityAnimation.timeOffset = duration * drand48()
opacityAnimation.duration = duration
opacityAnimation.repeatCount = 0
opacityAnimation.autoreverses = true
opacityAnimation.delegate = self
opacityAnimation.setValue("opacityValue", forKey: "opacity")
starLayers.forEach {
$0.add(opacityAnimation, forKey: nil)
}
}
}
그런데, 한번 더 동작하고 더이상 동작하지 않았습니다.
음. 왜지?
애니메이션을 레이어에 추가하는 스레드가 먹통인가? "그럼 애니메이션만 추가하는 아토믹한 명령으로 만들어보자"라는 생각으로 CATransaction을 이용했어요.
CATransaction.begin()
starLayers.forEach {
$0.add(opacityAnimation, forKey: nil)
}
CATransaction.commit()
이번엔 되긴 했어요. 근데 duration을 무시하고 빠른 속도로 깜박입니다..
음.. 이유에 대해서 생각해봤어요.
.
.
.
모르겠습니다. 고민은 정말 많이 했는데 그 원인을 찾기가 쉽지 않았습니다. 검색해도 나오지가 않더라구요. 이유가 있을까요...? iOS 커뮤니티와 오픈채팅방에도 여쭤봤지만 답이 없었습니다.. 클린 코드 책에서는 "말이 안되는 실패는 잠정적인 스레드 문제로 취급하라" 고 합니다. 스레드를 디버깅하는 방식은 더 공부해봐야할 것 같아 Main 쓰레드에서 코드를 작성해보았지만 그마저도 실행되지 않았습니다.
UIView의 애니메이션으로 구현하면 매번 프로퍼티값을 랜덤하게 설정해줄 수 있기 때문에 원하는 애니메이션을 만들 수는 있습니다. 하지만 별이 정말 많아지면, cpu에 부담을 많이 줄 수 있습니다. Core 애니메이션으로 구현하면 매번 프로퍼티값을 랜덤하게 설정할 수가 없어요. 트레이드오프가 있네요... (사실 제가 코어 애니메이션으로 못하고 있는 것 같습니다만.. 아신다면 댓글에 이유를 남겨주세요!..!)
물론! 코어 애니메이션으로 원하는 애니메이션의 개수가 딱 정해져있으면 체이닝이 가능합니다!
1. beginTime 프로퍼티와 CAAnimationGroup을 이용하는 방법이 있고,
2. CATransaction의 setCompletionBlock 프로퍼티를 이용하는 방법도 있습니다.
무한으로 체이닝하려고 하기 때문에 위와 같은 이슈가 발생하는 것입니다. 이 이슈로 오늘 6시간을 보냈습니다. 더 찾아보고 싶지만 이제 지금 하고 있는 프로젝트를 개발하러 가야겠습니다... 아쉽지만 다음에 찾아보도록 할게요. 오늘도 읽어주셔서 감사합니다.
'iOS' 카테고리의 다른 글
@Published 프로퍼티 래퍼를 protocol에 정의하기 (0) | 2022.11.05 |
---|---|
ViewController 생명주기의 몰랐던 점 (feat. 트러블 슈팅) (0) | 2022.10.24 |
[CoreAnimation] 물체를 path 따라 움직이기 (feat. 우주비행사 날리기) (0) | 2022.10.22 |
[CoreAnimation] AnchorPoint, position의 관계 (feat. SwiftUI에선 anchortPoint가 없다..?) (0) | 2022.10.22 |
[CoreAnimation] Layer Masking (feat. 키 모양 만들기) (0) | 2022.10.20 |