Site icon Wireframe

UIDynamics를 사용하여 뷰들에 물리 시뮬레이션을 적용하기 – Swift

iOS 7.0부터 UIDynmic이라는 새로운 기술이 UIKit에 도입되었다. 이 기술은 UIView요소에 대해서 키 프레임 애니메이션등으로 구현하기 힘든 자연스러운 움직임을 쉽게 구현할 수 있게 해주는데, 대략 다음과 같은 것들이 있다.

이는 마치 2차원 UIView에 대해 각 뷰가 질량을 가지는 강체로 가정하고 중력, 질량가속도, 밀도, 탄성등을 적용한 물리 엔진에 의한 애니메이션 계산을 가능케 하는 것이다.
SpriteKit이 나오면서 이러한 효과를 마치 게임처럼 SpriteKit으로 UI를 구성하는 방식도 많이 사용되고 있어서 UIDynimc에 대한 직접적인 사용이 그리 많지 않은 것 같긴한데, 혹시 나중에 쓸일이 있을지 몰라서 간략하게 정리해본다.

작동 방식

UIDynamicUIView들을 대상으로 하는 애니메이션 엔진이며, 위치, 크기, 회전요소들이 계산에 고려된다. 매 순간 각 뷰가 받는 힘과 힘에 의한 효과는 UIDynamicAnimator 인스턴스에 의해서 계산된다.
계산의 효율을 높이기 위해 UIDynamicAnimator는 각 뷰가 취해야하는 동작의 종류를 가급적 줄이려 한다. 예를 들어 특정 지점에 연결된 용수철 효과만이 고려되는 뷰에 대해서 다른 뷰와의 충돌을 계산할 필요가 없듯이 각각의 동작의 종류는 카테고리화 되고, 애니메이터는 각 카테고리별로 계산해야 할 항목이 다르다.
이러한 항목들은 UIDynamicItemBehavior라는 클래스에 대해서 정의된다. 그리고 UIDynamic은 이 추상 클래스의 몇 가지 서브 클래스들을 이용해서, 푸시, 낙하, 충돌, 스냅, 연결등의 효과를 정의해두었다. 아마 대부분의 경우에는 이러한 효과들을 그대로 사용하면 되고, 별도의 behavior를 작성해야 할 일은 극히 드물 것이다.
다시 UIDynamicItemBehaivor 의 서브클래스들은 자신이 정의하고 있는 동작에 영향을 받는 아이템들 생성시에 알고 있어야 한다. 이들 아이템은 UIDynamicItem이라는 프로토콜을 따르고 있어야 하는데, 이 프로토콜은 앞에서 말한 ‘크기’, ‘위치’, ‘각도’ 등이 정의되어 있는 화면에 표시될 수 있는 요소이다. 기본적으로 UIView 와 그 서브 클래스들은 이 프로토콜을 기본적으로 지원하고 있다.
그렇다면 우리가 이러한 효과를 사용하기 위해서 해야할일은, 각 뷰들이 어떤 동작을 지원할것인지를 결정하고 그에 맞는 UIDynamicItemBehavior 객체를 생성하여, UIDynamicAnimator 인스턴스에 추가해주면 된다. 그러면 뷰들의 초기 상태가 결정되고 나면 애니메이터가 자동으로 각 뷰들이 받는 힘을 계산하여 매 순간의 뷰의 위치를 알려주고, 이들을 모두 포함하는 부모뷰는 그 결과에 따라 하위 뷰들을 애니메이트한다.

셋업

따라서 우리가 해야 할 일은 다음과 같은 순서를 따른다.

  1. 애니메이션에 참여할 UIView들에 대한 참조점을 알고 있어야 한다.
  2. 해당 뷰들을 모아서 UIDynamicItemBehavior 객체를 만든다.
  3. 부모뷰를 기준으로하는 UIDynamicAnimatoer객체를 만든다.
  4. 2의 객체를 3의 애니메이터에 추가해준다. (addBehavior(_:))
  5. profit!

예제

간단한 예제를 하나 작성해보겠다. 뷰 컨트롤러를 하나 작성할 건데, 이 뷰는 작은 서브뷰 하나를 가지고 있다. (일단 귀찮으니 인터페이스 빌더에서 추가한 것으로 치고…) 이 서브뷰는 터치를 통해서 이리 저리 옮길 수도 있고, 손을 떼면 중력의 영향을 받아서 아래로 떨어져 바닥에 부딪힌다.

class MYViewController: UIViewController {
    @IBOulet weak var draggableView: UIView!
    lazy var panRecognizer: UIPanGestureRecognizer = { [unowned self] in
        let p = UIPanGestureRecognizer(target: self,
                                       action: #selector(self.panned(:))
        return p
    }
    lazy var animator: UIDynamicAnimator = { [unowned self] in
        let amin = UIDynamicAnimator(referencingView: self.view)
        return amin
    }()
    override func viewDidLoad() {
      super.viewDidLoad()
      let collision = UICollisionBehaivor(items:[draggableView])
      let gravity = UIGravityBehaivior(items:[draggableView])
      // collision.translatesReferenceBoundsIntoBoundary = true
      // collision.collisionMode = .boundaries
      collision.addBoundary(withIdentifier:"floor", from:CGPoint.zero, to:CGPoint(x:view.bounds.width, y:0))
      animator.addBehavior(gravity)
      animator.addBehavior(collision)
    }
    func panned(_ sender: UIPanGestureRecognizer) {
      if sender.state == .ended {
        animator.updateItem(usingCurrentState: draggableView)
      }
      let translation = sender.translation(in: view)
      if let sview = sender.view {
        sview.center = CGPoint(x:sview.center.x + translation.x,
                               y:sview.center.y + translation.y)
        // translation을 매순간 이동하는 delta 값으로 쓰기 위해서
        // view의 좌표를 변경한 후에는 초기화시켜준다.
        sender.translation = CGPoint.zero
      }
    }
}

이 예제는 크게 두 가지 포인트에 초점을 맞추고 있다.

여기서 놓치지 말아야 할 포인트는 두 군데인데,

  1. collision을 생성한 후 addBoundary(withIdentifier:from:to:)를 이용해서 뷰 바닥에 경계선을 추가해준다. 이 과정이 없으면 자유낙하한 서브뷰가 바닥을 뚫고 아래로 사라져버린다.
  2. UIPanGestureRecognizer가 작동할 때 호출되는 panned(_:)에서 터치가 종료될 때 애니메이터에 대해서 updateItem(usingCurrentState:)를 호출해주는 점이다. 애니메이터는 물리 엔진의 계산 결과와 다르게 수동으로 변경이 발생하는 아이템에 대해서는 이후 계산을 포기한다. 따라서 현재 위치로부터 계산을 다시 시작할 수 있도록 상태를 업데이트해주는 과정이 필요하다.

정리

Exit mobile version