Skip to content

Feature paging #235

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .idea/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions .idea/Paging-3.iml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions .idea/modules.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions advanced/end/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ android {
}

dependencies {
implementation freeCompilerArgs += ["-Xopt-in=kotlin.RequiresOptIn"]
implementation fileTree(dir: 'libs')
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// GitHub page API is 1 based: https://developer.github.com/v3/#pagination
private const val GITHUB_STARTING_PAGE_INDEX = 1

class GithubPagingSource(
private val service: GithubService,
private val query: String
) : PagingSource<Int, Repo>() {

override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
val position = params.key ?: GITHUB_STARTING_PAGE_INDEX
val apiQuery = query + IN_QUALIFIER
return try {
val response = service.searchRepos(apiQuery, position, params.loadSize)
val repos = response.items
val nextKey = if (repos.isEmpty()) {
null
} else {
// initial load size = 3 * NETWORK_PAGE_SIZE
// ensure we're not requesting duplicating items, at the 2nd request
position + (params.loadSize / NETWORK_PAGE_SIZE)
}
LoadResult.Page(
data = repos,
prevKey = if (position == GITHUB_STARTING_PAGE_INDEX) null else position - 1,
nextKey = nextKey
)
} catch (exception: IOException) {
return LoadResult.Error(exception)
} catch (exception: HttpException) {
return LoadResult.Error(exception)
}
}
// The refresh key is used for subsequent refresh calls to PagingSource.load after the initial load
override fun getRefreshKey(state: PagingState<Int, Repo>): Int? {
// We need to get the previous key (or next key if previous is null) of the page
// that was closest to the most recently accessed index.
// Anchor position is the most recently accessed index
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -26,38 +26,34 @@ import com.example.android.codelabs.paging.db.RepoDatabase
import com.example.android.codelabs.paging.model.Repo
import kotlinx.coroutines.flow.Flow

/**
* Repository class that works with local and remote data sources.
/** Paging 3 now : )
* 1. Handles in-memory cache.
2. Requests data when the user is close to the end of the list.
*/
class GithubRepository(
private val service: GithubService,
private val database: RepoDatabase
) {

/**
* Search repositories whose names match the query, exposed as a stream of data that will emit
* every time we get more data from the network.
*/
fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
Log.d("GithubRepository", "New query: $query")
class GithubRepository(private val service: GithubService,
private val database: RepoDatabase
) {

// appending '%' so we can allow other characters to be before and after the query string
val dbQuery = "%${query.replace(' ', '%')}%"
val pagingSourceFactory = { database.reposDao().reposByName(dbQuery) }
// appending '%' so we can allow other characters to be before and after the query string
val dbQuery = "%${query.replace(' ', '%')}%"
val pagingSourceFactory = { database.reposDao().reposByName(dbQuery)}

fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
@OptIn(ExperimentalPagingApi::class)
return Pager(
config = PagingConfig(pageSize = NETWORK_PAGE_SIZE, enablePlaceholders = false),
remoteMediator = GithubRemoteMediator(
query,
service,
database
config = PagingConfig(
pageSize = NETWORK_PAGE_SIZE,
maxSize = NETWORK_MAX_SIZE,
enablePlaceholders = false
),
remoteMediator = GithubRemoteMediator(service, query, database) }
pagingSourceFactory = pagingSourceFactory
).flow
}

companion object {
const val NETWORK_PAGE_SIZE = 30
const val NETWORK_PAGE_SIZE = 50
const val NETWORK_MAX_SIZE = 150
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class ReposAdapter : PagingDataAdapter<UiModel, ViewHolder>(UIMODEL_COMPARATOR)
}

override fun areContentsTheSame(oldItem: UiModel, newItem: UiModel): Boolean =
oldItem == newItem
oldItem == newItem
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,7 @@ class SearchRepositoriesActivity : AppCompatActivity() {
setContentView(view)

// get the view model
val viewModel = ViewModelProvider(
this, Injection.provideViewModelFactory(
context = this,
owner = this
)
)
val viewModel = ViewModelProvider(this, Injection.provideViewModelFactory(owner = this, context = this))
.get(SearchRepositoriesViewModel::class.java)

// add dividers between RecyclerView's row items
Expand All @@ -74,21 +69,20 @@ class SearchRepositoriesActivity : AppCompatActivity() {
*/
private fun ActivitySearchRepositoriesBinding.bindState(
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<UiModel>>,
pagingData: Flow<PagingData<Repo>>,
uiActions: (UiAction) -> Unit
) {
val repoAdapter = ReposAdapter()
val header = ReposLoadStateAdapter { repoAdapter.retry() }
list.adapter = repoAdapter.withLoadStateHeaderAndFooter(
header = header,
header = ReposLoadStateAdapter { repoAdapter.retry() },
footer = ReposLoadStateAdapter { repoAdapter.retry() }
)

bindSearch(
uiState = uiState,
onQueryChanged = uiActions
)
bindList(
header = header,
repoAdapter = repoAdapter,
uiState = uiState,
pagingData = pagingData,
Expand Down Expand Up @@ -135,21 +129,21 @@ class SearchRepositoriesActivity : AppCompatActivity() {
}

private fun ActivitySearchRepositoriesBinding.bindList(
header: ReposLoadStateAdapter,
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<UiModel>>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
retryButton.setOnClickListener { repoAdapter.retry() }
list.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (dy != 0) onScrollChanged(UiAction.Scroll(currentQuery = uiState.value.query))
}
})
val notLoading = repoAdapter.loadStateFlow
.asRemotePresentationState()
.map { it == RemotePresentationState.PRESENTED }
// Only emit when REFRESH LoadState for the paging source changes.
.distinctUntilChangedBy { it.source.refresh }
// Only react to cases where REFRESH completes i.e., NotLoading.
.map { it.source.refresh is LoadState.NotLoading }

val hasNotScrolledForCurrentSearch = uiState
.map { it.hasNotScrolledForCurrentSearch }
Expand All @@ -162,6 +156,19 @@ class SearchRepositoriesActivity : AppCompatActivity() {
)
.distinctUntilChanged()


// Collecting from loadStateFlow directly.
lifecycleScope.launch {
repoAdapter.loadStateFlow.collect { loadState ->
val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
// show empty list
emptyList.isVisible = isListEmpty
// Only show the list if refresh succeeds.
list.isVisible = !isListEmpty
}
}

/*
lifecycleScope.launch {
pagingData.collectLatest(repoAdapter::submitData)
}
Expand All @@ -171,38 +178,7 @@ class SearchRepositoriesActivity : AppCompatActivity() {
if (shouldScroll) list.scrollToPosition(0)
}
}
*/

lifecycleScope.launch {
repoAdapter.loadStateFlow.collect { loadState ->
// Show a retry header if there was an error refreshing, and items were previously
// cached OR default to the default prepend state
header.loadState = loadState.mediator
?.refresh
?.takeIf { it is LoadState.Error && repoAdapter.itemCount > 0 }
?: loadState.prepend

val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
// show empty list
emptyList.isVisible = isListEmpty
// Only show the list if refresh succeeds, either from the the local db or the remote.
list.isVisible = loadState.source.refresh is LoadState.NotLoading || loadState.mediator?.refresh is LoadState.NotLoading
// Show loading spinner during initial load or refresh.
progressBar.isVisible = loadState.mediator?.refresh is LoadState.Loading
// Show the retry state if initial load or refresh fails.
retryButton.isVisible = loadState.mediator?.refresh is LoadState.Error && repoAdapter.itemCount == 0
// Toast on any error, regardless of whether it came from RemoteMediator or PagingSource
val errorState = loadState.source.append as? LoadState.Error
?: loadState.source.prepend as? LoadState.Error
?: loadState.append as? LoadState.Error
?: loadState.prepend as? LoadState.Error
errorState?.let {
Toast.makeText(
this@SearchRepositoriesActivity,
"\uD83D\uDE28 Wooops ${it.error}",
Toast.LENGTH_LONG
).show()
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,22 +45,24 @@ import kotlinx.coroutines.launch
*/
class SearchRepositoriesViewModel(
private val repository: GithubRepository,
private val savedStateHandle: SavedStateHandle
private val savedStateHandle: SavedStateHandle // SavedStateRegistryOwner
) : ViewModel() {

/**
* Stream of immutable states representative of the UI.
*/
val state: StateFlow<UiState>

val pagingDataFlow: Flow<PagingData<UiModel>>
val pagingDataFlow: Flow<PagingData<Repo>>

/**
* Processor of side effects from the UI which in turn feedback into [state]
*/
val accept: (UiAction) -> Unit

init {

// UiAction Stream
val initialQuery: String = savedStateHandle.get(LAST_SEARCH_QUERY) ?: DEFAULT_QUERY
val lastQueryScrolled: String = savedStateHandle.get(LAST_QUERY_SCROLLED) ?: DEFAULT_QUERY
val actionStateFlow = MutableSharedFlow<UiAction>()
Expand All @@ -80,6 +82,7 @@ class SearchRepositoriesViewModel(
)
.onStart { emit(UiAction.Scroll(currentQuery = lastQueryScrolled)) }

// flows for both PagingData and UiState
pagingDataFlow = searches
.flatMapLatest { searchRepo(queryString = it.query) }
.cachedIn(viewModelScope)
Expand Down Expand Up @@ -143,14 +146,16 @@ class SearchRepositoriesViewModel(
}

sealed class UiAction {
data class Search(val query: String) : UiAction()
data class Scroll(val currentQuery: String) : UiAction()
data class Search(val query: String) : UiAction() // query
data class Scroll(//val currentQuery: String,
val visibleItemCount: Int,
val lastVisibleItemPosition: Int,
val totalItemCount: Int) : UiAction() // scrolling down the screen to load alot of data
}

data class UiState(
val query: String = DEFAULT_QUERY,
val lastQueryScrolled: String = DEFAULT_QUERY,
val hasNotScrolledForCurrentSearch: Boolean = false
val query: String,
val searchResult: RepoSearchResult
)

sealed class UiModel {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class SeparatorViewHolder(view: View) : RecyclerView.ViewHolder(view) {
companion object {
fun create(parent: ViewGroup): SeparatorViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.separator_view_item, parent, false)
.inflate(R.layout.separator_view_item, parent, false)
return SeparatorViewHolder(view)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
enum class RemotePresentationState {
INITIAL, REMOTE_LOADING, SOURCE_LOADING, PRESENTED
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@ import com.example.android.codelabs.paging.data.GithubRepository
import com.example.android.codelabs.paging.ui.ViewModelFactory

/**
* Class that handles object creation.
* Class that handles creation.
* Like this, objects can be passed as parameters in the constructors and then replaced for
* testing, where needed.
*/
object Injection {

/**
/**object
* Creates an instance of [GithubRepository] based on the [GithubService] and a
* [GithubLocalCache]
*/
Expand Down
8 changes: 8 additions & 0 deletions basic/end/settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
// https://docs.gradle.org/current/userguide/platforms.html
enableFeaturePreview("VERSION_CATALOGS")

pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}

dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
Expand Down