App Store Card Transition with Andrei Blaj, Senior iOS Developer
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return transitionDuration
}
UIViewControllerAnimaedTransitioning
프로토콜을 따르는 함수에서 미리 매니저 클래스 내에서 선언해 둔 TimeInterval
을 리턴private func makeShrinkAnimator(for cardView: CardView) -> UIViewPropertyAnimator {
return UIViewPropertyAnimator(duration: shrinkDuration, curve: .easeOut) {
cardView.transform = CGAffineTransform(scaleX: 0.95, y: 0.5)
self.dimmingView.alpha = 0.05
}
}
private func makeExpandContractAnimator(for cardView: CardView, in containerView: UIView, yOrigin: CGFloat) -> UIViewPropertyAnimator {
let springTiming = UISpringTimingParameters(dampingRatio: 0.75, initialVelocity: .init(dx: 0, dy: 4))
let animator = UIViewPropertyAnimator(duration: transitionDuration - shrinkDuration, timingParameters: springTiming)
animator.addAnimations {
cardView.transform = .identity
cardView.containerView.layer.cornerRadius = self.transition.next.cornerRadius
cardView.frame.origin.y = yOrigin
self.blurEffectView.alpha = self.transition.blurAlpha
self.dimmingView.alpha = self.transition.dimAlpha
self.closeButton.alpha = self.transition.closeAlpha
self.whiteView.layer.cornerRadius = self.transition.next.cornerRadius
containerView.layoutIfNeeded()
self.whiteView.frame = self.transition == .presentation ? containerView.frame : cardView.containerView.frame
}
return animator
}
private func moveAndConvertToCardView(cardView: CardView, containerView: UIView, yOriginToMoveTo: CGFloat, completion: @escaping () -> ()) {
let shrinkAnimator = makeShrinkAnimator(for: cardView)
let expandContractAnimator = makeExpandContractAnimator(for: cardView, in: containerView, yOrigin: yOriginToMoveTo)
expandContractAnimator.addCompletion { _ in
completion()
}
if transition == .presentation {
shrinkAnimator.addCompletion { _ in
cardView.layoutIfNeeded()
cardView.updateLayout(for: self.transition.next.cardMode)
expandContractAnimator.startAnimation()
}
shrinkAnimator.startAnimation()
} else {
cardView.layoutIfNeeded()
cardView.updateLayout(for: self.transition.next.cardMode)
expandContractAnimator.startAnimation()
}
}
}
expandContractAnimator
를 적용할 때 함께 적용 func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let containerView = transitionContext.containerView
containerView.subviews.forEach({ $0.removeFromSuperview() })
addBackgroundViews(to: containerView)
let fromView = transitionContext.viewController(forKey: .from)
let toView = transitionContext.viewController(forKey: .to)
var cardView: CardView?
if transition == .presentation {
guard
let fromView = fromView as? TodayView,
let selectedCardView = fromView.selectedCellCardView() else { return }
cardView = selectedCardView
} else {
guard
let toView = toView as? TodayView,
let selectedCardView = toView.selectedCellCardView() else { return }
cardView = selectedCardView
}
guard let cardView = cardView else { return }
let cardViewCopy = createCardViewCopy(cardView: cardView)
containerView.addSubview(cardViewCopy)
cardView.isHidden = true
let absoluteCardViewFrame = cardView.convert(cardView.frame, to: nil)
cardViewCopy.frame = absoluteCardViewFrame
cardViewCopy.layoutIfNeeded()
whiteView.frame = transition == .presentation ? cardViewCopy.containerView.frame : containerView.frame
whiteView.layer.cornerRadius = transition.cornerRadius
cardViewCopy.insertSubview(whiteView, aboveSubview: cardViewCopy.shadowView)
// MARK: Close Button
if cardView.cardModel.backgroundType == .light {
closeButton.setImage(UIImage(named: "darkOnLight"), for: .normal)
} else {
closeButton.setImage(UIImage(named: "lightOnDark"), for: .normal)
}
cardViewCopy.containerView.addSubview(closeButton)
NSLayoutConstraint.activate([
closeButton.topAnchor.constraint(equalTo: cardViewCopy.shadowView.topAnchor, constant: 20.0),
closeButton.widthAnchor.constraint(equalToConstant: 30.0),
closeButton.heightAnchor.constraint(equalTo: closeButton.widthAnchor, multiplier: 1.0),
closeButton.trailingAnchor.constraint(equalTo: cardViewCopy.shadowView.trailingAnchor, constant: -20.0)
])
if transition == .presentation {
guard let detailView = toView as? DetailView else { return }
containerView.addSubview(detailView.view)
detailView.viewsAreHidden = true
moveAndConvertToCardView(cardView: cardViewCopy, containerView: containerView, yOriginToMoveTo: 0) {
detailView.viewsAreHidden = false
cardViewCopy.removeFromSuperview()
cardView.isHidden = false
detailView.createSnapshotOfView()
transitionContext.completeTransition(true)
}
closeButton.alpha = transition.next.closeAlpha
} else {
// Dismissal
guard
let detailView = fromView as? DetailView,
let size = detailView.cardView?.frame.size else { return }
detailView.viewsAreHidden = true
closeButton.alpha = transition.next.closeAlpha
cardViewCopy.frame = CGRect(origin: .zero, size: size)
moveAndConvertToCardView(cardView: cardViewCopy, containerView: containerView, yOriginToMoveTo: absoluteCardViewFrame.origin.y) {
cardView.isHidden = false
transitionContext.completeTransition(true)
}
}
}
extension CardTransitionManager: UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transition = .presentation
return self
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transition = .dismissal
return self
}
}
fromView
와 toView
를 커스텀, 모달 전환 상황에 따라서 서로 다른 뷰가 적용presentation
일 때에는 전환이 TodayView
에서 DetailView
: 현재 컨테이너 뷰에 해당 디테일 뷰를 추가한 뒤 디테일 뷰를 볼 수 있도록 설정. 카드 뷰 복사본은 지우고 디테일 뷰의 스냅샷을 생성dismissal
일 때에는 전환이 DetailView
에서 TodayView
: 현재 디테일 뷰, 클로즈 버튼 등을 숨기고 카드 뷰의 복사본을 사용해 디테일 뷰에서 홈 뷰로 넘어가는 듯한 애니메이션 적용func createSnapshot() -> UIImage? {
UIGraphicsBeginImageContextWithOptions(frame.size, false, .zero)
drawHierarchy(in: frame, afterScreenUpdates: true)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return image
}
private func addBackgroundViews(to containterView: UIView) {
blurEffectView.frame = containterView.frame
blurEffectView.alpha = transition.next.blurAlpha
containterView.addSubview(blurEffectView)
dimmingView.frame = containterView.frame
dimmingView.alpha = transition.next.dimAlpha
containterView.addSubview(dimmingView)
}
private func createCardViewCopy(cardView: CardView) -> CardView {
let cardModel = cardView.cardModel
cardModel.viewMode = transition.cardMode
let appView = AppView(cardView.appView?.viewModel)
let cardViewCopy = CardView(cardModel: cardModel, appView: appView)
return cardViewCopy
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let cardViewModel = cardsViewData[indexPath.row]
let detailView = DetailView(cardViewModel: cardViewModel)
detailView.modalPresentationStyle = .overCurrentContext
detailView.transitioningDelegate = transitionManager
present(detailView, animated: true, completion: nil)
// To wake up the UI, Apple issue with cells with selectionStyle = .none
CFRunLoopWakeUp(CFRunLoopGetCurrent())
}
transitionDelegate
를 위에서 구현한 매니저 클래스로 주기lazy var snapshotView: UIImageView = {
let imageView = UIImageView()
imageView.clipsToBounds = true
imageView.backgroundColor = .white
imageView.layer.shadowColor = UIColor.black.cgColor
imageView.layer.shadowOpacity = 0.2
imageView.layer.shadowRadius = 10.0
imageView.layer.shadowOffset = CGSize(width: -1, height: 2)
imageView.isHidden = true
return imageView
}()
isHidden
을 참으로 설정func scrollViewDidScroll(_ scrollView: UIScrollView) {
let yPositionForDismissal: CGFloat = 20.0
var yContentOffset = scrollView.contentOffset.y
let topPadding = UIWindow.topPadding
yContentOffset += topPadding
updateCloseButton(yContentOffset: yContentOffset)
if scrollView.isTracking {
scrollView.bounces = true
} else {
scrollView.bounces = yContentOffset > 0
}
if yContentOffset < 0 && scrollView.isTracking {
viewsAreHidden = true
snapshotView.isHidden = false
let scale = (100 + yContentOffset) / 100
snapshotView.transform = CGAffineTransform(scaleX: scale, y: scale)
snapshotView.layer.cornerRadius = -yContentOffset > yPositionForDismissal ? yPositionForDismissal : -yContentOffset
if yPositionForDismissal + yContentOffset <= 0 {
close()
}
} else {
viewsAreHidden = false
snapshotView.isHidden = true
}
}
y
값을 계산, 최상단인 상황에서도 계속해서 스크롤을 한다면 자동으로 현재 뷰를 축소시키면서 디스미스시키기 위한 코드snapshot
을 생성한 이유는 transform
을 적용해서 자동으로 축소되는 듯한 UI를 보여주기 위함private func updateCloseButton(yContentOffset: CGFloat) {
let topPadding = UIWindow.topPadding
if yContentOffset < 450 - topPadding && cardView?.cardModel.backgroundType == .dark {
closeButton.setImage(UIImage(named: "lightOnDark"), for: .normal)
} else {
closeButton.setImage(UIImage(named: "darkOnLight"), for: .normal)
}
}
UIKit의 세계란...