Skip to content
This repository was archived by the owner on Jan 10, 2025. It is now read-only.

Commit 1a9305b

Browse files
authored
Merge pull request #987 from android/dlam/loadstate-helper
Add a sample showing how to synchronously await for remote loads to be applied
2 parents 1a68de8 + 0bc6e42 commit 1a9305b

File tree

2 files changed

+189
-3
lines changed
  • PagingWithNetworkSample
    • app/src/main/java/com/android/example/paging/pagingwithnetwork/reddit/ui
    • lib/src/main/java/com/android/example/paging/pagingwithnetwork/reddit/paging

2 files changed

+189
-3
lines changed

PagingWithNetworkSample/app/src/main/java/com/android/example/paging/pagingwithnetwork/reddit/ui/RedditActivity.kt

+9-3
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import androidx.paging.LoadState
3131
import com.android.example.paging.pagingwithnetwork.GlideApp
3232
import com.android.example.paging.pagingwithnetwork.databinding.ActivityRedditBinding
3333
import com.android.example.paging.pagingwithnetwork.reddit.ServiceLocator
34+
import com.android.example.paging.pagingwithnetwork.reddit.paging.asMergedLoadStates
3435
import com.android.example.paging.pagingwithnetwork.reddit.repository.RedditPostRepository
3536
import kotlinx.coroutines.flow.collect
3637
import kotlinx.coroutines.flow.collectLatest
@@ -85,7 +86,7 @@ class RedditActivity : AppCompatActivity() {
8586

8687
lifecycleScope.launchWhenCreated {
8788
adapter.loadStateFlow.collectLatest { loadStates ->
88-
binding.swipeRefresh.isRefreshing = loadStates.refresh is LoadState.Loading
89+
binding.swipeRefresh.isRefreshing = loadStates.mediator?.refresh is LoadState.Loading
8990
}
9091
}
9192

@@ -97,10 +98,15 @@ class RedditActivity : AppCompatActivity() {
9798

9899
lifecycleScope.launchWhenCreated {
99100
adapter.loadStateFlow
100-
// Only emit when REFRESH LoadState for RemoteMediator changes.
101+
// Use a state-machine to track LoadStates such that we only transition to
102+
// NotLoading from a RemoteMediator load if it was also presented to UI.
103+
.asMergedLoadStates()
104+
// Only emit when REFRESH changes, as we only want to react on loads replacing the
105+
// list.
101106
.distinctUntilChangedBy { it.refresh }
102-
// Only react to cases where Remote REFRESH completes i.e., NotLoading.
107+
// Only react to cases where REFRESH completes i.e., NotLoading.
103108
.filter { it.refresh is LoadState.NotLoading }
109+
// Scroll to top is synchronous with UI updates, even if remote load was triggered.
104110
.collect { binding.list.scrollToPosition(0) }
105111
}
106112
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package com.android.example.paging.pagingwithnetwork.reddit.paging
2+
3+
import androidx.annotation.VisibleForTesting
4+
import androidx.paging.CombinedLoadStates
5+
import androidx.paging.LoadState
6+
import androidx.paging.LoadState.NotLoading
7+
import androidx.paging.LoadState.Loading
8+
import androidx.paging.LoadStates
9+
import kotlinx.coroutines.ExperimentalCoroutinesApi
10+
import kotlinx.coroutines.flow.Flow
11+
import kotlinx.coroutines.flow.scan
12+
import kotlin.Error
13+
import androidx.paging.PagingDataAdapter
14+
import androidx.paging.RemoteMediator
15+
import androidx.paging.PagingSource
16+
import androidx.paging.LoadType.REFRESH
17+
import androidx.paging.LoadType
18+
import com.android.example.paging.pagingwithnetwork.reddit.paging.MergedState.NOT_LOADING
19+
import com.android.example.paging.pagingwithnetwork.reddit.paging.MergedState.REMOTE_STARTED
20+
import com.android.example.paging.pagingwithnetwork.reddit.paging.MergedState.REMOTE_ERROR
21+
import com.android.example.paging.pagingwithnetwork.reddit.paging.MergedState.SOURCE_ERROR
22+
import com.android.example.paging.pagingwithnetwork.reddit.paging.MergedState.SOURCE_LOADING
23+
24+
/**
25+
* Converts the raw [CombinedLoadStates] [Flow] from [PagingDataAdapter.loadStateFlow] into a new
26+
* [Flow] of [CombinedLoadStates] that track [CombinedLoadStates.mediator] states as they are
27+
* synchronously applied in the UI. Any [Loading] state triggered by [RemoteMediator] will only
28+
* transition back to [NotLoading] after the fetched items have been synchronously shown in UI by a
29+
* successful [PagingSource] load of type [REFRESH].
30+
*
31+
* Note: This class assumes that the [RemoteMediator] implementation always invalidates
32+
* [PagingSource] on a successful fetch, even if no data was modified (which Room does by default).
33+
* Using this class without this guarantee can cause [LoadState] to get indefinitely stuck as
34+
* [Loading] in cases where invalidation doesn't happen because the fetched network data represents
35+
* exactly what is already cached in DB.
36+
*/
37+
@OptIn(ExperimentalCoroutinesApi::class)
38+
fun Flow<CombinedLoadStates>.asMergedLoadStates(): Flow<LoadStates> {
39+
val syncRemoteState = LoadStatesMerger()
40+
return scan(syncRemoteState.toLoadStates()) { _, combinedLoadStates ->
41+
syncRemoteState.updateFromCombinedLoadStates(combinedLoadStates)
42+
syncRemoteState.toLoadStates()
43+
}
44+
}
45+
46+
/**
47+
* Track the combined [LoadState] of [RemoteMediator] and [PagingSource], so that each load type
48+
* is only set to [NotLoading] when [RemoteMediator] load is applied on presenter-side.
49+
*/
50+
private class LoadStatesMerger {
51+
var refresh: LoadState = NotLoading(endOfPaginationReached = false)
52+
private set
53+
var prepend: LoadState = NotLoading(endOfPaginationReached = false)
54+
private set
55+
var append: LoadState = NotLoading(endOfPaginationReached = false)
56+
private set
57+
var refreshState: MergedState = NOT_LOADING
58+
private set
59+
var prependState: MergedState = NOT_LOADING
60+
private set
61+
var appendState: MergedState = NOT_LOADING
62+
private set
63+
64+
fun toLoadStates() = LoadStates(
65+
refresh = refresh,
66+
prepend = prepend,
67+
append = append
68+
)
69+
70+
/**
71+
* For every new emission of [CombinedLoadStates] from the original [Flow], update the
72+
* [MergedState] of each [LoadType] and compute the new [LoadState].
73+
*/
74+
fun updateFromCombinedLoadStates(combinedLoadStates: CombinedLoadStates) {
75+
computeNextLoadStateAndMergedState(
76+
sourceRefreshState = combinedLoadStates.source.refresh,
77+
sourceState = combinedLoadStates.source.refresh,
78+
remoteState = combinedLoadStates.mediator?.refresh,
79+
currentMergedState = refreshState,
80+
).also {
81+
refresh = it.first
82+
refreshState = it.second
83+
}
84+
computeNextLoadStateAndMergedState(
85+
sourceRefreshState = combinedLoadStates.source.refresh,
86+
sourceState = combinedLoadStates.source.prepend,
87+
remoteState = combinedLoadStates.mediator?.prepend,
88+
currentMergedState = prependState,
89+
).also {
90+
prepend = it.first
91+
prependState = it.second
92+
}
93+
computeNextLoadStateAndMergedState(
94+
sourceRefreshState = combinedLoadStates.source.refresh,
95+
sourceState = combinedLoadStates.source.append,
96+
remoteState = combinedLoadStates.mediator?.append,
97+
currentMergedState = appendState,
98+
).also {
99+
append = it.first
100+
appendState = it.second
101+
}
102+
}
103+
104+
/**
105+
* Compute which [LoadState] and [MergedState] to transition, given the previous and current
106+
* state for a particular [LoadType].
107+
*/
108+
private fun computeNextLoadStateAndMergedState(
109+
sourceRefreshState: LoadState,
110+
sourceState: LoadState,
111+
remoteState: LoadState?,
112+
currentMergedState: MergedState,
113+
): Pair<LoadState, MergedState> {
114+
if (remoteState == null) return sourceState to NOT_LOADING
115+
116+
return when (currentMergedState) {
117+
NOT_LOADING -> when (remoteState) {
118+
is Loading -> Loading to REMOTE_STARTED
119+
is Error -> remoteState to REMOTE_ERROR
120+
else -> NotLoading(remoteState.endOfPaginationReached) to NOT_LOADING
121+
}
122+
REMOTE_STARTED -> when {
123+
remoteState is Error -> remoteState to REMOTE_ERROR
124+
sourceRefreshState is Loading -> Loading to SOURCE_LOADING
125+
else -> Loading to REMOTE_STARTED
126+
}
127+
REMOTE_ERROR -> when (remoteState) {
128+
is Error -> remoteState to REMOTE_ERROR
129+
else -> Loading to REMOTE_STARTED
130+
}
131+
SOURCE_LOADING -> when {
132+
sourceRefreshState is Error -> sourceRefreshState to SOURCE_ERROR
133+
remoteState is Error -> remoteState to REMOTE_ERROR
134+
sourceRefreshState is NotLoading -> {
135+
NotLoading(remoteState.endOfPaginationReached) to NOT_LOADING
136+
}
137+
else -> Loading to SOURCE_LOADING
138+
}
139+
SOURCE_ERROR -> when (sourceRefreshState) {
140+
is Error -> sourceRefreshState to SOURCE_ERROR
141+
else -> sourceRefreshState to SOURCE_LOADING
142+
}
143+
}
144+
}
145+
}
146+
147+
/**
148+
* State machine used to compute [LoadState] values in [LoadStatesMerger].
149+
*
150+
* This allows [LoadStatesMerger] to track whether to block transitioning to [NotLoading] from the
151+
* [Loading] state if it was triggered by [RemoteMediator], until [PagingSource] invalidates and
152+
* completes [REFRESH].
153+
*/
154+
private enum class MergedState {
155+
/**
156+
* Idle state; defer to remote state for endOfPaginationReached.
157+
*/
158+
NOT_LOADING,
159+
160+
/**
161+
* Remote load triggered; start listening for source refresh.
162+
*/
163+
REMOTE_STARTED,
164+
165+
/**
166+
* Waiting for remote in error state to get retried
167+
*/
168+
REMOTE_ERROR,
169+
170+
/**
171+
* Source refresh triggered by remote invalidation, once this completes we can be sure
172+
* the next generation was loaded.
173+
*/
174+
SOURCE_LOADING,
175+
176+
/**
177+
* Remote load completed, but waiting for source refresh in error state to get retried.
178+
*/
179+
SOURCE_ERROR,
180+
}

0 commit comments

Comments
 (0)