プログラミング

AppStoreのカスタムトランジションを実装してみる

swift-eyecatch

普通のUINavigationpushの遷移はわかりやすいしアニメーションも最低限実装されているが、少し単調というか見飽きたというか。色々とカスタマイズしていきたいなと思いました。

純正アプリであるAppStoreアプリですが、カードが一覧で表示されてタップすると詳細画面にアニメーションで遷移する、このアニメーションめっちゃかっこいいですよね、是非真似していきたい、実装してみたい、ということで実装してみました。

実際には少し違うものが出来上がったのですが、ほとんど同じような挙動です。

AppStoreなど標準のiOSアプリは既にSwiftUIで置き換えられているのかもしれないですが、まだまだ知識経験がdeveloper全体の中でも自分としてもまだ十分ではないと感じています。UIKitでは簡単に表現できていたことがSwiftUIではまだまだ出来ないという部分も多くあると思います。まだまだ実際のプロダクトではUIKitをガッツリと使っていく状態は続くと思うので(趣味のアプリは別として)、今回もUIKitでAppStoreアプリと同じトランジションアニメーションを実装してみました。

少し複雑にはなるので、トランジションのアニメーション自体が初めての場合は、以下の記事のほうがより易しいです。

標準アプリAppStoreの確認(目標の確認)

まずは目標とするアプリの挙動を確認してみます。

このように、カードが一覧で表示されていて、タップすると、カードが広がりつつ詳細画面に遷移する、詳細画面からの遷移ではカードが縮小していくようなアニメーションがあり、またUIScrollViewcontentOffsetY0の状態で下スクロールすると、スクロールの大きさによってカード自体が小さく縮小していくアニメーション、そしてスクロールが一定量超えるとdismissされるようなアニメーションです。

実装したアプリの挙動確認

以下の動画は実際に今回作成したアプリの挙動です。

AppStoreとほとんど同じですが、カードはシンプルにしています。画像をカードいっぱいに表示しており、その上にUILabelを載せているのみです。タップすると同じようなアニメーションで拡大しつつ詳細画面に遷移させています。

サンプルアプリの構成要素

では実際に実装の解説に入っていきます。まずは必要となる構成要素からです。

  • 一覧画面(ListViewController)
  • 詳細画面(DetailViewController)
  • カードビュー(CardView)
  • カードビューセル(CardViewCell)
  • カードビューモデル(CardViewModel)
  • カードトランジションマネージャー(CardTransitionManager)

今回の実装で重要なのは上記の6つです。

一覧画面(ListViewController)

まずは一覧画面です。こちらはUITableViewListViewControllerviewaddし、このtableViewCardViewCellを表示しています。サンプルなのでセルの数は3つと決め打ちしています。

import UIKit
import SnapKit

class ListViewController: UIViewController {

    let transitionManager = CardTransitionManager()

    let cardViewModels: [CardViewModel] = [
        CardViewModel(image: UIImage(named: "card1")!, title: "FIRST CARD TITLE LABEL", subtitle: "This is a subtitle label."),
        CardViewModel(image: UIImage(named: "card2")!, title: "SECOND CARD TITLE LABEL", subtitle: "This is a subtitle label."),
        CardViewModel(image: UIImage(named: "card3")!, title: "THIRD CARD TITLE LABEL", subtitle: "This is a subtitle label."),
    ]

    lazy var tableView: UITableView = {
        let tableView = UITableView()
        tableView.delegate = self
        tableView.dataSource = self
        tableView.separatorStyle = .none

        tableView.register(CardViewCell.self, forCellReuseIdentifier: CardViewCell.identifier)
        return tableView
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(tableView)
        tableView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
    }
}

extension ListViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let vc = DetailViewController(cardViewModel: cardViewModels[indexPath.row])
        vc.modalPresentationStyle = .fullScreen
        vc.transitioningDelegate = transitionManager
        present(vc, animated: true, completion: nil)
    }
}

extension ListViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return cardViewModels.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: CardViewCell.identifier, for: indexPath) as! CardViewCell
        cell.configureCell(cardViewModel: cardViewModels[indexPath.row])
        return cell
    }

    func selectedCellCardView() -> CardView? {
        guard let indexPath = tableView.indexPathForSelectedRow else { return nil }
        let cell = tableView.cellForRow(at: indexPath) as! CardViewCell
        return cell.cardView
    }
}

extension ListViewController {
    var selectedCell: UITableViewCell? {
        guard let indexPath = tableView.indexPathForSelectedRow else { return nil }
        return tableView.cellForRow(at: indexPath)
    }

    var selectedCellCardView: CardView? {
        let cell = selectedCell as? CardViewCell
        return cell?.cardView
    }
}

AutoLayout

特にポイントというほどのポイントもないのですが、今回もSnapKitを使っています。SnapKitは、コードでAutoLayoutを少ない記述量で実装できるライブラリです。もしSnapKitを使わず純粋なコードで実装する場合は、 tableView.translatesAutoresizingMaskIntoConstraints = falseが必要になります。

CardViewModel

UITableViewに表示するセルですが、上記のように、決め打ちで3つとしています。

先にCardViewModelのコードを記載します。

import UIKit

struct CardViewModel {
    let image: UIImage
    let title: String
    let subtitle: String
}

これだけです。必要最低限だけを実装しています。

imageは、カードの背景に目一杯表示する画像です。Assets.scassetsに画像を3つ格納し、それを読み込んでいるだけです。

titleは、カードの真ん中に大きく表示するUILableに使用しています。

subtitleは、カードの左下に小さく表示するUILabelに使用しています。正直なくても問題ありません。

CardViewModelのイニシャライズ

CardViewModelの定義は上記のみで、ListViewControllerではこのCardViewModelをイニシャライズしています。

CardViewModelには、自動的にメンバーワイズイニシャライザーが実装されます。CardViewModel(と打ち込むとコード補完でイニシャライズ用コードが表示されますので、補完候補をそのまま使ってしまいます。

選択されているCell及びそのCardViewの取得

selectedCellselectedCellCardViewは選択されているセルとそのcardViewを取得するために実装したcomputed propertyです。これは後にトランジションアニメーションを実装するときに使用します。

詳細画面(DetailViewController)

次は遷移先の詳細画面です。

本記事のメインはアニメーション部分ですが、この詳細画面でもいくつかのポイントに触れておきます。

まず元のアプリであるAppStoreアプリの挙動を確認してみてください。

詳細画面では、まずスクロールが可能で、詳細画面で開いたアプリなどの詳細な情報が確認できるようになっています。ここでは、詳細説明の文章や画像などが表示されていますが、今回実装するアプリでは文字列(UILabel)のみを表示するようにします。

詳細画面の構成要素

立体的にみると次のような実装になっています。

  • UIScrollView
  • CardView
  • UILabel
  • UIButton

主な構成要素はこの4点です。

DetailViewControllerviewの上にはUIScrollViewUIScrollViewの上部には今回独自で実装するCardViewと閉じる用のUIButton、そしてCardViewの下側には、UILabelという構成になっています。

DetailViewControllerのイニシャライズ

詳細画面では、独自クラスの1つのインスタンスを詳細に表示するという目的の画面なので、独自クラスの1つのインスタンスでDetailViewControllerをイニシャライズするように実装しています。

let cardViewModel: CardViewModel
var cardView: CardView
init(cardViewModel: CardViewModel) {
    self.cardViewModel = cardViewModel
    self.cardView = CardView(cardViewModel: cardViewModel)
    super.init(nibName: nil, bundle: nil)
}

snapshotView

次に、スクロールでdismissするときのアニメーションのため、snapshotViewを実装しておきます。

private lazy var snapshotView: UIImageView = {
    let imageView = UIImageView()
    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
}()

AppStoreアプリの挙動とは少し違う部分がありますが、概ね同じです。UIScrollViewのスクロール部分が一番上にある状態でさらに下側にスワイプすると、DetailViewController自体が小さく縮小し、スクロール量が閾値を超えると、dismissを発火するようにします。

このときに縮小するUIはDetailControllerviewそのものではなく、そのviewのUIをUIImageとしてコピーし、その画像を縮小していく、という方法をとっています。

import UIKit

extension UIView {
    func createSnapshot() -> UIImage? {
        UIGraphicsBeginImageContextWithOptions(frame.size, false, 0.0)
        drawHierarchy(in: frame, afterScreenUpdates: true)
        let image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return image
    }
}

役割分担のため、スナップショットのUIImageを作成するためのextensionを実装しておきます。

func createSnapshotOfView() {
    let snapshotImage = view.createSnapshot()
    snapshotView.image = snapshotImage
    scrollView.addSubview(snapshotView)

    snapshotView.frame = CGRect(x: 0, y: 0, width: view.frame.width, height: view.frame.height)
}

あとは、関数を定義しておき、トランジションの中すぐに呼び出せるようにしておきます。

UIScrollViewの実装

スクロールの話も出たのでここで記載しておきます。

上述の通り、スクロールでdismiss出来るように実装していきます。

このとき、isTrackingによって、scrollView.bouncesを操作したり、スクロール量によって、諸々のviewの表示/非表示切り替え、snapshotViewの表示/非表示、縮小などを実装していきます。

そしてスクロール量が閾値を超えると、dismissします。

if yPositionForDismissal + yContentOffset <= 0 {
    dismiss(animated: true, completion: nil)
}

DetailViewControllerのコード

import UIKit

class DetailViewController: UIViewController {

    var viewsAreHidden: Bool = false {
        didSet {
            cardView.isHidden = viewsAreHidden
            descriptionLabel.isHidden = viewsAreHidden
            closeButton.isHidden = viewsAreHidden
        }
    }

    override var prefersStatusBarHidden: Bool {
        return true
    }

    fileprivate lazy var scrollView: UIScrollView = {
        let view = UIScrollView()
        view.showsHorizontalScrollIndicator = false
        view.showsVerticalScrollIndicator = false
        view.bounces = true
        view.clipsToBounds = true
        view.contentInsetAdjustmentBehavior = .never
        view.delegate = self
        return view
    }()

    lazy var closeButton: UIButton = {
        let button = UIButton()
        let config = UIImage.SymbolConfiguration(pointSize: 20, weight: .black, scale: .large)
        let image = UIImage(systemName: "xmark.circle.fill", withConfiguration: config)!
        button.setImage(image, for: .normal)
        button.tintColor = .white
        button.addTarget(self, action: #selector(closeButtonTapped(_:)), for: .touchUpInside)
        return button
    }()

    private lazy var descriptionLabel: UILabel = {
        let label = UILabel()
        label.numberOfLines = 0
        label.textColor = UIColor.black.withAlphaComponent(0.8)

        let style = NSMutableParagraphStyle()
        style.lineSpacing = 10
        style.alignment = .left
        let attributes: [NSAttributedString.Key : Any] = [
            .font : UIFont.systemFont(ofSize: 16, weight: .semibold),
            .paragraphStyle: style,
        ]
        label.attributedText = NSAttributedString(string: String.lorem200, attributes: attributes)
        return label
    }()

    private lazy var snapshotView: UIImageView = {
        let imageView = UIImageView()
        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
    }()

    let cardViewModel: CardViewModel
    var cardView: CardView
    init(cardViewModel: CardViewModel) {
        self.cardViewModel = cardViewModel
        self.cardView = CardView(cardViewModel: cardViewModel)
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .clear
        view.addSubview(scrollView)
        scrollView.addSubview(cardView)
        scrollView.addSubview(descriptionLabel)
        view.addSubview(closeButton)

        scrollView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
        cardView.snp.makeConstraints {
            $0.top.leading.trailing.equalToSuperview()
            $0.width.equalTo(UIScreen.main.bounds.width)
        }
        closeButton.snp.makeConstraints {
            $0.top.equalToSuperview().offset(16)
            $0.trailing.equalToSuperview().offset(-16)
        }
        descriptionLabel.snp.makeConstraints {
            $0.top.equalTo(cardView.snp.bottom).offset(16)
            $0.leading.equalToSuperview().offset(16)
            $0.trailing.bottom.equalToSuperview().offset(-16)
        }
    }
}

extension DetailViewController {
    @objc func closeButtonTapped(_ sender: UIButton) {
        dismiss(animated: true, completion: nil)
    }

    func createSnapshotOfView() {
        let snapshotImage = view.createSnapshot()
        snapshotView.image = snapshotImage
        scrollView.addSubview(snapshotView)

        snapshotView.frame = CGRect(x: 0, y: 0, width: view.frame.width, height: view.frame.height)
    }
}

extension DetailViewController: UIScrollViewDelegate {

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let yPositionForDismissal: CGFloat = 30.0
        let yContentOffset = scrollView.contentOffset.y

        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/2.5)) / 100
            snapshotView.transform = CGAffineTransform(scaleX: scale, y: scale)

            snapshotView.layer.cornerRadius = -yContentOffset > yPositionForDismissal ? yPositionForDismissal : -yContentOffset

            if yPositionForDismissal + yContentOffset <= 0 {
                dismiss(animated: true, completion: nil)
            }

        } else {
            viewsAreHidden = false
            snapshotView.isHidden = true
        }
    }

    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        scrollView.bounces = true
    }

    func updateCloseButton(yContentOffset: CGFloat) {
        if yContentOffset < 320 {
            closeButton.tintColor = .white
        } else {
            closeButton.tintColor = .darkGray
        }
    }
}

なお、詳細画面ではステータスバーを非表示にしています。

CardView / CardViewCell

CardViewの実装

一覧画面で表示するCardViewを実装していきます。

import UIKit
import SnapKit

final class CardView: UIView {

    lazy var backgroundImageView: UIImageView = {
        let view = UIImageView()
        view.layer.masksToBounds = true
        return view
    }()

    lazy var titleLabel: UILabel = {
        let label = UILabel()
        label.font = .systemFont(ofSize: 40, weight: .bold)
        label.textColor = .white
        label.numberOfLines = 0
        return label
    }()

    lazy var subtitleLabel: UILabel = {
        let label = UILabel()
        label.font = .systemFont(ofSize: 14, weight: .medium)
        label.textColor = .white
        label.numberOfLines = 0
        return label
    }()

    let cardViewModel: CardViewModel
    init(cardViewModel: CardViewModel) {
        self.cardViewModel = cardViewModel
        super.init(frame: .zero)

        addSubview(backgroundImageView)
        backgroundImageView.addSubview(titleLabel)
        backgroundImageView.addSubview(subtitleLabel)

        backgroundImageView.image = cardViewModel.image
        titleLabel.text = cardViewModel.title
        subtitleLabel.text = cardViewModel.subtitle

        backgroundImageView.snp.remakeConstraints {
            $0.top.leading.equalToSuperview()
            $0.trailing.bottom.equalToSuperview()
            $0.height.equalTo(332)
        }

        titleLabel.snp.remakeConstraints {
            $0.centerY.equalToSuperview()
            $0.leading.equalToSuperview().offset(16)
            $0.trailing.equalToSuperview().offset(-16)
        }

        subtitleLabel.snp.remakeConstraints {
            $0.leading.equalToSuperview().offset(16)
            $0.bottom.equalToSuperview().offset(-16)
        }
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

特に注意点などはないですが、こちらもCardViewModelでイニシャライズするように実装しています。

CardViewCellの実装

import UIKit
import SnapKit

class CardViewCell: UITableViewCell {
    static let identifier: String = {
        return String(describing: self)
    }()

    lazy var shadowView: UIView = {
        let view = UIView()
        view.backgroundColor = .white
        view.layer.cornerRadius = 20
        view.layer.shadowColor = UIColor.black.cgColor
        view.layer.shadowOpacity = 0.3
        view.layer.shadowRadius = 8
        view.layer.shadowOffset = .zero
        return view
    }()

    var cardView: CardView?

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        selectionStyle = .none
    }

    func configureCell(cardViewModel: CardViewModel) {
        contentView.subviews.forEach {
            $0.removeFromSuperview()
        }

        cardView = CardView(cardViewModel: cardViewModel)
        guard let cardView = cardView else { return }
        cardView.layer.cornerRadius = 20
        cardView.clipsToBounds = true
        contentView.addSubview(shadowView)
        contentView.addSubview(cardView)

        cardView.snp.makeConstraints {
            $0.top.leading.equalToSuperview().offset(16)
            $0.trailing.bottom.equalToSuperview().offset(-16)
        }
        shadowView.snp.makeConstraints {
            $0.edges.equalTo(cardView)
        }
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

CardView自体をセルの上にaddしており、cornerRadius及び上下左右のスペースをAutoLayoutで追加しています。

また、カード感を表現するために、CardViewの裏側にshadowViewaddしています。

これで、CardViewCardViewCellで表示するときには、影と角丸が追加され、詳細画面ではCardViewのみなので角丸と上下左右のスペースはなく、画面いっぱいに表示されるような実装になります。

CardTransitionManagerの実装

前回の記事と色々説明が重複するところがあるので、ポイントだけ記載します。

アニメーションのポイント

AppStoreアプリのアニメーションを確認して実装の方向性を検討します。今回実装することにしたポイントは以下の通りです。

  • 一覧から詳細にアニメーションする時、カードが少し小さくアニメーションしてから詳細画面に向けて拡大する。
  • 詳細から一覧にアニメーションするときは、すなおに、元の大きさにアニメーションするのみ。
  • 一覧から詳細にアニメーションするとき、裏側に白いViewがアニメーションで現れ、カードが広がっていくアニメーションを実装する。その際、裏側に表示されている一覧画面は徐々にblur効果がかかってぼやけるように見える。
  • 一覧から詳細、詳細から一覧の両方向のアニメーションにおいて、少しバウンドするようなアニメーションになっている。
import UIKit

enum CardTransitionType {
    case presentation
    case dismissal

    var blurAlpha: CGFloat { return self == .presentation ? 1 : 0 }
    var dimAlpha: CGFloat { return self == .presentation ? 0.5 : 0 }
    var closeAlpha: CGFloat { return self == .presentation ? 1 : 0 }
    var cornerRadius: CGFloat { return self == .presentation ? 20.0 : 0 }
    var next: CardTransitionType { return self == .presentation ? .dismissal : .presentation }
}

class CardTransitionManager: NSObject {
    let transitionDuration: Double = 0.8
    var transition: CardTransitionType = .presentation
    let shrinkDuration: Double = 0.2

    lazy var blurEffectView: UIVisualEffectView = {
        let blurEffect = UIBlurEffect(style: .light)
        let visualEffectView = UIVisualEffectView(effect: blurEffect)
        return visualEffectView
    }()

    lazy var dimmingView: UIView = {
        let view = UIView()
        view.backgroundColor = .black
        return view
    }()

    lazy var whiteView: UIView = {
        let view = UIView()
        view.backgroundColor = .white
        return view
    }()

    lazy var closeButton: UIButton = {
        let button = UIButton()
        let config = UIImage.SymbolConfiguration(pointSize: 20, weight: .black, scale: .large)
        let image = UIImage(systemName: "xmark.circle.fill", withConfiguration: config)!
        button.setImage(image, for: .normal)
        button.tintColor = .white
        return button
    }()

    private func addBackgroundView(to containerView: UIView) {
        blurEffectView.frame = containerView.frame
        blurEffectView.alpha = transition.next.blurAlpha
        containerView.addSubview(blurEffectView)

        dimmingView.frame = containerView.frame
        dimmingView.alpha = transition.next.dimAlpha
        containerView.addSubview(dimmingView)
    }

    private func createCardViewCopy(cardView: CardView) -> CardView {
        let cardViewCopy = CardView(cardViewModel: cardView.cardViewModel)
        cardViewCopy.frame = cardView.frame
        return cardViewCopy
    }
}

extension CardTransitionManager: UIViewControllerAnimatedTransitioning {
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return transitionDuration
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        switch transition {
        case .presentation:
            presentTransition(using: transitionContext)
        case .dismissal:
            dismissalTransition(using: transitionContext)
        }
    }

    private func presentTransition(using transitionContext: UIViewControllerContextTransitioning) {
        let containerView = transitionContext.containerView
        containerView.subviews.forEach { $0.removeFromSuperview() }

        guard let fromView = transitionContext.viewController(forKey: .from) as? ListViewController,
              let toView = transitionContext.viewController(forKey: .to) as? DetailViewController
        else { return }

        guard let cardView = fromView.selectedCellCardView else { return }
        let cardViewCopy = createCardViewCopy(cardView: cardView)

        cardViewCopy.frame = containerView.convert(cardView.frame, from: cardView.superview)
        cardViewCopy.layoutIfNeeded()

        toView.view.frame = transitionContext.finalFrame(for: toView)
        toView.view.alpha = 0
        toView.view.layoutIfNeeded()

        containerView.addSubview(fromView.view)
        addBackgroundView(to: containerView)
        whiteView.frame = cardViewCopy.frame
        whiteView.layer.cornerRadius = transition.cornerRadius
        containerView.addSubview(whiteView)
        containerView.addSubview(cardViewCopy)
        containerView.addSubview(toView.view)

        cardViewCopy.addSubview(closeButton)
        closeButton.snp.makeConstraints {
            $0.top.equalToSuperview().offset(16)
            $0.trailing.equalToSuperview().offset(-16)
        }
        closeButton.alpha = transition.next.closeAlpha

        cardViewCopy.backgroundImageView.layer.cornerRadius = transition.cornerRadius
        fromView.selectedCell?.isHidden = true
        cardView.isHidden = true

        moveAndConvertToCardView(cardView: cardViewCopy, containerView: containerView, targetFrame: toView.cardView.frame) {
            toView.view.alpha = 1
            fromView.view.removeFromSuperview()
            cardViewCopy.removeFromSuperview()
            fromView.selectedCell?.isHidden = false
            cardView.isHidden = false
            toView.createSnapshotOfView()
            transitionContext.completeTransition(true)
        }
    }

    private func dismissalTransition(using transitionContext: UIViewControllerContextTransitioning) {
        let containerView = transitionContext.containerView
        containerView.subviews.forEach { $0.removeFromSuperview() }

        guard let fromView = transitionContext.viewController(forKey: .from) as? DetailViewController,
              let toView = transitionContext.viewController(forKey: .to) as? ListViewController
        else { return }

        let cardView = fromView.cardView
        let cardViewCopy = createCardViewCopy(cardView: cardView)
        let targetCardView = toView.selectedCellCardView!

        let targetCardViewFrame = containerView.convert(targetCardView.frame, from: targetCardView.superview)

        cardViewCopy.frame = containerView.convert(cardView.frame, from: cardView.superview)
        cardViewCopy.layoutIfNeeded()

        toView.view.frame = transitionContext.finalFrame(for: toView)
        toView.view.layoutIfNeeded()

        containerView.addSubview(toView.view)
        addBackgroundView(to: containerView)
        whiteView.frame = containerView.frame
        whiteView.layer.cornerRadius = transition.cornerRadius
        containerView.addSubview(whiteView)
        containerView.addSubview(cardViewCopy)

        cardViewCopy.addSubview(closeButton)
        closeButton.snp.makeConstraints {
            $0.top.equalToSuperview().offset(16)
            $0.trailing.equalToSuperview().offset(-16)
        }
        closeButton.alpha = transition.next.closeAlpha

        cardViewCopy.backgroundImageView.layer.cornerRadius = transition.cornerRadius
        targetCardView.isHidden = true
        toView.selectedCell?.isHidden = true
        cardView.isHidden = true

        moveAndConvertToCardView(cardView: cardViewCopy, containerView: containerView, targetFrame: targetCardViewFrame) {
            cardViewCopy.removeFromSuperview()
            toView.selectedCell?.isHidden = false
            targetCardView.isHidden = false
            cardView.isHidden = false
            transitionContext.completeTransition(true)
        }
    }

    func makeShrinkAnimator(for cardView: CardView) -> UIViewPropertyAnimator {
        return UIViewPropertyAnimator(duration: shrinkDuration, curve: .easeOut) {
            cardView.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
            self.whiteView.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
            self.dimmingView.alpha = 0.05
        }
    }

    func makeExpandContractAnimator(for cardView: CardView, in containerView: UIView, to targetFrame: CGRect) -> UIViewPropertyAnimator {
        let springTiming = UISpringTimingParameters(dampingRatio: 0.75, initialVelocity: CGVector(dx: 0, dy: 2))
        let animator = UIViewPropertyAnimator(duration: transitionDuration - shrinkDuration, timingParameters: springTiming)
        animator.addAnimations {
            cardView.transform = .identity
            cardView.frame = targetFrame
            cardView.backgroundImageView.layer.cornerRadius = self.transition.next.cornerRadius

            self.blurEffectView.alpha = self.transition.blurAlpha
            self.dimmingView.alpha = self.transition.dimAlpha
            self.closeButton.alpha = self.transition.closeAlpha

            containerView.layoutIfNeeded()

            self.whiteView.frame = self.transition == .presentation ? containerView.frame : cardView.frame
            self.whiteView.layer.cornerRadius = self.transition.next.cornerRadius
            self.whiteView.transform = .identity
        }
        return animator
    }

    func moveAndConvertToCardView(cardView: CardView, containerView: UIView, targetFrame: CGRect, completion: @escaping () -> ()) {
        let shrinkAnimator = makeShrinkAnimator(for: cardView)
        let expandContractAnimator = makeExpandContractAnimator(for: cardView, in: containerView, to: targetFrame)

        expandContractAnimator.addCompletion { _ in
            completion()
        }

        switch transition {
        case .presentation:
            shrinkAnimator.addCompletion { _ in
                cardView.layoutIfNeeded()
                expandContractAnimator.startAnimation()
            }

            shrinkAnimator.startAnimation()
        case .dismissal:
            expandContractAnimator.startAnimation()
        }
    }
}

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
    }
}

一番大事な詳細説明で、力尽きました。。