diff --git a/app/src/main/java/com/example/android/codelabs/paging/data/GithubPagingSource.kt b/app/src/main/java/com/example/android/codelabs/paging/data/GithubPagingSource.kt new file mode 100644 index 00000000..5639b199 --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/data/GithubPagingSource.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.codelabs.paging.data + +import androidx.paging.PagingSource +import com.example.android.codelabs.paging.api.GithubService +import com.example.android.codelabs.paging.api.IN_QUALIFIER +import com.example.android.codelabs.paging.model.Repo +import kotlinx.coroutines.ExperimentalCoroutinesApi +import retrofit2.HttpException +import java.io.IOException + +// GitHub page API is 1 based: https://developer.github.com/v3/#pagination +private const val GITHUB_STARTING_PAGE_INDEX = 1 + +@ExperimentalCoroutinesApi +class GithubPagingSource( + private val service: GithubService, + private val query: String +) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + val currentPage = params.key ?: GITHUB_STARTING_PAGE_INDEX + val apiQuery = query + IN_QUALIFIER + try { + val apiResponse = service.searchRepos(apiQuery, currentPage, params.loadSize) + return if (apiResponse.isSuccessful) { + val repos = apiResponse.body()?.items ?: emptyList() + LoadResult.Page( + data = repos, + prevKey = if (currentPage == GITHUB_STARTING_PAGE_INDEX) null else currentPage - 1, + // if we don't get any results, we consider that we're at the last page + nextKey = if (repos.isEmpty()) null else currentPage + 1 + ) + } else { + LoadResult.Error(IOException(apiResponse.message())) + } + } catch (exception: IOException) { + return LoadResult.Error(exception) + } catch (exception: HttpException) { + return LoadResult.Error(exception) + } + } +} diff --git a/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt index a652f039..2cf1b249 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt @@ -17,20 +17,13 @@ package com.example.android.codelabs.paging.data import android.util.Log +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.PagingDataFlow import com.example.android.codelabs.paging.api.GithubService -import com.example.android.codelabs.paging.api.IN_QUALIFIER import com.example.android.codelabs.paging.model.Repo -import com.example.android.codelabs.paging.model.RepoSearchResult import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.channels.ConflatedBroadcastChannel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.asFlow -import retrofit2.HttpException -import java.io.IOException - -// GitHub page API is 1 based: https://developer.github.com/v3/#pagination -private const val GITHUB_STARTING_PAGE_INDEX = 1 /** * Repository class that works with local and remote data sources. @@ -38,86 +31,17 @@ private const val GITHUB_STARTING_PAGE_INDEX = 1 @ExperimentalCoroutinesApi class GithubRepository(private val service: GithubService) { - // keep the list of all results received - private val inMemoryCache = mutableListOf() - - // keep channel of results. The channel allows us to broadcast updates so - // the subscriber will have the latest data - private val searchResults = ConflatedBroadcastChannel() - - // keep the last requested page. When the request is successful, increment the page number. - private var lastRequestedPage = GITHUB_STARTING_PAGE_INDEX - - // avoid triggering multiple requests in the same time - private var isRequestInProgress = false - /** * 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. */ - suspend fun getSearchResultStream(query: String): Flow { + fun getSearchResultStream(query: String): Flow> { Log.d("GithubRepository", "New query: $query") - lastRequestedPage = 1 - requestAndSaveData(query) - - return searchResults.asFlow() - } - - suspend fun requestMore(query: String) { - if (isRequestInProgress) return - val successful = requestAndSaveData(query) - if (successful) { - lastRequestedPage++ - } - } - - suspend fun retry(query: String) { - if (isRequestInProgress) return - requestAndSaveData(query) - } - - private suspend fun requestAndSaveData(query: String): Boolean { - isRequestInProgress = true - var successful = false - - val apiQuery = query + IN_QUALIFIER - try { - val response = service.searchRepos(apiQuery, lastRequestedPage, NETWORK_PAGE_SIZE) - Log.d("GithubRepository", "response $response") - if (response.isSuccessful) { - if (response.isSuccessful) { - val repos = response.body()?.items ?: emptyList() - inMemoryCache.addAll(repos) - val reposByName = reposByName(query) - searchResults.offer(RepoSearchResult.Success(reposByName)) - successful = true - } else { - Log.d("GithubRepository", "fail to get data") - searchResults.offer(RepoSearchResult.Error(IOException(response.message() - ?: "Unknown error"))) - } - } else { - Log.d("GithubRepository", "fail to get data") - searchResults.offer(RepoSearchResult.Error(IOException(response.message() - ?: "Unknown error"))) - } - } catch (exception: IOException) { - searchResults.offer(RepoSearchResult.Error(exception)) - } catch (exception: HttpException) { - searchResults.offer(RepoSearchResult.Error(exception)) - } - - isRequestInProgress = false - return successful - } - private fun reposByName(query: String): List { - // from the in memory cache select only the repos whose name or description matches - // the query. Then order the results. - return inMemoryCache.filter { - it.name.contains(query, true) || - (it.description != null && it.description.contains(query, true)) - }.sortedWith(compareByDescending { it.stars }.thenBy { it.name }) + return PagingDataFlow( + config = PagingConfig(pageSize = NETWORK_PAGE_SIZE), + pagingSourceFactory = { GithubPagingSource(service, query) } + ) } companion object { diff --git a/app/src/main/java/com/example/android/codelabs/paging/model/RepoSearchResult.kt b/app/src/main/java/com/example/android/codelabs/paging/model/RepoSearchResult.kt deleted file mode 100644 index d00c1f07..00000000 --- a/app/src/main/java/com/example/android/codelabs/paging/model/RepoSearchResult.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.android.codelabs.paging.model - -import java.lang.Exception - -/** - * RepoSearchResult from a search, which contains List holding query data, - * and a String of network error state. - */ -sealed class RepoSearchResult { - data class Success(val data: List) : RepoSearchResult() - data class Error(val error: Exception) : RepoSearchResult() -} diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/LoadState.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/LoadState.kt deleted file mode 100644 index 068d5dc6..00000000 --- a/app/src/main/java/com/example/android/codelabs/paging/ui/LoadState.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.android.codelabs.paging.ui - -/** - * LoadState of a list load - */ -sealed class LoadState { - /** - * Loading is in progress. - */ - object Loading : LoadState() - - /** - * Loading is complete. - */ - object Done : LoadState() - - /** - * Loading hit an error. - * - * @param error [Throwable] that caused the load operation to generate this error state. - * - */ - data class Error(val error: Throwable) : LoadState() { - override fun toString() = "Error: $error" - } -} diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/ReposAdapter.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/ReposAdapter.kt index d959d265..e7aecf2f 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/ui/ReposAdapter.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/ReposAdapter.kt @@ -17,24 +17,24 @@ package com.example.android.codelabs.paging.ui import android.view.ViewGroup +import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.example.android.codelabs.paging.R import com.example.android.codelabs.paging.model.Repo /** * Adapter for the list of repositories. */ -class ReposAdapter : ListAdapter(REPO_COMPARATOR) { +class ReposAdapter : PagingDataAdapter(REPO_COMPARATOR) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): androidx.recyclerview.widget.RecyclerView.ViewHolder { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { return RepoViewHolder.create(parent) } - override fun onBindViewHolder(holder: androidx.recyclerview.widget.RecyclerView.ViewHolder, position: Int) { - val repoItem = getItem(position) - if (repoItem != null) { - (holder as RepoViewHolder).bind(repoItem) - } + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val repo = getItem(position) + (holder as RepoViewHolder).bind(repo) } companion object { diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/ReposLoadStateAdapter.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/ReposLoadStateAdapter.kt index fb47c226..a9e5150f 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/ui/ReposLoadStateAdapter.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/ReposLoadStateAdapter.kt @@ -13,56 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.example.android.codelabs.paging.ui import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView - -class ReposLoadStateAdapter(private val retry: () -> Unit) : RecyclerView.Adapter() { - - /** - * LoadState to present in the adapter. - * - * Changing this property will immediately notify the Adapter to change the item it's - * presenting. - */ - var loadState: LoadState = LoadState.Done - set(loadState) { - if (field != loadState) { - val displayOldItem = displayLoadStateAsItem(field) - val displayNewItem = displayLoadStateAsItem(loadState) - - if (displayOldItem && !displayNewItem) { - notifyItemRemoved(0) - } else if (displayNewItem && !displayOldItem) { - notifyItemInserted(0) - } else if (displayOldItem && displayNewItem) { - notifyItemChanged(0) - } - field = loadState - } - } +import androidx.paging.LoadState +import androidx.paging.LoadStateAdapter - override fun onBindViewHolder(holder: ReposLoadStateViewHolder, position: Int) { +class ReposLoadStateAdapter(private val retry: () -> Unit) : LoadStateAdapter() { + override fun onBindViewHolder(holder: ReposLoadStateViewHolder, loadState: LoadState) { holder.bind(loadState) } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ReposLoadStateViewHolder { + override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ReposLoadStateViewHolder { return ReposLoadStateViewHolder.create(parent, retry) } - - override fun getItemViewType(position: Int): Int = 0 - - override fun getItemCount(): Int = if (displayLoadStateAsItem(loadState)) 1 else 0 - - /** - * Returns true if the LoadState should be displayed as a list item when active. - * - * [LoadState.Loading] and [LoadState.Error] present as list items, - * [LoadState.Done] is not. - */ - private fun displayLoadStateAsItem(loadState: LoadState): Boolean { - return loadState is LoadState.Loading || loadState is LoadState.Error - } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/ReposLoadStateViewHolder.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/ReposLoadStateViewHolder.kt index cad644fb..19d806f6 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/ui/ReposLoadStateViewHolder.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/ReposLoadStateViewHolder.kt @@ -22,6 +22,7 @@ import android.view.ViewGroup import android.widget.Button import android.widget.ProgressBar import android.widget.TextView +import androidx.paging.LoadState import androidx.recyclerview.widget.RecyclerView import com.example.android.codelabs.paging.R import com.example.android.codelabs.paging.databinding.ReposLoadStateHeaderViewItemBinding @@ -60,4 +61,4 @@ class ReposLoadStateViewHolder( return ReposLoadStateViewHolder(binding, retry) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt index 79cbee73..121fdbb9 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt @@ -24,17 +24,16 @@ import android.view.inputmethod.EditorInfo import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.observe +import androidx.lifecycle.lifecycleScope +import androidx.paging.LoadState +import androidx.paging.LoadType import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.MergeAdapter -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.OnScrollListener import com.example.android.codelabs.paging.Injection import com.example.android.codelabs.paging.databinding.ActivitySearchRepositoriesBinding -import com.example.android.codelabs.paging.model.RepoSearchResult import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch @ExperimentalCoroutinesApi class SearchRepositoriesActivity : AppCompatActivity() { @@ -42,7 +41,7 @@ class SearchRepositoriesActivity : AppCompatActivity() { private lateinit var binding: ActivitySearchRepositoriesBinding private lateinit var viewModel: SearchRepositoriesViewModel private val adapter = ReposAdapter() - private lateinit var loadStateAdapter: ReposLoadStateAdapter + private var searchJob: Job? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -57,12 +56,12 @@ class SearchRepositoriesActivity : AppCompatActivity() { // add dividers between RecyclerView's row items val decoration = DividerItemDecoration(this, DividerItemDecoration.VERTICAL) binding.list.addItemDecoration(decoration) - setupScrollListener() initAdapter() val query = savedInstanceState?.getString(LAST_SEARCH_QUERY) ?: DEFAULT_QUERY - viewModel.searchRepo(query) + search(query) initSearch(query) + binding.retryButton.setOnClickListener { adapter.retry() } } override fun onSaveInstanceState(outState: Bundle) { @@ -71,30 +70,28 @@ class SearchRepositoriesActivity : AppCompatActivity() { } private fun initAdapter() { - loadStateAdapter = ReposLoadStateAdapter { viewModel.retry() } - binding.list.adapter = MergeAdapter( - adapter, - loadStateAdapter + binding.list.adapter = adapter.withLoadStateHeaderAndFooter( + header = ReposLoadStateAdapter { adapter.retry() }, + footer = ReposLoadStateAdapter { adapter.retry() } ) - viewModel.repoResult.observe(this) { result -> - when (result) { - is RepoSearchResult.Success -> { - showEmptyList(result.data.isEmpty()) - adapter.submitList(result.data) - } - is RepoSearchResult.Error -> { - Toast.makeText( - this, - "\uD83D\uDE28 Wooops $result.message}", - Toast.LENGTH_LONG - ).show() - } + adapter.addLoadStateListener { loadType, loadState -> + Log.d("SearchRepositoriesActivity", "adapter load: type = $loadType state = $loadState") + if (loadType == LoadType.REFRESH) { + binding.list.visibility = View.GONE + binding.progressBar.visibility = toVisibility(loadState == LoadState.Loading) + binding.retryButton.visibility = toVisibility(loadState is LoadState.Error) + } else { + binding.list.visibility = View.VISIBLE + binding.progressBar.visibility = View.GONE + binding.retryButton.visibility = View.GONE + } + if (loadState is LoadState.Error) { + Toast.makeText( + this, + "\uD83D\uDE28 Wooops $loadState.message}", + Toast.LENGTH_LONG + ).show() } - } - - viewModel.repoLoadStatus.observe(this) { loadState -> - Log.d("SearchRepositoriesActivity", "load state $loadState") - loadStateAdapter.loadState = loadState } } @@ -123,34 +120,26 @@ class SearchRepositoriesActivity : AppCompatActivity() { binding.searchRepo.text.trim().let { if (it.isNotEmpty()) { binding.list.scrollToPosition(0) - viewModel.searchRepo(it.toString()) - adapter.submitList(null) + search(it.toString()) } } } - private fun showEmptyList(show: Boolean) { - if (show) { - binding.emptyList.visibility = View.VISIBLE - binding.list.visibility = View.GONE - } else { - binding.emptyList.visibility = View.GONE - binding.list.visibility = View.VISIBLE + private fun search(query: String) { + // Make sure we cancel the previous job before creating a new one + searchJob?.cancel() + searchJob = lifecycleScope.launch { + viewModel.searchRepo(query).collect { + Log.d("SearchRepositoriesActivity", "query: $query, collecting $it") + adapter.presentData(it) + } } } - private fun setupScrollListener() { - val layoutManager = binding.list.layoutManager as LinearLayoutManager - binding.list.addOnScrollListener(object : OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy) - val totalItemCount = layoutManager.itemCount - val visibleItemCount = layoutManager.childCount - val lastVisibleItem = layoutManager.findLastVisibleItemPosition() - - viewModel.listScrolled(visibleItemCount, lastVisibleItem, totalItemCount) - } - }) + private fun toVisibility(constraint: Boolean): Int = if (constraint) { + View.VISIBLE + } else { + View.GONE } companion object { diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt index a755594e..8197f43b 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt @@ -16,13 +16,14 @@ package com.example.android.codelabs.paging.ui -import androidx.lifecycle.* +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn import com.example.android.codelabs.paging.data.GithubRepository -import com.example.android.codelabs.paging.model.RepoSearchResult -import kotlinx.coroutines.Dispatchers +import com.example.android.codelabs.paging.model.Repo import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.Flow /** * ViewModel for the [SearchRepositoriesActivity] screen. @@ -31,54 +32,24 @@ import kotlinx.coroutines.launch @ExperimentalCoroutinesApi class SearchRepositoriesViewModel(private val repository: GithubRepository) : ViewModel() { - companion object { - private const val VISIBLE_THRESHOLD = 5 - } + @Volatile + private var currentQueryValue: String? = null - private val _repoLoadStatus = MutableLiveData() - val repoLoadStatus: LiveData - get() = _repoLoadStatus.distinctUntilChanged() - - private val queryLiveData = MutableLiveData() - val repoResult: LiveData = queryLiveData.switchMap { queryString -> - liveData { - val repos = repository.getSearchResultStream(queryString).asLiveData(Dispatchers.Main) - emitSource(repos) - }.map { - // update the load status based on the result type - when (it) { - is RepoSearchResult.Success -> _repoLoadStatus.value = LoadState.Done - is RepoSearchResult.Error -> _repoLoadStatus.value = LoadState.Error(it.error) - } - it - } - } + @Volatile + private var currentSearchResult: Flow>? = null /** * Search a repository based on a query string. */ - fun searchRepo(queryString: String) { - queryLiveData.postValue(queryString) - } - - fun listScrolled(visibleItemCount: Int, lastVisibleItemPosition: Int, totalItemCount: Int) { - if (visibleItemCount + lastVisibleItemPosition + VISIBLE_THRESHOLD >= totalItemCount) { - val immutableQuery = queryLiveData.value - if (immutableQuery != null) { - _repoLoadStatus.postValue(LoadState.Loading) - viewModelScope.launch { - repository.requestMore(immutableQuery) - } - } - } - } - - fun retry() { - queryLiveData.value?.let { query -> - _repoLoadStatus.value = LoadState.Loading - viewModelScope.launch { - repository.retry(query) - } + fun searchRepo(queryString: String): Flow> { + val lastResult = currentSearchResult + if (queryString == currentQueryValue && lastResult != null) { + return lastResult } + currentQueryValue = queryString + val newResult: Flow> = repository.getSearchResultStream(queryString) + .cachedIn(viewModelScope) + currentSearchResult = newResult + return newResult } } diff --git a/app/src/main/res/layout/activity_search_repositories.xml b/app/src/main/res/layout/activity_search_repositories.xml index 738040a3..5695d150 100644 --- a/app/src/main/res/layout/activity_search_repositories.xml +++ b/app/src/main/res/layout/activity_search_repositories.xml @@ -59,16 +59,25 @@ app:layout_constraintTop_toBottomOf="@+id/input_layout" tools:ignore="UnusedAttribute"/> - + + +