Add refresh (#59)

* Add refresh to home fragment

* Remove forgotten code

* Remove unnecessary condition and fix HomeSection equality check

* Make HomeFragment fragment view model again

* Add order dependent check for home items equality

* Fix loading state overwriting error state on home refresh

* Revert to older swiperefreshlayout version

* Fixing error and loading state

Co-authored-by: Jarne Demeulemeester <32322857+jarnedemeulemeester@users.noreply.github.com>
This commit is contained in:
lsrom 2021-11-14 18:44:33 +01:00 committed by GitHub
parent 98cb038c24
commit d7a47b0a3e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 234 additions and 152 deletions

View file

@ -59,6 +59,8 @@ dependencies {
implementation("androidx.core:core-ktx:1.7.0") implementation("androidx.core:core-ktx:1.7.0")
implementation("androidx.core:core-splashscreen:1.0.0-alpha02") implementation("androidx.core:core-splashscreen:1.0.0-alpha02")
implementation("androidx.appcompat:appcompat:1.3.1") implementation("androidx.appcompat:appcompat:1.3.1")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
// Material // Material
implementation("com.google.android.material:material:1.4.0") implementation("com.google.android.material:material:1.4.0")

View file

@ -10,7 +10,7 @@ import dev.jdtech.jellyfin.databinding.NextUpSectionBinding
import dev.jdtech.jellyfin.databinding.ViewItemBinding import dev.jdtech.jellyfin.databinding.ViewItemBinding
import dev.jdtech.jellyfin.models.HomeSection import dev.jdtech.jellyfin.models.HomeSection
import dev.jdtech.jellyfin.models.View import dev.jdtech.jellyfin.models.View
import java.util.* import java.util.UUID
private const val ITEM_VIEW_TYPE_NEXT_UP = 0 private const val ITEM_VIEW_TYPE_NEXT_UP = 0
private const val ITEM_VIEW_TYPE_VIEW = 1 private const val ITEM_VIEW_TYPE_VIEW = 1
@ -51,7 +51,8 @@ class ViewListAdapter(
companion object DiffCallback : DiffUtil.ItemCallback<HomeItem>() { companion object DiffCallback : DiffUtil.ItemCallback<HomeItem>() {
override fun areItemsTheSame(oldItem: HomeItem, newItem: HomeItem): Boolean { override fun areItemsTheSame(oldItem: HomeItem, newItem: HomeItem): Boolean {
return oldItem.id == newItem.id return oldItem.ids.size == newItem.ids.size
&& oldItem.ids.mapIndexed { i, old -> old == newItem.ids[i] }.all { it }
} }
override fun areContentsTheSame(oldItem: HomeItem, newItem: HomeItem): Boolean { override fun areContentsTheSame(oldItem: HomeItem, newItem: HomeItem): Boolean {
@ -106,12 +107,12 @@ class ViewListAdapter(
sealed class HomeItem { sealed class HomeItem {
data class Section(val homeSection: HomeSection) : HomeItem() { data class Section(val homeSection: HomeSection) : HomeItem() {
override val id = homeSection.id override val ids = homeSection.items.map { it.id }
} }
data class ViewItem(val view: View) : HomeItem() { data class ViewItem(val view: View) : HomeItem() {
override val id = view.id override val ids = view.items?.map { it.id }.orEmpty()
} }
abstract val id: UUID abstract val ids: List<UUID>
} }

View file

@ -7,8 +7,12 @@ import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast
import android.widget.Toast.LENGTH_LONG
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R import dev.jdtech.jellyfin.R
@ -17,8 +21,15 @@ import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
import dev.jdtech.jellyfin.adapters.ViewListAdapter import dev.jdtech.jellyfin.adapters.ViewListAdapter
import dev.jdtech.jellyfin.databinding.FragmentHomeBinding import dev.jdtech.jellyfin.databinding.FragmentHomeBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.models.ContentType
import dev.jdtech.jellyfin.models.ContentType.EPISODE
import dev.jdtech.jellyfin.models.ContentType.MOVIE
import dev.jdtech.jellyfin.models.ContentType.TVSHOW
import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.utils.contentType
import dev.jdtech.jellyfin.viewmodels.HomeViewModel import dev.jdtech.jellyfin.viewmodels.HomeViewModel
import dev.jdtech.jellyfin.viewmodels.HomeViewModel.Loading
import dev.jdtech.jellyfin.viewmodels.HomeViewModel.LoadingError
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
@AndroidEntryPoint @AndroidEntryPoint
@ -57,49 +68,79 @@ class HomeFragment : Fragment() {
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel binding.viewModel = viewModel
binding.viewsRecyclerView.adapter = ViewListAdapter(ViewListAdapter.OnClickListener {
navigateToLibraryFragment(it)
}, ViewItemListAdapter.OnClickListener {
navigateToMediaInfoFragment(it)
}, HomeEpisodeListAdapter.OnClickListener { item ->
when (item.type) {
"Episode" -> {
navigateToEpisodeBottomSheetFragment(item)
}
"Movie" -> {
navigateToMediaInfoFragment(item)
}
}
}) setupView()
bindState()
viewModel.finishedLoading.observe(viewLifecycleOwner, { return binding.root
binding.loadingIndicator.visibility = if (it) View.GONE else View.VISIBLE }
})
viewModel.error.observe(viewLifecycleOwner, { error -> override fun onResume() {
if (error != null) { super.onResume()
checkIfLoginRequired(error)
binding.errorLayout.errorPanel.visibility = View.VISIBLE
binding.viewsRecyclerView.visibility = View.GONE
} else {
binding.errorLayout.errorPanel.visibility = View.GONE
binding.viewsRecyclerView.visibility = View.VISIBLE
}
})
binding.errorLayout.errorRetryButton.setOnClickListener { viewModel.refreshData()
viewModel.loadData() }
private fun setupView() {
binding.refreshLayout.setOnRefreshListener {
viewModel.refreshData()
} }
binding.viewsRecyclerView.adapter = ViewListAdapter(
onClickListener = ViewListAdapter.OnClickListener { navigateToLibraryFragment(it) },
onItemClickListener = ViewItemListAdapter.OnClickListener {
navigateToMediaInfoFragment(it)
},
onNextUpClickListener = HomeEpisodeListAdapter.OnClickListener { item ->
when (item.contentType()) {
EPISODE -> navigateToEpisodeBottomSheetFragment(item)
MOVIE -> navigateToMediaInfoFragment(item)
else -> Toast.makeText(requireContext(), R.string.unknown_error, LENGTH_LONG)
.show()
}
})
}
private fun bindState() {
viewModel.onStateUpdate(lifecycleScope) { state ->
when (state) {
is Loading -> bindLoading(state)
is LoadingError -> bindError(state)
}
}
}
private fun bindError(state: LoadingError) {
checkIfLoginRequired(state.message)
binding.errorLayout.errorPanel.isVisible = true
binding.viewsRecyclerView.isVisible = false
binding.loadingIndicator.isVisible = false
binding.refreshLayout.isRefreshing = false
binding.errorLayout.errorDetailsButton.setOnClickListener { binding.errorLayout.errorDetailsButton.setOnClickListener {
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show( ErrorDialogFragment(state.message).show(
parentFragmentManager, parentFragmentManager,
"errordialog" "errordialog"
) )
} }
return binding.root binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.refreshData()
}
}
private fun bindLoading(state: Loading) {
binding.errorLayout.errorPanel.isVisible = false
binding.viewsRecyclerView.isVisible = true
binding.loadingIndicator.visibility = when {
state.inProgress && binding.refreshLayout.isRefreshing -> View.GONE
state.inProgress -> View.VISIBLE
else -> {
binding.refreshLayout.isRefreshing = false
View.GONE
}
}
} }
private fun navigateToLibraryFragment(view: dev.jdtech.jellyfin.models.View) { private fun navigateToLibraryFragment(view: dev.jdtech.jellyfin.models.View) {
@ -113,12 +154,12 @@ class HomeFragment : Fragment() {
} }
private fun navigateToMediaInfoFragment(item: BaseItemDto) { private fun navigateToMediaInfoFragment(item: BaseItemDto) {
if (item.type == "Episode") { if (item.contentType() == EPISODE) {
findNavController().navigate( findNavController().navigate(
HomeFragmentDirections.actionNavigationHomeToMediaInfoFragment( HomeFragmentDirections.actionNavigationHomeToMediaInfoFragment(
item.seriesId!!, item.seriesId!!,
item.seriesName, item.seriesName,
"Series" TVSHOW.type
) )
) )
} else { } else {
@ -126,7 +167,7 @@ class HomeFragment : Fragment() {
HomeFragmentDirections.actionNavigationHomeToMediaInfoFragment( HomeFragmentDirections.actionNavigationHomeToMediaInfoFragment(
item.id, item.id,
item.name, item.name,
item.type ?: "Unknown" item.type ?: ContentType.UNKNOWN.type
) )
) )
} }

View file

@ -0,0 +1,19 @@
package dev.jdtech.jellyfin.models
import dev.jdtech.jellyfin.models.CollectionType.Books
import dev.jdtech.jellyfin.models.CollectionType.HomeVideos
import dev.jdtech.jellyfin.models.CollectionType.LiveTv
import dev.jdtech.jellyfin.models.CollectionType.Music
import dev.jdtech.jellyfin.models.CollectionType.Playlists
enum class CollectionType (val type: String) {
HomeVideos("homevideos"),
Music("music"),
Playlists("playlists"),
Books("books"),
LiveTv("livetv")
}
fun unsupportedCollections() = listOf(
HomeVideos, Music, Playlists, Books, LiveTv
)

View file

@ -3,5 +3,6 @@ package dev.jdtech.jellyfin.models
enum class ContentType(val type: String) { enum class ContentType(val type: String) {
MOVIE("Movie"), MOVIE("Movie"),
TVSHOW("Series"), TVSHOW("Series"),
EPISODE("Episode"),
UNKNOWN("") UNKNOWN("")
} }

View file

@ -1,10 +1,8 @@
package dev.jdtech.jellyfin.models package dev.jdtech.jellyfin.models
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import java.util.*
data class HomeSection( data class HomeSection(
val id: UUID, val name: String,
val name: String?, var items: List<BaseItemDto>
var items: List<BaseItemDto>? = null
) )

View file

@ -1,7 +1,7 @@
package dev.jdtech.jellyfin.models package dev.jdtech.jellyfin.models
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import java.util.* import java.util.UUID
data class View( data class View(
val id: UUID, val id: UUID,

View file

@ -48,7 +48,7 @@ internal class HomeFragment : BrowseSupportFragment() {
setOnClickListener { navigateToSettingsFragment() } setOnClickListener { navigateToSettingsFragment() }
} }
viewModel.views.observe(viewLifecycleOwner) { homeItems -> viewModel.views().observe(viewLifecycleOwner) { homeItems ->
rowsAdapter.clear() rowsAdapter.clear()
homeItems.map { section -> rowsAdapter.add(section.toListRow()) } homeItems.map { section -> rowsAdapter.add(section.toListRow()) }
} }

View file

@ -16,13 +16,11 @@ import dev.jdtech.jellyfin.models.DownloadMetadata
import dev.jdtech.jellyfin.models.DownloadRequestItem import dev.jdtech.jellyfin.models.DownloadRequestItem
import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.repository.JellyfinRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.UserItemDataDto import org.jellyfin.sdk.model.api.UserItemDataDto
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.util.* import java.util.UUID
fun requestDownload(uri: Uri, downloadRequestItem: DownloadRequestItem, context: Fragment) { fun requestDownload(uri: Uri, downloadRequestItem: DownloadRequestItem, context: Fragment) {
// Storage permission for downloads isn't necessary from Android 10 onwards // Storage permission for downloads isn't necessary from Android 10 onwards
@ -209,7 +207,7 @@ fun parseMetadataFile(metadataFile: List<String>) : DownloadMetadata {
suspend fun syncPlaybackProgress(jellyfinRepository: JellyfinRepository, context: Context) { suspend fun syncPlaybackProgress(jellyfinRepository: JellyfinRepository, context: Context) {
val items = loadDownloadedEpisodes(context) val items = loadDownloadedEpisodes(context)
items.forEach(){ items.forEach{
try { try {
val localPlaybackProgress = it.metadata?.playbackPosition val localPlaybackProgress = it.metadata?.playbackPosition
val localPlayedPercentage = it.metadata?.playedPercentage val localPlayedPercentage = it.metadata?.playedPercentage

View file

@ -0,0 +1,8 @@
package dev.jdtech.jellyfin.utils
import android.view.View
import androidx.core.view.isVisible
fun View.toggleVisibility() {
isVisible = !isVisible
}

View file

@ -22,6 +22,7 @@ fun BaseItemDto.toView(): View {
fun BaseItemDto.contentType() = when (type) { fun BaseItemDto.contentType() = when (type) {
"Movie" -> ContentType.MOVIE "Movie" -> ContentType.MOVIE
"Series" -> ContentType.TVSHOW "Series" -> ContentType.TVSHOW
"Episode" -> ContentType.EPISODE
else -> ContentType.UNKNOWN else -> ContentType.UNKNOWN
} }
@ -32,5 +33,6 @@ fun Fragment.checkIfLoginRequired(error: String) {
} }
} }
inline fun Context.toast(@StringRes text: Int, duration: Int = Toast.LENGTH_SHORT) = inline fun Context.toast(@StringRes text: Int, duration: Int = Toast.LENGTH_SHORT) =
Toast.makeText(this, text, duration).show() Toast.makeText(this, text, duration).show()

View file

@ -1,6 +1,7 @@
package dev.jdtech.jellyfin.viewmodels package dev.jdtech.jellyfin.viewmodels
import android.app.Application import android.app.Application
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@ -8,108 +9,104 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.R import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.adapters.HomeItem import dev.jdtech.jellyfin.adapters.HomeItem
import dev.jdtech.jellyfin.adapters.HomeItem.Section
import dev.jdtech.jellyfin.adapters.HomeItem.ViewItem
import dev.jdtech.jellyfin.models.HomeSection import dev.jdtech.jellyfin.models.HomeSection
import dev.jdtech.jellyfin.models.View import dev.jdtech.jellyfin.models.unsupportedCollections
import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.syncPlaybackProgress import dev.jdtech.jellyfin.utils.syncPlaybackProgress
import dev.jdtech.jellyfin.utils.toView import dev.jdtech.jellyfin.utils.toView
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber import timber.log.Timber
import java.util.*
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class HomeViewModel class HomeViewModel @Inject internal constructor(
@Inject
constructor(
private val application: Application, private val application: Application,
private val jellyfinRepository: JellyfinRepository private val repository: JellyfinRepository
) : ViewModel() { ) : ViewModel() {
private val views = MutableLiveData<List<HomeItem>>()
private val state = MutableSharedFlow<State>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
init {
loadData(updateCapabilities = true)
}
private val continueWatchingString = application.resources.getString(R.string.continue_watching) private val continueWatchingString = application.resources.getString(R.string.continue_watching)
private val nextUpString = application.resources.getString(R.string.next_up) private val nextUpString = application.resources.getString(R.string.next_up)
private val _views = MutableLiveData<List<HomeItem>>() fun views(): LiveData<List<HomeItem>> = views
val views: LiveData<List<HomeItem>> = _views
private val _items = MutableLiveData<List<BaseItemDto>>() fun onStateUpdate(
val items: LiveData<List<BaseItemDto>> = _items scope: LifecycleCoroutineScope,
collector: (State) -> Unit
private val _finishedLoading = MutableLiveData<Boolean>() ) {
val finishedLoading: LiveData<Boolean> = _finishedLoading scope.launch { state.collect { collector(it) } }
private val _error = MutableLiveData<String>()
val error: LiveData<String> = _error
init {
loadData()
} }
fun loadData() { fun refreshData() = loadData(updateCapabilities = false)
_error.value = null
_finishedLoading.value = false private fun loadData(updateCapabilities: Boolean) {
state.tryEmit(Loading(inProgress = true))
viewModelScope.launch { viewModelScope.launch {
try { try {
jellyfinRepository.postCapabilities() if (updateCapabilities) repository.postCapabilities()
val items = mutableListOf<HomeItem>() val updated = loadDynamicItems() + loadViews()
views.postValue(updated)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
syncPlaybackProgress(repository, application)
val resumeItems = jellyfinRepository.getResumeItems()
val resumeSection =
HomeSection(UUID.randomUUID(), continueWatchingString, resumeItems)
if (!resumeItems.isNullOrEmpty()) {
items.add(HomeItem.Section(resumeSection))
}
val nextUpItems = jellyfinRepository.getNextUp()
val nextUpSection = HomeSection(UUID.randomUUID(), nextUpString, nextUpItems)
if (!nextUpItems.isNullOrEmpty()) {
items.add(HomeItem.Section(nextUpSection))
}
} }
state.tryEmit(Loading(inProgress = false))
val views: MutableList<View> = mutableListOf()
withContext(Dispatchers.Default) {
val userViews = jellyfinRepository.getUserViews()
for (view in userViews) {
Timber.d("Collection type: ${view.collectionType}")
if (view.collectionType == "homevideos" ||
view.collectionType == "music" ||
view.collectionType == "playlists" ||
view.collectionType == "books" ||
view.collectionType == "livetv"
) continue
val latestItems = jellyfinRepository.getLatestMedia(view.id)
if (latestItems.isEmpty()) continue
val v = view.toView()
v.items = latestItems
views.add(v)
}
}
withContext(Dispatchers.Default) {
syncPlaybackProgress(jellyfinRepository, application)
}
_views.value = items + views.map { HomeItem.ViewItem(it) }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
_error.value = e.toString() state.tryEmit(LoadingError(e.toString()))
} }
_finishedLoading.value = true
} }
} }
private suspend fun loadDynamicItems() = withContext(Dispatchers.IO) {
val resumeItems = repository.getResumeItems()
val nextUpItems = repository.getNextUp()
val items = mutableListOf<HomeSection>()
if (resumeItems.isNotEmpty()) {
items.add(HomeSection(continueWatchingString, resumeItems))
}
if (nextUpItems.isNotEmpty()) {
items.add(HomeSection(nextUpString, nextUpItems))
}
items.map { Section(it) }
}
private suspend fun loadViews() = withContext(Dispatchers.IO) {
repository
.getUserViews()
.filter { view -> unsupportedCollections().none { it.type == view.collectionType } }
.map { view -> view to repository.getLatestMedia(view.id) }
.filter { (_, latest) -> latest.isNotEmpty() }
.map { (view, latest) -> view.toView().apply { items = latest } }
.map { ViewItem(it) }
}
sealed class State
data class LoadingError(val message: String) : State()
data class Loading(val inProgress: Boolean) : State()
} }

View file

@ -1,48 +1,63 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android" <layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools"
>
<data> <data>
<variable <variable
name="viewModel" name="viewModel"
type="dev.jdtech.jellyfin.viewmodels.HomeViewModel" /> type="dev.jdtech.jellyfin.viewmodels.HomeViewModel"
/>
</data> </data>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
<androidx.constraintlayout.widget.ConstraintLayout android:id="@+id/refresh_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".fragments.HomeFragment"> >
<com.google.android.material.progressindicator.LinearProgressIndicator <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/loading_indicator" android:layout_width="match_parent"
android:layout_width="0dp" android:layout_height="match_parent"
android:layout_height="wrap_content" tools:context=".fragments.HomeFragment"
android:indeterminate="true" >
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<include <com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/error_layout" android:id="@+id/loading_indicator"
layout="@layout/error_panel" /> android:layout_width="0dp"
android:layout_height="wrap_content"
android:indeterminate="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:visibility="gone"
/>
<androidx.recyclerview.widget.RecyclerView <include
android:id="@+id/views_recycler_view" android:id="@+id/error_layout"
android:layout_width="0dp" layout="@layout/error_panel"
android:layout_height="0dp" />
android:clipToPadding="false"
android:paddingTop="16dp" <androidx.recyclerview.widget.RecyclerView
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" android:id="@+id/views_recycler_view"
app:layout_constraintBottom_toBottomOf="parent" android:layout_width="0dp"
app:layout_constraintEnd_toEndOf="parent" android:layout_height="0dp"
app:layout_constraintStart_toStartOf="parent" android:clipToPadding="false"
app:layout_constraintTop_toTopOf="parent" android:paddingTop="16dp"
app:views="@{viewModel.views}" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:itemCount="4" app:layout_constraintBottom_toBottomOf="parent"
tools:listitem="@layout/view_item" /> app:layout_constraintEnd_toEndOf="parent"
</androidx.constraintlayout.widget.ConstraintLayout> app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:views="@{viewModel.views()}"
tools:itemCount="4"
tools:listitem="@layout/view_item"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</layout> </layout>