Replicating Instagram’s Shared Transition on iOS (UIKit) — Part II.

Kolos Foltányi
Supercharge's Digital Product Guide
9 min readNov 21, 2023

This is Part II. of our two-part series on replicating Instagram’s shared frame transition with UIKit. This segment focuses on making our pop transition interactive and gesture-driven. If you haven’t already, see Part I. for the fundamentals of the transition.

Making it Interactive

In our current configuration, users can tap the back button on the detail screen to return to the profile screen with our shared transition. Yet, the coolest feature of the original transition is its interactive, gesture-driven nature.

On Instagram’s detail screen, you can drag the image view, causing the root view to gradually shrink as you drag. When you release the gesture, it seamlessly continues with our established shared frame animation, transitioning the image view back to its original image cell position:

The Final Transition (Slowed)

UIKit naturally provides support for interactive transitions through the UIViewControllerInteractiveTransitioning protocol. This protocol has a single requirement, startInteractiveTransition(_ transitionContext:), which gets triggered at the start of the interactive transition.

This initiation point allows us to compute and cache the necessary prerequisites for the transition. After the system calls this method, it’s up to us to dictate the evolution of the interactive transition’s progress (you will see this in detail later).

It’s important to note, that this new participant doesn’t replace our existing animation controller. Instead, the two work together to drive the interactive portion of the transition.

UIKit offers a default implementation for this interaction controller through UIPercentDrivenInteractiveTransition. This implementation leverages our existing animation controller, utilizing the very animation we’ve already established for the custom transition. Although this won’t deliver the desired outcome yet, we’ll start by using this default implementation to gain a basic understanding of interactive transitions.

UIPercentDrivenInteractionController, while conforming to UIViewControllerInteractiveTransitioning, offers three additional methods to drive the interactive transition:

  • update(_ percentComplete:): Updates the progress of our animation based on a percentage value ranging from 0 to 1.
  • cancel(): Reverts the animation, transitioning our views back to their initial state and consequently aborting the transition.
  • finish(): Concludes the animation by playing it through to the end from its current progress point.

To begin, we must inform our navigation controller of our intention to utilize an interaction controller and support interactive transitions. First, let’s add a new property storing our original animation controller and introduce an optional property to store our new interaction controller:

class DetailScreen: UIViewController {
private let transitionAnimator = SharedTransitionAnimator()
private var interactionController: UIPercentDrivenInteractiveTransition?
}

Similarly to what we did for the profile screen, let’s provide the navigation controller with both our animation controller and our new (optional) interaction controller:

extension DetailScreen {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
navigationController?.delegate = self
}
}

extension DetailScreen: UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController,
animationControllerFor operation: UINavigationController.Operation,
from fromVC: UIViewController,
to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
guard fromVC is Self, toVC is ProfileScreen else { return nil }
transitionAnimator.transition = .pop
return transitionAnimator
}

func navigationController(
_ navigationController: UINavigationController,
interactionControllerFor animationController: UIViewControllerAnimatedTransitioning
) -> UIViewControllerInteractiveTransitioning? {
interactionController
}
}

The reason for making our interaction controller optional is to ensure that the original transition is preserved when the back button is used for back navigation. We will instantiate it solely when a gesture is detected and will reset it once the gesture is completed.

The final step is to add a gesture recognizer to the detail screen and implement its event handler to update our percent-driven interaction controller:

class DetailScreen: UIViewController {

private lazy var recognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan))

override func viewDidLoad() {
super.viewDidLoad()
// 1
view.addGestureRecognizer(recognizer)
}

@objc
func handlePan(_ recognizer: UIPanGestureRecognizer) {
let window = UIApplication.keyWindow!
// 2
switch recognizer.state {
case .began:
// 3
let velocity = recognizer.velocity(in: window)
guard abs(velocity.x) > abs(velocity.y) else { return }
// 4
interactionController = UIPercentDrivenInteractiveTransition()
navigationController?.popViewController(animated: true)
case .changed:
// 5
let translation = recognizer.translation(in: window)
let progress = translation.x / window.frame.width
interactionController?.update(progress)
case .ended:
// 6
if recognizer.velocity(in: window).x > 0 {
interactionController?.finish()
} else {
interactionController?.cancel()
}
interactionController = nil
default:
// 7
interactionController?.cancel()
interactionController = nil
}
}
}

Here is what we do:

  1. Attach a UIPanGestureRecognizer to the root view of our detail screen.
  2. In the event handler, evaluate the recognizer’s state.
  3. Disregard vertical movements, focusing only on horizontal swipe gestures.
  4. When a gesture that is more horizontal than vertical has begun, we create our interaction controller and instantiate the pop navigation on our navigation controller. Prior to initiating the pop navigation, our navigation controller will check for the presence of an interaction controller (using the delegate we implemented). If it finds one, it will delegate the responsibility of driving the transition’s progress to our percent-driven interaction controller, rather than merely executing the custom transition of our animation controller.
  5. If the pan gesture changes, we calculate the progress based on the swipe distance relative to the window’s width, then update our interaction controller with this new progress value.
  6. Once the gesture concludes, we evaluate the x-axis velocity to infer the swipe’s final direction. If it appears the user swiped to reverse the transition, we’ll abort it. Otherwise, the transition proceeds to completion. Post this gesture-driven transition, it’s important to reset our interaction controller to nil to ensure it doesn’t inadvertently affect future back navigations.
  7. In any other case, we will abort the transition.

That’s it! We’ve successfully implemented our first interactive transition, which appears as follows:

Interactive Pop using the Percent-driven Animator

Given the complexity of what we aimed to achieve, the amount of code we’ve written is impressively small. UIKit deserves much of the credit though, as UIPercentDrivenInteractiveTransition will do the hard work for us by:

  • Pausing the animation, allowing us to drive it based on a progress value.
  • Ensuring the transition is canceled and reverses the animation when necessary.
  • Resuming and finishing our animation from wherever it was paused by the progress-driven state.

While the outcome is not bad, it doesn’t entirely match our goal. In Instagram’s interactive pop gesture, the gesture drives an animation distinct from the transition itself.

As the gesture progresses on Instagram, the detail screen scales down and tracks the gesture’s position, creating the impression of drawing the screen away to reveal the profile screen below. Once the gesture concludes, the shared frame transition we previously implemented takes over:

Instagram’s Interactive Pop Animation

To achieve this, we need to say bye to our percent-driven interaction controller and create our own implementation by conforming to UIViewControllerInteractiveTransitioning. Let’s start by declaring our new class:

class SharedTransitionInteractionController: NSObject {
// 1
struct Context {
var transitionContext: UIViewControllerContextTransitioning
var fromFrame: CGRect
var toFrame: CGRect
var fromView: UIView
var toView: UIView
var mask: UIView
var transform: CGAffineTransform
var overlay: UIView
var placeholder: UIView
}
// 2
private var context: Context?
}

In our class definition, we do the following:

  1. Introduce an internal ‘Context’ structure, designed to store all essential frames, transformations, and references to other participants of the transition.
  2. Store our context in a property. This is crucial since the system invokes `startInteractiveTransition(_ transitionContext:)` only once at the beginning of the transition. To successfully execute the interactive transition, we’ll require access to these components throughout the process.

Next, let’s conform to UIViewControllerInteractiveTransitioning by implementing ‘startInteractiveTransition’:

func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) {
// 1
guard let (fromView, fromFrame, toView, toFrame) = setup(with: transitionContext) else {
transitionContext.completeTransition(false)
return
}
// 2
let transform: CGAffineTransform = .transform(
parent: fromView.frame,
soChild: fromFrame,
aspectFills: toFrame
)

let mask = UIView(frame: fromView.frame).then {
$0.layer.cornerCurve = .continuous
$0.backgroundColor = .black
$0.layer.cornerRadius = 39
}
fromView.mask = mask
let placeholder = UIView().then {
$0.frame = toFrame
$0.backgroundColor = .white
}
toView.addSubview(placeholder)
let overlay = UIView().then {
$0.backgroundColor = .black
$0.layer.opacity = .5
$0.frame = toView.frame
}
toView.addSubview(overlay)
// 3
context = Context(
transitionContext: transitionContext,
fromFrame: fromFrame,
toFrame: toFrame,
fromView: fromView,
toView: toView,
mask: mask,
transform: transform,
overlay: overlay,
placeholder: placeholder
)
}

Here is our breakdown:

  1. Retrieve the participating views and frames using the same setup function we established earlier. As you might remember, this will also add the profile screen to the container beneath the detail view.
  2. Compute the transformation required for the shared element animation, utilizing our magical helper function. Subsequently, we configure the mask, overlay, and placeholder elements. These steps should be familiar from the ‘popAnimation()’ function of our animation controller.
  3. We populate our custom context struct with the calculated values and store it for later usage.

Notice that we haven’t initiated any animation at this point, and no visible changes have been applied either. We’ll only make changes according to the progress of the gesture recognizer.

To execute the interactive transition, we’ll adopt the same API that the default percent-driven implementation utilizes. Specifically, we’ll implement our custom ‘update’, ‘cancel’, and ‘finish’ methods.

Let’s add these methods, starting with ‘update’:

// 1
func update(_ recognizer: UIPanGestureRecognizer) {
guard let context else { return }
// 2
let window = UIApplication.keyWindow!
let translation = recognizer.translation(in: window)
let progress = translation.x / window.frame.width
// 3
context.transitionContext.updateInteractiveTransition(progress)
// 4
var scaleFactor = 1 - progress * 0.4
scaleFactor = min(max(scaleFactor, 0.6), 1)
// 5
context.fromView.transform = .init(scaleX: scaleFactor, y: scaleFactor)
.translatedBy(x: translation.x, y: translation.y)
}
  1. We take the recognizer as an input parameter, allowing us more flexibility.
  2. The progress is determined based on the swipe gesture’s translation in relation to the window’s width.
  3. We must inform UIKit of our transition’s progress, which we do using the transitionContext reference previously saved in our context.
  4. We determine the scale factor that will be applied as the user drags the detail screen. We are going to minimize this to a scale of 0.6.
  5. Lastly, we concatenate the scale transform with a translation that will follow the pan gesture and apply it to the detail screen.

Now let’s implement our cancel method:

func cancel() {
guard let context else { return }
// 1
context.transitionContext.cancelInteractiveTransition()
// 2
UIView.animate(withDuration: 0.25) {
context.fromView.transform = .identity
context.mask.frame = context.fromView.frame
context.mask.layer.cornerRadius = 39
context.overlay.layer.opacity = 0.5
} completion: { _ in
// 3
context.overlay.removeFromSuperview()
context.placeholder.removeFromSuperview()
context.toView.removeFromSuperview()
// 4
context.transitionContext.completeTransition(false)
}
}

Here is the breakdown:

  1. We notify UIKit that we intend to cancel the interactive portion of our transition.
  2. We then revert any changes we’ve made during the gesture by resetting the transform, mask, and overlay back to their initial states.
  3. Upon completion, we clean up our view hierarchy by removing any views we’ve added during the transition process.
  4. Lastly, we must tell UIKit that the transition is now completed with an unsuccessful end result.

Finally, we implement the finish method as follows:

func finish() {
guard let context else { return }
// 1
context.transitionContext.finishInteractiveTransition()
// 2
let maskFrame = context.toFrame.aspectFit(to: context.fromFrame)
UIView.animate(withDuration: 0.25) {
context.fromView.transform = context.transform
context.mask.frame = maskFrame
context.mask.layer.cornerRadius = 0
context.overlay.layer.opacity = 0
} completion: { _ in
// 3
context.overlay.removeFromSuperview()
context.placeholder.removeFromSuperview()
context.transitionContext.completeTransition(true)
}
}

Here is what we do:

  1. We inform UIKit that the interactive part of the transition is finished.
  2. We calculate the end frame of the mask intended for the detail screen. In the animation block, we apply the pre-calculated transform and implement the same animation we have in our animation controller’spopAnimation’ function.
  3. Once the animation concludes, we clean up and signal the completion of the transition using the transition context.

With our interaction controller fully prepared, it’s time to update our detail screen to use it, replacing the default percent-driven solution:

@objc
func handlePan(_ recognizer: UIPanGestureRecognizer) {
let window = UIApplication.keyWindow!
switch recognizer.state {
case .began:
let velocity = recognizer.velocity(in: window)
guard abs(velocity.x) > abs(velocity.y) else { return }
interactionController = SharedTransitionInteractionController()
navigationController?.popViewController(animated: true)
case .changed:
interactionController?.update(recognizer)
case .ended:
if recognizer.velocity(in: window).x > 0 {
interactionController?.finish()
} else {
interactionController?.cancel()
}
interactionController = nil
default:
interactionController?.cancel()
interactionController = nil
}
}

Having implemented this final adjustment, we’ve successfully completed the final segment of the transition. Let’s take a moment to admire the culmination of our efforts:

The Final Transition

Introducing interactivity to our transition truly elevates the user experience. The ability for users to cancel a transition by simply reversing a gesture gives them more control and enables a smoother navigational flow.

Conclusion

In modern mobile applications, small details can influence user experience more than we often realize. Throughout our process of crafting this interactive transition, we’ve observed how subtle enhancements can refine the UI.

Our aim was straightforward: to replicate the gesture-driven, shared element experience of Instagram’s post detail transition. Through a step-by-step approach, we designed a transition that’s practical and intuitive.

What truly stands out for me in this animation is its resemblance to the iOS app-closing animation, but on a different axis. Details like the bezel-matching corner radius truly encapsulate that authentic native feel. These minute features, while not groundbreaking, play a role in creating a familiar user environment.

In conclusion, this exercise illustrates the value of considering user interactions in design and development. By ensuring that transitions are not only functional but also user-friendly, we can provide a more streamlined experience.

--

--