(upbeat music)
- Hi everyone, I’m Manuel, and I'm here with Ian to talk through
how well Jetpack Compose, our new UI toolkit,
works with the existing Jetpack libraries
you might already be using in your apps.
One of the most important parts when designing Compose
was that you should be able to incrementally adopt it
with your existing apps without rewriting everything from scratch.
It means your business and data layers below the UI layer
can be used with our shiny new Compose UI.
Let’s take, for example, this app, Bloom.
Bloom is a home and garden shopping app that gives users the ability to search
through a huge set of plants,
and look through prebuilt collections of related plants.
Let’s dive into how we could write this screen in Compose
instead of using Views and XML. All that with the power of Jetpack.
In Bloom’s case we’ve taken heavy advantage of many other Jetpack libraries.
If we take a look at the architecture of the app,
Bloom uses Room to store the plants in the database.
Paging to load them in memory efficient chunks,
ViewModels for handling UI logic,
Kotlin coroutines Flows to expose state and data
between each layer and Hilt as the dependency injection solution.
Bloom’s HomeViewModel serves two main purposes.
First, it provides a separation
between the state that is exposed to the UI and how that state is produced.
It is the ViewModel that is responsible
for handling the business logic of the Home UI.
Second, as it extends the Jetpack ViewModel class,
it survives configuration changes,
ensuring the latest data is instantly available.
The paged list of plants is exposed from the repo layer,
and the rest of the UI state for this screen
consists of the collections of related plants and signals
in case the page is loading or an error needs to be shown.
Looking at the HomeScreen,
we could break it down into a number of separate composables
for each part of our UI,
each taking in only the data that it needs.
Jumping into the implementation of HomeScreen,
we can get an instance of the HomeViewModel
in a composable by using the ViewModel() method.
This method is doing two things for you.
First, it is automatically scoping the ViewModel
to the closest ViewModelStoreOwner.
If we were to put our HomeScreen directly in an activity,
you could use that.
If it was in a Fragment, it will use the Fragment scope instead.
Secondly, it is using the default Factory.
For an AndroidEntryPoint annotated Activity or Fragment case,
Hilt has already been installed itself as the default factory,
so our Hilt-powered ViewModel works right out of the box.
If you aren’t using Hilt or any other dependency injection solution,
you will need to create the ViewModel Factory by hand
and pass that into the viewModel function.
In Compose, it is a good idea to prefer composables that don’t hold state
for better reusability and testability.
We call them stateless composables
as opposed to stateful ones that do hold internal state.
In this composable, HomeScreen is retrieving an instance of HomeViewModel
inside the function itself, which makes it a stateful composable.
To make it stateless,
we should provide the ViewModel as a parameter instead.
In this way, we delegate the responsibility
of obtaining the viewmodel to the parent,
so that this composable can focus on emitting UI.
Once we have an instance of the ViewModel,
we can collect the uiState flow using the collectAsState function.
This will re-execute the composable where the state is read
and will update the UI accordingly with the new values.
If you use LiveData or RxJava, don’t worry.
We’ve got you covered as well with the observerAsState
and subscribeAsState functions.
We said that we have a lot of plants in Bloom
so using Paging to load those in chunks makes a lot of sense.
The Paging Compose artifact makes it possible to transform
our Flow of PagingData into state
that we can use in Compose with the collectAsLazyPagingItems() method.
With that, we can easily display them using a LazyColumn.
Also, by looking at loadState, we can easily add our own loading composable
to indicate when the data is refreshing
and even when we get to the bottom of this screen
while more data is being appended onto our list.
And now, on the HomeScreen, we can call PlantList
with the data coming from the ViewModel.
Before moving on,
let’s pause to acknowledge that we’ve been using a lot of Jetpack libraries
in our Compose UI without modifying our ViewModel
or changing any other layer of our app.
That’s cool, isn’t it?
Looking at the design of our app,
there’s a piece of UI that we might want to reuse in other screens,
and that’s the Browse CollectionsCarousel
that can be reused for recommendations on our PlantDetailScreen,
as you can see here.
What’s interesting about this carousel is that when you tap on an item,
it expands to show plants related to that collection.
In its implementation, the carousel needs logic and internal state
to expand or collapse itself,
as well as handling logic to allow only one collection
to be expanded and go to the PlantDetailScreen
when the user taps on a plant.
Where do we put that logic? In our ViewModel?
Well, that’s not possible for reusable UI components.
Let’s dive into this.
To make CollectionsCarousel reusable, we follow Compose best practices
by passing state in and exposing events out.
Encapsulating the logic
and controlling the state of the composable in a class
is a good way to protect the composable from displaying an inconsistent UI,
and avoid repeating the same code in different places.
For the state of CollectionsCarousel,
we can create a regular class called CollectionsCarouselState
that takes in the collections as a parameter.
It exposes information about whether the carousel is expanded
the selected collection, and the plants to show.
And it encapsulates the logic to update the state
when a collection is clicked in a single entry point method,
onCollectionClick.
This state is now provided to the composable
as a parameter that callers of CollectionsCarousel
need to create and manage.
For events, CollectionsCarousel takes one extra parameter,
a lambda that is called whenever the user taps on the plant.
But you might be wondering
why do we have CollectionsCarouselState as a regular class
and events to indicate user interactions
instead of having all of that in a ViewModel?
As a rule of thumb, you would create a ViewModel to host state,
handle logic, and process events
when the composable is close to the root level of the screen,
for example, a HomeScreen or Logincreen composable.
When that’s not the case,
for example, for reusable composables like a Carousel,
don’t use a ViewModel.
Instead, create a regular state holder holder class to manage state
and expose events to the calling function.
This is because ViewModels are scoped
to either an Activity, Fragment or a destination of a navigation graph.
Therefore, you can only get one instance of a ViewModel type in that scope.
The benefits for ViewModels at screening level of composables
are that they can survive configuration changes,
data can survive process death via SavedStateHandle,
it’s a better place for state whose life cycle matches the screen
and they have an already built-in coroutine scope
that you can use to tailor your business logic.
Going back to the code,
as HomeScreen’s state i’s managed by HomeViewModel,
carouselState should be part of the UI state that the ViewModel exposes.
Then, that’s passed to CollectionsCarousel.
But what about the navigation event? What should we do with that?
And what about the HomeScreen itself?
We should be able to apply the same testability
and preview capabilities to the whole screen, right?
- That’s absolutely right.
We want to be able to write tests
against our HomeScreen and pass fake data to a preview
without worrying about our ViewModel and collecting state.
It’s easy enough to make those changes to our HomeScreen,
just add the UI state, the flow of the paged plants,
and an onPlantClick lambda as parameters.
While this might not seem like a big deal,
we’ve just pushed the call to ViewModel() and collectAsState() up a level,
it makes a big difference when thinking about where this home screen is going to go
in relation to everything else in your app.
If Bloom was an app with just this single screen,
we’d be able to use the setContent function
to set our Composable directly in the activity.
But our mocks show a much more complicated app,
so adding our composable directly to the activity
might not be the right approach.
A very natural migration path for an existing app
when starting to adopt Compose would be to migrate one screen at a time.
if you’re using Fragments, for instance,
this HomeScreen could actually be the only content of your HomeFragment,
creating the ComposeView directly in onCreateView().
In this type of approach, you’d use APIs like FragmentScenario
to test your whole fragment.
Once every screen in your app
is just a wrapper around a reusable, testable Composable,
the next step would be to tie all those screens together
with Navigation Compose.
The Jetpack Navigation Component was built from the start as a generic runtime
that knows nothing about any type of destination.
This abstraction was then used to build the Navigation Fragment artifact
and more recently, the Navigation Compose artifact.
This shared runtime means that each of these implementations
got out-of-the-box support for building a navigation graph
of the screens or destinations in your app via Kotlin DSL,
automatic scoping of the lifecycle, ViewModels,
and Saved State each destination, deep linking, returning a result,
and integrating with the system back button.
In Navigation Compose, there’s two main pieces you need:
a NavController and the NavHost.
The NavController follows the same pattern we used
for the CollectionsCarousel state,
by allowing you to create the stateful NavController
as a separate object from the NavHost
that takes the NavController as input.
This allows other components outside of the NavHost
to react to the current destination changing by using the NavController
as the single source of truth for the state.
This is helpful when setting the selected item
in a bottom nav bar, for instance.
The NavHost Composable is responsible for adding your Composable destination
into the Compose hierarchy.
It’s here where you define your navigation graph via a Kotlin DSL.
This is a map of all possible destinations in your app.
So for our HomeScreen,
we can create a home composable destination.
You’ll note that we declare a route on our HomeScreen.
This is the unique path that you use to navigate to that destination.
Since this is the main screen of the app,
we set it as the start destination of our graph.
We’ve needed to make one change to our code though.
Instead of using ViewModel(),
we’re using the Hilt Navigation Compose’s hiltNavGraphViewModel() method.
Like I mentioned, Navigation automatically scopes your ViewModels
to the individual destination,
but since the destination isn’t an @AndroidEntryPoint Activity or Fragment,
it doesn’t have Hilt’s factory as the default factory.
The hiltNavGraphViewModel() method is a convenient wrapper around viewModel()
that ensures that the right factory is always set for you.
Note that we didn’t need to change our HomeScreen at all.
It’s totally unaware that it is being used within Navigation Compose
and can still be tested independently.
Of course, the big benefit of Navigation Compose
comes when you have more than one screen.
That’s when the ability to scope the savd state and ViewModels
to the individual destination
ensures the state is created only when is needed,
and is removed only when you pop the destination off the back stack.
In the case of Bloom, the tiny images on the PlantList or the expanded carousel
should just be a preview for a detail screen
that really gives the user an idea of what they can get for their garden.
The first step is to build the PlantDetailsScreen.
Just like our HomeScreen,
we should build our PlantDetailsScreen as a stateless component
so that we can use previews
and test it in isolation right from the start.
You’ll note our CollectionsCarousel makes another appearance.
Building reusable components
means not only a more consistent experience for users across your app,
but also helps when building out new screens.
Next, we need to add the PlantDetailsScreen
to our navigation graph.
This is just another composable destination,
but there’s one difference here compared to our HomeScreen,
we need to know what plant the user selected.
The routes in Navigation have a lot in common with web URLs
in that they’re designed to serve as the address of the content we show.
This means that our route isn’t just ‘plant/’,
but ‘plant/’ with the ID of the plant we want to show
using this placeholder syntax.
For our Plant class, the ID is an integer,
so we declare it with the IntType.
Now, we could extract the ID directly from the back stack entry
the NavHost passes to our plant detail destination,
but we really want that ID in our plant ViewModel, not here.
To accomplish this, we can use another API
that’s part of Jetpack ViewModel, SavedStateHandle.
A SavedStateHandle is really just a fancy key value map,
but the important thing is that it's automatically populated
by the arguments we put in our route.
This allows our ViewModel to pass that ID directly through to our repository,
to retrieve the plant details and provide that observable Flow to the UI.
This means that if background loading updates the price of the plant
or otherwise changes our single source of truth,
our PlantDetailScreen will automatically recompose with the new data.
Thanks to Manuel’s work on making the HomeScreen stateless,
we already have exposed a lambda that’s triggered when the user taps on a plant.
Our HomeScreen destination then implements that lambda,
calling navigate() with the route of our PlantDetailScreen,
filling in the ID.
The NavController takes care of the rest of it,
saving the state of the HomeScreen and swapping in the PlantDetailScreen.
Hitting the system back button automatically takes you back to the HomeScreen
in exactly the state you left it in.
We’re just scratching the surface of what you can do with Jetpack Compose,
and all of the integration points with other Jetpack libraries.
If you want to learn more,
I’d strongly recommend reading through our guide on composed interoperability
and the guide specifically around Navigating with Compose in our docs.
For practical examples, check out all of our Compose samples
and a realistic example of converting an existing app to Compose
in the sample app, Tivi.
All the links are in the description.
Whether you’re working on a brand new project in Compose
or adapting an existing project, Jetpack is here to help.
Thanks.
(upbeat music)