Skip to content

Navigating Android’s UI Landscape

In July 2021, Android launched its new UI toolkit, Jetpack Compose, forever changing the landscape of UI development on Android. During this time, the Whatnot Android team was in the middle of a rewrite. The goal was simple: build the app quickly so we could officially drop support for the original React Native app and continue building impactful features.

The app-in-progress was already using Android’s traditional view system, which wasn’t bound to change anytime soon. We were intimately familiar with it and its continued use would allow us to accomplish the rewrite in the shortest amount of time. While we were interested in adopting new technologies, getting the latest Whatnot app into the hands of customers was our top priority. Whatnot is one of the fastest growing marketplaces in history, and so it was important to move as quickly as possible and continue momentum.

The time has come

Fast forward to January 2022. The native app had been in the store for several months and was considered stable, so the question arose once again: should we start using Compose? I personally had my doubts. Not because I didn’t think Compose wasn’t viable or stable, but using Compose didn’t seem to align with our main goal of optimizing deliverability; I didn’t want the adoption of Compose to be a bottleneck during feature development, especially because the features coming through the pipeline were rather large initiatives.

However, after lengthy discussions, the decision was in: the time had come to integrate Compose into our application. We knew that once we got over the hurdle of learning Compose, it would allow us to move even faster by dramatically reducing boilerplate and allowing us to very easily reuse common UI components.

Our current stack

The stack we had at the time involved Conductor as a replacement for Fragments. The two key components of Conductor are the Controller and the Router.

From the Conductor docs:

The Controller is the View wrapper that will give you all of your lifecycle management features.

A Router implements navigation and backstack handling for Controllers.

Views themselves are not lifecycle aware and Fragments are notorious for their poor design. So Conductor, while far from perfect, provided everything we needed out of the box to hit the ground running.

On top of Conductor we used custom views and simple Presenter/Renderable interfaces:

interface Presenter<State, Event> {
  fun bind(lastState: State?, event: Flow<Event>): Flow<State>
}

interface Renderable<State, Event> {
  fun events(): Flow<Event>

  fun render(state: State)
}

In addition, we had a PresentationController base class whose job was to automate the binding of the Presenter to the view through the Renderable interface.

A typical implementation would like something like the following:

data class CounterState(val count: Int = 0)

sealed interface CounterEvent {
  object Increment : CounterEvent
  object Decrement : CounterEvent
}

class CounterPresenter @Inject constructor()
  : Presenter<CounterState, CounterEvent> {
  override fun bind(
    lastState: CounterState?,
    events: Flow<CounterEvent>,
  ): Flow<CounterState> {
    val startState = lastState ?: CounterState()
    return events.scan(startState) { previous, event ->
      when (event) {
        CounterEvent.Increment -> {
          previous.copy(count = previous.count + 1)
        }
        CounterEvent.Decrement -> {
          previous.copy(count = previous.count - 1)
        }
      }
    }
  }
}

class CounterView(
  context: Context,
  attrs: AttributeSet?
) : ConstraintLayout(context, attrs),
  Renderable<CounterState, CounterEvent> {
  private lateinit var binding: CounterViewBinding

  override fun onFinishInflate() {
    super.onFinishInflate()
    binding = CounterViewBinding.bind(this)
  }

  override fun events(): Flow<CounterEvent> = merge(
    binding.increment.clicks().map { CounterEvent.Increment },
    binding.decrement.clicks().map { CounterEvent.Decrement },
  )

  override fun render(state: CounterState) {
    binding.counterText.text = state.count.toString()
  }
}

This worked well for us. It was a simple, home-brewed solution that we had full control over. It was simple to understand, easy to spin up, and allowed us to move quickly during the rewrite process.

You may notice there’s no mention of ViewModel. Conductor’s Controller instances are retained across configuration changes, obviating the need for ViewModel.

Compose UI joined 👋

Compose is pretty upfront about one of its major advantages—making it possible to create reusable UI elements and compose them together as necessary.

Whatnot has two distinct livestream experiences: buyer and seller. Within a few months, we chose to migrate the core live experience to Compose. Each experience has an important UI component that is shared: chat. It was only natural to create a composable that both experiences could use.

The Whatnot buyer experience with Pokemon cards displayed
Whatnot buyer experience
data class ChatUser(
  val id: String,
  val profileImage: String?,
  val username: String,
)

sealed interface ChatItem {
  data class Message(
    val user: ChatUser,
    val text: String,
  ) : ChatItem

  @JvmInline
  value class UserJoin(val user: ChatUser) : ChatItem
}

@Composable
fun LiveChat(
  chatItems: List<ChatItem>,
  onEvent: (LiveChatEvent) -> Unit,
  modifier: Modifier = Modifier,
) {
  LazyColumn(
    reverseLayout = true,
    verticalArrangement = Arrangement.spacedBy(8.dp),
  ) {
    items(chatItems) { item ->
      when (item) {
        is ChatItem.Message -> Message(item)
        is ChatItem.UserJoin -> UserJoin(item)
      }
    }
  }
}

@Composable
private fun Message(item: ChatItem.Message) { /*...*/ }

@Composable
private fun UserJoin(item: ChatItem.UserJoin) { /*...*/ }

From that point we had a reusable composable function that both the live buyer and seller experience could drop in… or could they? chatItems still need to come from somewhere. Each experience has its own Presenter, and given the above code, would each be required to hook into the chat data source, map to a list of ChatItems, and push that data into the UI.

It’s only natural to give the LiveChat composable its own presentation logic. After all, why would we want to write the same code twice? Given that we have a LiveChatPresenter, our LiveChat composable becomes the following:

@Composable
fun LiveChat(
  presenter: LiveChatPresenter,
  onEvent: (LiveChatEvent) -> Unit,
  modifier: Modifier = Modifier,
) { /*...*/ }

But where does the presenter instance come from?

Up to this point, our only mechanism for scoping presenters was to retain their instances in the Controller, and now we wanted to add the ability to scope multiple presenters to a Controller. So we had two options: either we build such a mechanism ourselves (doubling down on Conductor and continuing with our home-brewed architecture), or we turn to Android Jetpack for help.

Sunsetting Conductor

Fortunately for us, it just so happened that Conductor added support for Compose around the time Compose was released. But there was one issue that permeated our code base: we lacked a centralized definition of our navigation graph. Navigation was currently being performed via callbacks through controllers and Conductor’s Router component. We wanted to be able to define the app’s navigation in one place, making it easy to follow instead of having to jump through potentially several files to understand a flow. The time to sunset Conductor was upon us.

The Jetpack navigation component in conjunction with Compose makes it fairly easy to define your app’s navigation in a single place. Just create a NavHost (or AnimatedNavHost), add your screens through the composable function, and you’re good to go! NavBackStackEntry is also a ViewModelStoreOwner, giving us ViewModel scoping for free, which was enough to coerce us into accepting our fate as proud ViewModel users.

Finally, ViewModel

Prior to adopting Compose, we really never had a use for ViewModel. As previously mentioned, its usage wasn’t necessary given that Conductor retains Controller instances. But, given that the navigation component provides automatic ViewModel scoping, adopting them was an easy decision.

Building on our LiveChat example from above, let’s see how the definition changes when we migrate from our traditional Presenter interface to ViewModel:

@Composable
fun LiveChat(
  viewModel: LiveChatViewModel = viewModel { … },
  onEvent: (LiveChatEvent) -> Unit,
  modifier: Modifier = Modifier,
) { /*...*/ }

The viewModel composable accepts a factory function, which is where the actual creation of the ViewModel takes place. In addition to the factory parameter, there’s an additional viewModelStoreOwner parameter, whose default value is the result of LocalViewModelStoreOwner.current¹. Assuming the composable resides within a node in the navigation component’s graph, the viewModelStoreOwner is the NavBackStackEntry. And we’re now allowed to call the LiveChat composable from any composable, as long as it resides within the navigation component.

Navigating This Exciting Migration

Currently, new navigation graphs are spun up in our Controllers as an easy way of integrating with the current system:

class LiveBuyerController(args) : ComposeController(args) {
  @Composable fun Content() {
    val navController = rememberAnimatedNavController()
    AnimatedNavHost(
      navController = navController,
      startDestination = “livestream”,
    ) {
      composable(route = “livestream”) {
        LiveBuyer(/*...*/)
      }
    }
  }
}

However, we’re also migrating existing screens to Compose, phasing out Conductor in the process.

Over time, as we upgrade features and refactor old ones, our app will be fully in Compose, the navigation graph will be centralized, and we’ll be able to spend even more time working on what matters most: delivering impactful features to the end user.

If you’d like to join us in our endeavors, we’re hiring!


[1]: https://developer.android.com/jetpack/compose/compositionlocal

Apps

Every shop has a story. Help us build the platform behind it.