Blog Infos
Author
Published
Topics
Published

This is a journey into real-time updates, flows creation, cancelation, and proper lifecycle scoping. We’ll be exploring all of this using two examples:

1. Imitating podcast download progress. (on the right)
2. Imitating currency updates for visible items. (on the left)

This is a journey into real-time updates, flows creation, cancelation, and proper lifecycle scoping. We’ll be exploring all of this using two examples: Imitating podcast download progress. (on the right), Imitating currency updates for visible items. (on the left)
Podcast downloads
Step 1: Creating podcasts ViewModel
class PodcastsViewModel : ViewModel() {
private val _podcasts = MutableStateFlow(listOf<Podcast>())
val podcasts: StateFlow<List<Podcast>> get() = _podcasts
private val downloadQueue: MutableMap<Int, Flow<Int>> = mutableMapOf()
init {
getPodcasts()
}
private fun getPodcasts() {
viewModelScope.launch(Dispatchers.Default) {
val initialPodcasts = arrayListOf<Podcast>()
repeat(100) {
initialPodcasts += Podcast(
id = it,
title = "Podcast #$it",
downloadProgress = 0
)
}
_podcasts.emit(initialPodcasts)
}
}
}

We’re using getPodcasts() to populate list of podcasts, which we then put into our _podcasts, which is a MutableStateFlowWe’ll be observing podcasts from our UI layer to build a LazyColumn of cards. downloadQueue map will be used to store active downloads.

Step 2: Creating podcasts UI
@Composable
fun PodcastsScreen(viewModel: PodcastsViewModel) {
val podcasts by viewModel.podcasts.collectAsStateWithLifecycle()
LazyColumn {
itemsIndexed(podcasts, { _, item -> item.id }) { index, podcast ->
PodcastCard(
podcast = podcast,
onDownloadClick = { viewModel.onDownloadPodcastClicked(podcast.id, index) })
}
}
}
PodcastsScreen

Observes viewModel.podcasts containing with the help of .collectAsState(). This means that list of cards will be diffed & recomposed each time the value of viewModel.podcasts changes.

Whenever user taps on the download icon, we invoke the onDownloadPodcastClicked(), which we’ll declare in the next step in the viewModel.

PodcastCard

Composable that contains podcast title & download status.

Step 3: Imitating the download & updating the UI

Let’s add these 3 functions into the PodcastsViewModel.

fun onDownloadPodcastClicked(podcastId: Int, index: Int) {
if (downloadQueue.containsKey(podcastId)) return
val download: Flow<Int> = provideDownloadFlow(podcastId)
downloadQueue[podcastId] = download
observeDownload(index, download)
}
private fun provideDownloadFlow(podcastId: Int): Flow<Int> {
return flow {
var progress = 10
emit(progress)
repeat(100) {
progress += Random.nextInt(10, 25)
delay(500L)
if (progress >= 100) emit(100) else emit(progress)
if (progress >= 100) {
downloadQueue.remove(podcastId)
return@flow
}
}
}.flowOn(Dispatchers.Default)
}
private fun observeDownload(index: Int, downloadFlow: Flow<Int>) {
viewModelScope.launch(Dispatchers.Default) {
downloadFlow.collect { progress ->
val updatedPodcast = _podcasts.value[index].copy(downloadProgress = progress)
val mutablePodcasts = _podcasts.value.toMutableList()
mutablePodcasts[index] = updatedPodcast
_podcasts.value = mutablePodcasts.toList()
}
}
}
onDownloadPodcastClicked()
  1. Checks whether downloadQueue already has specified podcastId inside. If it does – we do nothing, since this means that we already have an active download for that podcast.
  2. Creates the download flow using provideDownloadFlow():Flow<Int> to imitate the download & puts it in our downloadQueue map using podcastId as a key.
  3. Starts observation of the download progress by calling observeDownload().
provideDownloadFlow()
  1. Creates a flow which emits random numbers in range between 10 & 25 representing the download percentage in 0.5 second intervals.
  2. Whenever progress is 100 — it removes itself from the downloadQueue, and cancels the producing flow.
observeDownload()
  1. Observes the provided downloadFlow, and on each progress change updates the _podcasts list. Since we are observing podcasts from the UI layer, compose diffs the changes and recomposes the parts of UI whose values have been changed.
  2. This flow will be automatically canceled whenever the viewModel will be cleared, since we are launching it in viewModelScope . This is the behaviour we’d usually want here, since we don’t care whether the card tapped is still visible, or user scrolled somewhere.

This results into the progress bar updates, and whenever progress hits 100 — we change the icon+ colour to indicate that podcast download is finished.

I’d advice to use WorkManager if you want to download files reliably, without scoping to screen/logical flow of screens.

Currency updates
Step 1: Creating currencies ViewModel
class CurrenciesViewModel : ViewModel() {
private val _currencyPrices = MutableStateFlow(listOf<CurrencyPrice>())
val currencyPrices: StateFlow<List<CurrencyPrice>> get() = _currencyPrices
private val producers: MutableMap<Int, Job> = mutableMapOf()
init {
getCurrencyPrices()
}
private fun getCurrencyPrices() {
viewModelScope.launch(Dispatchers.Default) {
val initialCurrencyPrices = arrayListOf<CurrencyPrice>()
run loop@{
Currency.getAvailableCurrencies().forEachIndexed { index, currency ->
if (index == 100) return@loop
initialCurrencyPrices += CurrencyPrice(
id = index,
name = "1 USD to ${currency.currencyCode}",
price = Random.nextInt(0, 100),
priceFluctuation = PriceFluctuation.UNKNOWN
)
}
}
_currencyPrices.emit(initialCurrencyPrices)
}
}
}

Logic is similar to PodcastsViewModel, except we’ll use map<Int, Job> instead of map<Int, Flow<Int>>.

We want to subscribe the card to updates when it becomes visible to user, and unsubscribe + cancel the flow whenever card goes outside of the visible screen bounds.

Having job here allows us to stop the currencyPriceUpdateFlow whenever we want, since it’s bound to the coroutineScope it’s being launched in.

Step 2: Creating currencies UI
@Composable
fun CurrenciesScreen(viewModel: CurrenciesViewModel) {
val currencyPrices by viewModel.currencyPrices.collectAsStateWithLifecycle()
LazyColumn {
itemsIndexed(currencyPrices) { index, currencyPrice ->
CurrencyPriceCard(
currencyPrice = currencyPrice,
onActive = { viewModel.onCardActive(currencyPrice.id, index) },
onDisposed = { viewModel.onCardDisposed(currencyPrice.id) })
}
}
}
@Composable
fun CurrencyPriceCard(
currencyPrice: CurrencyPrice,
onActive: () -> Unit,
onDisposed: () -> Unit,
) {
LaunchedEffect(Unit) { onActive() }
DisposableEffect(Unit) { onDispose { onDisposed() } }
CurrencyCard(currencyPrice.name, "${currencyPrice.price}", currencyPrice.priceFluctuation)
}
CurrenciesScreen

It’s same as PodcastsScreen, apart from different data source & composable inside. We’ll be invoking the viewModel.onCardActive() whenever the card is visible, and viewModel.onCardDisposed() when it’s outside of the visible area.

CurrencyPriceCard
  • LaunchedEffect(Unit) is invoked when composable is being composed for the first time. That’s exactly where we want the subscription to price updates to start.
  • DisposableEffect(Unit) is invoked when the composable is being outside of visible screen bounds. We’ll use this callback to cancel the subscription.
CurrencyCard

Composable that contains currency title, current price & animation logic.

Step 3: starting & cancelling subscriptions + updating UI

Let’s add four functions to the viewModel:

onCardActive()
fun onCardActive(currencyId: Int, index: Int) {
if (producers.containsKey(currencyId)) return
val currencyPriceUpdateFlow: Flow<Int> = provideCurrencyUpdateFlow()
val currencyPriceUpdateJob = viewModelScope.launch {
observePriceUpdateFlow(index, currencyPriceUpdateFlow)
}
producers[currencyId] = currencyPriceUpdateJob
}
view raw onCardActive.kt hosted with ❤ by GitHub
  1. Checks if producers already have the specified currencyId subscribed.
  2. Creates currencyPriceUpdateFlow that emits currency price updates.
  3. Creates currencyUpdateJob, which is being used as a scope to launch the currencyUpdateFlow in.
  4. Adds the job in producers map using currencyId as a key.
onCardDisposed()
fun onCardDisposed(currencyId: Int) {
if (producers.containsKey(currencyId)) {
producers.getValue(currencyId).job.cancel()
producers.remove(currencyId)
}
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Migrating to Jetpack Compose – an interop love story

Most of you are familiar with Jetpack Compose and its benefits. If you’re able to start anew and create a Compose-only app, you’re on the right track. But this talk might not be for you…
Watch Video

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer for Jetpack Compose
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engin ...
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer f ...
Google

Jobs

When the card is not visible to the user — we want to unsubscribe from the updates, and cancel the flow.

provideCurrencyUpdateFlow()
private fun provideCurrencyUpdateFlow(): Flow<Int> {
return flow {
repeat(10000) {
delay(Random.nextLong(500L, 2500L))
emit(Random.nextInt(0, 100))
}
}.flowOn(Dispatchers.Default).distinctUntilChanged()
}

Creates a flow that emits random numbers in random intervals of time.

observePriceUpdateFlow()
private suspend fun observePriceUpdateFlow(index: Int, currencyPriceUpdateFlow: Flow<Int>) {
currencyPriceUpdateFlow.collect { newPrice ->
val newFluctuation = when {
newPrice > _currencyPrices.value[index].price -> PriceFluctuation.UP
else -> PriceFluctuation.DOWN
}
val updatedCurrencyPrice = _currencyPrices.value[index].copy(
price = newPrice,
priceFluctuation = newFluctuation
)
val mutableCurrencyPrices = _currencyPrices.value.toMutableList()
mutableCurrencyPrices[index] = updatedCurrencyPrice
_currencyPrices.value = mutableCurrencyPrices.toList()
}
}

Observes the flow. On each emission we update the _currencyPrices, which results into UI diffing, recomposing & triggering animations that indicate whether the price went up or down, from the last known price value.

Lack of proper lifecycle-awareness

We are subscribing, unsubscribing, everything works just fine. But there is a subtle issue. Remember the scope we’ve used to launch the flow collection in? — viewModelScope. And that’s the problem.

Whenever we put the app in background by switching to a different app, or switching the screen off, price updates are still coming. UI won’t be recomposed in this scenario, but producers are still active and emit new values even when we can’t show them to the user.

Let’s look on what we have to do in order to fix this.

Currency updates with proper lifecycle-awareness
Step 1: final viewModel
class LifecycleAwareCurrenciesViewModel : ViewModel() {
private val _currencyPrices = MutableStateFlow(listOf<CurrencyPrice>())
val currencyPrices: StateFlow<List<CurrencyPrice>> get() = _currencyPrices
init {
getCurrencyPrices()
}
private fun getCurrencyPrices() {} //same as before
fun onCurrencyUpdated(newPrice: Int, index: Int) { } //same as before
fun provideCurrencyUpdateFlow(): Flow<Int> { } //same as before
}

We’ve removed the flow collection logic from the viewModel, since we don’t want to use the viewModelScope anymore.

onCurrencyUpdated()

Invoked when we have a new price incoming for the currency updates flow. Mutates the list, updates it and updates the currencyPrices.

Step 2: final UI
@Composable
fun LifecycleAwareCurrenciesScreen(viewModel: LifecycleAwareCurrenciesViewModel) {
val currencyPrices by viewModel.currencyPrices.collectAsStateWithLifecycle()
LazyColumn {
itemsIndexed(currencyPrices, { _, item -> item.id }) { index, currencyPrice ->
LifecycleAwareCurrencyPriceCard(
currencyPrice = currencyPrice,
currencyPriceUpdateFlow = viewModel.provideCurrencyUpdateFlow(),
onDisposed = { viewModel.onDisposed(index) },
onCurrencyUpdated = { newPrice -> viewModel.onCurrencyUpdated(newPrice, index) })
}
}
}
LifecycleAwareCurrenciesScreen

The only thing worth mentioning here is that we provide the currencyUpdateFlow from the viewModel for each item visible, for samples sake.

@Composable
fun LifecycleAwareCurrencyPriceCard(
currencyPrice: CurrencyPrice,
currencyPriceUpdateFlow: Flow<Int>,
onCurrencyUpdated: (progress: Int) -> Unit,
onDisposed: () -> Unit,
) {
val lifecycleOwner = LocalLifecycleOwner.current
val lifecycleAwareCurrencyPriceFlow = remember(currencyPriceUpdateFlow, lifecycleOwner) {
currencyPriceUpdateFlow.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
}
LaunchedEffect(Unit) {
lifecycleAwareCurrencyPriceFlow.collect { progress -> onCurrencyUpdated(progress) }
}
DisposableEffect(Unit) { onDispose { onDisposed() } }
CurrencyCard(currencyPrice.name, "${currencyPrice.price}", currencyPrice.priceFluctuation)
}
  1. lifecycleOwner — our current composable lifecycle. Using it will allow us to properly pause producers launched in it, when app goes to background.
  2. lifecycleAwareCurrencyPriceFlow — is a modified currencyPriceUpdateFlow, scoped to lifecycle of this composable. Achieved using .flowWithLifecycle().
  3. LaunchedEffect(Unit) is used to collect the flow updates. The beauty of it allows us to not worry about coroutineScope cancelation anymore, since:

When LaunchedEffect enters the composition it will launch block into the composition’s CoroutineContext. The coroutine will be cancelled and re-launched when LaunchedEffect is recomposed with a different keys. The coroutine will be cancelled when the LaunchedEffect leaves the composition.

Compose version in the repo: 1.0.3.

Full example can be found here

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu