Last active
October 18, 2021 14:47
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
) | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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