4

I am completely new to Jetpack Compose AND Kotlin, but not to Android development in Java. Wanting to make first contact with both technologies, I wanted to make a really simple app which populates a LazyColumn with images from Dog API.

All the Retrofit connection part works OK, as I've managed to populate one card with a random puppy, but when the time comes to populate the list, it's just impossible. This is what happens:

  1. The interface is created and a white screen is shown.
  2. The API is called.
  3. Wait about 20 seconds (there's about 400 images!).
  4. dogImages gets updated automatically.
  5. The LazyColumn never gets recomposed again so the white screen stays like that.

Do you have any ideas? I can't find any tutorial on this matter, just vague explanations about state for scroll listening.

Here's my code:

class MainActivity : ComponentActivity() {
    private val dogImages = mutableStateListOf<String>()

    @ExperimentalCoilApi
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            PuppyWallpapersTheme {
                // A surface container using the 'background' color from the theme
                Surface(color = MaterialTheme.colors.background) {
                    DogList(dogImages)
                    searchByName("poodle")
                }
            }
        }
    }

    private fun getRetrofit():Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://dog.ceo/api/breed/")
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }

    private fun searchByName(query: String) {
        CoroutineScope(Dispatchers.IO).launch {
            val call = getRetrofit().create(APIService::class.java).getDogsByBreed("$query/images")
            val puppies = call.body()
            runOnUiThread {
                if (call.isSuccessful) {
                    val images = puppies?.images ?: emptyList()
                    dogImages.clear()
                    dogImages.addAll(images)
                }
            }
        }
    }

    @ExperimentalCoilApi
    @Composable
    fun DogList(dogs: SnapshotStateList<String>) {
        LazyColumn() {
            items(dogs) { dog ->
                DogCard(dog)
            }
        }
    }

    @ExperimentalCoilApi
    @Composable
    fun DogCard(dog: String) {
        Card(
            modifier = Modifier
                .fillMaxWidth()
                .padding(15.dp),
            elevation = 10.dp
        ) {
            Image(
                painter = rememberImagePainter(dog),
                contentDescription = null
            )
        }
    }
}

Thank you in advance! :)

xvlaze
  • 837
  • 1
  • 10
  • 30

1 Answers1

10

Your view of the image cannot determine the aspect ratio before it loads, and it does not start loading because the calculated height is zero. See this reply for more information.

Also a couple of tips about your code.

  1. Storing state inside MainActivity is bad practice, you can use view models. Inside a view model you can use viewModelScope, which will be bound to your screen: all tasks will be cancelled, and the object will be destroyed when the screen is closed.
  2. You should not make state-modifying calls directly from the view constructor, as you do with searchByName. This code can be called many times during recomposition, so your call will be repetitive. You should do this with side effects. In this case you can use LaunchedEffect, but you can also do it in the init view model, because it will be created when your screen appears.
  3. It's very convenient to pass Modifier as the last argument, in this case you don't need to add a comma at the end and you can easily add/remove modifiers.
  4. You may have many composables, storing them all inside MainActivity is not very convenient. A good practice is to store them simply in a file, and separate them logically by files.

Your code can be updated to the following:

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            PuppyWallpapersTheme {
                DogsListScreen()
            }
        }
    }
}

@Composable
fun DogsListScreen(
    // pass the view model in this form for convenient testing
    viewModel: DogsModel = viewModel()
) {
    // A surface container using the 'background' color from the theme
    Surface(color = MaterialTheme.colors.background) {
        DogList(viewModel.dogImages)
    }
}

@Composable
fun DogList(dogs: SnapshotStateList<String>) {
    LazyColumn {
        items(dogs) { dog ->
            DogCard(dog)
        }
    }
}

@Composable
fun DogCard(dog: String) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(15.dp),
        elevation = 10.dp
    ) {
        Image(
            painter = rememberImagePainter(
                data = dog,
                builder = {
                    // don't use it blindly, it can be tricky.
                    // check out https://stackoverflow.com/a/68908392/3585796
                    size(OriginalSize)
                },
            ),
            contentDescription = null,
        )
    }
}

class DogsModel : ViewModel() {
    val dogImages = mutableStateListOf<String>()

    init {
        searchByName("poodle")
    }

    private fun getRetrofit(): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://dog.ceo/api/breed/")
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }

    private fun searchByName(query: String) {
        viewModelScope
            .launch {
                val call = getRetrofit()
                    .create(APIService::class.java)
                    .getDogsByBreed("$query/images")
                val puppies = call.body()
                if (call.isSuccessful) {
                    val images = puppies?.images ?: emptyList()
                    dogImages.clear()
                    dogImages.addAll(images)
                }
            }
    }
}
Phil Dukhov
  • 67,741
  • 15
  • 184
  • 220
  • Thank you so much!! Worked like a charm. I definitely have to read all the documentation you provided me because this topic's completely new for me. By the way, at ```fun DogsListScreen```, replace ```viewModel: DogsModel = viewModel()``` with ```viewModel: DogsModel =DogsModel()``` or an error will be thrown (function invoke is not found). Thanks again! – xvlaze Sep 07 '21 at 07:14
  • @xvlaze no, that's not a correct fix! `viewModel()` is not just creates a new object, it'll remember same object if function will be recalled(e.g. when you rotate device). Make sure you have correct import, and check out [documentation](https://developer.android.com/jetpack/compose/state#viewmodel-state) – Phil Dukhov Sep 07 '21 at 07:22
  • @xvlaze also you can start from [tutorials](https://developer.android.com/jetpack/compose/tutorial) – Phil Dukhov Sep 07 '21 at 07:23
  • My error is ```Expression 'viewModel' of type 'DogsModel' cannot be invoked as a function. The function 'invoke()' is not found".``` My imports are: ```import androidx.lifecycle.ViewModel``` ```import androidx.lifecycle.viewModelScope``` I couldn't find any wrong import at the documentation you provided me. – xvlaze Sep 07 '21 at 08:25
  • 2
    @xvlaze you need `import androidx.lifecycle.viewmodel.compose.viewModel` – Phil Dukhov Sep 07 '21 at 08:42
  • Life-saving! I was starting to mess with Gradle imports and was getting really confused. Thank you! – xvlaze Sep 07 '21 at 08:47