Arnaud
Arnaud

Reputation: 17767

Zoom animation with a UICollectionView inside a UINavigationController

I'm trying to animate the transition from a UICollectionViewController to a UIViewController.

TLDR: in the last function, how can I get the frame that will take the navigation bar into account?

Each cell has a UIImageView, and I want to zoom in on it as I transition to the view controller. This is similar to the Photos app, except that the image is not centered and there are other views around (labels, etc.).

The UIImageView is placed using AutoLayout, centered horizontally and 89pts from the top safe layout guide.

It almost works, except that I can't manage to get the proper frame for the target UIImageView, it doesn't take into account the navigation and so the frame y origin is off by as much.

My UICollectionViewController is the UINavigationControllerDelegate

class CollectionViewController: UINavigationControllerDelegate {
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        guard segue.identifier == detailSegue,
            let destination = segue.destination as? CustomViewController,
            let indexPath = sender as? IndexPath else { return }
        navigationController?.delegate = self
        …
    }

    func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        switch operation {
        case .push:
            return ZoomInAnimator()
        default:
            return nil
        }
    }
}

The ZoomInAnimator object looks like that:

open class ZoomInAnimator: NSObject, UIViewControllerAnimatedTransitioning {
    public func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.5
    }

public func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        guard let fromViewController = transitionContext.viewController(forKey: .from),
            let fromContent = (fromViewController  as? ZoomAnimatorDelegate)?.animatedContent(),
            let toViewController = transitionContext.viewController(forKey: .to),
            let toContent = (toViewController as? ZoomAnimatorDelegate)?.animatedContent() else { return }

        let finalFrame = toContent.pictureFrame!

        // Set pre-animation state
        toViewController.view.alpha = 0
        toContent.picture.isHidden = true

        // Add a new UIImageView where the "from" picture currently is
        let transitionImageView = UIImageView(frame: fromContent.pictureFrame)
        transitionImageView.image = fromContent.picture.image
        transitionImageView.contentMode = fromContent.picture.contentMode
        transitionImageView.clipsToBounds = fromContent.picture.clipsToBounds

        transitionContext.containerView.addSubview(toViewController.view)
        transitionContext.containerView.addSubview(transitionImageView)

        // Animate the transition
        UIView.animate(
            withDuration: transitionDuration(using: transitionContext),
            delay: 0,
            usingSpringWithDamping: 0.8,
            initialSpringVelocity: 0,
            options: [.transitionCrossDissolve],
            animations: {
                transitionImageView.frame = finalFrame
                toViewController.view.alpha = 1
        }) { completed in
            transitionImageView.removeFromSuperview()
            toContent.picture.isHidden = false
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        }
    }
}

As you can see, I ask both controllers for their UIImageViews and their frames using animatedContent(). This function is defined in both the UICollectionViewController and the UIViewController, which both conforms to this protocol:

protocol ZoomAnimatorDelegate {
    func animatedContent() -> AnimatedContent?
}

With AnimatedContent being a simple struct:

struct AnimatedContent {
    let picture: UIImageView!
    let pictureFrame: CGRect!
}

Here's how that function is implemented on both sides.

This side works fine (the UICollectionViewController / fromViewController in the transition):

extension CollectionViewController: ZoomAnimatorDelegate {

    func animatedContent() -> AnimatedContent? {
        guard let indexPath = collectionView.indexPathsForSelectedItems?.first else { return nil }
        let cell = cellOrPlaceholder(for: indexPath) // Custom method to get the cell, I'll skip the details
        let frame = cell.convert(cell.picture.frame, to: view)
        return AnimatedContent(picture: cell.picture, pictureFrame: frame)
    }
}

This is the problematic part.

The picture frame origin is 89pts and ignores the navigation bar which adds a 140pt offset. I tried all sorts of convert(to:) without any success. Also, I'm a bit concerned about calling view.layoutIfNeeded here, is it the way to do it?

class ViewController: ZoomAnimatorDelegate {
    @IBOutlet weak var picture: UIImageView!
    func animatedContent() -> AnimatedContent? {
        view.layoutIfNeeded()
        return AnimatedContent(picture: picture, profilePictureFrame: picture.frame)
    }
}

EDIT: Here's an updated version of animateTransition (not using snapshots yet, but I'll get there)

public func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
    guard let fromViewController = transitionContext.viewController(forKey: .from) as? CollectionViewController,
        let fromContent = fromViewController.animatedContent(),
        let toViewController = transitionContext.viewController(forKey: .to) as? ViewController,
        let toImageView = toViewController.picture else { return }

    transitionContext.containerView.addSubview(toViewController.view)
    toViewController.view.frame = transitionContext.finalFrame(for: toViewController)
    toViewController.view.setNeedsLayout()
    toViewController.view.layoutIfNeeded()
    let finalFrame = toViewController.view.convert(toImageView.frame, to: transitionContext.containerView)

    let transitionImageView = UIImageView(frame: fromContent.pictureFrame)
    transitionImageView.image = fromContent.picture.image
    transitionImageView.contentMode = fromContent.picture.contentMode
    transitionImageView.clipsToBounds = fromContent.picture.clipsToBounds
    transitionContext.containerView.addSubview(transitionImageView)

    // Set pre-animation state
    toViewController.view.alpha = 0
    toImageView.isHidden = true

    UIView.animate(
        withDuration: transitionDuration(using: transitionContext),
        delay: 0,
        usingSpringWithDamping: 0.8,
        initialSpringVelocity: 0,
        options: [.transitionCrossDissolve],
        animations: {
            transitionImageView.frame = finalFrame
            toViewController.view.alpha = 1
    }) { completed in
        transitionImageView.removeFromSuperview()
        toImageView.isHidden = false
        transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
    }
}

Upvotes: 1

Views: 512

Answers (1)

matt
matt

Reputation: 535801

The problem is that you are obtaining the final frame incorrectly:

let finalFrame = toContent.pictureFrame!

Here's what you should have done:

  1. Start by asking the transition context for the final frame of the to view controller’s view, by calling finalFrame(for:). The fact that you never call that method is a clear bug; you should always call it!

  2. Now use that as either the frame to animate to (if you know that the image will fill it completely), or use it as the basis of the calculation of that frame.

    What I do in the latter case is to perform a dummy layout operation to learn what the image frame will be within the view controller’s view, and then convert the coordinate system to that of the transition context. The fact that I don't see you performing any coordinate system conversion is a sign of another bug in your code. Remember, frame is calculated in terms of a view's superview's coordinate system.

Here’s an example from one of my apps; it’s a presented VC not a pushed VC, but that makes no difference to the calculation:

enter image description here

Observe how I know where the red swatch will be in the final frame of the presented view controller's view, and I move the red swatch snapshot to that frame in terms of the transition context's coordinate system.

Also note that I use a snapshot view as a proxy. You are using a loose image view as a proxy and that might be fine too.

Upvotes: 1

Related Questions