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_…