CoreAnimation으로 원을 따라 움직이는 화살표 만들기

Lily·2022년 12월 28일
0
post-thumbnail

원의 둘레를 따라 움직이며 셀렉된 버튼을 가리키는 화살표를 구현한 과정을 정리해보겠습니다.


💡 구현 아이디어

  1. 애니메이션의 시작, 종료 지점의 각을 받아서 Arc(호) 모양의 UIBezierPath를 만듭니다.
  2. 해당 path를 CAKeyframeAnimation(keyPath: "position")path 로 지정합니다.
  3. 애니메이션을 시작합니다.

1. CAShapelayer subclass 정의

final class AnimatableArrowLayer: CAShapeLayer {
    
    private let defaultStartAngle = 1.5 * .pi // 기본 위치인 iphone의 angle
    private let centerPoint: CGPoint // 경로가 될 원의 중심
    private let pathRadius: CGFloat // 경로가 될 원의 반지름
    private var startAngle: CGFloat! // 애니메이션 시작점을 저장
    
    override init(layer: Any) {
        centerPoint = (layer as! AnimatableArrowLayer).centerPoint
        pathRadius = (layer as! AnimatableArrowLayer).pathRadius
        super.init(layer: layer)
    }
    
    init(center: CGPoint, radius: CGFloat) {
        centerPoint = center
        pathRadius = radius
        super.init()
        let arrowImage = UIImage(named: "arrow")!
        contents = arrowImage.cgImage
        bounds = CGRect(
            x: 0.0,
            y: 0.0,
            width: arrowImage.size.width,
            height: arrowImage.size.height)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    ...
 }

처음엔 커스텀 이니셜라이저안에서 super.init() 만 호출하는 형태의 이니셜라이저만 구현했는데, 그렇게 하면 아래와 같은 크래시가 발생했습니다.

Fatal error: Use of unimplemented initializer 'init(layer:)' for class 'app_show_room.AnimatableArrowLayer'

구현하지 않은 init(layer:) 를 사용했기 때문이라고합니다.

스택 오버플로우에 따르면 init(layer:) 는 Presentation layer에서 사용되기 위한 레이어의 복사본들을 생성하는데 사용됩니다. 서브클래스들은 이 이니셜라이저를 오버라이드하여 인스턴스 변수들을 복사해서 presentation layer에게 전달할 수 있습니다. 그래서 CoreAnimation이 레이어의 복사본들이 필요하다고 판단이 되면 이 이니셜라이저를 호출하게됩니다. (예를 들어 레이어의 strokeColor를 바꾸는 경우)

그래서 서브클래스에 커스텀 변수가 있다면 해당 변수의 값을 이 이니셜라이저의 프로퍼티에 전달해주어야합니다. 여기서 매개변수로 전달되는 레이어는 old layer이기 때문에 서브클래스로 타입캐스팅을 한 후,super.init(layer:)을 호출하기전에 전달해야합니다.


2. 애니메이션 만들기

화살표의 position을 애니메이션 줄 것이기 때문에 CAKeyframeAnimation(keyPath: "position") 을 만듭니다.

func animate(to end: CGFloat) {
        let positionAnimation = CAKeyframeAnimation(keyPath: "position")

startAngleendAngle로 정의된 Arc(호) Path를 만듭니다.

let arcPath = UIBezierPath(
      				arcCenter: centerPoint,
            		radius: pathRadius,
            		startAngle: startAngle,
            		endAngle: end,
           			clockwise: clockwise)

애니메이션 방향은 startAngleendAngle의 크기를 비교하여 정합니다.
그리고 keyframeAnimation의 path로 지정합니다. 그럼 화살표의 포지션이 path에 따라 변화하며 움직이게됩니다.
duration (지속시간)과 timingFunction을 입맛대로 설정합니다.

let clockwise = (startAngle < end) ? true : false
positionAnimation.path = arcPath.cgPath
positionAnimation.timingFunction = .init(name:CAMediaTimingFunctionName.easeInEaseOut)
positionAnimation.duration = 1

여기까지하고 애니메이션을 실행해주면 아래와 같이

  • 화살표의 방향이 고정된 채로 움직이고
  • 애니메이션이 끝난 후 제자리로 돌아오게됩니다.

이제 위 2가지 버그를 고쳐볼게요!

3. 화살표를 path에 따라 rotation하기

화살표가 path를 따라 움직인 만큼 rotation되도록 하려면 rotationMode 를 설정합니다.

path를 따라 움질일 때 path의 탄젠트에 맞춰서 객체를 돌릴지 결정하는 프로퍼티입니다.

 if clockwise {
        positionAnimation.rotationMode = .rotateAuto
  } else {
       positionAnimation.rotationMode = .rotateAutoReverse
  }

시계 반대 방향으로 움직일 땐 .rotateAutoReverse를 하지 않으면 화살표가 원의 안쪽을 향하더라구요.. path의 방향에 따라 탄젠트 계산이 달리되는 건가요??


4. 애니메이션 후 상태 유지하기

애니메이션이 진행될 때는 presentation layer의 값 들로 layer가 셋팅되다가, 종료되면 model layer의 값으로 reset된다고 합니다. 이 때 model layer의 값을 presentation layer의 값으로 변경 시켜주는 프로퍼티가 fillMode 입니다.

여기서 presentaion layer와 model layer는 아래 개념에서 등장하는 내용인데요.

CALayer는 3가지 종류의 Layer object가 존재합니다.

  • layer의 프로퍼티를 관리하는 layer tree(model layer tree), 보통 Property를 변경하면 layer tree의 value가 변경됩니다.
  • 현재 스크린에 보여지는 상태의 값(in-flight value)을 관리하는 presentation tree
  • 실제 애니메이션을 수행하는 render tree, private하기 때문에 접근 불가.

공식문서 원문

현재 model layer의 position 프로퍼티는 startAngle로 설정되어있기 때문에 처음 시작점으로 보여지게 되는 것이고, presentation layer에서 마지막으로 보여진 angle로 현재 position을 설정합니다. .forwards 는 애니메이션이 완료된 후 최종 상태로 남게됩니다.

positionAnimation.fillMode = .forwards

isRemovedOnCompletion은 디폴트로 true이기 때문에 애니메이션이 종료된 후 레이어의 애니메이션에서 제거됩니다. 따라서false로 설정해줍니다.

positionAnimation.isRemovedOnCompletion = false

그리고 기존에 설정된 기기를 반영한 화살표 위치를 잡아주기 위한 애니메이션인 setPosition(_ start:)도 위 방법과 같이 구현해줍니다. setPosition(_ start:)은 이 클래스가 생성되자마자 호출해줍니다.

func setPosition(_ start: CGFloat) {
        self.startAngle = start
        let initialPositionAnimation = CAKeyframeAnimation(keyPath: "position")
        let clockwise = (defaultStartAngle < start) ? true : false
        let postionPath = UIBezierPath(
            arcCenter: centerPoint,
            radius: pathRadius,
            startAngle: defaultStartAngle,
            endAngle: start,
            clockwise: clockwise)
        initialPositionAnimation.path = postionPath.cgPath
        // 화면이 나타나자마자 실행되므로 원래 그 위치인 것 처럼 보이기위해 아주 짧은 시간동안 진행
        initialPositionAnimation.duration = 0.01
        initialPositionAnimation.isRemovedOnCompletion = false
        initialPositionAnimation.fillMode = .forwards
        if clockwise {
            initialPositionAnimation.rotationMode = .rotateAuto
        } else {
            initialPositionAnimation.rotationMode = .rotateAutoReverse
        }
        
        add(initialPositionAnimation, forKey: "initialPostion")
    }

여기까지 하면 아래와 같은 애니메이션이 완성됩니다.
그런데 애니메이션 종료 후 위치는 남지만, rotation의 상태가 이상하게 동작합니다.

애니메이션이 시작되기 전 기존 애니메이션(초기 위치를 설정해주는 애니메이션)을 삭제해주었더니 정상적으로 작동합니다.
애니메이션을 계속해서 추가해서 .fillMode가 오작동한 것으로 추측은 되는데...확실하겐 모르겠네요🥲

func animate(to end: CGFloat) {
		// 직전에 실행되었던 애니메이션을 제거 
        self.removeAnimation(forKey: "initialPostion")
        
        let positionAnimation = CAKeyframeAnimation(keyPath: "position")
        let clockwise = (startAngle < end) ? true : false
        let arcPath = UIBezierPath(
            arcCenter: centerPoint,
            radius: pathRadius,
            startAngle: startAngle,
            endAngle: end,
            clockwise: clockwise)
        positionAnimation.path = arcPath.cgPath
        positionAnimation.timingFunction = .init(name: CAMediaTimingFunctionName.easeInEaseOut)
        positionAnimation.duration = 1
        positionAnimation.fillMode = .forwards
        positionAnimation.isRemovedOnCompletion = false
        if clockwise {
            positionAnimation.rotationMode = .rotateAuto
        } else {
            positionAnimation.rotationMode = .rotateAutoReverse
        }
        add(positionAnimation, forKey: "position")
        
        self.startAngle = end
    }

우여곡절 끝에 완성!!!✌️

전체코드

//
//  AnimatableArrowLayer.swift
//  app-show-room
//
//  Created by Moon Yeji on 2022/12/27.
//

import UIKit

final class AnimatableArrowLayer: CAShapeLayer {
    
    private let defaultStartAngle = 1.5 * .pi
    private let centerPoint: CGPoint
    private let pathRadius: CGFloat
    private var startAngle: CGFloat!
    
    override init(layer: Any) {
        centerPoint = (layer as! AnimatableArrowLayer).centerPoint
        pathRadius = (layer as! AnimatableArrowLayer).pathRadius
        super.init(layer: layer)
    }
    
    init(center: CGPoint, radius: CGFloat) {
        centerPoint = center
        pathRadius = radius
        super.init()
        let arrowImage = UIImage(named: "arrow")!
        contents = arrowImage.cgImage
        bounds = CGRect(
            x: 0.0,
            y: 0.0,
            width: arrowImage.size.width,
            height: arrowImage.size.height)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func setPosition(_ start: CGFloat) {
        self.startAngle = start
        let initialPositionAnimation = CAKeyframeAnimation(keyPath: "position")
        let clockwise = (defaultStartAngle < start) ? true : false
        let postionPath = UIBezierPath(
            arcCenter: centerPoint,
            radius: pathRadius,
            startAngle: defaultStartAngle,
            endAngle: start,
            clockwise: clockwise)
        initialPositionAnimation.path = postionPath.cgPath
        initialPositionAnimation.duration = 0.01
        initialPositionAnimation.isRemovedOnCompletion = false
        initialPositionAnimation.fillMode = .forwards
        if clockwise {
            initialPositionAnimation.rotationMode = .rotateAuto
        } else {
            initialPositionAnimation.rotationMode = .rotateAutoReverse
        }
        
        add(initialPositionAnimation, forKey: "initialPostion")
    }
    
    func animate(to end: CGFloat) {
        self.removeAnimation(forKey: "initialPostion")
        
        let positionAnimation = CAKeyframeAnimation(keyPath: "position")
        let clockwise = (startAngle < end) ? true : false
        let arcPath = UIBezierPath(
            arcCenter: centerPoint,
            radius: pathRadius,
            startAngle: startAngle,
            endAngle: end,
            clockwise: clockwise)
        positionAnimation.path = arcPath.cgPath
        positionAnimation.timingFunction = .init(name: CAMediaTimingFunctionName.easeInEaseOut)
        positionAnimation.duration = 0.2
        positionAnimation.fillMode = .forwards
        positionAnimation.isRemovedOnCompletion = false
        if clockwise {
            positionAnimation.rotationMode = .rotateAuto
        } else {
            positionAnimation.rotationMode = .rotateAutoReverse
        }
        add(positionAnimation, forKey: "moveAroundArc")
        
        self.startAngle = end
    }
    
}

🗂 프로젝트 깃헙

profile
i🍎S 개발을 합니다

0개의 댓글