Implement Glide image preload in RecyclerView

This commit is contained in:
Kirill Kamakin
2022-04-18 18:08:06 +02:00
parent 1ffd2d2359
commit 3079cd9588
23 changed files with 289 additions and 123 deletions

View File

@@ -1,8 +0,0 @@
package gq.kirmanak.mealient.ui.recipes
import android.widget.ImageView
interface RecipeImageLoader {
fun loadRecipeImage(view: ImageView, slug: String?)
}

View File

@@ -4,6 +4,7 @@ import androidx.recyclerview.widget.RecyclerView
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding
import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader
import timber.log.Timber
class RecipeViewHolder(
@@ -18,7 +19,7 @@ class RecipeViewHolder(
fun bind(item: RecipeSummaryEntity?) {
Timber.v("bind() called with: item = $item")
binding.name.text = item?.name ?: loadingPlaceholder
recipeImageLoader.loadRecipeImage(binding.image, item?.slug)
recipeImageLoader.loadRecipeImage(binding.image, item)
item?.let { entity ->
binding.root.setOnClickListener {
Timber.d("bind: item clicked $entity")

View File

@@ -14,6 +14,8 @@ import gq.kirmanak.mealient.databinding.FragmentRecipesBinding
import gq.kirmanak.mealient.extensions.collectWhenViewResumed
import gq.kirmanak.mealient.extensions.refreshRequestFlow
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader
import gq.kirmanak.mealient.ui.recipes.images.RecipePreloaderFactory
import timber.log.Timber
import javax.inject.Inject
@@ -26,6 +28,9 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
@Inject
lateinit var recipeImageLoader: RecipeImageLoader
@Inject
lateinit var recipePreloaderFactory: RecipePreloaderFactory
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
@@ -45,19 +50,22 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
private fun setupRecipeAdapter() {
Timber.v("setupRecipeAdapter() called")
val adapter = RecipesPagingAdapter(recipeImageLoader, ::navigateToRecipeInfo)
binding.recipes.adapter = adapter
val recipesAdapter = RecipesPagingAdapter(recipeImageLoader, ::navigateToRecipeInfo)
with(binding.recipes) {
adapter = recipesAdapter
addOnScrollListener(recipePreloaderFactory.create(recipesAdapter))
}
collectWhenViewResumed(viewModel.pagingData) {
Timber.v("setupRecipeAdapter: received data update")
adapter.submitData(lifecycle, it)
recipesAdapter.submitData(lifecycle, it)
}
collectWhenViewResumed(adapter.onPagesUpdatedFlow) {
collectWhenViewResumed(recipesAdapter.onPagesUpdatedFlow) {
Timber.v("setupRecipeAdapter: pages updated")
binding.refresher.isRefreshing = false
}
collectWhenViewResumed(binding.refresher.refreshRequestFlow()) {
Timber.v("setupRecipeAdapter: received refresh request")
adapter.refresh()
recipesAdapter.refresh()
}
}

View File

@@ -6,12 +6,14 @@ import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding
import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader
import timber.log.Timber
class RecipesPagingAdapter(
private val recipeImageLoader: RecipeImageLoader,
private val clickListener: (RecipeSummaryEntity) -> Unit
) : PagingDataAdapter<RecipeSummaryEntity, RecipeViewHolder>(RecipeDiffCallback) {
override fun onBindViewHolder(holder: RecipeViewHolder, position: Int) {
val item = getItem(position)
holder.bind(item)

View File

@@ -0,0 +1,9 @@
package gq.kirmanak.mealient.ui.recipes.images
import android.widget.ImageView
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
interface RecipeImageLoader {
fun loadRecipeImage(view: ImageView, recipe: RecipeSummaryEntity?)
}

View File

@@ -0,0 +1,22 @@
package gq.kirmanak.mealient.ui.recipes.images
import android.widget.ImageView
import androidx.fragment.app.Fragment
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import dagger.hilt.android.scopes.FragmentScoped
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import timber.log.Timber
import javax.inject.Inject
@FragmentScoped
class RecipeImageLoaderImpl @Inject constructor(
private val fragment: Fragment,
private val requestOptions: RequestOptions,
) : RecipeImageLoader {
override fun loadRecipeImage(view: ImageView, recipe: RecipeSummaryEntity?) {
Timber.v("loadRecipeImage() called with: view = $view, recipe = $recipe")
Glide.with(fragment).load(recipe).apply(requestOptions).into(view)
}
}

View File

@@ -0,0 +1,31 @@
package gq.kirmanak.mealient.ui.recipes.images
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.model.ModelCache
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.stream.BaseGlideUrlLoader
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import java.io.InputStream
class RecipeModelLoader(
private val recipeImageUrlProvider: RecipeImageUrlProvider,
concreteLoader: ModelLoader<GlideUrl, InputStream>,
cache: ModelCache<RecipeSummaryEntity, GlideUrl>,
) : BaseGlideUrlLoader<RecipeSummaryEntity>(concreteLoader, cache) {
override fun handles(model: RecipeSummaryEntity): Boolean = true
override fun getUrl(
model: RecipeSummaryEntity?,
width: Int,
height: Int,
options: Options?
): String? {
Timber.v("getUrl() called with: model = $model, width = $width, height = $height, options = $options")
return runBlocking { recipeImageUrlProvider.generateImageUrl(model?.slug) }
}
}

View File

@@ -0,0 +1,27 @@
package gq.kirmanak.mealient.ui.recipes.images
import com.bumptech.glide.load.model.*
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider
import timber.log.Timber
import java.io.InputStream
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RecipeModelLoaderFactory @Inject constructor(
private val recipeImageUrlProvider: RecipeImageUrlProvider,
) : ModelLoaderFactory<RecipeSummaryEntity, InputStream> {
private val cache = ModelCache<RecipeSummaryEntity, GlideUrl>()
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<RecipeSummaryEntity, InputStream> {
Timber.v("build() called with: multiFactory = $multiFactory")
val concreteLoader = multiFactory.build(GlideUrl::class.java, InputStream::class.java)
return RecipeModelLoader(recipeImageUrlProvider, concreteLoader, cache)
}
override fun teardown() {
// Do nothing
}
}

View File

@@ -0,0 +1,40 @@
package gq.kirmanak.mealient.ui.recipes.images
import androidx.fragment.app.Fragment
import androidx.paging.PagingDataAdapter
import com.bumptech.glide.Glide
import com.bumptech.glide.ListPreloader
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.request.RequestOptions
import dagger.hilt.android.scopes.FragmentScoped
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import timber.log.Timber
import javax.inject.Inject
class RecipePreloadModelProvider(
private val adapter: PagingDataAdapter<RecipeSummaryEntity, *>,
private val fragment: Fragment,
private val requestOptions: RequestOptions,
) : ListPreloader.PreloadModelProvider<RecipeSummaryEntity> {
override fun getPreloadItems(position: Int): List<RecipeSummaryEntity> {
Timber.v("getPreloadItems() called with: position = $position")
return adapter.peek(position)?.let { listOf(it) } ?: emptyList()
}
override fun getPreloadRequestBuilder(item: RecipeSummaryEntity): RequestBuilder<*> {
Timber.v("getPreloadRequestBuilder() called with: item = $item")
return Glide.with(fragment).load(item).apply(requestOptions)
}
@FragmentScoped
class Factory @Inject constructor(
private val fragment: Fragment,
private val requestOptions: RequestOptions,
) {
fun create(
adapter: PagingDataAdapter<RecipeSummaryEntity, *>,
) = RecipePreloadModelProvider(adapter, fragment, requestOptions)
}
}

View File

@@ -0,0 +1,10 @@
package gq.kirmanak.mealient.ui.recipes.images
import androidx.paging.PagingDataAdapter
import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
interface RecipePreloaderFactory {
fun create(adapter: PagingDataAdapter<RecipeSummaryEntity, *>): RecyclerViewPreloader<RecipeSummaryEntity>
}

View File

@@ -0,0 +1,33 @@
package gq.kirmanak.mealient.ui.recipes.images
import androidx.fragment.app.Fragment
import androidx.paging.PagingDataAdapter
import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader
import com.bumptech.glide.util.ViewPreloadSizeProvider
import dagger.hilt.android.scopes.FragmentScoped
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import javax.inject.Inject
@FragmentScoped
class RecipePreloaderFactoryImpl @Inject constructor(
private val recipePreloadModelProvider: RecipePreloadModelProvider.Factory,
private val fragment: Fragment,
) : RecipePreloaderFactory {
override fun create(
adapter: PagingDataAdapter<RecipeSummaryEntity, *>,
): RecyclerViewPreloader<RecipeSummaryEntity> {
val preloadSizeProvider = ViewPreloadSizeProvider<RecipeSummaryEntity>()
val preloadModelProvider = recipePreloadModelProvider.create(adapter)
return RecyclerViewPreloader(
fragment,
preloadModelProvider,
preloadSizeProvider,
MAX_PRELOAD
)
}
companion object {
const val MAX_PRELOAD = 10
}
}

View File

@@ -14,7 +14,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.databinding.FragmentRecipeInfoBinding
import gq.kirmanak.mealient.ui.recipes.RecipeImageLoader
import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader
import timber.log.Timber
import javax.inject.Inject
@@ -46,7 +46,6 @@ class RecipeInfoFragment : BottomSheetDialogFragment() {
with(binding) {
ingredientsList.adapter = ingredientsAdapter
instructionsList.adapter = instructionsAdapter
recipeImageLoader.loadRecipeImage(image, arguments.recipeSlug)
}
with(viewModel) {
@@ -60,6 +59,7 @@ class RecipeInfoFragment : BottomSheetDialogFragment() {
ingredientsHolder.isVisible = uiState.areIngredientsVisible
instructionsGroup.isVisible = uiState.areInstructionsVisible
uiState.recipeInfo?.let {
recipeImageLoader.loadRecipeImage(image, it.recipeSummaryEntity)
title.text = it.recipeSummaryEntity.name
description.text = it.recipeSummaryEntity.description
ingredientsAdapter.submitList(it.recipeIngredients)