Reading Time: 14 minutes
The ActionCard pattern reduces app screen UI, navigation (routing) logic, and other app logic into simple, decoupled elements. The UI elements are called cards, and the associated reusable logical elements are called actions. Together cards and actions are configured to create app screens and features. Every screen is backed by a server-driven feed of card data models.
The ActionCard pattern implementation described here is the result of our team leveraging learnings from across Uber engineering and a lot of our own trial and error. The result is a pattern that allows us to quickly launch new features across multiple screens and apps with a focus on rapid iteration.
The ActionCard pattern has allowed us to reduce complexity and eliminate redundancy. Our hope is that it might be helpful for other teams who want to go fast.
Where We Started: A New Team with big Plans and Limited Resources
Before Uber formed a dedicated Membership engineering team, features (Eats Pass and Uber Pass) were implemented by a variety of teams that also focused on multiple other products. That all changed when we discovered a significant opportunity to enhance the Uber experience for our over 100 million monthly active users by providing a richer Membership experience.
Our new dedicated Membership team was chartered with the goal of expanding a relatively simple implementation of Membership features, consisting of only a few screens, into a rich set of features that would be integrated into the user experience across the Eats, Rides, and Postmates apps.
Our current product was relatively simple and primarily included the screens shown here:
The roadmap ahead included over 16 new or redesigned screens and looked something like the following:
When we reviewed the logic and layouts in the screens above and put them against our 12-month timeline, it was immediately clear that our major challenge would be building many different screens with unique layouts, and supporting different behavior across those screens.
Mobile engineers were already spending too much effort coding new layouts and integrating business logic. Each screen required a new implementation. Building flows for users to navigate between a series of screens required unique implementation for each flow.
Our mobile team would never be able to deliver all of this in the limited time we had planned, and we certainly would not have time for A/B testing and iteration. We had an aggressive roadmap to enhance nearly every part of the Uber experience with a goal to reach 10 million members.
We had just 12 months to transform the Membership experience across both Eats and Rides apps and meet that goal. The only way to realize this would be to find a more efficient way to deliver all of these features.
Too much redundant logic, and too many layouts
- Each screen had its own layout logic; some screens had multiple variations of layout, making layout constraints more complex and requiring additional testing whenever changes were needed
- Behaviors like selecting payment or making a purchase were re-integrated for each type of purchase screen, renewal, free trial, post expiration, and others
- Navigating to new flows to perform an action and then navigating back required custom logic to launch new screens and then return and refresh after the user had completed an action
Build a simple set of composable cards and actions which would provide all of the features we planned to launch in the next 12 months
We set out to see if we could reduce the unit of reusability to a single card and a single action by doing the following:
- Keep everything simple and create a granular set of cards and actions
- Encapsulate nearly all of app logic for each screen inactions
- Only code action handlers (ActionFlows) once and be able to launch them from any card
- Only code a card once and present it everywhere
- Create new screens and flows by simply configuring a different set of models for cards and actions from the backend
Chop everything down to cards and decouple all of the logic into reusable actions
- Use standard UI components to build a relatively small set of cards that would provide all of the layouts our team needed.
- Leverage Uber’s RIBs app architecture to easily decouple routing logic from interactors into actions that could launch card screens.
- Provide a default set of ActionFlows that could handle most actions everywhere. Allow actions to “bubble up” to a specialized action handler when required. For example, actions that would only make sense on the Eats or Rides app would be handled by a specialized action handler.
Cards and Actions
Cards are individual UI elements which have a single purpose. This could be as basic as displaying an image or rich text. They are backed by BaseUI and also contain complex views like a List view or Message view.
All card models follow the same simple structure. They contain a viewModel for the card, which usually inflates a single already existing standard UI view. And an optional associated action that can be performed when the user interacts with or taps on the card (some cards support multiple actions).
Example card model:
Actions are what happens when a user taps on a button or interacts with UI. For example, navigating to a new screen, making a purchase, or updating a preference.
- Action models contain all required data to perform the action.
- Actions can be configured to be attached to any card on any screen.
- ActionFlows are the app logic that performs actions. Some ActionFlows route to new screens, others ActionFlows handle making API requests and updating state with the response.
Composing a Screen with Cards and Actions
Card screens are configured through a composition of cards with associated actions. The model for a CardScreenPresentation provided by our API to inflate a card screen is as follows:
Internally, to build a CardScreen configuration we use a backend GUI tool called DisplayConfig. Once the action and card models are coded on the backend, they can be easily arranged or added to new screens with the DisplayConfig (further details of our implementation of DisplayConfig is a topic for another article).
What We Accomplished
Instead of building 16 unique screens, we implemented just the 11 cards shown below and created about 30 unique actions:
From the cards above and associated actions we configured, instead of implemented, the following 16 screens:
We took the best of our combined learnings from across engineering teams at Uber. We applied our knowledge to solving problems based on real use cases with a focus on simplicity.
The result was a powerful implementation of the ActionCard pattern that allowed us to support a robust set of features across multiple apps and across way more than the 16 screens above. With the ActionCard pattern, we are able to launch complex flows, perform a wide array of actions, and allow users to navigate based on selection.
We have refined this pattern, reducing the element of reusability from an entire screen to a single card or action. We have followed the DRY principle to the max and avoided writing countless lines of redundant code.
- Layouts are only coded in cards used everywhere
- App logic is only coded once in action handlers and used wherever needed
- All screens use the same rendering RIB, described here as the CardScreenPresenter
- Actions can launch flows or perform complex operations like purchasing or canceling a membership
- New navigation flows can be configured between multiple levels of screens using only actions
Building It: Implementing the ActionCard Pattern
The ActionCard pattern is fundamentally simple. It involves presenting a screen of cards, and handling actions. Card models inflate CardViewables and action models provide the data needed for ActionFlows. ActionFlows handle the execution of the action whether that is launching a new screen or performing an API and updating state with the response.
The ActionFlow updates the CardScreenPresentation with a CardScreenPresentation object that contains the card models and related metadata. The CardViewableProvider generates CardViewables from the card models and returns them to the CardScreenPresenter, which renders them.
The Elements of Presentation
The model that contains all cards (with associated actions) and metadata required to present a screen of cards. Analytics events will use the metadata to associate taps, impressions and other events with this specific card screen configuration.
This is the renderer that is used to render all card screens. Our current renderer implementation has two lists of Ccrds: one that scrolls from top down (Main Cards) and another which are fixed to the bottom and stack up (Bottom Pinned Cards). In the future, multiple different presenters may be needed; however, this single CardScreenPresenter (below) is currently sufficient for all of our use cases.
Each card model contains the data required to render a CardViewable. They also contain the action models for associated actions.
The CardViewable is the actual renderable view for the card.
A factory for building CardViewables from the card models.
Each action model has the data required to perform the action. Actions can navigate the user to another screen like openHelp or openCheckout, or be specialized actions which mutate user state like makePurchase or changeDefaultPaymentMethod.
The ActionFlow uses data from the action model to perform the action. ActionFlows can launch new screens or can handle other types of actions including making network requests to make a purchase.
The ActionFlowProvider provides an ActionFlow for a specific action. Specialized handlers handle actions intended for a specific context, such as an action that can only work on a specific app, or within a specified context.
ActionFlows open the purchase screen and make a purchase
What We Learned About Cards
A small set of simple cards is better than a large set of complex cards
Make simple cards and then stack them to create more complex layouts. This ultimately results in a smaller number of cards. It greatly reduces the number of layouts that must be maintained within CardViewables. It will also actually increase the number of new screens that can be created without making more cards. Start with a simple RichTextCard element and ImageCard, then add your basic UI components as needed. Use generic names for each card so that you do not describe a specific business function when the card may be used for many different purposes.
Use one layout for each card
Keep the layouts simple. Managing multiple layouts within each card makes changes to the card slower because you have to test every other layout each time you make a small change. Many of our base UI components handle various layouts internally, so a single card can support quite a few layouts without adding any layout logic to the CardViewable itself.
CardViewables can adjust their presentation to the elements available in the card model, but avoid if/else logic or worse switch statements in your layout logic when possible. Just make a new card if things get complex. It’ll make everything much simpler in the long run.
Leverage BaseUI for everything
The best layout is the one that’s already used in many places across your app. Embed standard UI components in CardViewables whenever available.
Keep horizontal margins simple, use vertical spacer cards
Use spacer cards with configurable height and an optional background color to define space between cards. This eliminates any special spacing logic.
What We Learned About Actions
Action handling should exist in ActionFlows
ActionFlows are the action handlers in the card framework. Once written an ActionFlow can handle actions on any screen. ActionFlows can launch screens, update screen state, and do things like make purchases or cancel subscriptions. While it might be possible to add action handling in other places, it’s best to handle all actions with an ActionFlow.
Make sure to completely decouple Actions from UI elements
Any action should be able to be handled by any UI element.
CompletionActions make the ActionCard framework powerful and dynamic
A CompletionAction is an ordinary action that is performed after another ActionFlow has completed. CompletionActions are either SuccessActions or FailureActions, and they often update the screen to display a new state or navigate the user to another location after an action like making a purchase or canceling a subscription has been completed. Any action can have a CompletionAction. Chained together, CompletionActions make it possible for complex flows and dynamic behavior to be introduced without adding additional code.
Actions often used as CompletionActions:
- Navigate forward to a specified card screen
- Navigate back
- Reload the current card screen
With ActionCards Analytics are accurate and comprehensive
Analytics should be fully integrated into your ActionCard implementation so that every impression, tap, and user journey is captured by default. One nice thing about having analytics events built into the framework is that they are almost always accurate and complete, even when testing new features you can rely on well tested analytics logic.
Backend should provide an analytics identifier for each screen
The endpoint that hydrates the screen must provide a unique identifier that can be used to identify impressions and tap events from that specific card screen configuration.
How We Made Card Screens Responsive to User Interaction Without Making Callbacks to the Server
Example: ActionCard screen changing behavior based on a user selection in a survey.
The survey above is composed of a RadioOptionGroupCard. The radio options together are presented by a single card, which handles the dynamic update state of the user selection.
Updating the continue button based on user selection
The Continue button starts disabled and becomes enabled once the user has made an initial selection. The UpdateCards action handles toggling the enabled state of the “continue” button by updating the card model for the button.
The UpdateCards Action
The UpdateCards action is used to update the state of other cards based on user selection. The UpdateCardsActionFlow can include any complex logic required to update state. Here we use a simple ActionFlow that simply replaces the card model with an enabled state model.
Submitting the Survey and Navigating to the next screen based on user selection
A stream is shared by the RadioOptionGroupCard and the SubmitSurveyActionFlow. This stream contains data about the selected option and text entered by the user. The ActionFlow submits the survey and based on the option ID navigates the user to the next screen. We use a general ActionCardData stream to facilitate this type of logic so that it is available wherever needed.
How We Built Dynamic Flows
Canceling Membership and returning to the previous screen using CompletionActions
CompletionActions are implemented as successAction and FailureAction in action models. ActionFlows complete after they have performed their specified Action. If the action has opened a card screen it completes when that screen is dismissed.
Following is an example of the CancelMembershipAction, which has both successAction and failureAction CompletionActions.
If the CancelMembershipAction succeeds it completes with the NavigateBackAction. The NavigateBackAction completes with the ReloadAction. This is accomplished by nesting a ReloadAction inside of the NavigateBackAction (It is possible to navigate additional levels with this approach where needed).
The below diagram shows the actions required to (1) navigate from the MangeMembership screen to the EndMembershipScreen, (2) to actually cancel the membership, (3) return the user to ManageMembership screen, and (4) reload the EndMembership screen to show the new canceled membership state.
There is no intermediate layer
ActionCards are simple native UI elements that are easy to create, maintain, and debug. There is no complex backend dependency controlling layout logic. There is no middle layer. Some frameworks solve the problem of layout or dynamic flows by pushing logic to the backend. We’ve solved the problem by simply reducing the element of reusability and completely decoupling actions from UI.
We don’t spend very much time anymore writing UI layouts
Most new screens require 1 or even zero new cards to be created. Engineers on our team no longer spend a significant amount of time writing/maintaining layouts. If a layout engine that could do all things perfectly became available it would have little impact on our productivity because we spend very little time working on layouts.
ActionCards allow for complex behavior
ActionCards power configurable screens with dynamic screen state. They allow launching into new flows and returning the user back after completing a task with a refreshed screen. They do this without requiring new mobile code to be written.
The ActionCard pattern has brought a great deal of simplicity to our team’s mobile engineering work. We continue to launch aggressively and are now able to focus on only building the elements of a new feature that are actually new. Often, this means just creating a single card and action to launch a new feature. We are able to A/B test with only a configuration change and iterate more quickly than ever. We’ve built in robust analytics by default so we can easily observe the impact of our work.
We hope that our learnings, shared in this article, are helpful for other small teams wanting to go faster with limited resources.
Everything described here is the shared work of the Uber Membership Mobile team. The following team members have each had a significant role in architecting/refining this design pattern: Aleksandr Nikiforov, Alok Sharma, Ameya Daphalapurkar, Andrew Paul Simmons, Dan Deng, Francisco Medina Bravo, Jessica Thrasher, Justin Muller, Omkar Sawant, Philip Donald, Sam Hollingsworth, Sergey Evseev, and Xiang Li! Special thanks to Zachary Thompson for pushing us, at the start, to go further and implement the most flexible and dynamic version of the framework and to Or Weizman for supporting us in this ambitious project! We literally rebuilt everything across 3 mobile apps, and couldn’t have done it without his support all along the way!