I am using Hilt dependency injection to retrieve data from a Firestore Database. I have a getResources coroutine which is called when by the init in my viewModel. In my view, I attempt to get the first x elements of the populated data. However, the app crashes with this error.
java.lang.IndexOutOfBoundsException: Empty list doesn't contain element at index 0.
at kotlin.collections.EmptyList.get(Collections.kt:36)
at kotlin.collections.EmptyList.get(Collections.kt:24)
I am guessing that the data load from Firestore has not completed, so when I try and get resources[i], it's empty and it crashes. This happens even when I do a null check. I cannot init{} without a coroutine and if I try and use a suspend function it doesn't like it either. How do I make the view wait until the viewmodel has the loaded data?
ViewModel.kt
#HiltViewModel
class ResourcesViewModel #Inject constructor(
private val repository: ResourcesRepository
) : ViewModel() {
val data: MutableState<DataOrException<List<Resource>, Exception>> = mutableStateOf(
DataOrException(
listOf(),
Exception("")
)
)
init {
getResources()
}
private fun getResources() {
viewModelScope.launch {
Log.d("getting resources", "currently getting resources")
data.value = repository.getResourcesFromFireStore()
Log.d("complete","complete")
}
}
}
ResourcesRepository.kt
#Singleton
class ResourcesRepository #Inject constructor(
private val db: FirebaseFirestore
) {
val resources = ArrayList<Resource>()
suspend fun getResourcesFromFireStore(): DataOrException<List<Resource>, Exception> {
val resourcesRef: CollectionReference = db.collection("updated-resources-new")
val dataOrException = DataOrException<List<Resource>, Exception>()
try {
dataOrException.data = resourcesRef.get().await().map { document ->
document.toObject(Resource::class.java)
}
Log.d(TAG, "${dataOrException.data}")
} catch (e: FirebaseFirestoreException) {
dataOrException.e = e
}
View.kt
#Composable
fun ResourcesScreenContent(viewModel: ResourcesViewModel) {
LazyColumn (
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 20.dp)
) {
val resources : List<Resource>? = viewModel.data.value.data
items(30) {
for (i in 0 ..30) {
ExpandableCard(resource = resources!![i])
Divider(thickness = 20.dp, color = Color.White)
}
}
}
}
How do I fix this?
you're doing one minor mistake, in forloop you are using 0...30 so it will run 30 times irrespective of resources list size. so in for loop you have to pass resources.count
#Composable
fun ResourcesScreenContent(viewModel: ResourcesViewModel) {
LazyColumn (
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 20.dp)
) {
val resources : List<Resource>? = viewModel.data.value.data
items(resources.count) { i ->
ExpandableCard(resource = resources!![i])
Divider(thickness = 20.dp, color = Color.White)
}
}
}
Hope it is clear my brother
Related
I'm loading an image using Coil for Compose like below.
#Composable
fun SvgImageSample() {
val painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(LocalContext.current)
.decoderFactory(SvgDecoder.Factory())
.data("https://someserver.com/SVG_image.svg")
.size(Size.ORIGINAL)
.build()
)
Image(
painter = painter,
modifier = Modifier.size(100.dp).testTag("myImg"),
contentDescription = null
)
}
The image is loaded properly. Now, I would like to write a test to check if the image was loaded. Is there any assertion out-of-the-box for that?
Something like this:
class MyTest {
#get:Rule
val composeTestRule = createComposeRule()
#Test
fun checkIfTheImageLoads() {
composeTestRule.setContent {
MyAppThemeTheme {
SvgImageSample()
}
}
composeTestRule.onNodeWithTag("myImg")
.assertCoilImageIsLoaded() // <- this is what I want
}
}
I found what I was looking for... Please let me know if anyone has a better solution.
This is what I did:
Add this dependency in your build.gradle.
implementation "androidx.test.espresso.idling:idling-concurrent:3.5.0-alpha07"
This is necessary to use the IdlingThreadPoolExecutor class.
Declare the an IdlingThreadPool object like below:
object IdlingThreadPool: IdlingThreadPoolExecutor(
"coroutinesDispatchersThreadPool",
Runtime.getRuntime().availableProcessors(),
Runtime.getRuntime().availableProcessors(),
0L,
TimeUnit.MILLISECONDS,
LinkedBlockingQueue(),
Executors.defaultThreadFactory()
)
I get this hint from this issue in the Coil github page.
Use the object declared above in the ImageRequest object.
#Composable
fun SvgImageSample() {
val painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(LocalContext.current)
.dispatcher(IdlingThreadPool.asCoroutineDispatcher()) // << here
.decoderFactory(SvgDecoder.Factory())
.data("https://someserver.com/SVG_image.svg")
.size(Size.ORIGINAL)
.build()
)
Image(
painter = painter,
modifier = Modifier
.size(100.dp)
.semantics {
testTag = "myImg"
coilAsyncPainter = painter
},
contentDescription = null
)
}
Notice the IdlingThreadPool object was used in the dispatcher function. The other detail is coilAsyncPainter property which is receiving the painter object. It will be necessary during the test to check if the image was loaded.
Declare the coilAsyncPainter semantic property.
val CoilAsyncPainter = SemanticsPropertyKey<AsyncImagePainter>("CoilAsyncPainter")
var SemanticsPropertyReceiver.coilAsyncPainter by CoilAsyncPainter
This is what you need to do in the application code.
In the test code, declare a new SemanticNodeInteration.
fun SemanticsNodeInteraction.isAsyncPainterComplete(): SemanticsNodeInteraction {
assert(
SemanticsMatcher("Async Image is Success") { semanticsNode ->
val painter = semanticsNode.config.getOrElseNullable(CoilAsyncPainter) { null }
painter?.state is AsyncImagePainter.State.Success
}
)
return this;
}
So here, basically the painter object is obtained from the semantic property and then is checked if the current state is Success.
Finally, here it is the test.
class MyTest {
#get:Rule
val composeTestRule = createComposeRule()
#Test
fun async_image_was_displayed() {
composeTestRule.setContent {
MyAppThemeTheme {
SvgImageSample()
}
}
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag("myImg")
.isAsyncPainterComplete()
}
}
Another way would be to implement an EventListener and check the right events are emitted. Will save you using testTags and semantic properties in the app code.
https://coil-kt.github.io/coil/api/coil-base/coil-base/coil/-event-listener/index.html
A quick hacky attempt, but you could wrap this in a Composable helper that does this for any block passed in.
#Test
fun imageLoader() {
var success = 0
var errors = 0
composeTestRule.setContent {
Coil.setImageLoader(
ImageLoader.Builder(context)
.eventListener(object : EventListener {
override fun onSuccess(
request: ImageRequest,
result: SuccessResult
) {
success++
}
override fun onError(
request: ImageRequest,
result: ErrorResult
) {
errors++
}
})
.build()
)
MyAppThemeTheme {
SvgImageSample()
}
}
Thread.sleep(500)
assertThat(errors).isEqualTo(0)
assertThat(success).isEqualTo(1)
}
First, I have to say that the approach suggested by #nglauber worked. However, I cringed at that level of complexity for a simple test, so I tried a straight forward test and that works as well and I will keep so.
First, I loaded the image simply with AsyncImage
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(template.previewUrl)
.crossfade(true)
.build(),
placeholder = painterResource(template.thumbNailResId),
contentDescription = stringResource(R.string.template_description),
contentScale = ContentScale.Fit,
)
Then in the test, I simply checked for the node with content description is displayed like so
#Test
fun intialImageDisplayedTest() {
val template = TemplateRepository.getTemplate()[0]
composeTestRule.setContent {
val selectedIndex = remember{ mutableStateOf(-1) }
TemplateItem(
selectedId = selectedIndex,
template = template,
onPreviewButtonClicked = {}
)
}
composeTestRule.onNodeWithTag("template_${template.templateId}").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription(getImageDescriptionText()).assertIsDisplayed()
}
private fun getImageDescriptionText(): String {
return composeTestRule.activity.resources.getString(R.string.template_description)
}
Again keeping it simple. I also added a matcher with a test tag. No Idling resource needed.
In the following viewModel code I am generating a list of items from graphQl server
private val _balloonsStatus =
MutableStateFlow<Status<List<BalloonsQuery.Edge>?>>(Status.Loading())
val balloonsStatus get() = _balloonsStatus
private val _endCursor = MutableStateFlow<String?>(null)
val endCursor get() = _endCursor
init {
loadBalloons(null)
}
fun loadBalloons(cursor: String?) {
viewModelScope.launch {
val data = repo.getBalloonsFromServer(cursor)
if (data.errors == null) {
_balloonsStatus.value = Status.Success(data.data?.balloons?.edges)
_endCursor.value = data.data?.balloons?.pageInfo?.endCursor
} else {
_balloonsStatus.value = Status.Error(data.errors!![0].message)
_endCursor.value = null
}
}
}
and in the composable function I am getting this data by following this code:
#Composable
fun BalloonsScreen(
navHostController: NavHostController? = null,
viewModel: SharedBalloonViewModel
) {
val endCursor by viewModel.endCursor.collectAsState()
val balloons by viewModel.balloonsStatus.collectAsState()
AssignmentTheme {
Column(modifier = Modifier.fillMaxSize()) {
when (balloons) {
is Status.Error -> {
Log.i("Reyjohn", balloons.message!!)
}
is Status.Loading -> {
Log.i("Reyjohn", "loading..")
}
is Status.Success -> {
BalloonList(edgeList = balloons.data!!, navHostController = navHostController)
}
}
Spacer(modifier = Modifier.weight(1f))
Button(onClick = { viewModel.loadBalloons(endCursor) }) {
Text(text = "Load More")
}
}
}
}
#Composable
fun BalloonList(
edgeList: List<BalloonsQuery.Edge>,
navHostController: NavHostController? = null,
) {
LazyColumn {
items(items = edgeList) { edge ->
UserRow(edge.node, navHostController)
}
}
}
but the problem is every time I click on Load More button it regenerates the view and displays a new set of list, but I want to append the list at the end of the previous list. As far I understand that the list is regenerated as the flow I am listening to is doing the work behind this, but I am stuck here to get a workaround about how to achieve my target here, a kind hearted help would be much appreciated!
You can create a private list in ViewModel that adds List<BalloonsQuery.Edge>?>
and instead of
_balloonsStatus.value = Status.Success(data.data?.balloons?.edges)
you can do something like
_balloonsStatus.value = Status.Success(myLiast.addAll(
data.data?.balloons?.edges))
should update Compose with the latest data appended to existing one
I'm have a LazyColumn that renders a list of items. However, I now want to fetch more items to add to my lazy list. I don't want to re-render items that have already been rendered in the LazyColumn, I just want to add the new items.
How do I do this with a StateFlow? I need to pass a page String to fetch the next group of items, but how do I pass a page into the repository.getContent() method?
class FeedViewModel(
private val resources: Resources,
private val repository: FeedRepository
) : ViewModel() {
// I need to pass a parameter to `repository.getContent()` to get the next block of items
private val _uiState: StateFlow<UiState> = repository.getContent()
.map { content ->
UiState.Ready(content)
}.catch { cause ->
UiState.Error(cause.message ?: resources.getString(R.string.error_generic))
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = SUBSCRIBE_TIMEOUT_FOR_CONFIG_CHANGE),
initialValue = UiState.Loading
)
val uiState: StateFlow<UiState>
get() = _uiState
And in my UI, I have this code to observe the flow and render the LazyColumn:
val lifecycleAwareUiStateFlow: Flow<UiState> = remember(viewModel.uiState, lifecycleOwner) {
viewModel.uiState.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
}
val uiState: UiState by lifecycleAwareUiStateFlow.collectAsState(initial = UiState.Loading)
#Composable
fun FeedLazyColumn(
posts: List<Post> = listOf(),
scrollState: LazyListState
) {
LazyColumn(
modifier = Modifier.padding(vertical = 4.dp),
state = scrollState
) {
// how to add more posts???
items(items = posts) { post ->
Card(post)
}
}
}
I do realize there is a Paging library for Compose, but I'm trying to implement something similar, except the user is in charge of whether or not to load next items.
This is the desired behavior:
I was able to solve this by adding the new posts to the old posts before emitting it. See comments below for the relevant lines.
private val _content = MutableStateFlow<Content>(Content())
private val _uiState: StateFlow<UiState> = repository.getContent()
.mapLatest { content ->
_content.value = _content.value + content // ADDED THIS
UiState.Ready(_content.value)
}.catch { cause ->
UiState.Error(cause.message ?: resources.getString(R.string.error_generic))
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = SUBSCRIBE_TIMEOUT_FOR_CONFIG_CHANGE),
initialValue = UiState.Loading
)
private operator fun Content.plus(content: Content): Content = Content(
posts = this.posts + content.posts,
youTubeNextPageToken = content.youTubeNextPageToken
)
class YouTubeDataSource(private val apiService: YouTubeApiService) :
RemoteDataSource<YouTubeResponse> {
private val nextPageToken = MutableStateFlow<String?>(null)
fun setNextPageToken(nextPageToken: String) {
this.nextPageToken.value = nextPageToken
}
override fun getContent(): Flow<YouTubeResponse> = flow {
// retrigger emit when nextPageToken changes
nextPageToken.collect {
emit(apiService.getYouTubeSnippets(it))
}
}
}
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:
The interface is created and a white screen is shown.
The API is called.
Wait about 20 seconds (there's about 400 images!).
dogImages gets updated automatically.
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! :)
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.
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.
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.
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.
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)
}
}
}
}
I am currently trying to write an App for my thesis and currently, I am looking into different approaches. Since I really like Flutter and the Thesis requires me to use Java/Kotlin I would like to use Jetpack compose.
Currently, I am stuck trying to update ListElements.
I want to have a List that shows Experiments and their state/result. Once I hit the Button I want the experiments to run and after they are done update their state. Currently, the run Method does nothing besides setting the state to success.
The problem is I don't know how to trigger a recompose from the viewModel of the ExperimentRow once an experiment updates its state.
ExperimentsActivity:
class ExperimentsActivity : AppCompatActivity() {
val exViewModel by viewModels<ExperimentViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//For now this is just Dummy Data and will be replaced
exViewModel.experiments += listOf(
Experiment("Test1", exViewModel::experimentStateChanged),
Experiment("Strongbox", exViewModel::experimentStateChanged)
)
setContent {
TpmTheme {
// A surface container using the 'background' color from the theme
Surface {
ExperimentScreen(
exViewModel.experiments,
exViewModel::startTests
)
}
}
}
}
}
ExperimentViewModel:
class ExperimentViewModel : ViewModel() {
var experiments by mutableStateOf(listOf<Experiment>())
fun startTests() {
for (exp in experiments) {
exp.run()
}
}
fun experimentStateChanged(experiment: Experiment) {
Log.i("ViewModel", "Changed expState of ${experiment.name} to ${experiment.state}")
// HOW DO I TRIGGER A RECOMPOSE OF THE EXPERIMENTROW FOR THE experiment????
//experiments = experiments.toMutableList().also { it.plus(experiment) }
Log.i("Vi", "Size of Expirments: ${experiments.size}")
}
}
ExperimentScreen:
#Composable
fun ExperimentScreen(
experiments: List<Experiment>,
onStartExperiments: () -> Unit
) {
Column {
LazyColumnFor(
items = experiments,
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(top = 8.dp),
) { ep ->
ExperimentRow(
experiment = ep,
modifier = Modifier.fillParentMaxWidth(),
)
}
Button(
onClick = { onStartExperiments() },
modifier = Modifier.padding(16.dp).fillMaxWidth(),
) {
Text("Run Tests")
}
}
}
#Composable
fun ExperimentRow(experiment: Experiment, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(experiment.name)
Icon(
asset = experiment.state.vAsset,
)
}
Experiment:
class Experiment(val name: String, val onStateChanged: (Experiment) -> Unit) {
var state: ExperimentState = ExperimentState.DEFAULT
set(value) {
field = value
onStateChanged(this)
}
fun run() {
state = ExperimentState.SUCCESS;
}
}
enum class ExperimentState(val vAsset: VectorAsset) {
DEFAULT(Icons.Default.Info),
RUNNING(Icons.Default.Refresh),
SUCCESS(Icons.Default.Done),
FAILED(Icons.Default.Warning),
}
There's a few ways to address this but key thing is that you need to add a copy of element (with state changed) to experiments to trigger the recomposition.
One possible example would be
data class Experiment(val name: String, val state: ExperimentState, val onStateChanged: (Experiment) -> Unit) {
fun run() {
onStateChanged(this.copy(state = ExperimentState.SUCCESS))
}
}
and then
fun experimentStateChanged(experiment: Experiment) {
val index = experiments.toMutableList().indexOfFirst { it.name == experiment.name }
experiments = experiments.toMutableList().also {
it[index] = experiment
}
}
though I suspect there's probably cleaner way of doing this.