Revision control
Copy as Markdown
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
package mozilla.components.lib.state.ext
import android.view.View
import androidx.annotation.MainThread
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
import mozilla.components.support.ktx.android.view.toScope
/**
* Helper extension method for consuming [State] from a [Store] sequentially in order inside a
* [Fragment]. The [block] function will get invoked for every [State] update.
*
* This helper will automatically stop observing the [Store] once the [View] of the [Fragment] gets
* detached. The fragment's lifecycle will be used to determine when to resume/pause observing the
* [Store].
*/
@MainThread
fun <S : State, A : Action> Fragment.consumeFrom(store: Store<S, A>, block: (S) -> Unit) {
val fragment = this
val view = checkNotNull(view) { "Fragment has no view yet. Call from onViewCreated()." }
val scope = view.toScope()
val channel = store.channel(owner = this)
scope.launch {
channel.consumeEach { state ->
// We are using a scope that is bound to the view being attached here. It can happen
// that the "view detached" callback gets executed *after* the fragment was detached. If
// a `consumeFrom` runs in exactly this moment then we run inside a detached fragment
// without a `Context` and this can cause a variety of issues/crashes.
//
// To avoid this, we check whether the fragment still has an activity and a view
// attached. If not then we run in exactly that moment between fragment detach and view
// detach. It would be better if we could use `viewLifecycleOwner` which is bound to
// onCreateView() and onDestroyView() of the fragment. But:
// - `viewLifecycleOwner` is only available in alpha versions of AndroidX currently.
// - We found a bug where `viewLifecycleOwner.lifecycleScope` is not getting cancelled
// causing this coroutine to run forever.
// Once those two issues get resolved we can remove the `isAdded` check and use
// `viewLifecycleOwner.lifecycleScope` instead of the view scope.
//
// In a previous version we tried using `isAdded` and `isDetached` here. But in certain
// situations they reported true/false in situations where no activity was attached to
// the fragment. Therefore we switched to explicitly check for the activity and view here.
if (fragment.activity != null && fragment.view != null) {
block(state)
}
}
}
}
/**
* Helper extension method for consuming [State] from a [Store] as a [Flow].
*
* The lifetime of the coroutine scope the [Flow] is launched in, and [block] is executed in, is
* bound to the [View] of the [Fragment]. Once the [View] gets detached, the coroutine scope will
* automatically be cancelled and no longer observe the [Store].
*
* An optional [LifecycleOwner] can be passed to this method. It will be used to automatically pause
* and resume the [Store] subscription. With that an application can, for example, automatically
* stop updating the UI if the application is in the background. Once the [Lifecycle] switches back
* to at least STARTED state then the latest [State] and further will be passed to the [Flow] again.
* By default, the fragment itself is used as a [LifecycleOwner].
*/
@MainThread
fun <S : State, A : Action> Fragment.consumeFlow(
from: Store<S, A>,
owner: LifecycleOwner? = this,
block: suspend (Flow<S>) -> Unit,
) {
val fragment = this
val view = checkNotNull(view) { "Fragment has no view yet. Call from onViewCreated()." }
// It's important to create the flow here directly instead of in the coroutine below,
// as otherwise the fragment could be removed before the subscription is created.
// This would cause us to create an unnecessary subscription leaking the fragment,
// as we only unsubscribe on destroy which already happened.
val flow = from.flow(owner)
val scope = view.toScope()
scope.launch {
val filtered = flow.filter {
// We ignore state updates if the fragment does not have an activity or view
// attached anymore.
// See comment in [consumeFrom] above.
fragment.activity != null && fragment.view != null
}
block(filtered)
}
}