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