Custom Navigational Transitions in iOS

Explores how our iOS App incorporates custom navigation in a backend-driven UI

photo of Kanupriya Gupta
Kanupriya Gupta

Senior Software Engineer (iOS)

Posted on Jul 04, 2024

Introduction

In present mobile development, the emphasis lies on achieving both speed and personalization. As the demand for rapid delivery intensifies, continuously improving the user experience for customers is essential.

One avenue through which this aspiration materializes is via screen transitions. These transitions serve a dual purpose: they facilitate seamless navigation while striving to establish a sense of continuity in user interactions, transcending the mere act of moving from one screen to another.

In this article, we will focus on screen transitions for iOS apps. Rather than implementing a custom transition for a basic scenario, which many resources already cover, we will explore a real example from Zalando's iOS App showcasing navigation between two screens that are entirely backend-driven.

Navigation Transition

In our prior article Backend-driven UI for mobile apps, we explained how the screen functions as a composed structure of a limited number of primitive components within the framework. So our problem space is: How to enhance navigational experience in a Backend-driven UI system?. To understand that challenge, we will break down what is needed to implement one. But first, let's have a look on the status quo of a transition from an outfit-card to outfit-details screen.

Current Outfits Transition

Here, one of the outfits from the carousel is tapped and an outfit-details screen is pushed on the navigation stack with the default transition. Notice the image in the carousel and the image on the detail screen are the same, the interaction could be enhanced in many ways here. One way is to build a custom navigational experience, where the image that is interacted grows into the detailed view (similar transitions can be noticed on the iOS App Store for reference).

While in case of static content implementing the UIViewControllerAnimatedTransitioning protocol provided by UIKit's View Controller Transitions API and using a custom navigation delegate would be enough. Whereas in our scenario, the process isn't straightforward due to the following facts:

  • Backend-driven UI: Given that the UI of the initial screen is determined by the backend, identifying the user's interaction—whether it's with an image or a layout—poses a challenge. We require precise information about the tapped view, including its position and size (i.e., its frame within the screen).

  • Generic deep-link navigation: With a generic deep-link navigation approach, the URL is passed to the router, which handles the navigation independently in a separate module. This means that the router lacks the context of the next screen, complicating the transition process further.

When an outfit-card is tapped (event), it triggers a deep link navigation (action), this action is propagated from Appcraft iOS framework to the Zalando App to be handled by a common router. We can intercept this flow and identify the location of the tap event. Once we do that, we can take a snapshot of the tapped view, which in this case is an Outfits-card. This solves the first problem stated above.

Code caption: Method initially used to capture the tapped view and convert into an image

extension UIView {
    func asImage() -> UIImage {
        let renderer = UIGraphicsImageRenderer(bounds: bounds)
        return renderer.image { rendererContext in
            drawHierarchy(in: bounds, afterScreenUpdates: true)
        }
    }
}

Code caption: Once we have a snapshot to work with, we propagate the UIImage and its frame to the framework's navigation service, enabling us to pass this information to the router for handling the transition. Implementing the navigation controller and UIViewControllerAnimatedTransitioning, facilitating a transition process similar to the following:

// At the call site
let navigationController = UINavigationController(
    rootViewController: initialViewController
)
navigationController.delegate = CustomNavigationDelegate()
navigationController.pushViewController(nextViewController,
                                        animated: true)

// Custom Navigation Delegate
class CustomNavigationDelegate: NSObject,
                                UINavigationControllerDelegate {
    func navigationController(
        _ navigationController: UINavigationController,
        animationControllerFor operation: UINavigationController.Operation,
        from fromVC: UIViewController,
        to toVC: UIViewController
    ) -> UIViewControllerAnimatedTransitioning? {
        if operation == .push {
            return SourceScaleTransition()
        }
        return nil
    }
}

// SourceScaleTransition class
final class SourceScaleTransition: NSObject,
                                   UIViewControllerAnimatedTransitioning {
    let transitionInfo; // contains the image and it's frame

    public func transitionDuration(
        using transitionContext: UIViewControllerContextTransitioning?
    ) -> TimeInterval {
        animationDuration
    }

    func animateTransition(
        using transitionContext: UIViewControllerContextTransitioning
    ) {
        guard let _ = transitionContext.viewController(forKey: .from),
              let toViewController = transitionContext.viewController(forKey: .to) as?
                SnapshotTransitionPushedController else { return }

        let containerView = transitionContext.containerView

        let animatingView = transitionInfo.sourceView
        containerView.contentMode = .scaleAspectFill
        containerView.addSubview(toViewController.view)
        containerView.addSubview(animatingView)

        toViewController.view.layoutIfNeeded()

        let finalFrame = calculatedFrame;
        // calculate final frame based on the destination and app safe areas
        toViewController.snapshotFromSourceView = animatingView
        animatingView.frame = transitionInfo.sourceRect

        toViewController.view.isHidden = true
        UIView.animate(withDuration: animationDuration,
                       delay: 0.0, animations: { [weak self] in
            animatingView.frame = finalFrame
        }) { finished in
            toViewController.view.isHidden = false
            transitionContext.completeTransition(true)
        }
    }
}

In addition to the above, we also created a protocol for destination controllers so that the transition concluded in a smooth way

/// Destination ViewController must conform to
/// `SnapshotTransitionPushedController`
/// so that the snapshot could be seemlessly added
/// & removed from transitional view
public protocol SnapshotTransitionPushedController: UIViewController {

    /// `snapshotFromSourceView` is the snapshot of the view tapped.
    ///  It was propagated with deeplink information &
    ///  will be scaled in an animating view in a Custom Transition
    var snapshotFromSourceView: UIView? { get set }

    /// Call `removeTransitionalView()` to remove the snapshot.
    /// Example, when view has loaded/rendered.
    func removeTransitionalView()
}

Although initially promising, this approach proved insufficient for production use. Issues such as image pixelation and awkward text scaling, leading to abrupt disappearances, were observed. We identified two key problems that needed addressing:

  • Selective rendering Not all components are necessary for the transition and should be omitted.

  • Quality of Scaling view: The transition should occur smoothly without pixelation, ensuring high-quality visuals throughout.

Our solution involved devising an approach where the tapped layout undergoes recursive traversal and re-rendering to produce a high-quality snapshot. This recursive methodology offers the added advantage of enabling us to selectively choose the components essential to the transition. Each component autonomously manages the rendering of its snapshot, enhancing the efficiency and precision of the process.

Below is a simplified version of selective rendering where Label & Button Components are ignored while rendering a snapshot view of a Composed component. There is a dedicated handling of snapshot(:) method in the Image Component, shown further below.`

extension ComponentRenderer {
    func snapshot(renderer: Renderer) -> UIView {
        // Selective Rendering
        if self is LabelComponent || self is ButtonComponent {
            return EmptyView()
        }
        // Implement this method in relevant components
        // for dedicated handling
        return snapshot(renderer: renderer)
    }
}

Render an actual view, and not just a snapshot to get a good quality transitional view

struct Image: ComponentRenderer {
    ...
    func snapshot(renderer: Renderer) -> UIView {
        UIImageView(image: props.image)
    }
}

Let's look at the resulting outfits-card transition:

New Outfits Transition

Isn't it much better than the vanilla transition? It definitely is! Bonus - The same transition can now be enabled to other screens since it is in a generic screen framework and backend driven.

To conclude, each interaction is unique, and there's no one-size-fits-all solution, but this is a solid starting point. By collaborating with designers, engineers can create smooth, visually appealing animations. While these enhancements are not must-haves, they contribute significantly to a more enjoyable user experience. By focusing on advanced aspects of UIKit's View Controller Transitions API, you can improve your app's aesthetics and functionality, making it more engaging for users.


We're hiring! Do you like working in an ever evolving organization such as Zalando? Consider joining our teams as a Mobile Engineer!



Related posts