Want to create stunning grid layouts in your Jetpack Compose app? Look no further thanΒ LazyVerticalGrid
. This powerful toolΒ simplifiesΒ the process of designing and implementing efficient grid-based interfaces. In this comprehensive tutorial, Iβll share my insights and experience usingΒ LazyVerticalGrid
Β in a real-worldΒ productionΒ app on Google Play. Iβll explore its key features, best practices, and practical tips to help you create stunning grids that captivate your users. π€
To populate the grid with player data, I make a network call to retrieve information for the selected season.Β Hereβs how I have implemented that:
// Wrapper for state management
sealed class PlayersUiState {
data object Loading : PlayersUiState()
data class Success(val players: List<Player>) : PlayersUiState()
data class Error(val throwable: Throwable) : PlayersUiState()
}
private val _uiState = MutableStateFlow<PlayersUiState>(PlayersUiState.Loading)
val uiState: StateFlow<PlayersUiState> = _uiState.asStateFlow()
private val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
viewModelScope.launch {
_uiState.emit(PlayersUiState.Error(throwable = throwable))
}
}
suspend fun getSkatersAndGoalies(season: String) {
viewModelScope.launch(context = ioDispatcher + coroutineExceptionHandler) {
repository.getAllNhlPlayers(season)
.catch { e ->
_uiState.emit(PlayersUiState.Error(Throwable(e.message ?: "Unknown error")))
}
.collectLatest { players ->
val sortedPlayers = (players.forwards + players.defensemen + players.goalies).sortedBy { it.lastName.default }
_uiState.emit(PlayersUiState.Success(sortedPlayers))
}
}
}
- Fetch player data:Β UseΒ
repository.getAllNhlPlayers(season)
Β to retrieve player data for the specified season. - Handle errors:Β Catch any exceptions that might occur during the network call and emit an error state to the UI.
- Sort players:Β Combine the forwards, defensemen, and goalies, then sort them by last name.
- Emit success:Β Emit a success state to the UI, including the sorted players and the transformed season string.
UI Composable:
Now I bring the state to life by connecting it to the UI components.Β Hereβs how Iβve implemented it:
@Composable
fun ShowLazyVerticalGridPlayers(uiState: PlayersUiState.Success, navController: NavController) {
val players = uiState.players
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val isCollapsed by remember { derivedStateOf { scrollBehavior.state.collapsedFraction == 1f } }
val title = if (!isCollapsed) "ALL NHL\nPLAYERS" else "PLAYERS"
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
ParallaxToolBarV2(
scrollBehavior = scrollBehavior,
navController = navController,
title = title,
color = DefaultBlack,
actions = {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(DefaultNhlTeam.teamLogo)
.decoderFactory(SvgDecoder.Factory())
.crossfade(true)
.diskCachePolicy(CachePolicy.ENABLED)
.build(),
contentDescription = null,
modifier = Modifier.padding(horizontal = 8.dp).size(60.dp)
)
Spacer(modifier = Modifier.width(dimensionResource(R.dimen.margin_medium_large)))
}
)
},
bottomBar = { BottomAppBar(Modifier.fillMaxWidth()) { SetAdmobAdaptiveBanner() } },
) { padding ->
LazyVerticalGrid(
modifier = Modifier.padding(padding),
columns = GridCells.Fixed(3),
contentPadding = PaddingValues(start = 12.dp, top = 16.dp, end = 12.dp, bottom = 16.dp),
content = {
items(players.size) { index ->
PlayerCell(players[index], navController)
}
}
)
}
}
TheΒ LazyVerticalGrid
Β component creates a grid layout with 3 columns. It applies padding around the grid and its content, and populates the grid withΒ PlayerCell
Β components based on theΒ players
Β list.
Compose Fun Fact:
You should hoist UI state to the lowest common ancestor between all the composables that read and write it.
Note: You shouldnβt pass ViewModel instances down to other composables. (You canβt buildΒ @Preview) βπ
β Instead β
Use:Β Property drilling
βProperty drillingβ refers to passing data through several nested children components to the location where theyβre read.
The Cell:
TheΒ PlayerCellΒ composable displays each player’s information in a simple card format.Β It includes the player’s headshot, name, and a “PROFILE” button to navigate to their details. Here’s how it’s structured:
@Composable
fun PlayerCell(player: Player, navController: NavController) {
val scope = rememberCoroutineScope()
DisposableEffect(scope) { onDispose { scope.cancel() } }
Card(modifier = Modifier.padding(4.dp).fillMaxWidth(),
border = BorderStroke(1.dp, colorResource(R.color.whiteSmokeColor)),
colors = CardDefaults.cardColors(containerColor = colorResource(R.color.whiteColor))) {
Column(modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(Modifier.height(12.dp))
Box(Modifier.clip(CircleShape).size(74.dp).background(colorResource(R.color.offWhiteColor))
.border(shape = CircleShape, width = 1.dp, color = colorResource(R.color.whiteSmokeColor))) {
AsyncImage(model = player.headshot, contentDescription = null, modifier = Modifier.clip(CircleShape))
}
Spacer(Modifier.height(6.dp))
Text(
text = player.firstName.default,
style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)),
fontSize = 15.dp.value.sp,
)
val lastName = player.lastName.default.takeIf { it.length > 9 }?.substring(0, 9)?.plus("..") ?: player.lastName.default
Text(
text = lastName,
fontWeight = FontWeight.Bold,
style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)),
fontSize = 15.dp.value.sp,
)
Spacer(Modifier.height(6.dp))
Text(
text = "PROFILE",
textAlign = TextAlign.Center,
fontSize = 12.dp.value.sp,
fontWeight = FontWeight.SemiBold,
style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)),
modifier = Modifier.border(shape = RoundedCornerShape(30.dp), width = 1.dp, color = Color.Black)
.background(Color.Transparent).padding(horizontal = 16.dp, vertical = 2.dp)
.clickable { scope.launch { navController.navigate(PlayerProfile.createRoute(id = player.id)) } }
)
Spacer(Modifier.height(12.dp))
}
}
}
Job Offers
@Preview the Grid in Android Studio:
ShowLazyVerticalGridPlayersScreenPreview
Β composable, allowing developers to visualize how theΒ ShowLazyVerticalGridPlayersScreen
Β component will look and behave without running the entire app. It uses aΒ @Preview
Β annotation to specify the preview configuration and provides a sample list of players to populate the grid.
@RequiresApi(Build.VERSION_CODES.O)
@Preview(showBackground = true, showSystemUi = true)
@Composable
private fun ShowLazyVerticalGridPlayersScreenPreview(
@PreviewParameter(ShowLazyVerticalGridPlayersScreenPreviewParameterProvider::class) players: List<Player>
) {
Column {
ShowLazyVerticalGridPlayers(PlayersUiState.Success(players, ""), rememberNavController())
}
}
private class ShowLazyVerticalGridPlayersScreenPreviewParameterProvider : PreviewParameterProvider<List<Player>> {
override val values: Sequence<List<Player>> =
sequenceOf(
listOf(
Player(firstName = Default("Connor"), lastName = Default("McDavid")),
Player(firstName = Default("James"), lastName = Default("van Riemsdyk")),
Player(firstName = Default("John"), lastName = Default("Brackenborough")),
Player(firstName = Default("Sidney"), lastName = Default("Crosby")),
Player(firstName = Default("Bobby"), lastName = Default("Brink")),
Player(firstName = Default("Austin"), lastName = Default("Matthews"))
)
)
}
DONβT FORGET TO TEST, TEST, TEST:Β π§ͺπ§ͺπ§ͺ
To ensure theΒ getSkatersAndGoaliesΒ function is working correctly, I have written a unit test to verify its behavior.Β Here’s a breakdown of the test:
@Test
fun `getSkatersAndGoalies() should emit list of skaters`() = runTest {
// Given
val goalie = Player(positionCode = "G")
val skater = Player(positionCode = "C")
val mockPlayers = Players(forwards = listOf(skater), goalies = listOf(goalie))
val mockSeason = "20232024"
// When
coEvery { mockDateUtilsRepository.getCurrentSeasonInYears() } returns mockSeason
coEvery { mockRepository.getAllNhlPlayers(mockSeason) } returns flowOf(mockPlayers)
viewModel.getSkatersAndGoalies(mockSeason)
advanceUntilIdle()
// Then
val actualPlayers = (viewModel.uiState.value as? PlayersUiState.Success)?.players.orEmpty()
assertEquals(2, actualPlayers.size)
}
Major tech companies (PayPal, Google, Meta, Salesforceβ¦)Β value engineers who understand the significance of testingΒ for building reliable and high-quality applications and may help you land that big bank jobby-job. π€π½ππ°
Thatβs a wrap!Β WithΒ LazyVerticalGrid
, youβve unlocked the power to build stunning grid layouts in your Jetpack Compose app. Ready to see it in action?Β Download the NHL Hockey app on Google PlayΒ and experience the magic firsthand. Donβt forget to leave a review and let me know what you think!
π£οΈ: reach out onΒ XΒ orΒ Insta
Best,
RC
This article is previously published on proandroiddev.com