Как сделать @Preview в JetpackCompose, когда компонент зависит от некоторых данных, предоставляемых ViewModel

Я разрабатывал приложение, в котором пытаюсь внедрить некоторые новые технологии, такие как Jetpack Compose. И в целом, это отличный инструмент, за исключением того, что он имеет жесткую систему предварительной визуализации (@Preview), чем обычные файлы дизайна xml.

Моя проблема возникает, когда я пытаюсь создать @Preview компонента, который представляет разные строки, куда я загружаю данные для восстановления из сети.

В моем случае я сделал это:

@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"

        )
    )
}

Чтобы представить этот @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")
        }
    }
}

Я пытаюсь аннотировать с помощью @Nullable navController и модели представления PokemonListScreen @Composable, но тоже не работает. Я все еще вижу пустой экран:

Как сделать @Preview в JetpackCompose, когда компонент зависит от некоторых данных, предоставляемых ViewModel

Поэтому я пытаюсь найти документацию Jetpack, но это просто определение довольно простых Composables.

Так что, если у вас есть дополнительные знания об этом и вы можете помочь, заранее спасибо!

Основная проблема заключается в том, что если я хочу предварительно просмотреть этот @Composable, хотя я сделал @Nullable для параметра модели представления, что, я думаю, здесь проблема, AS все еще требует инициализации. Потому что я предполагаю, что правильный способ передать аргумент в предварительный просмотр — это аннотация @PreviewArgument.

[РЕДАКТИРОВАТЬ]

После некоторого копания я обнаружил, что AS возвращает следующую ошибку на экране предварительного просмотра:

Как сделать @Preview в JetpackCompose, когда компонент зависит от некоторых данных, предоставляемых ViewModel

Итак, в любом случае, чтобы избежать ошибки модели просмотра ??

[РЕШЕНИЕ]

Наконец, примените следующее решение, которое работает, потому что причина проблемы связана с тем, что у Hilt есть некоторые несовместимости с превью Jetpack Compose:

  1. Создайте интерфейс вашей ViewModel, который восстанавливает все переменные и методы.
  2. Сделайте свой текущий класс viemodel расширением интерфейса.
  3. Создайте класс 2º, который распространяется на интерфейс, и передайте его в свой @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")
    }
}

Фактический предварительный просмотр выглядит следующим образом, и хотя изображение не отображается, оно отображается правильно:

Как сделать @Preview в JetpackCompose, когда компонент зависит от некоторых данных, предоставляемых ViewModel

Обычный подход к отображению предварительного просмотра предполагает, что предварительный просмотр не должен зависеть от каких-либо дополнительных параметров в аргументах метода, в то время как компонуемый для предварительного просмотра также должен быть подъемным состоянием.

Jeel Vankhede 05.04.2022 12:38

Спасибо за ваши комментарии, но во избежание этого, почему вы должны использовать аннотацию @PreviewParamater в случае, если вы хотите просмотреть какой-то компонент, которому нужны некоторые данные, верно? Итак, как мне изменить свой код, чтобы он работал?

Manuel Lucas 05.04.2022 12:52

Глядя на ваш код, похоже, что он должен работать, но если это не так, попробуйте сделать недействительным кеш или что-то в этом роде. Возможно, проблема связана с составным компилятором.

Jeel Vankhede 05.04.2022 13:31

@Jeel Vankhede пытался, но не работает

Manuel Lucas 05.04.2022 16:31
1
4
60
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Ответ принят как подходящий

Я не уверен в глубине этого приложения, но потенциальной идеей было бы кодировать интерфейс, а не реализацию.

То есть создайте интерфейс со всеми функциями, которые вам нужны (которые могут уже существовать в вашей ViewModel), реализуйте его PokemonListViewModel и создайте еще один фиктивный класс, который также его реализует. Передайте макет в предварительный просмотр и оставьте реальную реализацию с PokemonListViewModel

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

Как только вы создадите свой интерфейс, вы можете просто обновить свои составные объекты, чтобы они ожидали, например, объект, который «является» PokeListViewModel.

Надеюсь, это поможет

Спасибо, я попробовал ваше решение, и оно работает, хотя изображение не загружается в предварительный просмотр. Я объясняю решение в последнем редактировании

Manuel Lucas 05.04.2022 17:55

Вы можете создать другой компонуемый объект, который вызывает логику модели представления через лямбда-функции вместо использования самой модели представления. Извлеките свой uiState в отдельный класс, чтобы его можно было использовать как StateFlow в вашей модели представления, что, в свою очередь, можно наблюдать из компонуемого.

@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()
    }
}

Другие вопросы по теме