이전편을 보고 오셔야 합니다.
/// 디스미스 시 사용할 애니메이터 객체를 반환
func animationController(
forDismissed dismissed: UIViewController
) -> UIViewControllerAnimatedTransitioning? {
print("\(#function)")
return dismissalTransitionAnimator
}
위의서 반환하는 뒤로가기 애니메이터를 제작하겠습니다.
final class CustomDismissalTransitionAnimator: NSObject {
private let config: CustomZoomTransitionConfiguration
private let referenceView: UIView
// MARK: - 초기화
init(config: CustomZoomTransitionConfiguration, referenceView: UIView) {
self.config = config
self.referenceView = referenceView
}
}
주입 받아야 하는 멤버는 다음과 같습니다.
- 컨피규레이션
- 돌아갈 타겟 뷰
구성해야 하는 것은 다음과 같습니다.
- UIViewControllerContextTransitioning
전편에서도 잠깐 나왔었는데 이번편에서 설명하겠습니다.
UIViewControllerAnimatedTransitioning 를 채택한 클래스 즉 객체에게
지금 전환 상황이 어떤지 알려주는 객체입니다. 프로토콜로 구성되있습니다.
-- 구성 --
@MainActor public protocol UIViewControllerAnimatedTransitioning : NSObjectProtocol {
func transitionDuration(using transitionContext: (any UIViewControllerContextTransitioning)?) -> TimeInterval
func animateTransition(using transitionContext: any UIViewControllerContextTransitioning)
/// A conforming object implements this method if the transition it creates can
/// be interrupted. For example, it could return an instance of a
/// UIViewPropertyAnimator. It is expected that this method will return the same
/// instance for the life of a transition.
@available(iOS 10.0, *)
optional func interruptibleAnimator(using transitionContext: any UIViewControllerContextTransitioning) -> any UIViewImplicitlyAnimating
optional func animationEnded(_ transitionCompleted: Bool)
}
-- 집중 --
func animateTransition(using transitionContext: UIViewControllerContextTransitioning)
위 메서드를 통해 해당 객체를 받아올 수 있습니다.
let fromVC = transitionContext.viewController(forKey: .from)
let toVC = transitionContext.viewController(forKey: .to)
let containerView = transitionContext.containerView
func transitionDuration(
using transitionContext: UIViewControllerContextTransitioning?
) -> TimeInterval {
print("\(#function)")
return config.transitionDuration
}
사용자가 뷰를 제스처를 통해서 이전 뷰 위치로 돌아가는 형태입니다.
햇갈리시면 않되는 것이 제스처 도중에는 Dismiss가 된 상황이 아닙니다.
즉 시작하는순간 해당 함수에서 최종으로 내릴건지 다시 원위치 할지를 판단해야 합니다.
func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) {
print(#function)
// 1. 전환에 참여하는 뷰컨을 가져온다.
// 사라질(from) 쪽을 기준으로 (사용자가 드래그 하는 뷰)
guard let fromVC = transitionContext.viewController(forKey: .from) else {
return
}
// 2. 원래 뷰는 숨김 -> 안하면 돌아가는 애니메이션 중 기존뷰도 잠깐 보이다 사라짐
fromVC.view.isHidden = true
// 3. 전환은 항상 containerView 위에서 일어나야 함.
let containerView = transitionContext.containerView
// 4. 애니메이션이 시작될 위치/크기 = 현재 from 뷰의 프레임
let startPoint = CGPoint(x: fromVC.view.frame.minX,
y: fromVC.view.frame.minY)
let startSize = fromVC.view.frame.size
// 5. 실제로 움직일 임시 뷰(transitionView)를 만든다.
// - 시작 프레임은 from 뷰와 동일
// - 색상도 from 뷰와 맞춰서 자연스럽게
// - cornerRadius는 일단 0에서 시작
let transitionView = UIView(frame: CGRect(origin: startPoint, size: startSize))
transitionView.backgroundColor = fromVC.view.backgroundColor
transitionView.layer.masksToBounds = true
transitionView.layer.cornerRadius = 0
containerView.addSubview(transitionView)
// 6. 애니메이션이 끝났을 때 도달해야 하는 최종 프레임을 구한다.
// 기준 뷰(referenceView)의 bounds를
// containerView 좌표로 변환해서 해당 위치로 가게 하는 구조.
let finalFrame = containerView.convert(referenceView.bounds, from: referenceView)
// 7. 스프링 애니메이션으로 뷰를 최종 위치로 늘리면서
// cornerRadius도 대상 뷰와 동일하게 맞춰준다.
UIView.animate(withDuration: config.transitionDuration,
delay: 0,
usingSpringWithDamping: config.springWithDamping,
initialSpringVelocity: config.initialSpringVelocity,
options: [.beginFromCurrentState]) { [weak self] in
guard let self else { return }
transitionView.frame = finalFrame
transitionView.layer.cornerRadius = referenceView.layer.cornerRadius
} completion: { _ in
// 8. 전환이 끝났다고 시스템에 알려준다.
// transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
// 처럼 취소 여부를 반영해주는 게 안전하다.
transitionContext.completeTransition(true)
}
}
// MARK: - 제스처 처리
/// 프레젠트된 화면에서 팬 제스처를 감지해 이동/알파/디스미스를 제어
@objc
private func handlePanGesture(_ gesture: UIPanGestureRecognizer) {
guard let presentedVC = presentationController?.presentedViewController,
let container = presentedVC.view.superview else {
return
}
guard let targetView = presentedVC.view else { return }
switch gesture.state {
case .began:
targetView.layer.cornerRadius = 45
// 1) 기존 뷰의 중앙, 포인트를 저장
originalCenter = targetView.center
originalAnchor = targetView.layer.anchorPoint
// 2) 터치 지점을 뷰 좌표로
let touchInView = gesture.location(in: targetView)
// 3) anchorPoint로 바꿀 비율 (0~1)
let newAnchor = CGPoint(
x: touchInView.x / targetView.bounds.width,
y: touchInView.y / targetView.bounds.height
)
// 0.5, 0.5 (중심) --> 드래그 앵커
targetView.layer.anchorPoint = newAnchor
// 4) anchorPoint를 바꾸면 position이 바뀌어버리니, 그만큼 보정
let oldPos = targetView.layer.position
print("began - oldPos: ", oldPos) // began - oldPos: (220.0, 478.0)
print("began - originalAnchor", originalAnchor) // began - originalAnchor (0.5, 0.5)
print("began - newAnchor", newAnchor) // began - newAnchor (0.02727272727272727, 0.3158995815899582)
let moveX = (newAnchor.x - originalAnchor.x) // 기준점x 0.47..만큼 왼쪽으로
let moveY = (newAnchor.y - originalAnchor.y) // 기준점y 0.19..만큼 위로
// px 로 전환
let moveXPx = moveX * targetView.bounds.width
let moveYPx = moveY * targetView.bounds.height
let newPos = CGPoint(
x: oldPos.x + moveXPx,
y: oldPos.y + moveYPx
)
targetView.layer.position = newPos
case .changed:
let translation = gesture.translation(in: container)
let dx = translation.x
let dy = translation.y
// 전체 이동 거리 (피타고라스)
let distance = hypot(dx, dy)
// 얼마나 줄일지
let maxDrag: CGFloat = 300
let progress = min(distance / maxDrag, 1)
let minScale: CGFloat = 0.4
let scale = 1 - (1 - minScale) * progress
let scaleTransform = CGAffineTransform(scaleX: scale, y: scale)
let translateTransform = CGAffineTransform(translationX: dx, y: dy)
targetView.transform = scaleTransform.concatenating(translateTransform)
// 투명도도 전체 거리 기준
targetView.alpha = 1 - 0.4 * progress
case .ended, .cancelled:
// 원래대로
let shouldDismiss = targetView.transform.ty > 150
if shouldDismiss {
presentedVC.dismiss(animated: true)
} else {
UIView.animate(withDuration: 0.2, animations: { [weak self] in
guard let weakSelf = self else { return }
targetView.transform = .identity
targetView.alpha = 1
// anchorPoint도 원래대로
targetView.layer.anchorPoint = weakSelf.originalAnchor
targetView.center = weakSelf.originalCenter
targetView.layer.cornerRadius = 0
})
}
default:
break
}
}

ZoomTransition 4번째 폴더
이번 편은 정말 꽤 흥미로운 내용이였다고 생각합니다.
아직 끝이 아닌게
네비게이션 방식일땐 지금 같이 해두어도 동작을 안합니다.
다음편은 네비게이션 방식에서도 해보는것, 그리고, 그 다음편은 WebRTC 편을 다루어 보도록 하겠습니다.
감사힙니다.