Zoom animation with a UICollectionView inside a UINavigationController - ios

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

The problem is that you are obtaining the final frame incorrectly:
let finalFrame = toContent.pictureFrame!
Here's what you should have done:
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!
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:
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.

Related

SWIFT: touchesEnded not called in my tableViewController

So I have a tableviewController called SettingsViewController, and it has the following touchesEnded function:
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
if let touch = touches.first {
print("yoyoyoyoyoyoyoyQVEWEVIWNE")
let touchLocation = touch.location(in: view)
// 290 because the width of the view is 414, and the SettingsViewController width gets set to 0.7 * the view width in SlideInTransition. 0.7 * 414 is 289.8
if touchLocation.x > 200 {
dismiss(animated: true)
}
}
}
I made the print statement to see if it was being called, which it is not. This view controller is presented with a 'menu-esque' slide in custom transition. I have a suspicion that the bounds of the UIView is the problem somehow. Here's the custom transition code:
class SlideInTransition: NSObject, UIViewControllerAnimatedTransitioning {
var isPresenting: Bool = false
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.5
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
// Make sure they exist
// The view controller being transitioned from, using the context (ex: here it's the MapViewController)
guard let fromViewController = transitionContext.viewController(forKey: .from),
// The view controller being transitioned to, using the context (ex: here it's the SettingsTableViewController)
let toViewController = transitionContext.viewController(forKey: .to) else {return}
let containerView = transitionContext.containerView
// Constants for appearance of SettingsViewController
let vcWidth = toViewController.view.bounds.width * 0.7
let vcHeight = toViewController.view.bounds.height
if isPresenting {
// Add SettingsViewController to container
containerView.addSubview(toViewController.view)
// Initial frame for view controller, off the screen to the left to start, that way it appears to slide in
toViewController.view.frame = CGRect(x: -vcWidth, y: 0, width: vcWidth, height: vcHeight)
}
// Animate view controller onto the screen, sliding in from left
let transform = {
toViewController.view.transform = CGAffineTransform(translationX: vcWidth, y: 0)
}
// Animate back off screen
let identity = {
// .identity returns the vc to the initial frame, as created above in the isPresenting if statement
fromViewController.view.transform = .identity
}
// Animation of the transition
let duration = transitionDuration(using: transitionContext)
let isCancelled = transitionContext.transitionWasCancelled
UIView.animate(withDuration: duration, animations: {
// If presenting, transform SettingsViewController (to) onto screen, otherwise set it back off the screen.
self.isPresenting ? transform() : identity()
}) { (Bool) in
transitionContext.completeTransition(!isCancelled)
}
}
}
I made my touchesEnded code so that when the user touches outside the viewController, it dismisses, (the view controller only 70% the width of the screen) but it simply doesn't get called, regardless of where on the screen I tap. Any idea why? Thanks.
https://developer.apple.com/documentation/uikit/uiresponder/1621084-touchesended
«If you override this method without calling super (a common use pattern), you must also override the other methods for handling touch events, even if your implementations do nothing.»
This would be a start.

How to open side menu from tabbar

I am trying to achieve some thing like following side menu open from tabbar item click.
I used the following class for Transition Animation ...
class SlideInTransition: NSObject, UIViewControllerAnimatedTransitioning {
var isPresenting = false
let dimmingView = UIView()
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 3
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let toViewController = transitionContext.viewController(forKey: .to),
let fromViewController = transitionContext.viewController(forKey: .from) else { return }
let containerView = transitionContext.containerView
let finalWidth = toViewController.view.bounds.width * 0.3
let finalHeight = toViewController.view.bounds.height
if isPresenting {
// Add dimming view
dimmingView.backgroundColor = .black
dimmingView.alpha = 0.0
containerView.addSubview(dimmingView)
dimmingView.frame = containerView.bounds
// Add menu view controller to container
containerView.addSubview(toViewController.view)
// Init frame off the screen
toViewController.view.frame = CGRect(x: -finalWidth, y: 0, width: finalWidth, height: finalHeight)
}
// Move on screen
let transform = {
self.dimmingView.alpha = 0.5
toViewController.view.transform = CGAffineTransform(translationX: finalWidth, y: 0)
}
// Move back off screen
let identity = {
self.dimmingView.alpha = 0.0
fromViewController.view.transform = .identity
}
// Animation of the transition
let duration = transitionDuration(using: transitionContext)
let isCancelled = transitionContext.transitionWasCancelled
UIView.animate(withDuration: duration, animations: {
self.isPresenting ? transform() : identity()
}) { (_) in
transitionContext.completeTransition(!isCancelled)
}
}
}
and use it in my code as follow
guard let menuViewController = storyboard?.instantiateViewController(withIdentifier: "MenuVC") as? MenuVC else { return }
menuViewController.modalPresentationStyle = .overCurrentContext
menuViewController.transitioningDelegate = self as? UIViewControllerTransitioningDelegate
menuViewController.tabBarItem.image = UIImage(named: "ico_menu")
menuViewController.tabBarItem.selectedImage = UIImage(named: "ico_menu")
viewControllers = [orderVC,serverdVC,canceledVC,menuViewController]
extension TabbarVC: UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transiton.isPresenting = true
return transiton
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transiton.isPresenting = false
return transiton
}
}
but animation doe't work at all ... I want to open it like side menu over current context ..
How can i achieve some thing like that ...
TabBar is not made to handle animate transition for just a single child View controller. If you apply a custom transition, it will be applied in all of its tabs (child view controllers). Plus last time i checked, airbnb's app doesn't behave like that when opening the user profile. :)
What you can do, though, is have a separate menu button at the top of your navigation view controller or wherever and call the slide in from there:
func slideInView() {
let vcToShow = MenuViewController()
vcToShow.modalPresentationStyle = .overCurrentContext
vcToShow.transitioningDelegate = self as? UIViewControllerTransitioningDelegate
present(vcToShow, animated: true, completion: nil)
}
Or if you insist on having the menu a part of the tabs, then you can do this.
Hope this helps. :)

PushViewController details?

What I am trying to do is a custom animation of pushing ViewController from the left side.
I have created my custom transitioning delegate and I provide my custom animation, and everything works fine (new view slides from the left side).
The only problem is that push animation in iOS isn't only about sliding a view from the right side. The VC being obscured is also slightly moving in the same directions as the VC being pushed. Also, navigation bar kinda blinks. I can of course try to imitate this behaviour by guessing what the parameters should be (for example how much the VC being obscured moves on different iPhones), but maybe it is possible to find the values somewhere?
Help greatly appreciated.
I would create a UIViewControllerAnimatedTransitioning protocol abiding object
class CustomHorizontalSlideTransition: NSObject, UIViewControllerAnimatedTransitioning {
var operation: UINavigationControllerOperation = .Push
convenience init(operation: UINavigationControllerOperation) {
self.init()
self.operation = operation
}
func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval {
return 0.5
}
func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
let containerView = transitionContext.containerView()
let disappearingVC = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey)!
let appearingVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey)!
let bounds = UIScreen.mainScreen().bounds
if self.operation == .Push {
appearingVC.view.frame = CGRectOffset(bounds, -bounds.size.height, 0)
containerView!.addSubview(disappearingVC.view)
containerView!.addSubview(appearingVC.view)
} else {
appearingVC.view.frame = bounds
disappearingVC.view.frame = bounds
containerView!.addSubview(appearingVC.view)
containerView!.addSubview(disappearingVC.view)
}
UIView.animateWithDuration(transitionDuration(transitionContext),
delay: 0.0,
options: UIViewAnimationOptions.CurveEaseInOut,
animations: { () -> Void in
if self.operation == .Push {
appearingVC.view.frame = bounds
} else {
disappearingVC.view.frame = CGRectOffset(bounds, -bounds.size.width, 0)
}
}) { (complete) -> Void in
transitionContext.completeTransition(true)
}
}
}
Then in your "From" and "To" view controllers, set the navigationController's delegate to self in view ViewDidAppear
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
navigationController?.delegate = self
}
The in both view controllers, override the following to provide a transitionAnimatedTransition delegate method and return the protocol abiding instance for your animation
override func transitionAnimatedTransition(operation: UINavigationControllerOperation) -> UIViewControllerAnimatedTransitioning? {
return CustomHorizontalSlideTransition(operation: operation)
}

Swift - How to do a custom slide animation?

I've been looking for swift code to make simple custom slide transitions between views (just left to right or right to left, without bounce) but I only found code for complicated animations. Thanks everyone for your help !
Oscar
I finally found the answer here : http://mathewsanders.com/animated-transitions-in-swift/#custom-transition-animations and adpated it a little bit.
1) Create this Swift NSObject file
class TransitionManager2: NSObject, UIViewControllerAnimatedTransitioning, UIViewControllerTransitioningDelegate {
private var presenting = true
// MARK: UIViewControllerAnimatedTransitioning protocol methods
// animate a change from one viewcontroller to another
func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
// get reference to our fromView, toView and the container view that we should perform the transition in
let container = transitionContext.containerView()
let fromView = transitionContext.viewForKey(UITransitionContextFromViewKey)!
let toView = transitionContext.viewForKey(UITransitionContextToViewKey)!
// set up from 2D transforms that we'll use in the animation
let offScreenRight = CGAffineTransformMakeTranslation(container.frame.width, 0)
let offScreenLeft = CGAffineTransformMakeTranslation(-container.frame.width, 0)
// prepare the toView for the animation
toView.transform = self.presenting ? offScreenRight : offScreenLeft
// set the anchor point so that rotations happen from the top-left corner
toView.layer.anchorPoint = CGPoint(x:0, y:0)
fromView.layer.anchorPoint = CGPoint(x:0, y:0)
// updating the anchor point also moves the position to we have to move the center position to the top-left to compensate
toView.layer.position = CGPoint(x:0, y:0)
fromView.layer.position = CGPoint(x:0, y:0)
// add the both views to our view controller
container.addSubview(toView)
container.addSubview(fromView)
// get the duration of the animation
// DON'T just type '0.5s' -- the reason why won't make sense until the next post
// but for now it's important to just follow this approach
let duration = self.transitionDuration(transitionContext)
// perform the animation!
// for this example, just slid both fromView and toView to the left at the same time
// meaning fromView is pushed off the screen and toView slides into view
// we also use the block animation usingSpringWithDamping for a little bounce
UIView.animateWithDuration(duration, delay: 0.0, usingSpringWithDamping: 1, initialSpringVelocity: 1, options: nil, animations: {
// slide fromView off either the left or right edge of the screen
// depending if we're presenting or dismissing this view
fromView.transform = self.presenting ? offScreenLeft : offScreenRight
toView.transform = CGAffineTransformIdentity
}, completion: { finished in
// tell our transitionContext object that we've finished animating
transitionContext.completeTransition(true)
})
}
// return how many seconds the transiton animation will take
func transitionDuration(transitionContext: UIViewControllerContextTransitioning) -> NSTimeInterval {
return 0.4
}
// MARK: UIViewControllerTransitioningDelegate protocol methods
// return the animataor when presenting a viewcontroller
// remmeber that an animator (or animation controller) is any object that aheres to the UIViewControllerAnimatedTransitioning protocol
func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
// these methods are the perfect place to set our `presenting` flag to either true or false - voila!
self.presenting = true
return self
}
// return the animator used when dismissing from a viewcontroller
func animationControllerForDismissedController(dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
self.presenting = false
return self
}
}
2) Change the segue between the 2 ViewControllers to "Custom"
3) Add in the first ViewController this code :
let transitionManager = TransitionManager2()
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
// this gets a reference to the screen that we're about to transition to
let toViewController = segue.destinationViewController as! UIViewController
// instead of using the default transition animation, we'll ask
// the segue to use our custom TransitionManager object to manage the transition animation
toViewController.transitioningDelegate = self.transitionManager
}
What you need to do is subclass UIStoryboardSegue Class and override the perform method.
The code inside your perform method would be something like this
var ourOriginViewController = self.sourceViewController as! UIViewController
ourOriginViewController.navigationController?.pushViewController(self.destinationViewController as! UIViewController, animated: false)
var transitionView = ourOriginViewController.navigationController?.view
UIView.transitionWithView(transitionView!, duration: 1, options: UIViewAnimationOptions.TransitionFlipFromRight, animations: { () -> Void in
}) { (success) -> Void in
}
Assign this segue class to your custom segue in storyboard
Attaching screenshot for reference

iOS Swift: UIViewControllerAnimatedTransitioning end frame in wrong position

I have a Swift project, learning a weather API and also trying to get a better handle on the AnimatedTransitions. I have a UITableView using a custom UITableViewCell with images and text. Tapping a cell in the tableView transitions to a new UIViewController as a Show (push), with the whole thing embedded in a UINavigationController.
When the transition is invoked, the image from the cell is supposed to move to the final location of the UIImageView on the destination viewController. However, what it does is move past that point to the far side of the screen before the transition completes and the view changes, making the image appear to snap back to the center of the view.
I have read a lot of tutorials trying to fix this and have read a lot of StackOverflow but have failed to figure it out. Can someone point out to me what I have missed, please? I'm going crazy, here.
The segue that invokes the transition, in the original ViewController:
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
self.performSegueWithIdentifier("SHOW_DETAIL", sender: self)
}
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
if segue.identifier == "SHOW_DETAIL" {
let detailVC = segue.destinationViewController as DetailViewController
let indexPathForForecast = self.tableView.indexPathForSelectedRow() as NSIndexPath!
let detailForecast = self.forecasts?[indexPathForForecast.row]
let cell = self.tableView.cellForRowAtIndexPath(indexPathForForecast) as WeatherCell
let image = cell.forecastImage.image
detailVC.forecastForDetail = detailForecast
detailVC.forecastDetailImage = image
}
}
func navigationController(navigationController: UINavigationController, animationControllerForOperation operation: UINavigationControllerOperation, fromViewController fromVC: UIViewController, toViewController toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if fromVC == self && toVC.isKindOfClass(DetailViewController) {
let transitionVC = AnimateToDetailVCController()
return transitionVC
} else {
return nil
}
}
And here's the animateTransition code from the UIViewControllerAnimatedTransitioning object (EDIT: solution code edited into code block, thanks #jrturton!)
func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
let fromViewController = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey) as ViewController
let toViewController = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) as DetailViewController
let containerView = transitionContext.containerView()
let duration = self.transitionDuration(transitionContext)
let selectedRow = fromViewController.tableView.indexPathForSelectedRow()
let cell = fromViewController.tableView.cellForRowAtIndexPath(selectedRow!) as WeatherCell
let weatherSnapshot = cell.forecastImage.snapshotViewAfterScreenUpdates(false)
weatherSnapshot.frame = containerView.convertRect(cell.forecastImage.frame, fromView: fromViewController.tableView.cellForRowAtIndexPath(selectedRow!)?.superview)
cell.forecastImage.hidden = true
toViewController.view.frame = transitionContext.finalFrameForViewController(toViewController)
toViewController.view.alpha = 0
toViewController.detailImageView.hidden = true
containerView.addSubview(toViewController.view)
containerView.addSubview(weatherSnapshot)
var toFrame = toViewController.locationIs
UIView.animateWithDuration(duration, animations: { () -> Void in
// EDIT: This solved the issue, thanks JRTurton!
toViewController.view.setNeedsLayout() // Solution: This is where it was needed
toViewController.view.layoutIfNeeded() // Solution: This is where it was needed
toViewController.view.alpha = 1.0
var endRect = containerView.convertRect(toViewController.detailImageView.frame, fromView: toViewController.view)
weatherSnapshot.frame = endRect
}) { (finished) -> Void in
toViewController.detailImageView.hidden = false
cell.forecastImage.hidden = false
weatherSnapshot.removeFromSuperview()
transitionContext.completeTransition(true)
}
}
It's hard to say, but here's my guess: you're using size classes, designing at the any/any size, and the image in your to view controller is still centered in respect of that when you get its frame to use for your animation, making it too far to the right. Once the transition is complete, a layout pass happens and it gets corrected.
To fix, after you set the frame of the to view controller, force a layout pass:
toViewController.view.setNeedsLayout()
toViewController.view.layoutIfNeeded()
Before making the above change, you can first confirm if this is the issue by checking the image view's frame before the animation.

Resources