관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] UIGestureRecognizer, UIViewPropertyAnimator - 응용(swipe하여 뷰 넘기기) 본문

UI 컴포넌트 (swift)

[iOS - swift] UIGestureRecognizer, UIViewPropertyAnimator - 응용(swipe하여 뷰 넘기기)

jake-kim 2022. 5. 11. 22:30

swipe하여 뷰 넘기기

구현 아이디어

  • UIGestureRecognizer를 사용하여 제스쳐 이벤트를 감지
    • 제스쳐 이벤트가 발생하면, 애니메이션 실행
  • 애니메이션은 UIViewPropertyAnimator를 사용하여 구현
    • 애니메이션은 다음 뷰가 점점 보여지도록 설정
    • UIViewPropertyAnimator를 사용하면 현재 진행되고 있는 진행률에 따라 애니메이션을 진행시킬 수 있고, 다시 reversed 시키는 기능이 편하므로 사용
  • 드래그하면 뷰가 따라서 이동되게끔 하는 구현 아이디어 
    • animator 정의 (transform 사용하여 뷰가 왼쪽으로 이동되는 애니메이션)
    • gesture의 began상태 - animator를 생성
    • gesture의 changed 상태 - 현재까지 swipe된 x좌표와, width를 구하고 비율을 구하여 UIViewPropertyAnimator의 fractionComplete에 적용
    • gesture의 ended or cancelled 상태 - animator의 isReversed를 사용하여 특정 임계값만큰 넘지 않거나 canclled상태인 경우 다시 뷰가 닫히도록 적용

 

* UIViewPropertyAnimator 개념은 이전 포스팅 글 참고

gesture 정의

  • right에서 left로 제스쳐 이벤트만 감지하도록 UIPanGestureRecognizer를 서브클래싱
    • 사용하는쪽에서 gesture의 state 프로퍼티를 가지고 상태를 확인
    • 대표적으로 .began, .changed, .ended, .cancelled 상태가 존재
    • gesture의 manitude와 velocity를 확인하여 좌에서 우로 swipe되는 경우가 아니면 failed 상태가 되도록 설정
import UIKit

final class RightToLeftSwipeGestureRecognizer: UIPanGestureRecognizer {
  override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
    super.touchesMoved(touches, with: event)
    guard let view = self.view, self.state == .began else { return }
    
    // x가 더 크면 좌우로 스와이프, y가 더 크면 위 아래로 스와이프
    if velocity(in: view).x.magnitude > velocity(in: view).y.magnitude {
      velocity(in: view).x < 0 ? print("우->좌") : print("좌->우")
    } else {
      velocity(in: view).y < 0 ? print("하->상") : print("상->하")
    }
    
    guard
      velocity(in: view).x.magnitude > velocity(in: view).y.magnitude, // 수평 스와이프
      velocity(in: view).x < 0 // 우 -> 좌 스와이프
    else {
      self.state = .failed
      return
    }
  }
}

샘플 데이터 정의

  • 하나의 ViewController에서 views들을 가지고 이 뷰들을 swipe하면서 테스트해야하므로, views들을 준비
import UIKit

private var randomColor: UIColor {
  UIColor(red: CGFloat(drand48()), green: CGFloat(drand48()), blue: CGFloat(drand48()), alpha: 1.0)
}

enum Mock {
  static func getViews() -> [UIView] {
    (0...30).map {
      let view = UIView()
      
      // label
      let label = UILabel()
      label.text = "\($0) 번째 뷰"
      label.textColor = .black
      label.translatesAutoresizingMaskIntoConstraints = false
      view.addSubview(label)
      NSLayoutConstraint.activate([
        view.centerYAnchor.constraint(equalTo: label.centerYAnchor),
        view.centerXAnchor.constraint(equalTo: label.centerXAnchor),
      ])
      view.backgroundColor = randomColor
      return view
    }
  }
}

구현

  • ViewController 클래스 준비
// ViewController.swift

import UIKit

class ViewController: UIViewController {

}
  • gesture인스턴스와 animator 인스턴스 준비
  private let gesture = RightToLeftSwipeGestureRecognizer()
  private var animator: UIViewPropertyAnimator?
  private var views = Mock.getViews()
  • viewDidLoad에서 테스트 데이터 view 세팅
    • 테스트 view들을 self.view에 addSubview()
  override func viewDidLoad() {
    super.viewDidLoad()
    
    self.views.forEach {
      self.view.addSubview($0)
      $0.alpha = 0
      $0.translatesAutoresizingMaskIntoConstraints = false
      NSLayoutConstraint.activate([
        $0.leftAnchor.constraint(equalTo: self.view.leftAnchor),
        $0.rightAnchor.constraint(equalTo: self.view.rightAnchor),
        $0.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
        $0.topAnchor.constraint(equalTo: self.view.topAnchor),
      ])
    }
    
    self.views.first?.alpha = 1
    self.view.addGestureRecognizer(self.gesture)
    self.gesture.addTarget(self, action: #selector(handleGesture(_:)))
  }
  
  @objc private func handleGesture(_ gesture: UIPanGestureRecognizer) {
  
  }
  • handleGesture에서는 UIPanGestureRecognizer의 상태를 구분하여 처리
  @objc private func handleGesture(_ gesture: UIPanGestureRecognizer) {
    switch gesture.state {
    case .began:
	
    case .changed:

    case
      .ended,
      .cancelled
    :

    default:
      break
    }
  }
  • gesture의 began상태 - animator를 생성 
    case .began:
      self.view.isUserInteractionEnabled = false
      self.animator = self.getNextAnimator() // 해당 메소드는 아래에서 정의
  • gesture의 changed 상태 - 현재까지 swipe된 x좌표와, width를 구하고 비율을 구하여 UIViewPropertyAnimator의 fractionComplete에 적용
    case .changed:
      let translationX = -gesture.translation(in: self.view).x / self.view.bounds.width
      let fractionComplete = translationX.clamped(to: 0...1) // 0보다 작으면 0, 1보다 크면1로 되게끔하는 메소드 따로 extension한것
      guard self.animator?.fractionComplete != fractionComplete else { break }
      self.animator?.fractionComplete = fractionComplete
  • gesture의 ended or cancelled 상태 - animator의 isReversed를 사용하여 특정 임계값만큰 넘지 않거나 canclled상태인 경우 다시 뷰가 닫히도록 적용
    case
      .ended,
      .cancelled
    :
      self.animator?.isReversed = gesture.velocity(in: self.view).x > -200 || gesture.state == .cancelled
      self.animator?.startAnimation() // isReversed를 사용하면 다시 active해야하므로
  • getNextAnimator() 메소드에서 애니메이션 정의
    • 해당 메소드에서 지금 진행중인 애니메이션이 있으면 종료
    • views 데이터에서 획득
  private func getNextAnimator() -> UIViewPropertyAnimator? {
    self.animator?.stopAnimation(false) // true인 경우 completionHandler호출없이 inactive상태, false인 경우 애니메이션이 멈춘 Stopped상태 (finishAnimation과 같이 사용)
    self.animator?.finishAnimation(at: .end) // completion handler 호출, inactive 상태로 전환
    
    guard self.views.count > 1 else { return nil }
    let currentView = self.views[0]
    let nextView = self.views[1]
  • UIViewPropertyAnimation 정의 3가지 (Init, Animation, Completion)

init) duration값은 panGesture시 뷰가 따라오는 시간이므로 왠만하면 짧게 설정

 

    // Init
    let animator = UIViewPropertyAnimator(
      duration: 0.5,
      timingParameters: UICubicTimingParameters(animationCurve: .easeInOut)
    )

Animation) currentView의 transform에 x좌표를 화면의 왼쪽으로 이동되게 설정 + 등장할 뷰의 alpha값도 1로 설정

    // Animation
    animator.addAnimations {
      UIView.animate(withDuration: 1) {
        currentView.transform = .init(translationX: -UIScreen.main.bounds.width, y: 0)
        nextView.alpha = 1
      }
    }

Completion) 애니메이션이 종료되면 뷰를 제거하고 animator를 deinit

    // Completion
    animator.addCompletion { [weak self, weak currentView] position in
      self?.view.isUserInteractionEnabled = true
      guard
        position == .end && animator.isReversed == false,
        let currentView = currentView,
        self?.views.isEmpty == false
      else { return }
      
      currentView.removeFromSuperview()
      self?.views.removeFirst()
      
      guard self?.animator === animator else { return }
      self?.animator = nil
    }
    
    return animator

* 전체 코드: https://github.com/JK0369/ExGestures

* 참고

https://stackoverflow.com/a/40868784

Comments