Luke Zhao iOS / Web Developer ★ UI & 3D Design ★ Waterloo, Canada

Interactive transitions with Hero (Pt 1)

This is a tutorial for making interactive transitions with Hero. You can follow along by downloading the non-interactive version. Or checkout the completed example project on GitHub. Each step in this tutorial is a commit in the repo. You can view the diffs here.

Making interactive transition with Hero is very straightforward. All you need is a gesture recognizer and a few extra lines of code. Once you have downloaded the non-interactive version. Follow along and lets make it interactive!!!

Non-interactive → Finished Version

General Approach (Tl;Dr)

When implementing a interactive transition, the general approach is to setup a gesture recognizer first. Then do the following 3 things inside the gesture handler:

  1. Tell Hero when to start the transition

    Usually happens when gestureRecognizer.state == .began. Begin the transition as normal.

  2. Tell Hero to update the animations through out the transition

    Use Hero.shared.update(progress:) & Hero.shared.apply(modifiers:to:) to modify the progress & view states.

  3. Tell Hero to end/cancel the transition

    Use Hero.shared.end() or Hero.shared.cancel()

We will go through a detailed example below to get you familiarized with Hero interactive transition.

Step 1: Setup gesture recognizer

For any interactive transition. First we need to have a gesture recognizer that catches user’s touch. Usually this would be a UIPanGestureRecognizer, but other gesture recognizers work too. Lets initialize a pan gesture recognizer inside our SecondViewController to handle user events for our dismiss transition.

class SecondViewController: UIViewController {
  var panGR: UIPanGestureRecognizer!
  override func viewDidLoad() {
    // ...
    panGR = UIPanGestureRecognizer(target: self, 
              action: #selector(handlePan(gestureRecognizer:)))
    view.addGestureRecognizer(panGR)
  }
}

Next, we have to implement the gesture callback. Lets put a simple one for now. Whenever the gesture recognizer begins, we perform our normal dismiss transition.

  func handlePan(gestureRecognizer:UIPanGestureRecognizer) {
    switch panGR.state {
    case .began:
      // begin the transition as normal
      dismiss(animated: true, completion: nil)
    default:
      break
    }
  }

When you begin dragging, you should now see the same non-interactive transition happening. You have no control of the progress though, the animations still run independently. So lets tell Hero the animation progress based on how far the user moved.

Step 2: Updating the progress

  func handlePan(gestureRecognizer:UIPanGestureRecognizer) {
    switch panGR.state {
    case .began:
      // begin the transition as normal
      dismiss(animated: true, completion: nil)
    case .changed:
      // calculate the progress based on how far the user moved
      let translation = panGR.translation(in: nil)
      let progress = translation.y / 2 / view.bounds.height
      Hero.shared.update(progress: Double(progress))
    default:
      // end the transition when user ended their touch
      Hero.shared.end()
    }
  }

Hero.shared.update(progress:) tells Hero to jump to a given progress. The progress parameter should be a number between 0 & 1.

Hero.shared.end() terminates the current transition. You have to call this if you called update(progress:), otherwise the transition will stay at the last progress and never terminate.

Hero.shared is the singleton object you can operate with when doing an interactive transition. It has the following 4 methods that are useful for interactive transition:

/**
 Update the progress for the interactive transition.
 - Parameters:
     - progress: the current progress, must be between 0...1
 */
public func update(progress: Double) 

/**
 Finish the interactive transition.
 Will stop the interactive transition and animate from the
 current state to the **end** state
 */
public func end(animate: Bool = true)

/**
 Cancel the interactive transition.
 Will stop the interactive transition and animate from the 
 current state to the **begining** state
 */
public func cancel(animate: Bool = true)

/**
 Override modifiers during an interactive animation.
 
 For example:
 
     Hero.shared.apply([.position(x:50, y:50)], to:view)
 
 will set the view's position to 50, 50
 - Parameters:
     - modifiers: the modifiers to override
     - view: the view to override to
 */
public func apply(modifiers: [HeroModifier], to view: UIView)

We will use all four of them in this tutorial. But before continuing, you should have something like this:

The transition progress is now controlled by how far the user moves vertically.

Step 3: Control view properties during interactive transition

Ideally, users should be able to drag the view. To do that we can use Hero.shared.apply(modifiers:, to:) method. Here is how:

// define a small helper function to add two CGPoints
func + (left: CGPoint, right: CGPoint) -> CGPoint {
  return CGPoint(x: left.x + right.x, y: left.y + right.y)
}

class SecondViewController: UIViewController {
  // ...
  func handlePan(gestureRecognizer:UIPanGestureRecognizer) {
    switch panGR.state {
    // ...
    case .changed:
      // ...

      // update views' position based on the translation
      Hero.shared.apply(modifiers: [.position(translation + imageView.center)], to: imageView)
      Hero.shared.apply(modifiers: [.position(translation + nameLabel.center)], to: nameLabel)
      Hero.shared.apply(modifiers: [.position(translation + descriptionLabel.center)], to: descriptionLabel)
    }
  }
}

These 3 lines modifies the view positions during the transition. Making them move with the gesture. Looking good already!

We can also limit the horizontal scrolling by doing some manual math:

// update views' position (limited to only vertical scroll)
let imagePosition = CGPoint(x: imageView.center.x, 
                            y: translation.y + imageView.center.y)
let namePosition = CGPoint(x: nameLabel.center.x, 
                           y: translation.y + nameLabel.center.y)
let descPosition = CGPoint(x: descriptionLabel.center.x, 
                           y: translation.y + descriptionLabel.center.y)
Hero.shared.apply(modifiers: [.position(imagePosition)], to: imageView)
Hero.shared.apply(modifiers: [.position(namePosition)], to: nameLabel)
Hero.shared.apply(modifiers: [.position(descPosition)], to: descriptionLabel)

Step 3: Make it cancellable.

Our dismiss transition is almost complete. There is just one more caveat. Our transition cannot be cancelled. Every time the transition starts, it will run to the end no matter how the user moves. We can make our transition cancellable by simply add the following:

func handlePan(gestureRecognizer:UIPanGestureRecognizer) {
  // calculate the progress based on how far the user moved
  let translation = panGR.translation(in: nil)
  let progress = translation.y / 2 / view.bounds.height

  switch panGR.state {
  // ...
  default:
    // end or cancel the transition based on the progress and user's touch velocity
    if progress + panGR.velocity(in: nil).y / view.bounds.height > 0.3 {
      Hero.shared.end()
    } else {
      Hero.shared.cancel()
    }
  }
}

Whats next?

Interactive transition are great way to delight your users. Hero made it as simple as possible for developers like you to adapt. But if you like to dig deeper and understand how normal view controller transition works, here are some useful resources that taught me:

Checkout the official Hero example project for some other interactive examples:

  1. Video Player
  2. Image Viewer
  3. Apple Home Page Transition

This completes Part 1 of this tutorial. You can download the Part 1 finished project here.

Stay tuned for Part 2, where we will be making a interactive present transition on top of this project.