Skip to content
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

Step 1 - Add a footer that displays progress and error states, using MergeAdapter #46

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -106,6 +106,7 @@ class GithubRepository(private val service: GithubService) {
} catch (exception: HttpException) {
searchResults.offer(RepoSearchResult.Error(exception))
}

isRequestInProgress = false
return successful
}
Expand Down
@@ -0,0 +1,42 @@
/*
* 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"
}
}
@@ -0,0 +1,68 @@
/*
* Copyright (C) 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

import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView

class ReposLoadStateAdapter(private val retry: () -> Unit) : RecyclerView.Adapter<ReposLoadStateViewHolder>() {

/**
* 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
}
}

override fun onBindViewHolder(holder: ReposLoadStateViewHolder, position: Int) {
holder.bind(loadState)
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): 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
}
}
@@ -0,0 +1,63 @@
/*
* Copyright (C) 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

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.example.android.codelabs.paging.R
import com.example.android.codelabs.paging.databinding.ReposLoadStateHeaderViewItemBinding

class ReposLoadStateViewHolder(
private val binding: ReposLoadStateHeaderViewItemBinding,
retry: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {

init {
binding.retryButton.also {
it.setOnClickListener { retry.invoke() }
}
}

fun bind(loadState: LoadState) {
if (loadState is LoadState.Error) {
binding.errorMsg.text = loadState.error.localizedMessage
}
binding.progressBar.visibility = toVisibility(loadState == LoadState.Loading)
binding.retryButton.visibility = toVisibility(loadState != LoadState.Loading)
binding.errorMsg.visibility = toVisibility(loadState != LoadState.Loading)
}

private fun toVisibility(constraint: Boolean): Int = if (constraint) {
View.VISIBLE
} else {
View.GONE
}

companion object {
fun create(parent: ViewGroup, retry: () -> Unit): ReposLoadStateViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.repos_load_state_header_view_item, parent, false)
val binding = ReposLoadStateHeaderViewItemBinding.bind(view)
return ReposLoadStateViewHolder(binding, retry)
}
}
}
Expand Up @@ -17,6 +17,7 @@
package com.example.android.codelabs.paging.ui

import android.os.Bundle
import android.util.Log
import android.view.KeyEvent
import android.view.View
import android.view.inputmethod.EditorInfo
Expand All @@ -26,6 +27,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.observe
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
Expand All @@ -40,6 +42,7 @@ class SearchRepositoriesActivity : AppCompatActivity() {
private lateinit var binding: ActivitySearchRepositoriesBinding
private lateinit var viewModel: SearchRepositoriesViewModel
private val adapter = ReposAdapter()
private lateinit var loadStateAdapter: ReposLoadStateAdapter

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Expand Down Expand Up @@ -68,7 +71,11 @@ class SearchRepositoriesActivity : AppCompatActivity() {
}

private fun initAdapter() {
binding.list.adapter = adapter
loadStateAdapter = ReposLoadStateAdapter { viewModel.retry() }
binding.list.adapter = MergeAdapter(
adapter,
loadStateAdapter
)
viewModel.repoResult.observe(this) { result ->
when (result) {
is RepoSearchResult.Success -> {
Expand All @@ -84,6 +91,11 @@ class SearchRepositoriesActivity : AppCompatActivity() {
}
}
}

viewModel.repoLoadStatus.observe(this) { loadState ->
Log.d("SearchRepositoriesActivity", "load state $loadState")
loadStateAdapter.loadState = loadState
}
}

private fun initSearch(query: String) {
Expand Down
Expand Up @@ -35,11 +35,22 @@ class SearchRepositoriesViewModel(private val repository: GithubRepository) : Vi
private const val VISIBLE_THRESHOLD = 5
}

private val _repoLoadStatus = MutableLiveData<LoadState>()
val repoLoadStatus: LiveData<LoadState>
get() = _repoLoadStatus.distinctUntilChanged()

private val queryLiveData = MutableLiveData<String>()
val repoResult: LiveData<RepoSearchResult> = 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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

map with a side effect feels weird, but looks like there's no better operator :P

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree! It felt strange for me writing it as well :(

Copy link

@pavlospt pavlospt Apr 2, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@florina-muntenescu @ChrisCraik I think this would be achievable by adding a new Observer and observeForever on repoResult. The observer would then be removed and nullified in onCleared. Just tried it on this branch and it seems to be behaving the same.

    val repoResult: LiveData<RepoSearchResult> = queryLiveData.switchMap { queryString ->
        liveData {
            val repos = repository.getSearchResultStream(queryString).asLiveData(Dispatchers.Main)
            emitSource(repos)
        }
    }

    private var repoResultObserver: Observer<RepoSearchResult>? = null

    init {
        repoResultObserver = Observer<RepoSearchResult> {
            _repoLoadStatus.value = when (it) {
                is RepoSearchResult.Success -> LoadState.Done
                is RepoSearchResult.Error -> LoadState.Error(it.error)
            }
        }.also {
            repoResult.observeForever(it)
        }
    }

    override fun onCleared() {
        super.onCleared()
        repoResultObserver?.let { repoResult.removeObserver(it) }
        repoResultObserver = null
    }

}
}

Expand All @@ -54,10 +65,20 @@ class SearchRepositoriesViewModel(private val repository: GithubRepository) : Vi
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)
}
}
}
}
43 changes: 43 additions & 0 deletions app/src/main/res/layout/repos_load_state_header_view_item.xml
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:padding="8dp">
<TextView
android:id="@+id/error_msg"
android:textColor="?android:textColorPrimary"
android:textSize="@dimen/error_text_size"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textAlignment="center"
tools:text="Timeout"/>
<ProgressBar
android:id="@+id/progress_bar"
style="?android:attr/progressBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"/>
<Button
android:id="@+id/retry_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/retry"/>
</LinearLayout>