This is the fifth day of my participation in Gwen Challenge

There is currently a Chinese manual project for Jetpack Compose, which aims to help developers better understand and master the Compose framework. This article has been included in this manual, welcome to refer to.

Jetpack + Compose


The recent announcement at Google I/O 2021 that Jetpack Compose 1.0 will be released in July means that Compose is ready for use in real-world projects. In addition to composing for UI development, there are several component libraries in Jetpack that are compatible with Compose and developers can use these libraries to develop functions beyond the UI.

Bloom is a Compose best practice Demo App that presents a list of various plants with detailed information.

Using Bloom as an example, how do you develop with Jetpack in Compose


1. Overall Architecture: App Architecture


Architecturally, Bloom is based entirely on Jetpack + Compose

The Jetpack components used from the bottom up are as follows:

  • Room: Provides data persistence capability as a data source
  • Paging: Paging loading capability. Paging requests Room data and displays it
  • Corouinte Flow: Responsive capability. The UI layer subscribes to data changes from Paging through Flow
  • ViewModel: Data management capability. The ViewModel manages data of type Flow for subscription by the UI layer
  • Compose: The UI layer is implemented entirely using Compose
  • Hilt: dependency injection capability. Viewmodels and the like rely on Hilt to build

Jetpack MVVM guides us through a good decoupling of the UI layer, logic layer, and data layer. The figure above is no different from a regular Jetpack MVVM project, except for Compose at the UI layer.

Next, take a look at the code for how Compose works with each Jetpack to implement HomeScreen and PlantDetailScreen.


2. List page: HomeScreen


HomeScreen’s layout consists of three main parts: the search box at the top, the rotation chart in the middle, and the list at the bottom

ViewModel + Compose

We want the Composable to only handle the UI, with state management in the ViewModel. HomeScreen’s Composable entry is normally called in an Activity or Fragment.

Viewmodel-compose makes it easy to get a viewModel from the current ViewModelStore: Implementation “androidx lifecycle: lifecycle – viewmodel – compose: 1.0.0 – alpha04”

@Composable
fun HomeScreen(a) {

    val homeViewModel = viewModel<HomeViewModel>() 
    
    / /...

}
Copy the code

Stateless Composable

A Composalbe with a ViewModel is equivalent to a “Statful Composalbe”, which is difficult to reuse and test singly, and a Composable with a ViewModel cannot be previewed in the IDE. Therefore, we prefer that Composable is a “Stateless Composable”.

The common way to create a StatelessComposable is to delegate the ViewModel creation to the parent and pass it in as a parameter only. This allows the Composalbe to focus on the UI

@Composable
fun HomeScreen( homeViewModel = viewModel
       
        ()
        
) {
    
    / /...

}
Copy the code

Of course, you can also pass State directly as an argument, further removing the dependency on the specific type of the ViewModel.

Let’s look at the implementation of HomeViewModel and its internal State definition


3. HomeViewModel


HomeViewModel is a standard Jetpack ViewModel subclass that can hold data while ConfigurationChanged.

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val plantsRepository: PlantsRepository
) : ViewModel() {


    private val _uiState = MutableStateFlow(HomeUiState(loading = true))
    val uiState: StateFlow<HomeUiState> = _uiState

    val pagedPlants: Flow<PagingData<Plant>> = plantsRepository.plants

    init {

        viewModelScope.launch {
            val collections = plantsRepository.getCollections()
            _uiState.value = HomeUiState(plantCollections = collections)
        }
    }
}

Copy the code

An Activity or Fragment with @androidEntryPoint can use Hilt to create viewModels for Composalbe. Hilt helps the ViewModel Inject dependencies declared by @Inject. For example, PlantsRepository is used in this example

PagedPlants provides page-loaded list data to the Composable through Paging, with data sources from Room.

Data other than the paging list is centrally managed in HomeUiState, including the required set of plants in the rotation diagram and page loading status and other information:

data class HomeUiState(
    val plantCollections: List<Collection<Plant>> = emptyList(),
    val loading: Boolean = false.val refreshError: Boolean = false.val carouselState: CollectionsCarouselState
        = CollectionsCarouselState(emptyList()) // The state of the multicast graph is described later
)
Copy the code

HomeScreen converts Flow to the Composalbe subscribeable State via collectAsState() :

@Composable
fun HomeScreen( homeViewModel = viewModel
       
        ()
        
) {
    
    val uiState by homeViewModel.uiState.collectAsState()
    
    if (uiState.loading) {
        / /...
    } else {
        / /...}}Copy the code

LiveData + Compose

Flow here can also be replaced with LiveData

Livedata-compose converts Livedata to a Composable subscriptable state: implementation “androidx.compose.runtime:runtime-livedata:$compose_version”

@Composable
fun HomeScreen( homeViewModel = viewModel
       
        ()
        
) {
    
    val uiState by homeViewModel.uiState.observeAsState() //uiState is a LiveData
    
    / /...

}
Copy the code

In addition, RxJava-compose is available with similar functionality.


4. Paging list: PlantList


PlantList loads and displays a list of plants in pages.

@Composable
fun PlantList(plants: Flow<PagingData<Plant> >) {
    val pagedPlantItems = plants.collectAsLazyPagingItems()

    LazyColumn {
        if(pagedPlantItems.loadState.refresh == LoadState.Loading) { item { LoadingIndicator() } } itemsIndexed(pagedPlantItems) {  index, plant ->if(plant ! =null) {
                PlantItem(plant)
            } else {
                PlantPlaceholder()
            }

        }

        if (pagedPlantItems.loadState.append == LoadState.Loading) {
            item { LoadingIndicator() }
        }
    }
}

Copy the code

Paging + Compose

Paging -compose provides paging data for pagging LazyPagingItems: implementation “Androidx. paging:paging-compose:1.0.0-alpha09”

Note that itemsIndexed here comes from Paging -compoee and may not be loadMore if used incorrectly

public fun <T : Any> LazyListScope.itemsIndexed(
    lazyPagingItems: LazyPagingItems<T>,
    itemContent: @Composable LazyItemScope. (index: Int.value: T?). ->Unit
) {
    items(lazyPagingItems.itemCount) { index ->
        itemContent(index, lazyPagingItems.getAsState(index).value)
    }
}
Copy the code

ItemsIndexed accepts the LazyPagingItems argument. LazyPagingItems#getAsState fetches data from PagingDataDiffer, and triggers a loadMore request when index is at the end of the list. Implement paging load.


5. Rotation diagram: CollectionsCarousel


CollectionsCarousel is a Composable that displays the multicast diagram.

In the following pages, the use of the rotation graph is used, so we require the reusability of CollectionsCarousel.

Reusable Composable

For composables with reuse requirements, we need to note that reusable components should not manage State through the ViewModel. Because viewModels are shared within scopes, composables reused within the same Scope need to have their State instances exclusive.

Therefore, CollectionsCarousel cannot use ViewModel to manage State and must be passed in with parameters and event callbacks.

@Composable
fun CollectionsCarousel(
    // State in,
    // Events out
) {
    // ...
}
Copy the code

The argument is passed in such a way that the CollectionsCarousel delegates its state to its Composable parent.

CollectionsCarouselState

Since the delegation is to the parent, in order to facilitate the use of the parent, State can be encapsulated to a certain extent, and the encapsulated State can be used together with the Composable. This is also common with Compose, such as LazyListState for LazyColumn, or ScaffoldState for Scallfold, etc

For CollectionsCarousel we have a requirement that when an Item is clicked, the layout of the rotation chart expands

Since ViewModel is not available, a regular Class is used to define CollectionsCarouselState and implement logic such as onCollectionClick

data class PlantCollection(
    val name: String,
    @IdRes val asset: Int.val plants: List<Plant>
)

class CollectionsCarouselState(
    private val collections: List<PlantCollection>
) {
    private var selectedIndex: Int? by mutableStateOf(null)
        
    val isExpended: Boolean
        get() = selectedIndex ! =null

    privat var plants by mutableStateOf(emptyList<Plant>())
        
    val selectPlant by mutableStateOf(null)
        private set

    / /...

    fun onCollectionClick(index: Int) {
        if (index >= collections.size || index < 0) return
        if (index == selectedIndex) {
            selectedIndex = null
        } else {
            plants = collections[index].plants
            selectedIndex = index
        }
    }
}
Copy the code

It is then defined as a parameter to CollectionsCarousel

@Composable
fun CollectionsCarousel(
    carouselState: CollectionsCarouselState,
    onPlantClick: (Plant) - >Unit
) {
    // ...
}
Copy the code

In order to further facilitate the parent calls, can provide rememberCollectionsCarouselState () method, the effect is equivalent to remember {CollectionsCarouselState ()}

Finally, when the parent Composalbe accesses CollectionsCarouselState, it can be saved in the parent’s ViewModel to support ConfigurationChanged. In this example, it will be managed in HomeUiState.


6. Detail page: PlantDetailScreen & PlantViewModel


In PlantDetailScreen, except for the reuse of CollectionsCarousel, most of the layout is conventional and relatively simple.

To highlight the PlantViewModel, get details from PlantsRepository by id.

class PlantViewModel @Inject constructor(
    plantsRepository: PlantsRepository,
    id: String
) : ViewModel() {

    val plantDetails: Flow<Plant> = plantsRepository.getPlantDetails(id)
    
}
Copy the code

How do I pass in the ID here?

. One way is by using ViewModelProvider Factory construct ViewModel and incoming id

@Composable
fun PlantDetailScreen(id: String) {
    
    val plantViewModel : PlantViewModel = viewModel(id, remember {
        object : ViewModelProvider.Factory {
            override fun <T : ViewModel> create(modelClass: Class<T>): T {
                return PlantViewModel(PlantRepository, id)
            }
        }
    })
}

Copy the code

This construction method is expensive, and as described above, if you want to ensure the reusability and testability of the PlantDetailScreen, it is best to delegate the creation of the ViewModel to the parent.

In addition to delegating to the parent, we can also create PlantViewModel with Navigation and Hilt, which will be discussed later.


7. Navigation


Click on a Plant in the HomeScreen list to jump to PlantDetailScreen.

One of the common ideas for jumping between multiple pages is to wrap a Framgent around Screen and then use Navigation to jump between fragments

@AndroidEntryPoint
class HomeFragment : Fragment() {
    override fun onCreateView(inflater: LayoutInflaterAnd the container, ViewGroup? , savedInstanceState:Bundle?).= ComposeView(requireContext()).apply { setContent { HomeScreen(...) }}}Copy the code

Navigation abstracts the nodes in the rollback stack as a Destination, so this Destination does not have to be implemented as a Fragment, and Composable page jumps can be achieved without a Fragment.

Navigation + Compose

Navigation -compose can use implementation in navigation with Composalbe as Destination “androidx.navigation:navigation-compose:$version”

So let’s get rid of Framgent and implement page jumps:

@AndroidEntryPoint
class BloomAcivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle? , persistentState:PersistableBundle?). {
        setContent {

            val navController = rememberNavController()

            Scaffold(
                bottomBar = {/ *... * / }
            ) {
                NavHost(navController = navController, startDestination = "home") {
                    composable(route = "home") {
                        HomeScreen(...) { plant ->
                            navController.navigate("plant/${plant.id}")
                        }
                    }
                    composable(
                        route = "plant/{id}",
                        arguments = listOf(navArgument("id") { type = NavType.IntType })
                    ) {
                        PlantDetailScreen(...)
                    }
                }
            }

        }
    }
}
Copy the code

Navigaion relies on two things: NavController and NavHost:

  • The NavController holds BackStack information for the current Navigation and is therefore an object carrying state that needs to be created outside the Scope of NavHost like CollectionsCarouselState.

  • NavHost is the container for NavGraph, passing NavController as an argument. Destinations (Composable) in NavGraph listen to NavController as SSOT (Single Source Of Truth) for change.

NavGraph

Navigation -compose uses the Kotlin DSL to define NavGraph.

Comosable (route = "$id") {/ /...
}
Copy the code

Route Sets the index ID of Destination. HomeScreen uses “home” as the unique ID; The PlantDetailScreen uses “Plant /{ID}” as the ID. The ID in {id} is from the key parameter in the URI carried on the previous page jump. In this case it is plant.id:

HomeScreen(...) { plant ->
    navController.navigate("plant/${plant.id}")}Copy the code
composable(
    route = "plant/{id}",
    arguments = listOf(navArgument("id") { type = NavType.IntType })
) { //it: NavBackStackEntry 
    valid = it.arguments? .getString("id") ?: "". }Copy the code

NavArgument converts the arguments in the URI to the arguments of Destination, which is retrieved via NavBackStackEntry

As mentioned above, we can use Navigation to jump between screens and carry some basic parameters. In addition, Navigation helps us manage the rollback stack, greatly reducing development costs.

Hilt + Compose

As mentioned earlier, to ensure independent reuse of screens, we can delegate ViewModel creation to the parent Composable. So how do we create the ViewModel in Navigation’s NavHost?

Hilt-navigation-compose allows us to construct a ViewModel using HILt in navigation: implementation “androidx.hilt:hilt-navigation-compose:$version”

NavHost(navController = navController, 
        startDestination = "home",
        route = "root" // Set the id here for NavGraph.
        ) {
      composable(route = "home") {
            val homeViewModel: HomeViewModel = hiltNavGraphViewModel()
            val uiState by homeViewModel.uiState.collectAsState()
            val plantList = homeViewModel.pagedPlants
            
            HomeScreen(uiState = uiState) { plant ->
                   navController.navigate("plant/${plant.id}")
            }
        }
        
        composable(
            route = "plant/{id}",
            arguments = listOf(navArgument("id") { type = NavType.IntType })
        ) {
            val plantViewModel: PlantViewModel = hiltNavGraphViewModel()
            val plant: Plant by plantViewModel.plantDetails.collectAsState(Plant(0))
            
            PlantDetailScreen(plant = plant)
        }
}

Copy the code

In Navigation, each Destination is a ViewModelStore, so the Scope of the ViewModel can be limited to the Destination instead of the Activity as a whole. Furthermore, when Destination pops up from the BackStack, the corresponding Screen is unloaded from the view tree and the ViewModel within the Scope is emptied to avoid leakage.

  • HiltNavGraphViewModel () : You can get the ViewModel of the Destination Scope and build it using Hilt.

  • HiltNavGraphViewModel (“root”) : Specify the routeId of NavHost to share the ViewModel within the NavGraph Scope

Screen’s ViewModel is proxyed to NavHost, and screens that do not hold viewModels are well testable.

Take another look at the PlantViewModel

@HiltViewModel
class PlantViewModel @Inject constructor(
    plantsRepository: PlantsRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    val plantDetails: Flow<Plant> = plantsRepository.getPlantDetails(
        savedStateHandle.get<Int> ("id")!! }Copy the code

SavedStateHandle is actually a map of key-value pairs. When building the ViewModel using Hilt, the map is automatically populated with arguments in NavBackStackEntry, which are then injected into the ViewModel with arguments. The key value can then be obtained inside the ViewModel via get(XXX).

At this point, PlantViewModel create, through Hilt completed compared with previous ViewModelProvider. The Factory is much simpler.


8. Recap:


To summarize the capabilities that each Jetpack library brings to Compose:

  • Viewmodel-compose retrieves the viewModel from the current ViewModelStore
  • Livedate-compose converts LiveData to a Composable subscriptable state.
  • Paging -compose provides pagging’s paging data LazyPagingItems
  • Navigation -compose can use Composalbe as Destination in navigation
  • Hilt-navigation-compose allows us to build a ViewModel using HILT in navigation

In addition, there are several design specifications that need to be followed:

  • Loading the Composable ViewModel helps keep it reusable and testable
  • Avoid using viewModels to manage State when a Composable is reused within the same Scope

Reference: www.youtube.com/watch?v=0z_…