Quantcast
Channel: Kodeco | High quality programming tutorials: iOS, Android, Swift, Kotlin, Unity, and more
Viewing all articles
Browse latest Browse all 4370

How To Make A UIViewController Transition Animation Like in the Ping App

$
0
0
Update note: This tutorial has been updated to Xcode 9/Swift 4 by Luke Parham. The original tutorial was written by Rounak Jain.

Ping UIViewController Transition Animation

A while back, the makers of the anonymous social networking app Secret released an app called Ping, which allowed users to receive notifications about topics they were interested in.

One thing that stood out about Ping, aside from it’s unpredictable recommendations, was the circular transition between the main screen and the menu, as seen in the animation to the right.

Naturally, when you see something cool you want to see if you can figure out how they did it. Even if you’re some kind of nerd who doesn’t think that about every animation you see, you’ll get to learn a lot about view controller transition animations while exploring this animation.

In this tutorial, you’ll learn how to implement this cool animation in Swift using a UIViewController transition animation. In the process, you’ll learn about using shape layers, masking, the UIViewControllerAnimatedTransitioning protocol, the UIPercentDrivenInteractiveTransition class, and more.

Existing knowledge of view controller transition animations is useful, but not required for this tutorial. If you want an intro to the topic first, be sure to check out Custom UIViewController Transitions: Getting Started.

Strategizing

In Ping, the UIViewController transition animation happened when you went from one view controller to another.

In iOS, you can write custom transitions between view controllers by putting both view controllers inside a UINavigationController, and implementing iOS’s UIViewControllerAnimatedTransitioning protocol to animate the transition.

One thing to keep in mind before getting started is that you can implement these animations using any method you want, be it pop, UIView, UIKit Dynamics or the lower-level Core Animation APIs.

In this tutorial, you’ll be focusing on the standard UIView and Core Animation APIs.

Now that you know where the coding action happens, it’s time to think about how to actually implement the circle transition.

Just from looking, a good guess about the animation’s implementation goes something like:

  1. There’s a circle that originates from the button on the top right; it acts as a viewport into the view that’s appearing.
  2. The text of the view controller you’re leaving grows and animates offscreen to the left.
  3. The text of the view controller you’re moving to grows and fades in from the right; within the visible space of the expanding circle.

Now that you know vaguely what you’re going for, it’s time to get started.

Getting Started

To begin, download the starter app. If you build and run you’ll see an app that’s been created to mirror the simple elegance of text on a colored background. Go ahead and do so and tap that circular button a few times.

As you can see, you’ve got a boring old default push and pop animation on your hands. My mom always told me I could do anything if I put my mind to it so now I’m telling you, you can make this transition better!

Navigation Controller Delegates

UINavigationController instances have a delegate property that can be any object that implements the UINavigationControllerDelegate protocol.

The other four methods in this protocol have to do with reacting to view controllers being shown and specifying which orientations are supported, but there are two methods that allow you to specify objects that are responsible for implementing custom transitions.

Before you get too carried away, you’ll want to make a new class that can take care of being this delegate for your app.

With the starter app open and the Pong group selected, press ⌘+N to start adding a new file. Choose Cocoa Touch Class from the options and click Next. Name the new class TransitionCoordinator and make sure it’s set to Subclass of: NSObject and Language: Swift. Hit Next and then Create.

This class needs to adhere to the UINavigationControllerDelegate protocol. Change the class definition line to:

class TransitionCoordinator: NSObject, UINavigationControllerDelegate {

So far so good, next you’ll implement the only delegate method you care about at the moment. Add the following method to TransitionCoordinator:

func navigationController(_ navigationController: UINavigationController,
                          animationControllerFor operation: UINavigationControllerOperation,
                          from fromVC: UIViewController,
                          to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
  return nil
}

All this method needs to do is look at which view controller it’s moving from, along with the one it’s moving to and return an appropriate animation object for the pair.

At the moment, you’re just returning nil which is what’s been happening all along by default. When a navigation controller asks for an animation controller for a certain transition and receives nil, it ends up using the default push and pop transitions you saw earlier.

You’ll come back to this class to return a proper animation controller object in a little bit.

In AppDelegate.swift add the following just below the window property declaration:

let transitionCoordinator = TransitionCoordinator()

This initializes a TransitionCoordinator and keeps a strong reference to it.

Now find the line where you’re hiding the navigation bar:

nav.isNavigationBarHidden = true

After this line, assign the TransitionCoordinator to be the navigation controller’s delegate with the following:

nav.delegate = transitionCoordinator

Build and run to confirm it runs. You won’t see anything new happening yet since the delegate is returning a nil animation.

Yeah, I know it’s boring, I told you!

The UIViewControllerAnimatedTransitioning Protocol

The “animation object” the TransitionCoordinator will return is just something that conforms to UIViewControllerAnimatedTransitioning.

Objects that conform to this protocol have a simple job. They only really need to implement two methods. The first is a method that returns how long the transition will take in seconds. The second is a method that takes in a context object with all the information it needs to actually perform the animation.

A really common pattern is to create the animation object and assign it the UINavigationControllerOperation argument if your transition looks different between pushing and popping.

In this case, you don’t actually need to do it; the transition is the same whether you’re pushing or popping so if you write it generically, it will just work regardless of the direction you’re going.

Now that you know what you need, it’s time to write a new class. Press ⌘+N again, make another Cocoa Touch Class and this time, name it CircularTransition.

The first thing you need to do with your new class is have it conform to the UIViewControllerAnimatedTransitioning protocol. To do so, just add it after the NSObject inheritance declaration like so:

class CircularTransition: NSObject, UIViewControllerAnimatedTransitioning {

Per usual, you’ll immediately be told that your class doesn’t conform to this protocol. Well you just tell Xcode to be cool, cause that’s exactly what you’re about to do!

First, add the method that specifies how long our animation is going to take.

func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?)
  -> TimeInterval {
  return 0.5
}

This is the first method UIKit will call on your transition object after it’s been provided by the navigation controller delegate. Here you’re just saying that this transition should take around half a second to complete.

Next, add an empty definition of the actual animation method that you’ll come back to later.

func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
  //make some magic happen

}

This is where you’ll receive a transition context object that will have all the information you’ll need to write your animation code.

excited?

Head back over to TransitionCoordinator.swift and replace the current nil return statement with something a little more useful:

return CircularTransition()

Here, you’re telling the navigation controller that you’re sick of that boring push and pop transition it keeps trying to use and you’ve got something better in mind. Internally, UIKit will take this UIViewControllerAnimatedTransitioning object and use it to drive the animations for all transitions that occur for this navigation controller from now on.

It is pretty awesome that this is open to you, but remember, with great power, comes a lot of work. So head back to CircularTransition.swift and brace yourself for the real work!

The CircleTransitionable Protocol

If you’ve ever tried writing one of these transitions, you’ve probably figured out that it’s really easy to write code that digs into internal view controller state and feels generally “smelly”. Instead, you’ll define exactly what the view controller needs to provide up front and let any view controller that wishes to animate this way provide access to these views.

Add the following protocol definition at the top of CircularTransition.swift, before the class definition:

protocol CircleTransitionable {
  var triggerButton: UIButton { get }
  var contentTextView: UITextView { get }
  var mainView: UIView { get }
}

This protocol defines the information you’ll need from each view controller in order to successfully animate things.

  1. The triggerButton will be the button the user tapped.
  2. The contentTextView will be the text view to animate on or offscreen.
  3. The mainView will be the main view to animate on or offscreen.

Next, go to ColoredViewController.swift and make it conform to your new protocol by replacing the definition with the following.

class ColoredViewController: UIViewController, CircleTransitionable {

Luckily, this view controller already defines both the triggerButton and contentTextView so it’s already close to ready. The last thing you’ll need to do is add a computed property for the mainView property. Add the following immediately after the definition of contentTextView:

var mainView: UIView {
  return view
}

Here, all you had to do was return the default view property of the view controller.

The project contains a BlackViewController and WhiteViewController that display the two views in the app. Both are subclasses of ColoredViewController so you’ve officially set up both classes to be transitionable. Congrats!

Animating the Old Text Away

At long last, it’s time to do some actual animating!

🎉🎉🎉🎉

Navigate back to CircularTransition.swift and add the following guard statement to animateTransition(transitionContext:).

guard let fromVC = transitionContext.viewController(forKey: .from) as? CircleTransitionable,
  let toVC = transitionContext.viewController(forKey: .to) as? CircleTransitionable,
  let snapshot = fromVC.mainView.snapshotView(afterScreenUpdates: false) else {
    transitionContext.completeTransition(false)
    return
}

Here you’re making sure you have access to all the major pieces of the puzzle. The transitionContext allows you to grab references to the view controllers you’re transitioning between. You cast them to CircleTransitionable so you can later access their main views and text views.

snapshotView(afterScreenUpdates:) returns a snapshotted bitmap of fromVC.

Snapshot views are a really useful way to quickly grab a disposable copy of a view for animations. You can’t animate individual subviews around, but if you just need to animate an entire hierarchy without having to put things back when you’re done then a snapshot is an ideal solution.

In the else clause of your guard you’re calling completeTransition() on the transitionContext. You pass false to tell UIKit that you didn’t complete the transition and that it shouldn’t move to the next view controller.

After the guard, grab a reference to the container view the context provides.

let containerView = transitionContext.containerView

This view is like your scratchpad for adding and removing views on the way to your final destination.

When you’re done animating, you’ll have done the following in containerView:

  1. Removed the fromVC‘s view from the container.
  2. Added the toVC‘s view to the destination with subviews configured as they should be on appearance.

Add the following at the bottom of animateTransition(transitionContext:):

containerView.addSubview(snapshot)

To animate the old text offscreen without messing up the actual text view’s frame, you’ll animate a the snapshot.

Next, remove the actual view you’re coming from since you won’t be needing it anymore.

fromVC.mainView.removeFromSuperview()

Finally, add the following animation method below animateTransition(transitionContext:).

func animateOldTextOffscreen(fromView: UIView) {
  // 1
  UIView.animate(withDuration: 0.25,
                 delay: 0.0,
                 options: [.curveEaseIn],
                 animations: {
    // 2
    fromView.center = CGPoint(x: fromView.center.x - 1300,
                              y: fromView.center.y + 1500)
    // 3
    fromView.transform = CGAffineTransform(scaleX: 5.0, y: 5.0)
  }, completion: nil)
}

This method is pretty straightforward:

  1. You define an animation that will take 0.25 seconds to complete and eases into its animation curve.
  2. You animate the view’s center down and to the left offscreen.
  3. The view is blown up by 5x so the text seems to grow along with the circle that you’ll animate later.

This causes the text to both grow and move offscreen at the same time. The magic numbers probably seem a bit arbitrary, but they came from playing around and seeing what felt best. Feel free to tweak them yourself and see if you can come up with something you like better.

Add the following to the bottom of animateTransition(transitionContext:):

animateOldTextOffscreen(fromView: snapshot)

You pass the snapshot to your new method to animate it offscreen.

Build and run to see your masterpiece so far.

OK, so it’s still not all that impressive, but this is how complex animations are done, one small building block at a time.

Note: There is still one warning in CircularTransition.swift. Don’t worry; you will fix it soon!

Fixing the Background

One annoying thing that’s immediately noticeable is that since the entire view is animating away, you’re seeing a black background behind it.

This black background is the containerView and what you really want is for it to look like just the text is animating away, not the entire background. To fix this, you’ll need to add a new background view that doesn’t get animated.

In CircularTransition.swift, go to animateTransition(using:). After you grab a reference to the containerView and before you add the snapshotView as a subview, add the following code:

let backgroundView = UIView()
backgroundView.frame = toVC.mainView.frame
backgroundView.backgroundColor = fromVC.mainView.backgroundColor

Here you’re creating the backgroundView, setting its frame to be full screen and its background color to match that of the backgroundView.

Then, add your new background as a subview of the containerView.

containerView.addSubview(backgroundView)

Build and run to see your improved animation.

Much better.

The Circular Mask Animation

Now that you’ve got the first chunk done, the next thing you need to do is the actual circular transition where the new view controller animates in from the button’s position.

Start by adding the following method to CircularTransition:

func animate(toView: UIView, fromTriggerButton triggerButton: UIButton) {

}

This will complete the circular transition – you’ll implement it shortly!

In animateTransition(using:), add the following after animateOldTextOffscreen(fromView:snapshot):

containerView.addSubview(toVC.mainView)
animate(toView: toVC.mainView, fromTriggerButton: fromVC.triggerButton)

This adds your final view to the containerView and will animate it – once you’ve implemented the animation!

Now you have the skeleton for the circular transition. However, the real keys to making this animation work are understanding the handy CAShapeLayer class along with the concept of layer masking.

CAShapeLayer

CAShapeLayers are a special class of CALayer that, instead of always rendering as a square, can have their shape defined by first defining a bezier path and then assigning that path to the layer’s path property.

In this case, you’ll define two bezier paths and animate between them.

Add the following logic to the method you added earlier, animate(toView:triggerButton:):

// 1
let rect = CGRect(x: triggerButton.frame.origin.x,
                  y: triggerButton.frame.origin.y,
                  width: triggerButton.frame.width,
                  height: triggerButton.frame.width)
// 2
let circleMaskPathInitial = UIBezierPath(ovalIn: rect)

This creates a bezier path that defines a small circular window into the content, starting at the location of the triggerButton.

You created a:

  1. rect similar to the button’s frame, but with an equal width and height.
  2. bezier path oval from the rect which results in a circle.

Next, create a circle representing the ending state of the animation. Since you’ll only be able to see content inside of the circle, you don’t want any edge of the circle to still be visible by the end of the animation. Add the following below the code you just added:

// 1
let fullHeight = toView.bounds.height
let extremePoint = CGPoint(x: triggerButton.center.x,
                           y: triggerButton.center.y - fullHeight)
// 2
let radius = sqrt((extremePoint.x*extremePoint.x) +
                  (extremePoint.y*extremePoint.y))
// 3
let circleMaskPathFinal = UIBezierPath(ovalIn: triggerButton.frame.insetBy(dx: -radius,
                                                                           dy: -radius))

Here’s what this does:

  1. Defines a point that’s the full screen’s height above the top of the screen.
  2. Calculates the radius of your new circle by using the Pythagorean Theorem: a² + b² = c².
  3. Creates your new bezier path by taking the current frame of the circle and “insetting” it by a negative amount in both directions, thus pushing it out to go fully beyond the bounds of the screen in both directions.

Now that you’ve got your bezier paths set up, it’s time to actually put them to work. Still in animate(toView:triggerButton:), add:

let maskLayer = CAShapeLayer()
maskLayer.path = circleMaskPathFinal.cgPath
toView.layer.mask = maskLayer

This creates a CAShapeLayer layer and sets its path to the circular bezier path. maskLayer is then used as a mask for the destination view.

But wait a second, how exactly do masks work?

CALayer Masking

In general, a mask with an alpha value of 1 shows the layer content underneath, while an alpha value of 0 hides content beneath. Anything in-between partially reveals the layer’s content. Here’s a diagram to explain this:

mask diagram

Basically, you can think of whatever shape you can see as being the shape that’ll be cut out so you can see things underneath. Everything else will end up being hidden. With these bezier paths, the pixels inside the circle have an alpha of 1.0 while the parts outside the bounds of the circle are clear, and therefore make it so you can’t see the masked view at those points.

Now that you’ve got all this set up, the only thing left to do is actually animate between the two circular masks. The tricky thing is, so far you’ve only done UIView animations, but those don’t work for CALayers.

Animations with Core Animation

In this situation, you’ve hit a point where the UIView animation abstraction can no longer help you and you need to drop back down a level.

This was bound to happen sooner or later, but don’t worry, the API is super straightforward. It’s also good to understand since UIView animations are really just CATransactions under the hood anyway.

In contrast to the closure based API of UIView animations, Core Animation animations use an object based approach. This too is an abstraction that breaks down into a CATransaction under the hood, which is actually true for pretty much any view-related thing you do.

Still in animate(toView:triggerButton:), create a CABasicAnimation object that will perform the animation.

let maskLayerAnimation = CABasicAnimation(keyPath: "path")

Here, you create an animation object and tell it that the property that will be animated is the path property. This means you’ll animate the rendered shape.

Next, set the from and to-values for this animation.

maskLayerAnimation.fromValue = circleMaskPathInitial.cgPath
maskLayerAnimation.toValue = circleMaskPathFinal.cgPath

Here, you’re using the two bezier paths you previously created to define the two states your layer should animate between.

The last thing you have to do to configure the animation is tell the object how long to run. Add the following line to do so:

maskLayerAnimation.duration = 0.15

In this case, the animation will run for 0.15 seconds.

Instead of using completion blocks like UIView animations, CAAnimations use a delegate with callbacks to signal completion. While you don’t technically require one for this animation, you’ll implement the delegate to better understand it.

Start by adding the following line:

maskLayerAnimation.delegate = self

This class is now the animation object’s delegate.

Go to the bottom of the file and add this class extension to conform to the CAAnimationDelegate protocol.

extension CircularTransition: CAAnimationDelegate {
  func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {

  }
}

When this animation is complete, you can officially call this whole animation a success. In this callback you want to call completeTransition() on the context object you received at the beginning of the animation.

Unfortunately, this highlights one annoying thing about having to use this delegate callback. To get access to the context object you’ll have to save off a reference to it at the beginning of the main animation method.

First, go to the top of CircularTransition and add:

weak var context: UIViewControllerContextTransitioning?

Then, go to the line after the guard statement in animateTransition(transitionContext:) and save the incoming context for later.

context = transitionContext

Now, go back to animationDidStop(anim:finished:) in the extension and add the following line:

context?.completeTransition(true)

You’re now notifying the system when the animation completes successfully.

Now that you’ve got your animation object all set up, just add it to the maskLayer. Add the following line at the end of animate(toView:triggerButton:).

maskLayer.add(maskLayerAnimation, forKey: "path")

Once again you need to specify that you intend to animate the path of the maskLayer. Once you’ve added an animation to a layer, it will automatically start.

Build and run to see your almost completely finished transition!

The Finishing Touches

For the sake of completeness, you’ll add one more small animation to the transition. Instead of the circle just growing to reveal the destination view controller, you’ll also have the destination view’s text fade-in from the right.

Compared to the last animation, this one’s a breeze. Go to the bottom of the CircularTransition class definition and add the following method:

func animateToTextView(toTextView: UIView, fromTriggerButton: UIButton) {

}

Add the following lines to this new method:

let originalCenter = toTextView.center
toTextView.alpha = 0.0
toTextView.center = fromTriggerButton.center
toTextView.transform = CGAffineTransform(scaleX: 0.1, y: 0.1)

Here, you’re setting the starting state of toTextView. You set its alpha to 0, center it with the trigger button, and scale it to 1/10th its normal size.

Next, add the following UIView animation.

UIView.animate(withDuration: 0.25, delay: 0.1, options: [.curveEaseOut], animations: {
  toTextView.transform = CGAffineTransform(scaleX: 1.0, y: 1.0)
  toTextView.center = originalCenter
  toTextView.alpha = 1.0
}, completion: nil)

Here you’re just undoing everything you just did to animate your text view back to the center, to its full size with a quick fade in.

Finally, add the following call to the bottom of animateTransition(transitionContext:).

animateToTextView(toTextView: toVC.contentTextView, fromTriggerButton: fromVC.triggerButton)

You’re providing animateToTextView with the toVC text view and the fromVC button. Now, it’ll complete the text view animation alongside your other transition animations.

Build and run one final time to have a transition that closely mimics that of the original Ping app!

Where to Go From Here?

To download the completed Pong app, click here.

To see the official Apple documentation for UIViewController transition animation, check out Customizing the Transition Animations in the View Controller Programming Guide for iOS.

Hopefully you enjoyed this tutorial and feel a little more confident about trying to replicate other animations you’ve seen in the wild.

If you’re interested in learning more, feel free to check out our beginning and intermediate animation video courses as well as our full book, iOS Animations by Tutorials.

If you have questions, comments or would like to show off your own cool animations, please join the discussion below!

The post How To Make A UIViewController Transition Animation Like in the Ping App appeared first on Ray Wenderlich.


Viewing all articles
Browse latest Browse all 4370

Trending Articles



<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>