Skip to content

Instantly share code, notes, and snippets.

@elihart
Last active October 18, 2021 14:47
Show Gist options
  • Save elihart/8da7c172550c06f951a8acb295d14f73 to your computer and use it in GitHub Desktop.
Save elihart/8da7c172550c06f951a8acb295d14f73 to your computer and use it in GitHub Desktop.
Utility to set up a RecyclerView scroll listener that enables preloading support with Glide in Epoxy library usages.
package com.airbnb.epoxy
import android.content.Context
import android.graphics.Bitmap
import android.support.annotation.IdRes
import android.support.annotation.Px
import android.support.v7.widget.RecyclerView
import android.view.View
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.RequestManager
import com.bumptech.glide.request.target.Target
/**
* Used to create a scroll listener that prefetches images with Glide.
*
* To use this, create implementations of [EpoxyModelPreloader] for each EpoxyModel class that you want to preload.
* Then, use the [EpoxyPreloader.with] methods to create a ScrollListener that preloads models of that type.
* Finally, add the resulting scroll listener to your RecyclerView.
*/
class EpoxyPreloader private constructor(
private val adapter: BaseEpoxyAdapter,
private val requestManager: RequestManager,
errorHandler: PreloadErrorHandler,
maxItemsToPreload: Int,
vararg models: EpoxyModelPreloader<*, *>
) : RecyclerView.OnScrollListener() {
constructor(
epoxyController: EpoxyController,
requestManager: RequestManager,
errorHandler: PreloadErrorHandler,
maxItemsToPreload: Int,
vararg models: EpoxyModelPreloader<*, *>
) : this(epoxyController.adapter, requestManager, errorHandler, maxItemsToPreload, *models)
constructor(
adapter: EpoxyAdapter,
requestManager: RequestManager,
errorHandler: PreloadErrorHandler,
maxItemsToPreload: Int,
vararg models: EpoxyModelPreloader<*, *>
) : this(adapter as BaseEpoxyAdapter, requestManager, errorHandler, maxItemsToPreload, *models)
private val modelPreloaders: Map<Class<out EpoxyModel<*>>, EpoxyModelPreloader<*, *>> = models.associateBy { it.modelType }
private val viewDataCache = PreloadableViewDataProvider(adapter, errorHandler)
private val scrollListener = PreloadingScrollListener(this, requestManager, maxItemsToPreload)
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) = scrollListener.onScrolled(recyclerView, dx, dy)
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) = scrollListener.onScrollStateChanged(recyclerView, newState)
fun getPreloadItems(position: Int): List<EpoxyModelImageData<*, *>> {
@Suppress("UNCHECKED_CAST")
val epoxyModel = adapter.getModelForPosition(position) as? EpoxyModel<Any> ?: return emptyList()
@Suppress("UNCHECKED_CAST")
val preloader = modelPreloaders[epoxyModel::class.java] as? EpoxyModelPreloader<EpoxyModel<Any>, Any?> ?: return emptyList()
return viewDataCache
.dataForModel(preloader, epoxyModel, position)
.map { EpoxyModelImageData(preloader, epoxyModel, it) }
}
fun getPreloadRequestBuilder(modelData: EpoxyModelImageData<*, *>) = modelData.buildRequest(requestManager)
fun cancelImageLoads() = scrollListener.cancelAll()
companion object {
/** Helper to create a preload scroll listener. Add the result to your RecyclerView. */
fun with(
context: Context,
epoxyController: EpoxyController,
errorHandler: PreloadErrorHandler,
maxItemsToPreload: Int,
vararg models: EpoxyModelPreloader<*, *>
): EpoxyPreloader {
val requestManager = Glide.with(context)
return EpoxyPreloader(epoxyController, requestManager, errorHandler, maxItemsToPreload, *models)
}
/** Helper to create a preload scroll listener. Add the result to your RecyclerView. */
fun with(
context: Context,
epoxyAdapter: EpoxyAdapter,
errorHandler: PreloadErrorHandler,
maxItemsToPreload: Int,
vararg models: EpoxyModelPreloader<*, *>
): EpoxyPreloader {
val requestManager = Glide.with(context)
return EpoxyPreloader(epoxyAdapter, requestManager, errorHandler, maxItemsToPreload, *models)
}
}
}
class GlidePreloadException(errorMessage: String) : RuntimeException(errorMessage)
typealias PreloadErrorHandler = (RuntimeException) -> Unit
/**
* Declares ImageViews that should be preloaded. This can either be implemented by a custom view or by an [EpoxyHolder].
*
* The preloadable views can be recursive ie if [Preloadable.imageViewsToPreload] includes any views that are themselves Preloadable those nested
* views will instead by used.
*
*/
interface Preloadable {
val imageViewsToPreload: List<View>
}
/**
* Data about an image view to be preloaded. This data is used to construct a Glide image request.
*
* @param metadata Any custom, additional data that the [EpoxyModelPreloader] chooses to provide that may be necessary to create the image request.
*/
class ViewData<out U>(@IdRes val viewId: Int, @Px val width: Int, @Px val height: Int, val metadata: U)
/**
* Describes how images for an EpoxyModel should be preloaded.
*
* @param T The type of EpoxyModel that this preloader applies to
* @param U The type of metadata to provide to the request builder. Can be Unit if no extra data is needed.
*/
interface EpoxyModelPreloader<T : EpoxyModel<*>, U> {
val modelType: Class<T>
/**
* A list of view ids, one for each image view that should be preloaded.
* This should be left empty if the EpoxyModel's type uses the [Preloadable] interface.
*/
val imageViewIds: List<Int>
get() = emptyList()
/**
* An optional signature to differentiate views with the same model. This is useful if your EpoxyModel can contain varying amounts of image views,
* or image views of varying sizes.
*
* By default the model's class, span size, and layout resource, are used to differentiate views. This signature allows additional differentiation.
* For example, if your EpoxyModel shows an image that is either portrait of landscape, this orientation will affect the view dimensions.
* In this case you could return a boolean here to differentiate the two cases so that the preloaded image has the correct orientation.
*
* The returned object can be anything, but it must implement [Object.hashCode]
*/
@Suppress("Detekt.FunctionOnlyReturningConstant")
fun viewSignature(epoxyModel: T): Any? = null
/**
* Provide optional metadata about a view. This can be used in [EpoxyModelPreloader.buildRequest]
*
* A preload request works best if it exactly matches the actual image request (in order to match Glide cache keys)
* Things such as request transformations, thumbnails, or crop type can affect the cache key.
* If your ImageView is configurable you can capture those options via this metadata.
*/
fun buildViewMetadata(view: View): U
/**
* Create and return a new Glide [RequestBuilder] to request an image for the given model and view.
*
* @param epoxyModel The EpoxyModel whose image is being preloaded.
* @param viewData Information about the view that will hold the image.
*/
fun buildRequest(
requestManager: RequestManager,
epoxyModel: T,
viewData: ViewData<U>
): RequestBuilder<*>
companion object {
/**
* Helper to create a [EpoxyModelPreloader].
*
* @param imageViewIds see [EpoxyModelPreloader.imageViewIds].
* @param requestBuilder see [EpoxyModelPreloader.buildRequest].
*/
inline fun <reified T : EpoxyModel<*>> with(
vararg imageViewIds: Int,
crossinline requestBuilder: (requestManager: RequestManager, epoxyModel: T, viewData: ViewData<Unit>) -> RequestBuilder<*>?
): EpoxyModelPreloader<T, Unit> = with(*imageViewIds, viewMetadata = { _ -> }, viewSignature = { _ -> null }, requestBuilder = requestBuilder)
/**
* Helper to create a [EpoxyModelPreloader].
*
* @param viewSignature see [EpoxyModelPreloader.viewSignature]
* @param imageViewIds see [EpoxyModelPreloader.imageViewIds]
* @param viewMetadata see [EpoxyModelPreloader.buildViewMetadata]
* @param requestBuilder see [EpoxyModelPreloader.buildRequest]
*/
inline fun <reified T : EpoxyModel<*>, U> with(
vararg imageViewIds: Int,
crossinline viewMetadata: (View) -> U,
crossinline viewSignature: (T) -> Any? = { _ -> null },
crossinline requestBuilder: (requestManager: RequestManager, epoxyModel: T, viewData: ViewData<U>) -> RequestBuilder<*>?
): EpoxyModelPreloader<T, U> = object : EpoxyModelPreloader<T, U> {
override val modelType = T::class.java
override val imageViewIds = imageViewIds.asList()
override fun buildViewMetadata(view: View) = viewMetadata(view)
override fun viewSignature(epoxyModel: T) = viewSignature(epoxyModel)
override fun buildRequest(
requestManager: RequestManager,
epoxyModel: T,
viewData: ViewData<U>
): RequestBuilder<*> {
return requestBuilder(requestManager, epoxyModel, viewData) ?: NoOpRequestBuilder(requestManager)
}
}
/**
* Helper to create a [EpoxyModelPreloader]. This is similar to the other helper methods but not inlined so it can be used with Java.
*
* @param epoxyModelClass The specific type of EpoxyModel that this preloader is for.
* @param viewSignature see [EpoxyModelPreloader.viewSignature]
* @param imageViewIds see [EpoxyModelPreloader.imageViewIds]
* @param viewMetadata see [EpoxyModelPreloader.buildViewMetadata]
* @param requestBuilder see [EpoxyModelPreloader.buildRequest]
*/
fun <T : EpoxyModel<*>, U> with(
vararg imageViewIds: Int,
epoxyModelClass: Class<T>,
viewMetadata: (View) -> U,
viewSignature: (T) -> Any? = { _ -> null },
requestBuilder: (requestManager: RequestManager, epoxyModel: T, viewData: ViewData<U>) -> RequestBuilder<*>?
): EpoxyModelPreloader<T, U> = object : EpoxyModelPreloader<T, U> {
override val modelType = epoxyModelClass
override val imageViewIds = imageViewIds.asList()
override fun buildViewMetadata(view: View) = viewMetadata(view)
override fun viewSignature(epoxyModel: T) = viewSignature(epoxyModel)
override fun buildRequest(
requestManager: RequestManager,
epoxyModel: T,
viewData: ViewData<U>
): RequestBuilder<*> {
return requestBuilder(requestManager, epoxyModel, viewData) ?: NoOpRequestBuilder(requestManager)
}
}
}
}
class NoOpRequestBuilder(requestManager: RequestManager) : RequestBuilder<Bitmap>(Bitmap::class.java, requestManager.asBitmap()) {
override fun <Y : Target<Bitmap>?> into(target: Y) = target
}
class EpoxyModelImageData<T : EpoxyModel<*>, U>(
val preloader: EpoxyModelPreloader<T, U>,
val epoxyModel: T,
val viewData: ViewData<U>
) {
fun buildRequest(requestManager: RequestManager) = preloader.buildRequest(
requestManager = requestManager,
epoxyModel = epoxyModel,
viewData = viewData
)
}
package com.airbnb.epoxy
import android.support.v4.view.ViewCompat
import android.view.View
import kotlin.reflect.KClass
/**
* In order to preload images we need to know the size of the view that they will be loaded into.
* This class provides the view size, as well as other view metadata that might be necessary to construct the image request.
*/
internal class PreloadableViewDataProvider(
val adapter: BaseEpoxyAdapter,
val errorHandler: PreloadErrorHandler
) {
/**
* A given model class might have different sized images depending on configuration. We use this cache key to separate view configurations.
*/
private data class CacheKey(
val epoxyModelClass: KClass<out EpoxyModel<*>>,
val spanSize: Int,
val viewType: Int,
/** An optional, custom signature provided by the model preloader. This allows the user to specify custom cache mixins */
val signature: Any?
)
private val cache = mutableMapOf<CacheKey, List<ViewData<*>>?>()
/** @return A list containing the data necessary to load each view in the given model. */
fun <T : EpoxyModel<*>, U> dataForModel(
preloader: EpoxyModelPreloader<T, U>,
epoxyModel: T,
position: Int
): List<ViewData<U>> {
val cacheKey = cacheKey(preloader, epoxyModel, position)
@Suppress("UNCHECKED_CAST")
return cache.getOrPut(cacheKey) {
// Look up view data based on currently bound views. This can be null if a matching view type is not found.
// In that case we save the null so we know to try the lookup again next time.
findViewData(preloader, epoxyModel, cacheKey)
} as? List<ViewData<U>> ?: return emptyList()
}
private fun <T : EpoxyModel<*>> cacheKey(
preloader: EpoxyModelPreloader<T, *>,
epoxyModel: T,
position: Int
): CacheKey {
val modelSpanSize = if (adapter.isMultiSpan) {
epoxyModel.getSpanSizeInternal(adapter.spanCount, position, adapter.itemCount)
} else {
1
}
return CacheKey(epoxyModel::class, modelSpanSize, epoxyModel.viewType, preloader.viewSignature(epoxyModel))
}
private fun <T : EpoxyModel<*>, U> findViewData(preloader: EpoxyModelPreloader<T, U>, epoxyModel: T, cacheKey: CacheKey): List<ViewData<U>>? {
// It is a bit tricky to get details on the view to be preloaded, since the view doesn't necessarily exist at the time of preload.
// This approach looks at currently bound views and tries to get one who's cache key is the same as what we need.
// This should mostly work, since RecyclerViews generally the same type of views shown repeatedly.
// If a model is only shown sporadically we may never be able to get data about it with this approach, which we could address in the future.
val holderMatch = adapter.boundViewHolders.find {
val boundModel = it.model
if (boundModel::class == epoxyModel::class) {
@Suppress("UNCHECKED_CAST")
// We need the view sizes, but viewholders can be bound without actually being laid out on screen yet
ViewCompat.isAttachedToWindow(it.itemView)
&& ViewCompat.isLaidOut(it.itemView)
&& cacheKey(preloader, boundModel as T, it.adapterPosition) == cacheKey
} else {
false
}
}
val rootView = holderMatch?.itemView ?: return null
val boundObject = holderMatch.objectToBind() // Allows usage of view holder models
val imageViews: List<View> = when {
preloader.imageViewIds.isNotEmpty() -> rootView.findViews(preloader.imageViewIds, epoxyModel)
rootView is Preloadable -> rootView.imageViewsToPreload
boundObject is Preloadable -> boundObject.imageViewsToPreload
else -> emptyList()
}
if (imageViews.isEmpty()) {
errorHandler(GlidePreloadException("No preloadable views were found in ${epoxyModel::class.simpleName}"))
}
return imageViews
.flatMap { it.recursePreloadableViews() }
.mapNotNull { it.buildData(preloader, epoxyModel) }
}
/** Returns child views with the given view ids. */
private fun <T : EpoxyModel<*>> View.findViews(
viewIds: List<Int>,
epoxyModel: T
): List<View> {
return viewIds.mapNotNull { id ->
findViewById<View>(id).apply {
if (this == null) errorHandler(GlidePreloadException("View with id $id in ${epoxyModel::class.simpleName} could not be found."))
}
}
}
/** If a View with the [Preloadable] interface is used we want to get all of the image views contained in that Preloadable as well. */
private fun <T : View> T.recursePreloadableViews(): List<View> {
return if (this is Preloadable) imageViewsToPreload.flatMap { it.recursePreloadableViews() } else listOf(this)
}
private fun <T : EpoxyModel<*>, U> View.buildData(
preloader: EpoxyModelPreloader<T, U>,
epoxyModel: T
): ViewData<U>? {
// Glide's internal size determiner takes view dimensions and subtracts padding to get target size.
// TODO: We could support size overrides by allowing the preloader to specify a size override callback
val width = width - paddingLeft - paddingRight
val height = height - paddingTop - paddingBottom
if (width <= 0 || height <= 0) {
// If no placeholder or aspect ratio is used then the view might be empty before an image loads
errorHandler(GlidePreloadException("${this::class.simpleName} in ${epoxyModel::class.simpleName} has zero size. A size must be set to allow preloading an image."))
return null
}
return ViewData(id, width, height, preloader.buildViewMetadata(this))
}
}
package com.airbnb.epoxy
import android.os.Handler
import android.os.Looper
import android.support.annotation.NonNull
import android.support.annotation.Nullable
import android.support.v7.widget.LinearLayoutManager
import android.support.v7.widget.RecyclerView
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.RequestManager
import com.bumptech.glide.request.target.BaseTarget
import com.bumptech.glide.request.target.SizeReadyCallback
import com.bumptech.glide.request.transition.Transition
import com.bumptech.glide.util.Util
class PreloadingScrollListener(
private val preloader: EpoxyPreloader,
private val requestManager: RequestManager,
private val maxPreload: Int
) : RecyclerView.OnScrollListener() {
private val preloadTargetQueue = PreloadTargetQueue(maxPreload + 1, ::onResourceLoaded)
private var lastVisibleRange: IntRange = IntRange.EMPTY
private var lastPreloadRange: IntProgression = IntRange.EMPTY
private var totalItemCount = -1
private var scrollState: Int = RecyclerView.SCROLL_STATE_IDLE
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
scrollState = newState
}
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (dx == 0 && dy == 0) {
// Sometimes flings register a bunch of 0 dx/dy scroll events. To avoid redundant prefetching we just skip these
// Additionally, the first RecyclerView layout notifies a scroll of 0, since that can be an important time for
// performance (eg page load) we avoid prefetching at the same time.
return
}
if (dx.isFling() || dy.isFling()) {
// We avoid preloading during flings for two reasons
// 1. Image requests are expensive and we don't want to drop frames on fling
// 2. We'll likely scroll past the preloading item anyway
return
}
// Update item count before anything else because validations depend on it
totalItemCount = recyclerView.adapter.itemCount
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
val lastVisiblePosition = layoutManager.findLastVisibleItemPosition()
if (firstVisiblePosition.isInvalid() || lastVisiblePosition.isInvalid()) {
lastVisibleRange = IntRange.EMPTY
lastPreloadRange = IntRange.EMPTY
return
}
val visibleRange = IntRange(firstVisiblePosition, lastVisiblePosition)
if (visibleRange == lastVisibleRange) {
return
}
val isIncreasing = visibleRange.first > lastVisibleRange.first || visibleRange.last > lastVisibleRange.last
val preloadRange = calculatePreloadRange(firstVisiblePosition, lastVisiblePosition, isIncreasing)
// Start preload for any items that weren't already preloaded
preloadRange
.subtract(lastPreloadRange)
.forEach { preloadAdapterPosition(it) }
lastVisibleRange = visibleRange
lastPreloadRange = preloadRange
}
/**
* @receiver The number of pixels scrolled.
* @return True if this distance is large enough to be considered a fast fling.
*/
private fun Int.isFling() = Math.abs(this) > FLING_THRESHOLD_PX
private fun calculatePreloadRange(firstVisiblePosition: Int, lastVisiblePosition: Int, isIncreasing: Boolean): IntProgression {
val from = if (isIncreasing) lastVisiblePosition + 1 else firstVisiblePosition - 1
val to = from + if (isIncreasing) maxPreload - 1 else 1 - maxPreload
return IntProgression.fromClosedRange(
rangeStart = from.clampToAdapterRange(),
rangeEnd = to.clampToAdapterRange(),
step = if (isIncreasing) 1 else -1
)
}
/** Check if an item index is valid. It may not be if the adapter is empty, or if adapter changes have been dispatched since the last layout pass. */
private fun Int.isInvalid() = this == RecyclerView.NO_POSITION || this >= totalItemCount
private fun Int.clampToAdapterRange() = Math.min(totalItemCount - 1, Math.max(this, 0))
private fun preloadAdapterPosition(position: Int) {
preloader
.getPreloadItems(position)
.forEach { preloadItem(it) }
}
private fun preloadItem(@Nullable item: EpoxyModelImageData<*, *>) {
@Suppress("UNCHECKED_CAST")
val preloadRequestBuilder = preloader.getPreloadRequestBuilder(item) as? RequestBuilder<Any> ?: return
val width = item.viewData.width
val height = item.viewData.height
val preloadTarget = preloadTargetQueue.next(width, height)
preloadRequestBuilder.into(preloadTarget)
// Cancel any previous attempts to clear the target
mainThreadHandler.removeCallbacksAndMessages(preloadTarget)
}
fun cancelAll() {
for (i in 0 until maxPreload) {
requestManager.clear(preloadTargetQueue.next(0, 0))
}
}
private fun onResourceLoaded(preloadTarget: PreloadTarget) {
// Holding on to the bitmap is unnecessary and strains memory usage.
// Bitmap has been loaded into memory cache so we can remove our reference.
// Needs to be done async after the onResourceLoaded callback to prevent a Glide crash
// We use the target as the token so we can remove the runnable if another preload is started
mainThreadHandler.postAtTime({ requestManager.clear(preloadTarget) }, preloadTarget, 0)
}
private class PreloadTargetQueue(size: Int, onResourceLoaded: (PreloadTarget) -> Unit) {
private val queue = Util.createQueue<PreloadTarget>(size).apply {
for (i in 0 until size) {
offer(PreloadTarget(onResourceLoaded))
}
}
fun next(width: Int, height: Int): PreloadTarget {
val result = queue.poll()
queue.offer(result)
result.photoWidth = width
result.photoHeight = height
return result
}
}
private class PreloadTarget(val onResourceLoaded: (PreloadTarget) -> Unit) : BaseTarget<Any>() {
var photoHeight = 0
var photoWidth = 0
override fun onResourceReady(resource: Any, transition: Transition<in Any>?) {
onResourceLoaded(this)
}
override fun getSize(@NonNull cb: SizeReadyCallback) {
cb.onSizeReady(photoWidth, photoHeight)
}
override fun removeCallback(@NonNull cb: SizeReadyCallback) {
// Do nothing because we don't retain references to SizeReadyCallbacks.
}
}
}
/**
*
* Represents a threshold for fast scrolling.
* This is a bit arbitrary and was determined by looking at values while flinging vs slow scrolling.
* Ideally it would be based on DP, but this is simpler.
*/
private const val FLING_THRESHOLD_PX = 75
private val mainThreadHandler = Handler(Looper.getMainLooper())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment