How to make a @Preview in JetpackCompose, when the component depends of some data provide by ViewModel

Viewed 820

I was developing an App where I try to implement some new technologies, as Jetpack Compose. And in general, it's a great tool, except the fact that it has hard pre-visualize system (@Preview) thn the regular xml design files.

My problem comes when I try to create a @Preview of the component which represent the different rows, where I load my data recover from network.

In my case I made this:

@Preview(
    name ="ListScreenPreview ",
    showSystemUi = true,
    showBackground = true,
    device = Devices.NEXUS_9)
@Composable
fun myPokemonRowPreview(
    @PreviewParameter(PokemonListScreenProvider::class) pokemonMokData: PokedexListModel
) {
        PokedexEntry(
            model = pokemonMokData,
            navController = rememberNavController(),
            viewModel = hiltViewModel())

}

class PokemonListScreenProvider: PreviewParameterProvider<PokedexListModel> {
    override val values: Sequence<PokedexListModel> = sequenceOf(
        PokedexListModel(
            pokemonName = "Cacamon",
            number = 0,
            imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/2.png"
        ),
        PokedexListModel(
            pokemonName = "Tontaro",
            number = 73,
            imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/1.png"

        )
    )
}

To represent this @Composable:


@Composable
fun PokemonListScreen(
    navController: NavController,
    viewModel: PokemonListViewModel
) {
    
    Surface(
        color = MaterialTheme.colors.background,
        modifier = Modifier.fillMaxSize()
    )
    {
        Column {
            Spacer(modifier = Modifier.height(20.dp))
            Image(
                painter = painterResource(id = R.drawable.ic_international_pok_mon_logo),
                contentDescription = "Pokemon",
                modifier = Modifier
                    .fillMaxWidth()
                    .align(CenterHorizontally)
            )
            SearchBar(
                hint = "Search...",
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp)

            ) {

                viewModel.searchPokemonList(it)

            }

            Spacer(modifier = Modifier.height(16.dp))
            PokemonList(navController = navController,
                        viewModel = viewModel)


        }
    }
}


@Composable
fun SearchBar(
    modifier: Modifier = Modifier,
    hint: String = " ",
    onSearch: (String) -> Unit = { }
) {

    var text by remember {
        mutableStateOf("")
    }

    var isHintDisplayed by remember {
        mutableStateOf(hint != "")
    }

    Box(modifier = modifier) {
        BasicTextField(value = text,
            onValueChange = {
                text = it
                onSearch(it)
            },
            maxLines = 1,
            singleLine = true,
            textStyle = TextStyle(color = Color.Black),
            modifier = Modifier
                .fillMaxWidth()
                .shadow(5.dp, CircleShape)
                .background(Color.White, CircleShape)
                .padding(horizontal = 20.dp, vertical = 12.dp)
                .onFocusChanged {
                    isHintDisplayed = !it.isFocused
                }
        )
        if (isHintDisplayed) {
            Text(
                text = hint,
                color = Color.LightGray,
                modifier = Modifier
                    .padding(horizontal = 20.dp, vertical = 12.dp)

            )
        }

    }
}

@Composable
fun PokemonList(
    navController: NavController,
    viewModel: PokemonListViewModel
) {

    val pokemonList by remember { viewModel.pokemonList }
    val endReached by remember { viewModel.endReached }
    val loadError by remember { viewModel.loadError }
    val isLoading by remember { viewModel.isLoading }
    val isSearching by remember { viewModel.isSearching }


    LazyColumn(contentPadding = PaddingValues(16.dp)) {

        val itemCount = if (pokemonList.size % 2 == 0) {
            pokemonList.size / 2
        } else {
            pokemonList.size / 2 + 1
        }

        items(itemCount) {
            if (it >= itemCount - 1 && !endReached && !isLoading && !isSearching) {
                viewModel.loadPokemonPaginated()
            }
            PokedexRow(rowIndex = it, models = pokemonList, navController = navController, viewModel = viewModel)
        }
    }


    Box(
        contentAlignment = Center,
        modifier = Modifier.fillMaxSize()
    ) {
        if (isLoading) {
            CircularProgressIndicator(color = MaterialTheme.colors.primary)
        }
        if (loadError.isNotEmpty()) {
            RetrySection(error = loadError) {
                viewModel.loadPokemonPaginated()
            }
        }
    }

}


@SuppressLint("LogNotTimber")
@Composable
fun PokedexEntry(
    model: PokedexListModel,
    navController: NavController,
    modifier: Modifier = Modifier,
    viewModel: PokemonListViewModel
) {
    val defaultDominantColor = MaterialTheme.colors.surface
    var dominantColor by remember {
        mutableStateOf(defaultDominantColor)
    }

    Box(
        contentAlignment = Center,
        modifier = modifier
            .shadow(5.dp, RoundedCornerShape(10.dp))
            .clip(RoundedCornerShape(10.dp))
            .aspectRatio(1f)
            .background(
                Brush.verticalGradient(
                    listOf(dominantColor, defaultDominantColor)
                )
            )
            .clickable {

                navController.navigate(
                    "pokemon_detail_screen/${dominantColor.toArgb()}/${model.pokemonName}/${model.number}"
                )
            }

    ) {

        Column {
            CoilImage(
                imageRequest = ImageRequest.Builder(LocalContext.current)
                    .data(model.imageUrl)
                    .target {
                        viewModel.calcDominantColor(it) { color ->
                            dominantColor = color
                        }
                    }.build(),
                imageLoader = ImageLoader.Builder(LocalContext.current)
                    .availableMemoryPercentage(0.25)
                    .crossfade(true)
                    .build(),
                contentDescription = model.pokemonName,
                modifier = Modifier
                    .size(120.dp)
                    .align(CenterHorizontally),
                loading = {
                    ConstraintLayout(
                        modifier = Modifier.fillMaxSize()
                    ) {
                        val indicator = createRef()
                        CircularProgressIndicator(
                            //Set constrains dynamically
                            modifier = Modifier.constrainAs(indicator) {
                                top.linkTo(parent.top)
                                bottom.linkTo(parent.bottom)
                                start.linkTo(parent.start)
                                end.linkTo(parent.end)
                            }
                        )
                    }
                },
                // shows an error text message when request failed.
                failure = {
                    Text(text = "image request failed.")
                }
            )

            Log.d("pokemonlist", model.imageUrl)
            Text(
                text = model.pokemonName,
                fontFamily = RobotoCondensed,
                fontSize = 20.sp,
                textAlign = TextAlign.Center,
                modifier = Modifier.fillMaxWidth(),

            )
        }
    }
}

@Composable
fun PokedexRow(
    rowIndex: Int,
    models: List<PokedexListModel>,
    navController: NavController,
    viewModel: PokemonListViewModel
) {
    Column {
        Row {
            PokedexEntry(
                model = models[rowIndex * 2],
                navController = navController,
                modifier = Modifier.weight(1f),
                viewModel = viewModel
            )

            Spacer(modifier = Modifier.width(16.dp))

            if (models.size >= rowIndex * 2 + 2) {
                PokedexEntry(
                    model = models[rowIndex * 2 + 1],
                    navController = navController,
                    modifier = Modifier.weight(1f),
                    viewModel = viewModel
                )
            } else {
                Spacer(modifier = Modifier.weight(1f))
            }
        }

        Spacer(modifier = Modifier.height(16.dp))
    }

}

@Composable
fun RetrySection(
    error: String,
    onRetry: () -> Unit,
) {
    Column() {
        Text(error, color = Color.Red, fontSize = 18.sp)
        Spacer(modifier = Modifier.height(8.dp))
        Button(
            onClick = { onRetry() },
            modifier = Modifier.align(CenterHorizontally)
        ) {
            Text(text = "Retry")
        }
    }
}

I try to annotate with the @Nullable navController and viewmodel of the PokemonListScreen @Composable, but doesn't work either. I'm still seeing an empty screen:

enter image description here

So I try to search into the Jetpack documentation but, it's just defining quite simple Composables.

So if you have some more knowledge about it and can help, thanks in advance !

The main problem is if I wanna Preview that @Composable, although I made @Nullable to the viewmodel parameter, which I guess it's the problem here, AS still demand to initialize. Because I guess the right way to pass argument to a preview is by @PreviewArgument annotation.

[EDIT]

After some digging, I found AS is returning the following error under the Preview Screen:

enter image description here

So, there anyway to avoid viewmodel error??

[SOLUTION]

Finally a apply the following solution which make works, because the cause of the problem is due to Hilt have some inconpatibilities with Jetpack Compose previews:

  1. Create an interface of the your ViewModel which recover all the variables and methods.
  2. Make yourcurrent viemodel class extends of the interface.
  3. Create a 2º class which extends on the interface and pass that to your @Preview

@SuppressLint("UnrememberedMutableState")
@Preview(
    name ="ListScreenPreview",
    showSystemUi = true,
    showBackground = true,
    device = Devices.PIXEL)
@Composable
fun MyPokemonRowPreview(
    @PreviewParameter(PokemonListScreenProvider::class) pokemonMokData: PokedexListModel
) {
    JetpackComposePokedexTheme {
        PokedexRow(
            rowIndex = 0,
            models = PokemonListScreenProvider().values.toList(),
            navController = rememberNavController(),
            viewModel = PokemonListViewModelMock(
                0, mutableStateOf(""), mutableStateOf(value = false),
                mutableStateOf(false), mutableStateOf(listOf(pokemonMokData))
            )
        )
    }
}

class PokemonListScreenProvider: PreviewParameterProvider<PokedexListModel> {
    override val values: Sequence<PokedexListModel> = sequenceOf(
        PokedexListModel(
            pokemonName = "Machasaurio",
            number = 0,
            imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/2.png"
        ),
        PokedexListModel(
            pokemonName = "Tontaro",
            number = 73,
            imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/1.png"

        )
    )
}

PokemonListViewModelInterface

interface PokemonListViewModelInterface {

    var curPage : Int

    var loadError: MutableState<String>
    var isLoading: MutableState<Boolean>
    var endReached: MutableState<Boolean>
    var pokemonList: MutableState<List<PokedexListModel>>

    fun searchPokemonList(query: String)

    fun loadPokemonPaginated()

    fun calcDominantColor(drawable: Drawable, onFinish: (Color) -> Unit)
}

PokemonListViewModelMock

class PokemonListViewModelMock (
    override var curPage: Int,
    override var loadError: MutableState<String>,
    override var isLoading: MutableState<Boolean>,
    override var endReached: MutableState<Boolean>,
    override var pokemonList: MutableState<List<PokedexListModel>>
): PokemonListViewModelInterface{
    override fun searchPokemonList(query: String) {
        TODO("Not yet implemented")
    }

    override fun loadPokemonPaginated() {
        TODO("Not yet implemented")
    }

    override fun calcDominantColor(drawable: Drawable, onFinish: (Color) -> Unit) {
        TODO("Not yet implemented")
    }
}

The actual Preview is the following, and although the image doesn't display, is shown correctly:

enter image description here

2 Answers

I'm not sure of the depth of this application, but a potential idea would be to code to an interface and not an implementation.

That is, create an interface with all of the functions you need (that may already exist in your ViewModel), have your PokemonListViewModel implement it, and create another mock class that implements it as well. Pass the mock into your preview and leave the real implementation with PokemonListViewModel

interface PokeListViewModel {
  ...
  // your other val's
  val isLoading: Boolean
  fun searchPokemonList(pokemon: String)
  fun loadPokemonPaginated()
  // your other functions
  ...
}
  

Once you create your interface you can simply update your composables to be expecting an object that "is a" PokeListViewModel, for example.

Hopefully this helps

You could create another composable which invokes the viewmodel logic via lambda functions instead of using the viewmodel itself. Extract your uiState to a separate class, so it can be used as a StateFlow in your viewmodel, which in turn can be observed from the composable.

@Composable
fun PokemonListScreen(
        navController: NavController,
        viewModel: PokemonListViewModel
) {
    /*
     rememberStateWithLifecyle is an extension function based on
     https://medium.com/androiddevelopers/a-safer-way-to-collect-flows-from-android-uis-23080b1f8bda
    */
    val uiState by rememberStateWithLifecycle(viewModel.uiState)

    PokemonListScreen(
        uiState = uiState,
        onLoadPokemons = viewModel::loadPokemons,
        onSearchPokemon = {viewModel.searchPokemon(it)},
        onCalculateDominantColor = {viewModel.calcDominantColor(it)},
        onNavigate = {route -> navController.navigate(route, null, null)},
    )
}

@Composable
private fun PokemonListScreen(
        uiState: PokemonUiState,
        onLoadPokemons:()->Unit,
        onSearchPokemon: (String) -> Unit,
        onCalculateDominantColor: (Drawable) -> Color,
        onNavigate:(String)->Unit,
) {


}


@HiltViewModel
class PokemonListViewModel @Inject constructor(/*your datasources*/) {

    private val loading = MutableStateFlow(false)
    private val loadError = MutableStateFlow(false)
    private val endReached = MutableStateFlow(false)
    private val searching = MutableStateFlow(false)
    private val pokemons = MutableStateFlow<Pokemon?>(null)

    val uiState: StateFlow<PokemonUiState> = combine(
        loading,
        loadError,
        endReached,
        searching,
        pokemons
    ) { loading, error, endReached, searching, pokemons ->
        PokemonUiState(
            isLoading = loading,
            loadError = error,
            endReached = endReached,
            isSearching = searching,
            pokemonList = pokemons,
        )
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = PokemonUiState.Empty,
    )
}


data class PokemonUiState(
        val pokemonList: List<Pokemon> = emptyList(),
        val endReached: Boolean = false,
        val loadError: Boolean = false,
        val isLoading: Boolean = false,
        val isSearching: Boolean = false,
) {
    companion object {
        val Empty = PokemonUiState()
    }
}
Related