Custom Navigational Transitions in iOS

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

extensionUIView{ funcasImage()->UIImage{ letrenderer=UIGraphicsImageRenderer(bounds:bounds) returnrenderer.image{rendererContextin 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 letnavigationController=UINavigationController( rootViewController:initialViewController ) navigationController.delegate=CustomNavigationDelegate() navigationController.pushViewController(nextViewController, animated:true) // Custom Navigation Delegate classCustomNavigationDelegate:NSObject, UINavigationControllerDelegate{ funcnavigationController( _navigationController:UINavigationController, animationControllerForoperation:UINavigationController.Operation, fromfromVC:UIViewController, totoVC:UIViewController )->UIViewControllerAnimatedTransitioning?{ ifoperation==.push{ returnSourceScaleTransition() } returnnil } } // SourceScaleTransition class finalclassSourceScaleTransition:NSObject, UIViewControllerAnimatedTransitioning{ lettransitionInfo;// contains the image and it's frame publicfunctransitionDuration( usingtransitionContext:UIViewControllerContextTransitioning? )->TimeInterval{ animationDuration } funcanimateTransition( usingtransitionContext:UIViewControllerContextTransitioning ){ guardlet_=transitionContext.viewController(forKey:.from), lettoViewController=transitionContext.viewController(forKey:.to)as? SnapshotTransitionPushedControllerelse{return} letcontainerView=transitionContext.containerView letanimatingView=transitionInfo.sourceView containerView.contentMode=.scaleAspectFill containerView.addSubview(toViewController.view) containerView.addSubview(animatingView) toViewController.view.layoutIfNeeded() letfinalFrame=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:{[weakself]in animatingView.frame=finalFrame }){finishedin 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

///DestinationViewControllermustconformto ///`SnapshotTransitionPushedController` ///sothatthesnapshotcouldbeseemlesslyadded ///&removedfromtransitionalview publicprotocolSnapshotTransitionPushedController:UIViewController{ ///`snapshotFromSourceView`isthesnapshotoftheviewtapped. ///Itwaspropagatedwithdeeplinkinformation& ///willbescaledinananimatingviewinaCustomTransition varsnapshotFromSourceView:UIView?{getset} ///Call`removeTransitionalView()`toremovethesnapshot. ///Example,whenviewhasloaded/rendered. funcremoveTransitionalView() }

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.`

extensionComponentRenderer{ funcsnapshot(renderer:Renderer)->UIView{ //SelectiveRendering ifselfisLabelComponent||selfisButtonComponent{ returnEmptyView() } //Implementthismethodinrelevantcomponents //fordedicatedhandling returnsnapshot(renderer:renderer) } }

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

structImage:ComponentRenderer{ ... funcsnapshot(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!

Accueil - Wiki
Copyright © 2011-2024 iteam. Current version is 2.139.0. UTC+08:00, 2024-12-27 21:26
浙ICP备14020137号-1 $Carte des visiteurs$