[iOS Programming Big Nerd Ranch] 7. 애니메이션 다루기


애니메이션을 적용하는 방법을 보자.


‘애니메이션’이라는 단어는 “삶으로 끌어들이는 행동”을 의미하는 라틴어에서 파생되었다. 애플리케이션에서는 애니메이션을 이용해 인터페이스 요소들을 화면으로 스무스하게 불러올 수 있고, 사용자의 관심을 이동하는 아이템으로 불러올 수 있으며 앱이 사용자의 행동에 어떻게 반응하는지 확실히 보여줄 수 있다.

Basic Animations

문서를 읽는 것은 iOS 기술을 아는 것의 좋은 출발점이 된다. 먼저 사용할 애니메이션은 basic animation이다. 기본 애니메이션은 시작 값에서 끝 깞으로 이동시킨다.

image

먼저 alpha 값(투명도)를 애니메이션으로 조정해보겠다. 여기서 하려는 것은 이전에 만들었던 Quiz 프로젝트에서 다음 질문으로 넘어갈 때, 라벨이 천천히 드러나게 하는 것이다. UIView에 이걸 도와주는 클래스 메서드가 있는데, 그 중 가장 간단한 UIView 애니메이션 메서드는 아래와 같다.

class func animate(withDuration duration: TimeInterval, animations: ()) -> Void)

이 크래스 메서드는 두 개의 변수를 받고 있다. 하나는 TimeInterval 타입(Double의 별명이기도 하다.)의 duration이고 클로저인 animation이다.

Closure

클로저는 코드에서 전달될 수 있는 기능의 집합이다. 클로저는 함수와 메서드와 비슷하다. 사실, 함수와 메서드는 클로저의 일부이다.

클로저는 함수와 메서드에 변수로써 전달되기 쉽게 가벼운 문법을 가지고 있다. 클로저는 함수나 메서드의 리턴타입도 될 수 있다. 여기서는 클로저를 이용해서 실행되었으면 하는 애니메이션을 정의할 것이다.

클로저는 변수들을 ,로 분리한 리스트와 화살표, 리턴 타입을 적어주면 된다.

(arguments) -> return type

이 문법을 보면 함수의 문법과 비슷한 것을 알 수 있다.

func functionName(arguments) -> return type

이제 animation 변수가 요구하는 클로저 부분을 보겠다.

class func animate(withDuration duration: TimeInterval, animations: ()->Void)

이 클로저는 아무런 인자도 받지 않고 리턴 타입도 없다. 여기서 ()로 표기된 것은 Void과 같은 의미다. 코드 안에서 클로저를 선언하는 것은 아래와 같이 하면 된다.

{ (arguments) -> return type in
  // code
} 

애니메이션을 만드려면 먼저 ViewController.siwft에서 애니메이션을 다루고 클로저 상수를 선언하는 메서드를 만든다. 그리고 내부에서 만든 애니메이션 클로저를 animate(withDuration:animations:)의 인자로 전달한다.

func animateLabelTransitions() {
    let animationClosure = { () -> Void in
        self.questionLabel.alpha = 1
    }
    
    UIView.animate(withDuration: 0.5, animations: animationClosure)
}

이미 questionLabel은 화면에 노출될 때 alpha 값을 1로 가지고 있다. ViewController의 뷰가 화면에 노출될 때마다 알파 값을 0으로 만들기 위해, viewWillAppear(_:)를 오버라이딩한다.

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)

    questionLabel.alpha = 0.0
}

위에서 선언한 animateLabelTransitions에서 클로저를 상수로 선언하지 않고, 바로 UiView.animate 함수의 인자에 클로저를 넣어버린다.

func animateLabelTransitions() {
    UIView.animate(withDuration: 0.5) {
        self.questionLabel.alpha = 1
    }
}

위와 같이 변경하면 두 가지를 변경하는 것이다.

  1. 클로저를 anonymously 하게 전달했다.(클로저를 변수나 상수로 할당하지 않고 바로 메서드에 전달했다.)
  2. 둘째로, 타입 정보를 제거했다. (문맥에서 클로저가 이를 추론할 수 있다.)

이제 사용자가 Next Question 버튼을 누를때 animateLabelTransitions() 함수를 호출하자.

@IBAction func showNextQuestion(_ sender: UIButton) {
    currentQuestionIndex += 1

    if currentQuestionIndex == questions.count {
        currentQuestionIndex = 0
    }

    let question: String = questions[currentQuestionIndex]
    questionLabel.text = question
    answerLabel.text = "???"

    animateLabelTransitions()
}

현재 된 것은 처음 버튼을 터치하면 잘 동작하지만 두 번 이상 탭하면 이미 라벨이 알파 값을 1을 가지고 있기 때문에 애니메이션이 잘 작동하지 않는 것처럼 보인다. 이를 위해 라벨 하나를 더 추가하고, 버튼을 터치했을 때 현재 라벨이 fade out, 다음 라벨이 fade In 하게 설정한다. 하지만 여기까지 했을 때 계속 탭한다면, 다음 라벨이 또 일피 깂을 1을 가지고 있는 채로 남아있다. 따라서 애니메이션이 끝나면 currentQuestionLabel은 화면에 떠 있고, ``nextQuestionLabel`은 화면에 떠 있으면 안된다. 여기서 애니메이션의 completion handler를 사용한다.

Animation Completion

animate(withDuration:animations:)는 즉시 리턴한다. 즉시 리턴한다는 것은, 애니메이션을 시작하지만 애니메이션이 종료되기를 기다리지 않는다는 것이다. 그렇다면 애니메이션이 완료되는지는 어떻게 알 수 있나? 예를 들어 애니메이션을 연쇄적으로 일어나게 하거나 한 애니메이션이 끝나면 다른 객체를 업데이트 하고 싶을 수 있다. 애니메이션이 끝날 때가 언제를 알기 위해, completion 인자에 클로저를 전달한다.

위에서 만든 애니메이션에 completion 인자를 설정한다.

func animateLabelTransitions() {
    UIView.animate(withDuration: 0.5,
                   delay: 0,
                   options: [],
                   animations: {
                        self.currentQuestionLabel.alpha = 0
                        self.nextQuestionLabel.alpha = 1
                    },
                   completion: {_ in
                    swap(&self.currentQuestionLabel, &self.nextQuestionLabel)
                   }
    )
}

위에서 delay는 애니메이션을 실행시키기 전에 시스템이 얼마나 기다려야 하는지를 명시한다. options는 뒤에서 다시 볼 것이다. 컴플리션 클로저에서는 swap(_:_:)이라는 두 개의 참조를 받아 바꾸는 함수를 이용해서 두 라벨을 바꿨다.

Animating Constraints

다음 질문 버튼을 누르면 다음 질문 라벨이 왼쪽에서 나오고 현재 질문 라벨이 오른쪽으로 날라가게 할 것이다. 여기서는 제약들에 애니메이션을 적용하는 것을 해 볼 것이다.

먼저, 수정되어야 하는 제약에 대한 참조가 필요하다. 현재 @IBOutlets들은 뷰 객체에 지정되어 있다. 하지만 outlet은 뷰에 한정되지 않고, 인터페이스 파일에 있는 제약들을 포함한 모든 객체는 outlet을 가질 수 있다.

두 라벨을 중심으로 정렬하는 제약에 대한 두 outlet을 선언한다. 그리고 스토리보드에서 ViewController에서 Constraint로 컨트롤 드래그해서 outlet을 설정했다. 화면 구성은 아래와 같이 설정할 것이다.

image

먼저 nextQuestionLabel의 위치를 왼쪽으로 옮긴다. 이제 제약에 애니메이션을 적용해야 하는데, 애니메이션 블럭에서 제약사항을 수정하면 아무런 애니메이션도 보이지 않는다. 왜냐하면 제약사항이 수정되면 시스템은 이 변경을 적용하기 위해 계층에 있는 관련된 모든 뷰들의 frame을 다시 계산해야 하기 때문이다. 따라서 이것을 자동으로 이 변화를 일으키게 하는 것은 꽤 낭비일 수 있다. 단지 몇개의 제약 사항을 업데이트 하는데 다른 모든 뷰들의 프레임을 다시 계산하고 싶지 않다. 그래서 끝났을 때 시스템에게 다시 계산해달라고 해야 한다. 이걸 위해 뷰에서 layoutIfNeeded() 메서드를 호출한다. 이 메서드는 뷰가 가장 최시의 제약사항을 바탕으로 하위 뷰들을 재배치하는 것을 강제시킨다.

아직도 문제가 있는데, 시뮬레이터에서 Debug 메뉴의 Slow Animations를 켜서 애니메이션을 확인해보면 라벨의 너비가 애니메이션처럼 펼쳐진다. 이건 텍스트가 바뀔 때 intrinsic content size가 바뀌기 때문이다. 이것을 바꾸기 위해 애니메이션이 시작되기 전에 뷰가 서브 뷰들을 배치하게 강제한다. 이것은 모든 라벨이 애니메이션이 시작하기 전에 다음 텍스트를 담도록 설정한다.

func animateLabelTransitions() {
        self.view.layoutIfNeeded()
        
        let screenWidth = view.frame.width
        self.nextQuestionLabelCenterXConstraint.constant = 0
        self.currentQuestionLabelCenterXConstraint.constant += screenWidth
        
        UIView.animate(withDuration: 0.5,
                       delay: 0,
                       options: [],
                       animations: {
                            self.currentQuestionLabel.alpha = 0
                            self.nextQuestionLabel.alpha = 1
                        
                        self.view.layoutIfNeeded()
                        },
                       completion: {_ in
                        swap(&self.currentQuestionLabel, &self.nextQuestionLabel)
                        swap(&self.currentQuestionLabelCenterXConstraint, &self.nextQuestionLabelCenterXConstraint)
                        self.updateOffScreenLabel()
                       }
        )
    }

Timing Functions

애니메이션의 가속도는 timing function에 의해 관리된다. 기본적으로, 애니메이션은 ease-in/ease-out 타이밍 함수를 사용한다. 이거는 처음에 속도를 부드럽게 올렸다가 마지막에 점점 속도를 늦추는 것이다.

다른 타이밍 함수는 linear(시작부터 끝까지 일정한 속도), ease-in(처음부터 속도를 계속 올린다), ease-out(처음에 풀 스피드로 시작해서 끝에서 감소시킨다)를 포함한다.

위에서 애니메이션의 options.curveLinear를 포함시킨다. 이제 linear animation curve를 이용해서 속도를 조정하게 된다. optionUIViewAnimationOptions 인수를 가진다. 이게 괄호로 감싸져 있는 이유는 타이밍 함수를 포함해서 애니메이션에 적용할 수 있는 많은 옵션이 있기 때문이다. 이때문에 배열로 옵션들을 받아서 하나 이상의 옵션을 적용할 수 있게 해놨다. UIViewAnimationOptionsOptionSet 프로토콜을 따르고, 이는 배열을 이용해서 다양한 값을 그룹핑할 수 있게 해준다.

options 파라미터에 전달할 수 있는 애니메이션 옵션들의 일부는 다음과 같다.

  • Animation curve options : 애니메이션의 가속화를 설정한다.
    • UIViewAnimationOptions.curveEaseInOut
    • UIViewAnimationOptions.curveEaseIn
    • UIViewAnimationOptions.curveEaseOut
    • UIViewAnimationOptions.curveLinear
  • UIViewAnimationOptions.allowUserInteraction : 기본적으로 뷰는 애니메이션 도중에 상호작용될 수 없다. 이거는 애니메이션을 반복하는데 유용하다.
  • UIViewAnimationOptions.repeat : 애니메이션을 무한반복한다. UIViewAnimationOptions.autorevrse와 같이 쓰기도 한다.
  • UIViewAnimationOptions.autoreverse : 애니메이션을 앞으로 먼저 실행하고 다시 원상복구 시켜서 뷰를 초기 상태로 되돌려 놓는다.