Chapters

Hide chapters

iOS Animations by Tutorials

Seventh Edition · iOS 15 · Swift 5.5 · Xcode 13

Section IV: Layer Animations

Section 4: 9 chapters
Show chapters Hide chapters

24. Interactive Animations With UIViewPropertyAnimator
Written by Marin Todorov

You’ve already covered a lot of the UIViewPropertyAnimator APIs such as basic animations, custom timings and springs, and abstracting of animations. But you haven’t yet looked into what makes this class really interesting compared to the old style “fire-and-forget” APIs.

UIView.animate(withDuration:...) offers a way to animate views on screen, but once you’ve defined the desired end state, the animations are sent off for rendering and control is out of your hands.

But what if you wanted to interact with the animations? Or to create animations, which aren’t static but are driven by user gestures or microphone input like you did in the part of the book covering layer animations?

This is where UIViewPropertyAnimator really comes through in regard to animating views. The animations created with this class are fully interactive: you can start, pause them, and alter their speed. Finally you can simply “scrub-through” the animation by directly setting the current progress.

Since UIViewPropertyAnimator can drive both preset animations and interactive animations, things get a bit complicated when it comes to telling what state an animator is currently. The next part of the chapter will teach you how to deal with animator state.

If you have completed the challenge in the previous chapter, just keep working on your Xcode project; if you skipped over the challenge, open the starter project provided for this chapter.

You should have the project featuring different animations which kick in when you enter text in the search bar, tap on an icon, or expand the widget view.

An Animation State Machine

Besides taking care of your animations, UIViewPropertyAnimator exhibits behaviors of a state machine, and can give you information about many different aspects of the current state of your animations.

You can check if an animation has started, if it has been paused or completely stopped, or whether the animation has been reversed. And finally, you can check where the animation “completed”, such as at the desired end state, from the beginning, or somewhere in between.

There are three properties on UIViewPropertyAnimator that help you figure out the current state:

The isRunning property (read-only) tells you if the animator’s animations are currently in motion. The property is false by default and becomes true when startAnimation() is called. It becomes false again if you pause or stop the animations, or your animations complete naturally.

The isReversed property is, by default, false since you always start your animations in forward direction, i.e. your animation plays from its start state to its end state. If you change this property to true, the animation will reverse direction and play back to its initial state.

The state property (read-only) determines whether the animator is active and currently animating, or in some other passive state.

By default, state is inactive. This usually means you’ve just created the animator and haven’t called any methods on it yet. Please note that this is not the same as having isRunning set to false: isRunning is really only concerned with animations being played, while when state is inactive that really means that the animator hasn’t done anything much yet.

state becomes active when you either:

  • Call startAnimation() to start your animations
  • Call pauseAnimation() without even starting your animations first,
  • Set the fractionComplete property to “rewind” the animation to a certain position.

Once your animations complete naturally, state switches back to inactive.

If you call stopAnimation() on your animator, it will set its state property to stopped. In this state, the only thing you could do is either abandon the animator altogether or call finishAnimation(at:) to complete the animations and bring the animator back to the inactive state.

As you probably figured out, UIViewPropertyAnimator can only switch between states in a certain sequence. It can’t go straight from inactive to stopped, nor from stopped to active.

There is one more option under your control: if you set the property called pausesOnCompletion, once the animator has finished running its animations instead of stopping itself it will pause. This will give you the opportunity to continue working with it from a paused state.

If you’re in doubt, you can always come back to this part of the chapter and consult the state flow diagram below:

Don’t worry if managing the state with UIViewPropertyAnimator sounds a bit complicated at first. Should you call a method you’re not allowed to call in the current state, your app will immediately crash so you will have the chance to figure out where you went wrong.

Interactive 3D Touch Animation

In this part of the chapter, you are going to create an interactive animation similar to the 3D touch interaction on your iPhone home screen:

Note: For this section, you’ll need either a 3D touch compatible iOS device, or a Force Touch trackpad for the simulator. A device is better as the 3D touch gives you finer control. Apple stopped building 3D touch into iPhones when the iPhone 11 was released. The iPhone 8 simulator in Xcode 13 supports the feature.

As you continue to press on a home screen icon, you’ll see the animation interactively progress under your finger; the background gets more and more blurred, and there’s a light blur frame growing out of the icon.

These two animations tell the user they are working through a gesture and gives them feedback about their progress through that animation. When you’ve pressed hard enough, the icon frame detaches from the icon and becomes a menu:

It’s a neat little interactive animation, which you will get to reproduce in this chapter.

Note: You are not going to learn the details about handling 3D touch with UIPreviewInteractionDelegate, since the chapter is about creating animations. If you want to learn more about UIPreviewInteractionDelegate, check out our iOS 10 by Tutorials book on raywenderlich.com.

Open WidgetView.swift and find the extension on WidgetView that conforms to UIPreviewInteractionDelegate. These are the delegate methods that UIKit calls when the user is pressing on your widget view.

In order to get you started developing the animation itself, the UIPreviewInteractionDelegate methods have already been wired to call relevant methods on LockScreenViewController.

What the code in WidgetView does is as follows:

  • Call LockScreenViewController.startPreview(for:) when the 3D touch starts.

  • Call LockScreenViewController.updatePreview(percent:) repeatedly while the user presses harder (or softer).

  • Call LockScreenViewController.finishPreview() when the peek interaction has finished successfully.

  • Finally, call LockScreenViewController.cancelPreview() if the user lifted their finger without completing the preview gesture.

Without further ado, let’s get to coding!

Open LockScreenViewController.swift and add these three properties, which you will need in order to create the peek interaction:

var startFrame: CGRect?
var previewView: UIView?
var previewAnimator: UIViewPropertyAnimator?

You will use startFrame to track where the animation started. previewView will be a snapshot view of your icon; you’ll use it temporarily during the animation.

The previewAnimator will be the interactive animator driving the preview animation.

Add one more property to hold the blur effect to display the icon frame (as in the screenshots above):

let previewEffectView = IconEffectView(blur: .extraLight)

IconEffectView is a custom class included with the starter project. It’s a simple blur view which contains a single label. You’re going to use it to mock the menu that pops out of the pressed icon like so:

Scroll down to extension LockScreenViewController: WidgetsOwnerProtocol and insert a new method inside:

func startPreview(for forView: UIView) {
  previewView?.removeFromSuperview()
  if let preview = forView.snapshotView(afterScreenUpdates: false) {
    previewView = preview
    view.insertSubview(preview, aboveSubview: blurView)
  }
}

As you saw previously, WidgetView calls startPreview(for:) whenever the user starts pressing on an icon. The forView parameter is the collection cell image that the user began the gesture on.

First you remove any existing previewView view, just in case to make sure you don’t leave artifacts on screen. Then you make a snapshot of the collection view icon and finally add it onscreen just above the blur effect view.

You can run the app right now and start pressing on an icon. You will see a copy of the icon popup at the top left corner!

Of course the icon isn’t covering the existing one because you haven’t set its position. Let’s keep on building the animation:

preview.frame = forView.convert(forView.bounds, to: view)
startFrame = preview.frame
addEffectView(below: preview)

You set the correct position on the icon copy so that it covers completely the existing icon. Then you store that start location and size for future reference in startFrame. Finally you call addEffectView(below:) to add the blur frame below the icon snapshot.

Add the implementation of addEffectView(below:) to LockScreenViewController using the snippet below to insert the effect below the icon snapshot:

func addEffectView(below forView: UIView) {
  previewEffectView.removeFromSuperview()
  previewEffectView.frame = forView.frame

  forView.superview?.insertSubview(
    previewEffectView,
    belowSubview: forView)
}

This completes the setup stage of the animation. Congrats for making it through!

Next switch to AnimatorFactory.swift to create the animation itself. Add the following method to AnimatorFactory:

static func grow(
  view: UIVisualEffectView,
  blurView: UIVisualEffectView
) -> UIViewPropertyAnimator {
  // 1
  view.contentView.alpha = 0
  view.transform = .identity

  // 2
  let animator = UIViewPropertyAnimator(
    duration: 0.5, curve: .easeIn)

  return animator
}

Your new factory method takes two parameters:

  • view: The view to animate
  • blurView The blur background that will animate alongside the primary animation.

It then performs the following actions:

  1. First, it baselines the current state of the view by fading out the view contents — that’s the label saying “Customize Actions…” — and resetting the transform on the view.

  2. Then a new animator is created with 0.5 seconds duration and an ease-in timing curve.

Now, just before the line return animator, you can add the animations and completions for this animator. To do that, insert the following:

// 3
animator.addAnimations {
  blurView.effect = UIBlurEffect(style: .dark)
  view.transform = CGAffineTransform(scaleX: 1.5, y: 1.5)
}

// 4
animator.addCompletion { _ in
  blurView.effect = UIBlurEffect(style: .dark)
}
  1. In the animation added here, setting the effect property on your blur view will create a nice blur transition. You’ve already done this in previous chapters, but this time the blur will happen interactively depending on how hard the user is pressing the screen. Finally, the blur is scaled up on the icon frame by simply adjusting its transform property.

  2. The completion method explicitly sets the final state of the blur view. These interactive animations with UIViewPropertyAnimator are sometimes a bit buggy, so as soon as your animations complete, UIKit will call your completion and the code in it will make sure you’re leaving the UI in the condition you want.

You’re almost ready with your grow animation. You just need to scrub through it interactively as the user presses on the icon.

Go back to LockScreenViewController.swift and append the following to startPreview():

previewAnimator = AnimatorFactory.grow(
  view: previewEffectView, 
  blurView: blurView)

Note how this time, unlike in the previous chapters, you create and configure your animator — but you don’t start the animations. This time, you’ll drive the animation progress interactively based on the 3D touch input.

In order to progress through the animation, implement the updatePreview(percent:) method. This is the one WidgetView will repeatedly call with the current touch force:

func updatePreview(percent: CGFloat) {
  previewAnimator?.fractionComplete = 
    max(0.01, min(0.99, percent))
}

The important aspect to understand is that you restrict fractionComplete in the range 0.01 and 0.99. If you set the fractionComplete to 0.0 or 1.0, the animator will complete and you don’t want that to happen inside updatePreview. You will finish or cancel the animation from the designated methods.

You can give your interactive animation a try right now!

Run the app and start gently pressing on one of the icons. When you start pressing, you will see the blur frame starting to “grow out” of the icon. The blur effect appears subtly in the background:

When you press harder and harder, the frame keeps growing and the blur becomes more prominent:

As soon as you apply enough force to complete the preview gesture, you will feel the haptic feedback under your finger and the animation will stop in this state.

Why does the animation stop? No, it’s not your fault — the peek gesture completes, and once it does that, it just stops sending updates; that is, it stops calling your updatePreview(percent:) method.

Next, you will implement the methods to cancel or complete the interaction.

You will (surprise!) need more animators. Open AnimatorFactory.swift and add an animator, which undoes everything your “grow” animator does.

One situation where you’ll need this animator is when the user cancels the gesture. Another is at the very end of a successful interaction when you need to clean up the UI.

Add the new factory method:

static func reset(
  frame: CGRect,
  view: UIVisualEffectView,
  blurView: UIVisualEffectView
) -> UIViewPropertyAnimator {
  UIViewPropertyAnimator(
    duration: 0.5,
    dampingRatio: 0.7) {
    view.transform = .identity
    view.frame = frame
    view.contentView.alpha = 0
    blurView.effect = nil
  }
}

This method takes in the starting frame of the original animation, the view to animate, and the background blurView. The animation block resets all properties in the state before the interaction started.

Switch back to LockScreenViewController.swift and add a new method inside the WidgetsOwnerProtocol extension:

func cancelPreview() {
  if let previewAnimator = previewAnimator {
    previewAnimator.isReversed = true
    previewAnimator.startAnimation()
  }
}

This is the method that WidgetView will call if the user abruptly lifts their finger, or if a FaceTime calls pops on screen, and cancels the ongoing gesture.

So far you haven’t started your animator at all. You have been repeatedly setting fractionComplete and this drove the animations interactively.

However, once the user cancels the interaction, you can’t keep driving the animation interactively because you have no more input. Instead, you play back the animation to its initial state by setting isReversed to true, and calling startAnimation(). Now this is something you can’t do with UIView.animate(withDuration:...)!

Give the interaction another try. Press through half of the animation and then let go to test cancelPreview().

The animation correctly plays back when you lift your finger but in the end the dark blur reappears abruptly.

The issue is rooted in your grow animator’s code. Switch back to AnimatorFactory.swift and look at the code in grow(view: UIVisualEffectView, blurView: UIVisualEffectView) —  more specifically, this part:

animator.addCompletion { _ in
  blurView.effect = UIBlurEffect(style: .dark)
}

Now that the animation can play either forwards or backwards, you need to take care of this in your completion block.

The parameter that addCompletion()’s closure takes is of type UIViewAnimatingPosition. Its value can be either .start, .end, or .current.

If your animation completed naturally, or otherwise reached its end state, you will get the .end value in your completion closure. If you reversed the animation, it will complete at the .start position. Finally, if you stop your animation mid-way and finish it right there, your completion block will get the .current value.

So, to handle the possibility of completing or canceling the preview gesture, remove the existing completion block and replace it with this:

animator.addCompletion { position in
  switch position {
    case .start:
      blurView.effect = nil
    case .end:
      blurView.effect = UIBlurEffect(style: .dark)
    default: 
      break
  }
}

In case the animation was reversed, you remove the blur effect. If it completed successfully, you explicitly adjust the effect to a dark blur.

Give the adjusted animation a try few times; make sure everything goes as expected. It should mostly do that.

Now there’s a new issue. If you cancel the press on a certain icon, you cannot press it anymore!

This is because the icon snapshot is still located just over the original icon, and it swallows all touches. To fix that issue, you need to remove the snapshot as soon as the reset animator has completed.

Let’s add this code to cancelPreview() back in LockScreenViewController.swift, just below previewAnimator.startAnimation():

previewAnimator.addCompletion { position in
  switch position {
  case .start:
    self.previewView?.removeFromSuperview()
    self.previewEffectView.removeFromSuperview()
  default: break
  }
}

Remember that this call to addCompletion(_:) does not replace the existing completion block, but rather adds a second one.

The goal is to check if the animation has been reversed; and if so, remove the snapshot and the icon frame from the view hierarchy. That’s why the only case you’re interested in is when position is .start.

Try the app again, and you will see that the icons are interactive again after a canceled gesture. Hooray! You’re almost there.

Let’s add one more animator to display the icon menu. Switch to AnimatorFactory.swift and add to it:

static func complete(view: UIVisualEffectView) -> UIViewPropertyAnimator {
  return UIViewPropertyAnimator(
    duration: 0.3, 
    dampingRatio: 0.7) {
    view.contentView.alpha = 1
    view.transform = .identity
    view.frame = CGRect(
      x: view.frame.minX - view.frame.minX / 2.5,
      y: view.frame.maxY - 140,
      width: view.frame.width + 120,
      height: 60
    )
  }
}

This time you create a simple spring animator. For its animators you do the following:

  • Fade in the “Customize Actions” menu item.
  • Reset the transform.
  • Animate the frame of the view directly to a location just above the icon.

The location of the menu changes depending on which icon the user pressed on.

You set the horizontal position to view.frame.minX - view.frame.minX/2.5, which shows the menu to the right if the icon is on the left side of the screen, and shows the menu to the left if the icon is on the right side of the screen. See the difference below:

The animator is ready to go, so open LockScreenViewController.swift and add the last required method in the WidgetsOwnerProtocol extension:

func finishPreview() {
  // 1
  previewAnimator?.stopAnimation(false)

  // 2
  previewAnimator?.finishAnimation(at: .end)

  // 3
  previewAnimator = nil
}

finishPreview() is called when the user pushes through the 3D touch gesture, at about the time you feel the haptic feedback.

  1. stopAnimation(_:) stops the animations currently running on screen and has two different behaviors depending on the boolean parameter you pass in.

  2. When you call stopAnimation(false), you put the animator in the stopped state. It will wait for you to call finishAnimation(at:) at some point later on. If you call stopAnimations(true), this will clear all animations and put the animator in the inactive state without calling your completion blocks. Use this to completely cancel the current animations on an animator.

Once you put the animator in the stopped state, you have a few options. The one you pursue in finishPreview() is to tell the animator to complete at its end state. Thus you call finishAnimation(at: .end); this will update all views with the target values of the scheduled animations and call your completion.

  1. You won’t need the previewAnimator anymore for this gesture, so you can remove it.

You can call finishAnimation(at:) with one of the following:

  • start: To reset the animations to their initial state.
  • current: To update your views’ properties from the current progress of the animation and complete.

After you call finishAnimation(at:), your animator is in the inactive state.

Back to the Widgets project. Since you got rid of the preview animator, you can run the complete animator to display the menu. Append the following to the end of finishPreview():

AnimatorFactory.complete(view: previewEffectView)
  .startAnimation()

That will complete the effect. As soon as you run the app and press on an icon, you will see its menu pop up interactively:

And when the animation is finished, the menu will position itself nicely along the icon:

Congratulations — you deserve a pat on the shoulder. That was a complex effect to develop! But brace yourself — that’s just the start! In the next chapter, you are going to work on interactive view controller transition animations!

Meanwhile, work through the challenges provided in this chapter so you can get a bit more experience with adding and re-using animators, and also with using interactive keyframe animations!

Key Points

  • You can introspect the state of an animator by checking the combination of the values of state, isRunning, and isReversed.

  • You can pause animators by toggling isRunning and reverse the execution of the animations by toggling isReversed.

  • When you allow animations to be reversed, take care to correctly wrap up those animations in your completion closures. This is because they might be ending on their intended final state or their initial state, depending on which direction they were running when they completed.

Challenges

Challenge 1: Allow the Users to Dismiss the Menu

Once the user sees the complete animation which displays the menu, they can’t do anything else with the app.

In this challenge, you are going to reset the UI if the user taps on the blur view or on the menu item. This will allow them to “dismiss” the menu and further interact with the app.

In finishPreview() add the following code to prepare the blur for being interactive:

blurView.effect = UIBlurEffect(style: .dark)
blurView.isUserInteractionEnabled = true
blurView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(dismissMenu)))

Above, you make sure that the blur effect is set to dark and you enable user interactivity on the blur view itself. This will allow the user to tap anywhere around the icon to dismiss the menu.

Finally, you add a touch recognizer and connect it to a method called dismissMenu().

Next — add dismissMenu() on your own:

  • Use the AnimatorFactory.reset(frame:, view:, blurView:) animator for the dismiss animation.
  • Use the startFrame property for the frame parameter of AnimatorFactory.reset(frame:, view:, blurView:).
  • Before starting the animator, add one more completion block which remove previewEffectView and previewView from the screen. Also disable user interactivity on the blur view so it doesn’t swallow any other touches.

Finally, in viewDidLoad(), add a tap recognizer on previewEffectView, connected to dismissMenu() as well. This will allow the users to tap on Customize Actions… to close the menu.

Run the app and try opening and dismissing the menu few times. Isn’t that jolly old fun?

Challenge 2: Interactive Keyframe Animations

In chapter 22 you learned how easy it is to add keyframe animations to an animator. If you have an animator with keyframes, you can still use it to create interactive animations. Your users can scrub through the keyframes back and forth.

To give that a try, you will add an extra element to the grow animation — the one you scrub through interactively while the user presses on an icon.

Open AnimatorFactory.swift and find the place in grow(view: UIVisualEffectView, blurView: UIVisualEffectView) where you add animations to the animator:

animator.addAnimations {
  blurView.effect = UIBlurEffect(style: .dark)
  view.transform = CGAffineTransform(scaleX: 1.5, y: 1.5)
}

Delete this whole block of code and replace it with:

animator.addAnimations {
  UIView.animateKeyframes(withDuration: 0.5, delay: 0.0) {
    UIView.addKeyframe(withRelativeStartTime: 0.0, relativeDuration: 1.0) {
      blurView.effect = UIBlurEffect(style: .dark)
      view.transform = CGAffineTransform(scaleX: 1.5, y: 1.5)
    }

    UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) {
      view.transform = view.transform.rotated(by: -.pi / 8)
    }
  }
}

You create an animation with two keyframes:

  • The first keyframe goes for the total duration of the animation, and runs the same animation you had previously.
  • The second keyframe kicks in the second half of the total duration and rotates the view slightly.

The new element in the animation will help give the user feedback when they are about to complete the gesture. Just before they’ve pressed hard enough, they’ll see the icon frame tilt:

This will also add a bit of playfulness to the complete animations that shows the menu:

With the complete interaction and all animations in place, you are now ready to proceed to the next chapter and dabble on creating view controller transitions with UIViewPropertyAnimator.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.