Motion Engineering at Scale
How Airbnb is applying declarative design patterns to rapidly build fluid transition animations
By: Cal Stephens
Motion is a key part of what makes a digital experience both easy and delightful to use. Fluid transitions between states and screens are key for helping the user preserve context as they navigate throughout a feature. Quick flourishes of animation make an app come alive, and help give it a distinct personality.
At Airbnb we launch hundreds of features and experiments that have been developed by engineers across many teams. When building at this scale, it’s critical to consider efficiency and maintainability throughout our tech stack–and motion is no exception. Adding animations to a feature needs to be fast and easy. The tooling must compliment and fit naturally with other components of our feature architecture. If an animation takes too long to build or is too difficult to integrate with the overall feature architecture, then it’s often the first part of a product experience that gets dropped when translating from design to implementation.
In this post, we’ll discuss a new framework for iOS that we’ve created to help make this vision a reality.
Imperative UIKit Transitions
Let’s consider this transition on the Airbnb app’s homepage, which takes users from search results to an expanded search input screen:
The transition is a key part of the design, making the entire search experience feel cohesive and lightweight.
Within traditional UIKit patterns, there are two ways to build a transition like this. One is to create a single, massive view controller that contains both the search results and the search input screens, and orchestrates a transition between the two states using imperative UIView animation blocks. While this approach is easy to build, it has the downside of tightly coupling these two screens, making them far less maintainable and portable.
The other approach is to implement each screen as a separate view controller, and create a bespoke UIViewControllerAnimatedTransitioning implementation that extracts relevant views from each view hierarchy and then animates them. This is typically more complicated to implement, but has the key benefit of letting each individual screen be built as a separate UIViewController like you would for any other feature.
In the past, we’ve built transitions with both of these approaches, and found that they both typically require hundreds of lines of fragile, imperative code. This meant custom transitions were time consuming to build and difficult to maintain, so they were typically not included as part of a team’s main feature development flow.
A common trend has been to move away from this sort of imperative system design and towards declarative patterns. We use declarative systems extensively at Airbnb–we leverage frameworks like Epoxy and SwiftUI to declaratively define the layout of each screen. Screens are combined into features and flows using declarative navigation APIs. We’ve found these declarative systems unlock substantial productivity gains, by letting engineers focus on defining how the app should behave and abstracting away the complex underlying implementation details.
Declarative Transition Animations
To simplify and speed-up the process of adding transitions to our app, we’ve created a new framework for building transitions declaratively, rather than imperatively as we did before. We’ve found that this new approach has made it much simpler to build custom transitions, and as a result far more engineers have been able to easily add rich and delightful transitions to their screens even on tight timelines.
To perform a transition with this framework, you simply provide the initial state and final state (or in the case of a screen transition, the source and destination view controllers) along with a declarative transition definition of how each individual element on the screen should be animated. The framework’s generic UIViewControllerAnimatedTransitioning implementation handles everything else automatically.
This new framework has become instrumental to how we build features. It powers many of the new features included in Airbnb’s 2022 Summer Release and 2022 Winter Release, helping make them easy and delightful to use:
As an introduction, let’s start with a example. Here’s a simple “search” interaction where a date picker in a bottom sheet slides up over a page of content:
In this example, there are two separate view controllers: the search results screen and the date picker screen. Each of the components we want to animate are tagged with an identifier to establish their identity.
These identifiers let us refer to each component semantically by name, rather than by directly referencing the UIView instance. For example, the Explore.searchNavigationBarPill component on each screen is a separate UIView instance, but since they’re tagged with the same identifier the two view instances are considered separate “states” of the same component.
Now that we’ve identified the components that we want to animate, we can define how they should animate. For this transition we want:
- The background to fade in
- The bottom sheet to slide up from the bottom of the screen
- The navigation bar to animate between the first state and second state (a “shared element” animation).
We can express this as a simple transition definition:
let transitionDefinition: TransitionDefinition = [
Revisiting the example above for expanding and collapsing the search input screen, we want:
- The background to blur
- The top bar and bottom bars to slide in
- The home screen search bar to transition into the “where are you going?” card
- The other two search cards to fade in while staying anchored relative to the “where are you going? card
Here’s how that animation is defined using the declarative transition definition syntax:
let transitionDefinition: TransitionDefinition = [
SearchInput.whenCard: .anchorTranslation(relativeTo: SearchInput.whereCard),
SearchInput.whoCard: .anchorTranslation(relativeTo: SearchInput.whereCard),
How It Works
This declarative transition definition API is powerful and flexible, but it only tells half the story. To actually perform the animation, our framework provides a generic UIViewControllerAnimatedTransitioning implementation that takes the transition definition and orchestrates the transition animation. To explore how this implementation works, we’ll return to the simple “search” interaction.
First, the framework traverses the view hierarchy of both the source and destination screens to extract the UIView for each of the identifiers being animated. This determines whether or not a given identifier is present on each screen, and forms an identifier hierarchy (much like the view hierarchy of a screen).
The identifier hierarchies of the source and destination are diffed to determine whether an individual component was added, removed, or present in both. If the view was added or removed, the framework will use the animation specified in the transition definition. If the view was present in both states, the framework instead performs a “shared element animation” where the component animates from its initial position to its final position while its content is updated. These shared elements are animated recursively–each component can provide its own identifier hierarchy of child elements, which is diffed and animated as well.
To actually perform these animations, we need a single view hierarchy that matches the structure of our identifier hierarchy. We can’t just combine the source and destination screens into a single view hierarchy by layering them on top of each other, because the ordering would be wrong. In this case, if we just placed the destination screen over the source screen then the source Explore.searchNavigationBarPill view would be below the destination BottomSheet.backgroundView element, which doesn’t match the identifier hierarchy.
Instead, we have to create a separate view hierarchy that matches the structure of the identifier hierarchy. This requires making copies of the components being animated and adding them to the UIKit transition container. Most UIViews aren’t trivially copyable, so copies are typically made by “snapshotting” the view (rendering it as an image). We temporarily hide the “original view” while the animation is playing, so only the snapshot is visible.
Once the framework has set up the transition container’s view hierarchy and determined the specific animation to use for each component, the animations just have to be applied and played. This is where the underlying imperative UIView animations are performed.
Like with Epoxy and other declarative systems, abstracting away the underlying complexity and providing a simple declarative interface makes it possible for engineers to focus on the what rather than the how. The declarative transition definition for these animations are only a few lines of code, which is by itself a huge improvement over any feasible imperative implementation. And since our declarative feature-building APIs have first-class support for UIKit UIViewControllerAnimatedTransitioning implementations, these declarative transitions can be integrated into existing features without making any architecture changes. This significantly accelerates feature development, making it easier than ever to create highly polished transitions, while also enabling long-term flexibility and maintainability.
We have a packed roadmap ahead. One area of active work is improving interoperability with SwiftUI. This lets us seamlessly transition between UIKit and SwiftUI-based screens, which unlocks incremental adoption of SwiftUI in our app without having to sacrifice motion. We’re also exploring making similar frameworks available on web and Android. Our long-term goal here is to make it as easy as possible to translate our designer’s great ideas into actual shipping products, on all platforms.
Interested in working at Airbnb? Check out these open roles:
Many thanks to Eric Horacek and Matthew Cheok for their major contributions to Airbnb’s motion architecture and our declarative transition framework.
All product names, logos, and brands are property of their respective owners. All company, product and service names used in this website are for identification purposes only. Use of these names, logos, and brands does not imply endorsement.