This is a guest post from Reddit Engineer, Aaron Oertel, on how they’ve leveraged Compose in their Android App.
When we first brought Reddit Recap to our users in late 2021, it was a huge success and we knew that it would come back in 2022. And while there was only one year in between, the way we build mobile apps at Reddit fundamentally changed which made us rebuild the Recap experience from the ground up with a more vibrant user experience, rich animations and advanced sharing capabilities.
One of the biggest changes was the introduction of Jetpack Compose and our composition-based presentation architecture. To fully leverage our reactive UI architecture we decided to rewrite all of the UI from the ground up in Compose. We deemed it to be worth it since Compose would allow us to express our UI with simple, reusable components.
In this post, we will cover how we leveraged Jetpack Compose to build a shiny new Reddit Recap experience for our users by creating reusable UI components, leveraging declarative animations and making the whole experience buttery smooth. Hopefully you will be as bananas over Compose as we are after hearing about our experience.
For those of you who didn’t get a chance to use Reddit Recap before, it is a collection of different cards that whimsically describe how a user used Reddit in the last year. From a UI perspective, most of these cards are similar and consist of a top-section graphic or infographic, a title, a subtitle, and common elements like the close and share buttons.
With this structure in mind, Compose made it really convenient for us to create a template for the base for each card. This template would then handle common operations the cards have in common such as positioning each component, handling insets for different device sizes, managing basic animations and more. To give an example, our generic card that displays an illustration, title and text could be declared like so:
We could then create a Composable function for each card type that leverages the template by passing in composables for the different styles of cards using content slots.
For the 2022 Recap experience, we wanted to elevate the experience and make it more delightful by making it more interactive through animations. Compose made building animations and transformations intuitive by allowing us to declare what the animation should look like instead of handling the internals.
We leveraged enter and exit animations that all cards could share as well as some custom animations for the user’s unique Ability Card (the shiny silver card in the above GIF). When we first discussed adding these animations, there were some concerns about complexity. In the past, we had to work through some challenges when working with animations in the Android View System in terms of managing animations, cancellations and view state.
Fortunately, Compose abstracts this away, since animations are expressed declaratively, unlike with Views. The framework is in charge of cancellation, resumption, and ensuring correct states. This was especially important for Recap, where the animation state is tied to the scroll state and manually managing animations would be cumbersome.
We started building the enter and exit animations into our layout template by wrapping each animated component in an AnimatedVisibility composable. This composable takes a boolean value that is used to trigger the animations. We added visibility tracking to our top-level, vertical content pager (that pages through all Recap cards), which passes the visible flag to each Recap card composable. Each card can then pass the visible flag into the layout scaffold or use it directly to add custom animations. AnimatedVisibility supports most of the features we need, such as transition type, easing, delays, durations. However, one issue we ran into was the clipping of animated content, specifically content that is scaled with an overshooting animation spec where the animated content scales outside of the parent’s bounds. To address this issue, we wrapped some animated composables in Boxes with additional padding to prevent clipping.
To make adding these animations easier to add, we created a set of composables that we wrapped around our animated layouts like this:
A special part of Reddit Recap is that each user gets a unique Ability Card that summarizes how they spent their year on Reddit. When we first launched Recap, we noticed how users loved sharing these cards on social media, so for this year we wanted to build something really special.
The challenge with building the Ability Card was that we had to fit a lot of customized content that’s different for every user and language into a relatively small space. To achieve this, we were initially looking into using ConstraintLayout but decided not to go that route because it makes the code harder to read and doesn’t offer performance benefits over using nested composables. Instead, we used a Box which allowed us to align the children and achieved relative positioning using a padding modifier that accepts percentage values. This worked quite well. However, text size became a challenge, especially when we started testing these cards in different languages. To mitigate text scaling issues and make sure that the experience was consistent across different screen sizes and densities, we decided to use a fixed text scale and use dynamic scaling of text (to scale text down as it gets longer).
Once the layout was complete, we started looking into how we can turn this static card into a fun, interactive experience. Our motion designer shared this Pokemon Card Holo Effect animation as an inspiration for what we wanted to achieve. Despite our concerns about layout complexity, we found Compose made it simple to build this animation as a single layout modifier that we could just apply to the root composable of our Ability Card layout. Specifically, we created a new stateful Modifier using the composedfunction (Note: This could be changed to use Modifier.Node which offers better performance) in which we observed the device’s rotation state (using the SensorManager API) and applied the rotation to the layout using the graphicsLayer modifier with the device’s (dampened) pitch and roll to mutate rotationX and rotationY. By using a DisposableEffect we can manage the SensorManager subscription without having to explicitly clean up the subscription in the UI.
This looks roughly like so:
Applying the graphicsLayer modifier to our ability card’s root composable gave us the neat effect that follows the rotation of the device while also handling the cleanup of the Sensor resources once the Composition ends. To really make this feature pop, we added a holographic effect.
We found that we can build this effect by animating a gradient that is laid on top of the card layout and using color blending using the BlendMode.ColorDodge when drawing the gradient. Color blending is the process of how elements are painted on a canvas, which, by default, uses BlendMode.SrcOver which just draws on top of the existing content. For the holo effect we are using BlendMode.ColorDodge, which divides the destination by the inverse of the source. Surprisingly, this is quite simple in Compose:
For the gradient, we created a class named AngledLinearGradient that extends ShaderBrush and determines the start and end coordinates of the linear gradient using the angle and drag offset. To draw the gradient over the content, we can use the drawWithContent modifier to set the color blend mode to create the holo effect.
Now we have the power to apply the holo effect to any composable element simply by adding the Modifier.applyHoloAndRotationEffect(). For the purposes of science, we had to test this on our app’s root layout and trust me, it is ridiculously beautiful.
Once we added the animations, however, we ran into some performance issues. The reason was simple: most animations trigger frequent recompositions, meaning that any top-level animations (such as animating the background color) could potentially trigger recompositions of unrelated UI elements. Therefore, it is important to make our composables skippable (meaning that composition can be skipped if all parameters are equal to their previous value). We also made sure any parameters we passed into our composables, such as UiModels, were immutable or stable, which is a requirement for making composables skippable.
To diagnose whether our composables and models meet these criteria, we leveraged Compose Compiler Metrics. These gave us stability information about the composable parameters and allowed us to update our UiModels and composables to make sure that they could be skipped. We ran into a few snags. At first, we were not using immutable collections, which meant that our list parameters were mutable and hence composables using these params could not be skipped. This was an easy fix. Another unexpected issue we ran into was that while our composables were skippable, we found that when lambdas were recreated, they weren’t considered equal to previous instances, so we wrapped the event handler in a remember call, like this:
Once we made all of our composables skippable and updated our UiModels, we immediately noticed big performance gains that resulted in a really smooth scroll experience. Another best-practice we followed was deferring state reads to when they are really needed which in some cases eliminates the need to recompose. As a result, animations ran smoothly and we had better confidence that recomposition would only happen when it really should.
Our awesome new experience was one worth sharing with friends and we noticed this even during playtesting that people were excited to show off their Ability Cards and stats. This made nailing the share functionality important. To make sharing a smooth, seamless experience with consistent images, we invested heavily into making this great. Our goals: Allow any card to be shared to other social platforms or to be downloaded, while also making sure that the cards look consistent across platforms and device types. Additionally, we wanted to have different aspect ratios for shared content for apps like Twitter or Instagram Stories and to customize the card’s background based on the card type.
While this sounds daunting, Compose also made this simple for us because we were able to leverage the same composables we used for the primary UI to render our shareable content. To make sure that cards look consistent, we used fixed sizing, aspect ratios, screen densities and font scales, all of which could be done using CompositionLocals and Modifiers. Unfortunately, we could not find a way to take a snapshot of composables, so we used an AndroidView that hosts the composable to take the snapshot.
Our utility for capturing a card looked something like this:
We are able to easily override font scales, layout densities and use a fixed size by wrapping our content in a set of composables. One caveat is that we had to apply the density override twice since we go from composable to Views and back to composables. Under the hood, RedditComposeView is used to render the content, wait for images to be rendered from the cache and snap a screenshot using view.drawToBitmap(). We integrated this rendering logic into our sharing flow, which calls into the renderer to create the card preview that we then share to other apps. That rounds out the user journey through Recap, all powered by seamlessly using Compose.
We were thrilled to give our users a delightful experience with rich animations and the ability to share their year on Reddit with their friends. Compared to the year before, Compose allowed us to do a lot more things with fewer lines of code, more reusable UI components, and faster iteration. Animations were intuitive to add and the capability of creating custom stateful modifiers, like we did for the holographic effect, illustrates just how powerful Compose is.