WWDC2024-Match_Zoom_Transition
오늘은 아래와 같은 전환 애니메이션을 구성해 볼겁니다.
SwiftUI 에서도 가능합니다. 다만, SwiftUI는 자료가 많으니 UIKit를 통해 이를 구현해 보겠습니다.

WWDC 영상의 방법은 iOS 18이상에 대한 이야기 입니다.
아직은 iOS 16이상인 기종이 꽤 존재 하기 때문에
간단히 다루고 미만 버전을 다루어 보겠습니다.
private func nextView() {
let vc = TestZoomNextViewController()
if #available(iOS 18.0, *) {
vc.preferredTransition = .zoom { [weak self] context in
guard let self else { return nil }
return testButton
}
}
self.present(vc, animated: true)
}
보시는 거와 같이
preferredTransition 의 zoom을 이용하여 간단하게
UIKit 에서도 해당 애니메이션을 구현할 수 있습니다.
자 그럼 iOS 18 이하는 못하는 걸까요?
아뇨 구현은 가능합니다.

위 gif 처럼 비슷하게 구현이 가능합니다.
A set of methods that vend objects used to manage a fixed-length or interactive transition between view controllers.
뷰 컨트롤러 간의 고정 길이 또는 대화형 전환을 관리하는 데 사용되는 객체를 벤더하는 일련의 방법입니다.
말이 참 어렵단 말이죠.
"이 뷰컨을모달로 띄울 때, 어떤애니메이션·제스처·레이아웃을 쓸지 정할께요"
func animationController(
forPresented presented: UIViewController,
presenting: UIViewController,
source: UIViewController
) -> UIViewControllerAnimatedTransitioning?
모달을 등장할때에 애니메이터를 반환합니다.
func animationController(
forDismissed dismissed: UIViewController
) -> UIViewControllerAnimatedTransitioning?
프렌젠트에서 제스처를 제공할 애니메이터 반환 합니다.
func animationController(
forDismissed dismissed: UIViewController
) -> UIViewControllerAnimatedTransitioning?
dismiss 시에 사용할 애니메이터를 반환 합니다.
func interactionControllerForDismissal(
using animator: UIViewControllerAnimatedTransitioning
) -> UIViewControllerInteractiveTransitioning?
드래그를 통한 제스처에 대한 뒤로가기 애니메이션 반환
func presentationController(
forPresented presented: UIViewController,
presenting: UIViewController?,
source: UIViewController
) -> UIPresentationController?
뷰 계층/딤뷰/코너/시트 높이 같은 레이아웃 관리 컨틀롤러를 반환합니다.
위의 메소드들을 통해서 위와 같은 Zoom Transition 을 구성해 보도록 하겠습니다.
기본제공되는 그런건 아닙니다.
애니메이션을 만들때에 사용할 기본적인 구조를 만들어 보겠습니다.
/// ZOOM 전환(프레젠트/디스미스) 시 사용되는 설정 값 모음
/// - 전환 시간, 스프링 파라미터
/// - 팬 제스처(양축) 기반 알파/디스미스 임계값 계산
struct CustomZoomTransitionConfiguration {
// MARK: - 애니메이션 기본 파라미터
/// 전환(프레젠트/디스미스)에 걸리는 총 시간
let transitionDuration: TimeInterval
/// 스프링 애니메이션의 감쇠 비율 --> (작을수록 더 출렁임)
let springWithDamping: CGFloat
/// 스프링 애니메이션의 초기 속도
let initialSpringVelocity: CGFloat
// MARK: - 팬 제스처 관련 파라미터
/// 이 거리(픽셀) 이상 이동하면 디스미스 처리
let panGestureDismissThreshold: CGFloat
/// 팬 제스처 중 최소 알파 값
let minAlphaDuringPan: CGFloat
/// 이 거리까지 선형으로 알파가 감소 (0 → minAlphaDuringPan)
let alphaRangeDistance: CGFloat
init(
transitionDuration: TimeInterval = 0.5,
springWithDamping: CGFloat = 0.85,
initialSpringVelocity: CGFloat = 0.8,
panGestureDismissThreshold: CGFloat = 120,
minAlphaDuringPan: CGFloat = 0.6,
alphaRangeDistance: CGFloat = 160
) {
self.transitionDuration = transitionDuration
self.springWithDamping = springWithDamping
self.initialSpringVelocity = initialSpringVelocity
self.panGestureDismissThreshold = panGestureDismissThreshold
self.minAlphaDuringPan = minAlphaDuringPan
self.alphaRangeDistance = alphaRangeDistance
}
}
자 이번엔 뷰가 등장할때에 대한 애니메이션을 정의 보고자 합니다.
final class CustomPresentingTransitionAnimator: NSObject {
private let config: CustomZoomTransitionConfiguration
private let referenceView: UIView
private var transitionContext: UIViewControllerContextTransitioning?
// MARK: - 초기화
init(config: CustomZoomTransitionConfiguration, referenceView: UIView) {
self.config = config
self.referenceView = referenceView
}
}
extension CustomPresentingTransitionAnimator: UIViewControllerAnimatedTransitioning {
/// 프레젠트 전환에 소요되는 총 시간을 반환
func transitionDuration(
using transitionContext: UIViewControllerContextTransitioning?
) -> TimeInterval {
print("\(#function)")
return config.transitionDuration
}
/// 버튼 위치/ 크기에서 시작해 화면 전체로 확대되는 프레젠트 애니메이션.
func animateTransition(
using transitionContext: UIViewControllerContextTransitioning
) {
print("\(#function)")
guard let presentedViewController = transitionContext.viewController(forKey: .to) else {
return
}
self.transitionContext = transitionContext
let containerView = transitionContext.containerView
// 배경 디밍 뷰를 준비하고 컨테이너에 추가
let backgroundView = makeBackgroundView(in: containerView)
// 최종 프레임과 버튼(referenceView)의 시작 프레임을 계산
let finalFrame = containerView.bounds
let refFrameInContainer = containerView.convert(referenceView.bounds, from: referenceView)
// 프레젠트될 뷰를 초기 상태(작게/버튼 위치)로 설정
let presentedView = presentedViewController.view!
preparePresentedView(presentedView,
finalFrame: finalFrame,
refFrameInContainer: refFrameInContainer)
// 컨테이너에 프레젠트 뷰를 추가합니다(디밍 뷰 위).
containerView.addSubview(presentedView)
// 확대 애니메이션
animatePresentation(backgroundView: backgroundView,
presentedView: presentedView,
finalFrame: finalFrame,
transitionContext: transitionContext)
}
/// 프레젠트 애니메이션 종료 시 호출됩니다.
func animationEnded(_ transitionCompleted: Bool) {
print("\(#function)")
transitionContext = nil
}
}
private extension CustomPresentingTransitionAnimator {
/// 컨테이너에 디밍(배경) 뷰를 생성/추가하고 반환
func makeBackgroundView(in containerView: UIView) -> UIView {
let backgroundView = UIView(frame: containerView.bounds)
backgroundView.backgroundColor = containerView.backgroundColor
backgroundView.alpha = 0
containerView.addSubview(backgroundView)
return backgroundView
}
/// 프레젠트될 뷰를 버튼 위치/크기에서 시작하도록 초기 상태를 설정
/// - 최종 프레임, 버튼 프레임을 기반으로 스케일/센터/코너 라운드를 구성
func preparePresentedView(_ presentedView: UIView,
finalFrame: CGRect,
refFrameInContainer: CGRect) {
// 최종 프레임을 기준으로 레이아웃 설정
presentedView.frame = finalFrame
// 확대/축소 중 코너 라운드가 잘 보이도록 마스킹 활성화
presentedView.layer.masksToBounds = true
let referenceCorner = referenceView.layer.cornerRadius
// 버튼 대비 전체 화면의 스케일 비율 계산
let minScale: CGFloat = 0.001 // 0 스케일 방지
let scaleX = max(refFrameInContainer.width / max(finalFrame.width, 1), minScale)
let scaleY = max(refFrameInContainer.height / max(finalFrame.height, 1), minScale)
// 스케일 상태에서 버튼 코너와 유사하게 보이도록 초기 코너 보정
let effectiveScale = max(min(scaleX, scaleY), 0.001)
let initialCorner = referenceCorner / effectiveScale
presentedView.layer.cornerRadius = initialCorner
// 버튼 중심 위치에서 작게 시작하도록 설정
presentedView.transform = CGAffineTransform(scaleX: scaleX, y: scaleY)
presentedView.center = CGPoint(x: refFrameInContainer.midX, y: refFrameInContainer.midY)
}
/// 디밍 알파/스케일/센터/코너 라운드를 애니메이션하여 화면 전체로 확대
func animatePresentation(backgroundView: UIView,
presentedView: UIView,
finalFrame: CGRect,
transitionContext: UIViewControllerContextTransitioning) {
UIView.animate(
withDuration: config.transitionDuration,
delay: 0,
usingSpringWithDamping: config.springWithDamping,
initialSpringVelocity: config.initialSpringVelocity,
options: [.beginFromCurrentState]
) {
backgroundView.alpha = 1
presentedView.transform = .identity
presentedView.center = CGPoint(x: finalFrame.midX, y: finalFrame.midY)
presentedView.layer.cornerRadius = 0
} completion: { _ in
backgroundView.removeFromSuperview()
transitionContext.completeTransition(transitionContext.transitionWasCancelled == false)
}
}
}
guard let presentedViewController = transitionContext.viewController(forKey: .to) else {
return
}

기본적인 개념과, 등장 애니메이션을 만들어 보았습니다.
다음 편은 사라질때에 대한 애니메이션을 구성해보겠습니다.