Home > Software design >  View’s frame jumps right before dismiss animation, using custom UIViewControllerTransitioningDelegat
View’s frame jumps right before dismiss animation, using custom UIViewControllerTransitioningDelegat

Time:12-20

So, straight to the point:

I’m using a custom UIViewControllerTransitioningDelegate, that provide a custom UIPresentationController and present/dismiss animations, to animate a view from one view controller to another. When an image is taped in a table view cell in the first view controller, the image is presented in full screen in the second view controller, animating from its position in the table view cell to its position in the presented view controller.

The gifs below shows what is going on. Note that everything works smooth for the present animation, but not for the dismiss animation.

Cell with image at the bottom of the screen Cell with image at the top of the screen

The issue I’m having is that when the dismiss animation fires, it looks like the frame of the animated view gets offset or transformed in a way. And I cant figure out why! The frame at the start of the animation is untouched (by me at least), and the frame at the end of the animation is the same as the frame for the present animation - which works perfectly fine!

Anyone has any idea of what is going on?

The code for my custom UIViewControllerTransitioningDelegate is provided below.

//
//  FullScreenTransitionManager.swift
//

import Foundation
import UIKit

// MARK: FullScreenPresentationController

final class FullScreenPresentationController: UIPresentationController {
    private lazy var backgroundView: UIVisualEffectView = {
        let blurVisualEffectView = UIVisualEffectView(effect: blurEffect)
        blurVisualEffectView.effect = nil
        return blurVisualEffectView
    }()
    
    private let blurEffect = UIBlurEffect(style: .systemThinMaterial)
    
    private lazy var tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(onTap))
    
    @objc private func onTap(_ gesture: UITapGestureRecognizer) {
        presentedViewController.dismiss(animated: true)
    }
    
    override func presentationTransitionWillBegin() {
        guard let containerView = containerView else { return }
        
        containerView.addGestureRecognizer(tapGestureRecognizer)
        
        containerView.addSubview(backgroundView)
        backgroundView.frame = containerView.frame
        
        guard let transitionCoordinator = presentingViewController.transitionCoordinator else { return }
        
        transitionCoordinator.animate(alongsideTransition: { context in
            self.backgroundView.effect = self.blurEffect
        })
    }
    
    override func presentationTransitionDidEnd(_ completed: Bool) {
        if !completed {
            backgroundView.removeFromSuperview()
            containerView?.removeGestureRecognizer(tapGestureRecognizer)
        }
    }
    
    override func dismissalTransitionWillBegin() {
        guard let transitionCoordinator = presentingViewController.transitionCoordinator else { return }
        
        transitionCoordinator.animate(alongsideTransition: { context in
            self.backgroundView.effect = nil
        })
    }
    
    override func dismissalTransitionDidEnd(_ completed: Bool) {
        if completed {
            backgroundView.removeFromSuperview()
            containerView?.removeGestureRecognizer(tapGestureRecognizer)
        }
    }
    
    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        guard
            let containerView = containerView,
            let presentedView = presentedView
        else { return }
        coordinator.animate(alongsideTransition: { context in
            self.backgroundView.frame = containerView.frame
            presentedView.frame = self.frameOfPresentedViewInContainerView
        })
    }
}

// MARK: FullScreenTransitionManager

final class FullScreenTransitionManager: NSObject, UIViewControllerTransitioningDelegate {
    private weak var anchorView: UIView?
    
    init(anchorView: UIView) {
        self.anchorView = anchorView
    }
    
    func presentationController(forPresented presented: UIViewController,
                                presenting: UIViewController?,
                                source: UIViewController) -> UIPresentationController? {
        FullScreenPresentationController(presentedViewController: presented, presenting: presenting)
    }
    
    func animationController(forPresented presented: UIViewController,
                             presenting: UIViewController,
                             source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        guard let anchorView = anchorView, let anchorViewSuperview = anchorView.superview else { return nil }
        let anchorViewFrame = CGRect(origin: anchorViewSuperview.convert(anchorView.frame.origin, to: nil), size: anchorView.frame.size)
        let anchorViewTag = anchorView.tag
        return FullScreenAnimationController(animationType: .present, anchorViewFrame: anchorViewFrame, anchorViewTag: anchorViewTag)
    }

    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        guard let anchorView = anchorView, let anchorViewSuperview = anchorView.superview else { return nil }
        let anchorViewFrame = CGRect(origin: anchorViewSuperview.convert(anchorView.frame.origin, to: nil), size: anchorView.frame.size)
        let anchorViewTag = anchorView.tag
        return FullScreenAnimationController(animationType: .dismiss, anchorViewFrame: anchorViewFrame, anchorViewTag: anchorViewTag)
    }
}

// MARK: UIViewControllerAnimatedTransitioning

final class FullScreenAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
    enum AnimationType {
        case present
        case dismiss
    }
    
    private let animationType: AnimationType
    private let anchorViewFrame: CGRect
    private let anchorViewTag: Int
    private let animationDuration: TimeInterval
    private var propertyAnimator: UIViewPropertyAnimator?
    
    init(animationType: AnimationType, anchorViewFrame: CGRect, anchorViewTag: Int, animationDuration: TimeInterval = 0.3) {
        self.animationType = animationType
        self.anchorViewFrame = anchorViewFrame
        self.anchorViewTag = anchorViewTag
        self.animationDuration = animationDuration
    }
    
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        animationDuration
    }
    
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        switch animationType {
        case .present:
            guard
                let toViewController = transitionContext.viewController(forKey: .to)
            else {
                return transitionContext.completeTransition(false)
            }
            transitionContext.containerView.addSubview(toViewController.view)
            propertyAnimator = presentAnimator(with: transitionContext, animating: toViewController)
        case .dismiss:
            guard
                let fromViewController = transitionContext.viewController(forKey: .from)
            else {
                return transitionContext.completeTransition(false)
            }
            propertyAnimator = dismissAnimator(with: transitionContext, animating: fromViewController)
        }
    }
    
    private func presentAnimator(with transitionContext: UIViewControllerContextTransitioning,
                                 animating viewController: UIViewController) -> UIViewPropertyAnimator {
        let window = transitionContext.containerView.window!
        let finalRootViewFrame = transitionContext.finalFrame(for: viewController)
        viewController.view.frame = finalRootViewFrame
        viewController.view.setNeedsUpdateConstraints()
        viewController.view.setNeedsLayout()
        viewController.view.layoutIfNeeded()
        let view: UIView = viewController.view.viewWithTag(anchorViewTag) ?? viewController.view
        let finalFrame = view.frame
        view.frame = CGRect(origin: window.convert(anchorViewFrame.origin, to: view.superview!), size: anchorViewFrame.size)
        view.setNeedsUpdateConstraints()
        view.setNeedsLayout()
        view.layoutIfNeeded()
        return UIViewPropertyAnimator.runningPropertyAnimator(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveEaseInOut], animations: {
            view.frame = finalFrame
            view.setNeedsUpdateConstraints()
            view.setNeedsLayout()
            view.layoutIfNeeded()
        }, completion: { _ in
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        })
    }
    
    private func dismissAnimator(with transitionContext: UIViewControllerContextTransitioning,
                                 animating viewController: UIViewController) -> UIViewPropertyAnimator {
        let window = transitionContext.containerView.window!
        let view: UIView = viewController.view.viewWithTag(anchorViewTag) ?? viewController.view
        let finalFrame = CGRect(origin: window.convert(anchorViewFrame.origin, to: view.superview!), size: anchorViewFrame.size)
        viewController.view.setNeedsUpdateConstraints()
        viewController.view.setNeedsLayout()
        viewController.view.layoutIfNeeded()
        return UIViewPropertyAnimator.runningPropertyAnimator(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveEaseInOut], animations: {
            view.frame = finalFrame
            view.setNeedsUpdateConstraints()
            view.setNeedsLayout()
            view.layoutIfNeeded()
        }, completion: { _ in
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        })
    }
}

Adding the code for my FullScreenImageViewController as well.

//
//  FullScreenImageViewController.swift
//

import UIKit
import TinyConstraints

class FullScreenImageViewController: UIViewController {
    private let imageView: UIImageView = {
        let image = UIImage(named: "Bananas")!
        let imageView = UIImageView(image: image)
        let aspectRatio = imageView.intrinsicContentSize.width / imageView.intrinsicContentSize.height
        imageView.contentMode = .scaleAspectFit
        imageView.widthToHeight(of: imageView, multiplier: aspectRatio)
        return imageView
    }()
    
    private lazy var imageViewWidthConstraint = imageView.widthToSuperview(relation: .equalOrLess)
    private lazy var imageViewHeightConstraint = imageView.heightToSuperview(relation: .equalOrLess)
    
    init(tag: Int) {
        super.init(nibName: nil, bundle: nil)
        imageView.tag = tag
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()

        configureUI()
    }
  
    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        super.traitCollectionDidChange(previousTraitCollection)
        traitCollectionChanged(from: previousTraitCollection)
    }
   
    private func configureUI() {
        view.backgroundColor = .clear
        
        view.addSubview(imageView)
        
        imageView.centerInSuperview()
        
        traitCollectionChanged(from: nil)
    }
    
    private func traitCollectionChanged(from previousTraitCollection: UITraitCollection?) {
        if traitCollection.horizontalSizeClass != .compact {
            // Landscape
            imageViewWidthConstraint.isActive = false
            imageViewHeightConstraint.isActive = true
        } else {
            // Portrait
            imageViewWidthConstraint.isActive = true
            imageViewHeightConstraint.isActive = false
        }
    }
}

And the code for actually presenting the FullScreenImageViewController (just for good measure)

//
//  ViewController.swift
//

import UIKit

class ViewController: UITableViewController {
    // ...
    // ...
    
    private var fullScreenTransitionManager: FullScreenTransitionManager?

    // ...
    // ...
    
    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        guard let cell = tableView.cellForRow(at: indexPath) as? TableViewCell else { return }
        let fullScreenTransitionManager = FullScreenTransitionManager(anchorView: cell.bananaImageView)
        let viewController = FullScreenImageViewController(tag: cell.bananaImageView.tag)
        viewController.modalPresentationStyle = .custom
        viewController.transitioningDelegate = fullScreenTransitionManager
        present(viewController, animated: true)
        self.fullScreenTransitionManager = fullScreenTransitionManager
    }
}

CodePudding user response:

I managed to figure it out after playing around for a good while. And I think I've had a feeling about what the the problem was all along...

Short answer: Do not try to animate views by changing their .frame or .bounds when using Auto Layout Constraints. Changing these properties might cause undefined behaviour (like the one I experienced). Instead, animate views by changing their constraints or the .center and/or .transform property. These properties do not conflict with the layout engine. When querying a view for its size, use the .bounds property, since this property is more reliable than .frame when using Auto Layout Constraints.

Slightly longer answer: Since I was using Auto Layout Constraints all over the place, combining it with manually changing the frames of views during animation did not work. Or more correct - had undefined behaviours. Since the Auto Layout Engine uses constraints to modify the view's frame for you, you should avoid touching the .frame (and .bounds) property yourself. Instead, animate your views by changing properties like .center and .transform. It seems like these properties do not conflict with Auto Layout, and changes to these properties will be applied to your views after the Auto Layout Engine has done its calculations. Event thought changing the .frame and .bounds of the view might work sometimes in combinations with Auto Layout Constraints, like I experienced with my custom presentation animation (which seemed to work flawlessly!), you should really avoid it. A workaround in some cases might be to temporary turn .translatesAutoresizingMaskIntoConstraints == true, but this is really not a god idea since it causes UIKit to generate Auto Layout Constraints for you, and those constraints might conflict with your own constraints. When querying a view for its size, use the .bounds property, since this property is more reliable than .frame when using Auto Layout Constraints and the .transform property.

Worthy mentions from the Apple documentation:

UIView.center:

Use this property, instead of the frame property, when you want to change the position of a view. The center point is always valid, even when scaling or rotation factors are applied to the view's transform. Changes to this property can be animated.

UIView.transform:

In iOS 8.0 and later, the transform property does not affect Auto Layout. Auto layout calculates a view’s alignment rectangle based on its untransformed frame.

Warning: When the value of this property is anything other than the identity transform, the value in the frame property is undefined and should be ignored.

UIView.translatesAutoresizingMaskIntoConstraints:

If this property’s value is true, the system creates a set of constraints that duplicate the behavior specified by the view’s autoresizing mask. This also lets you modify the view’s size and location using the view’s frame, bounds, or center properties, allowing you to create a static, frame-based layout within Auto Layout.

Note that the autoresizing mask constraints fully specify the view’s size and position; therefore, you cannot add additional constraints to modify this size or position without introducing conflicts. If you want to use Auto Layout to dynamically calculate the size and position of your view, you must set this property to false, and then provide a non ambiguous, nonconflicting set of constraints for the view.

By default, the property is set to true for any view you programmatically create. If you add views in Interface Builder, the system automatically sets this property to false.

For those interested, below is my final code for the custom UIViewControllerTransitioningDelegate. Only using Auto Layout Constraints, and only modifying the view properties mentioned above. Note: I'm using TinyConstraints to make writing constraints more pleasant.

//
//  FullScreenTransitionManager.swift
//

import Foundation
import UIKit
import TinyConstraints

// MARK: FullScreenPresentationController

final class FullScreenPresentationController: UIPresentationController {
    private lazy var backgroundView: UIVisualEffectView = {
        let blurVisualEffectView = UIVisualEffectView(effect: blurEffect)
        blurVisualEffectView.effect = nil
        return blurVisualEffectView
    }()
    
    private let blurEffect = UIBlurEffect(style: .systemThinMaterial)
    
    private lazy var tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(onTap))
    
    @objc private func onTap(_ gesture: UITapGestureRecognizer) {
        presentedViewController.dismiss(animated: true)
    }
    
    override func presentationTransitionWillBegin() {
        guard let containerView = containerView else { return }
        
        containerView.addGestureRecognizer(tapGestureRecognizer)
        
        containerView.addSubview(backgroundView)
        backgroundView.edgesToSuperview()
        
        guard let transitionCoordinator = presentingViewController.transitionCoordinator else { return }
        
        transitionCoordinator.animate(alongsideTransition: { context in
            self.backgroundView.effect = self.blurEffect
        })
    }
    
    override func presentationTransitionDidEnd(_ completed: Bool) {
        if !completed {
            backgroundView.removeFromSuperview()
            containerView?.removeGestureRecognizer(tapGestureRecognizer)
        }
    }
    
    override func dismissalTransitionWillBegin() {
        guard let transitionCoordinator = presentingViewController.transitionCoordinator else { return }
        
        transitionCoordinator.animate(alongsideTransition: { context in
            self.backgroundView.effect = nil
        })
    }
    
    override func dismissalTransitionDidEnd(_ completed: Bool) {
        if completed {
            backgroundView.removeFromSuperview()
            containerView?.removeGestureRecognizer(tapGestureRecognizer)
        }
    }
}

// MARK: FullScreenTransitionManager

final class FullScreenTransitionManager: NSObject, UIViewControllerTransitioningDelegate {
    private weak var anchorView: UIView?
    
    init(anchorView: UIView) {
        self.anchorView = anchorView
    }
    
    func presentationController(forPresented presented: UIViewController,
                                presenting: UIViewController?,
                                source: UIViewController) -> UIPresentationController? {
        FullScreenPresentationController(presentedViewController: presented, presenting: presenting)
    }
    
    func animationController(forPresented presented: UIViewController,
                             presenting: UIViewController,
                             source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        guard let anchorView = anchorView else { return nil }
        return FullScreenAnimationController(animationType: .present, anchorView: anchorView)
    }

    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        guard let anchorView = anchorView else { return nil }
        return FullScreenAnimationController(animationType: .dismiss, anchorView: anchorView)
    }
}

// MARK: UIViewControllerAnimatedTransitioning

final class FullScreenAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
    fileprivate enum AnimationType {
        case present
        case dismiss
    }
    
    private let animationType: AnimationType
    private let anchorViewCenter: CGPoint
    private let anchorViewSize: CGSize
    private let anchorViewTag: Int
    private let animationDuration: TimeInterval
    private var propertyAnimator: UIViewPropertyAnimator?
    
    fileprivate init(animationType: AnimationType, anchorView: UIView, animationDuration: TimeInterval = 0.3) {
        self.animationType = animationType
        self.anchorViewCenter = anchorView.superview?.convert(anchorView.center, to: nil) ?? .zero
        self.anchorViewSize = anchorView.bounds.size
        self.anchorViewTag = anchorView.tag
        self.animationDuration = animationDuration
    }
    
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        animationDuration
    }
    
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        switch animationType {
        case .present:
            guard
                let toViewController = transitionContext.viewController(forKey: .to)
            else {
                return transitionContext.completeTransition(false)
            }
            transitionContext.containerView.addSubview(toViewController.view)
            toViewController.view.edgesToSuperview()
            toViewController.view.layoutIfNeeded() // Force a layout update so that the view is ready for the animator
            propertyAnimator = presentAnimator(with: transitionContext, animating: toViewController)
        case .dismiss:
            guard
                let fromViewController = transitionContext.viewController(forKey: .from)
            else {
                return transitionContext.completeTransition(false)
            }
            propertyAnimator = dismissAnimator(with: transitionContext, animating: fromViewController)
        }
    }
    
    private func presentAnimator(with transitionContext: UIViewControllerContextTransitioning,
                                 animating viewController: UIViewController) -> UIViewPropertyAnimator {
        let view: UIView = viewController.view.viewWithTag(anchorViewTag) ?? viewController.view
        let finalSize = view.bounds.size
        let finalCenter = view.center
        view.transform = CGAffineTransform(scaleX: anchorViewSize.width / finalSize.width,
                                           y: anchorViewSize.height / finalSize.height)
        view.center = view.superview!.convert(anchorViewCenter, from: nil)
        return UIViewPropertyAnimator.runningPropertyAnimator(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveEaseInOut], animations: {
            view.transform = .identity
            view.center = finalCenter
        }, completion: { _ in
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        })
    }
    
    private func dismissAnimator(with transitionContext: UIViewControllerContextTransitioning,
                                 animating viewController: UIViewController) -> UIViewPropertyAnimator {
        let view: UIView = viewController.view.viewWithTag(anchorViewTag) ?? viewController.view
        let initialSize = view.bounds.size
        let finalCenter = view.superview!.convert(anchorViewCenter, from: nil)
        let finalTransform = CGAffineTransform(scaleX: self.anchorViewSize.width / initialSize.width,
                                               y: self.anchorViewSize.height / initialSize.height)
        return UIViewPropertyAnimator.runningPropertyAnimator(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveEaseInOut], animations: {
            view.transform = finalTransform
            view.center = finalCenter
        }, completion: { _ in
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        })
    }
}
  • Related