Last active
January 7, 2021 10:31
-
-
Save Moes81/0cfbb1f2d8492025a7ddaa9549f870e7 to your computer and use it in GitHub Desktop.
A custom SnapHelper implementation for the RecyclerView that snaps to a predefined block of items.
This file contains hidden or 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
import android.util.DisplayMetrics | |
import android.util.Log | |
import android.view.View | |
import android.view.animation.Interpolator | |
import android.widget.Scroller | |
import androidx.core.view.ViewCompat | |
import androidx.recyclerview.widget.* | |
import kotlin.math.abs | |
import kotlin.math.max | |
/** | |
* A custom [SnapHelper] implementation for the [RecyclerView] that snaps to a whole page of items, instead of the | |
* single recycler view items as with the `LinearSnapHelper`. | |
* | |
* The number of items in the RecyclerView should be a multiple of block size; otherwise, the extra item views will not | |
* be positioned on a block boundary when the end of the data is reached. Pad out with empty item views if needed. | |
* | |
* Taken from: [https://stackoverflow.com/questions/47514072/how-to-snap-recyclerview-items-so-that-every-x-items-would-be-considered-like-a#] | |
* Kudos to: Cheticamp [https://github.com/Cheticamp] for the idea | |
* and AndroidDeveloperLB [https://github.com/AndroidDeveloperLB] for the Kotlin port. | |
* Also check out the sample app: [https://github.com/Cheticamp/SnapToBlockDemo] | |
* | |
* Usage: | |
* ``` | |
* val snapToBlock: SnapToBlock = SnapToBlock(mMaxFlingPages) | |
* snapToBlock.attachToRecyclerView(recyclerView) | |
* ``` | |
* | |
* @param maxFlingBlocks Max blocks to move during most vigorous fling | |
**/ | |
class BlockSnapHelper(private val maxFlingBlocks: Int) : SnapHelper() { | |
private lateinit var recyclerView: RecyclerView | |
// Total number of items in a block of view in the RecyclerView | |
private var blocksize: Int = 0 | |
// Maximum number of positions to move on a fling. | |
private var maxPositionsToMove: Int = 0 | |
// Width of a RecyclerView item if orientation is horizonal; height of the item if vertical | |
private var itemDimension: Int = 0 | |
/** | |
* Callback interface when blocks are snapped. | |
*/ | |
private var snapBlockCallback: SnapBlockCallback? = null | |
/** | |
* When snapping, used to determine direction of snap. | |
*/ | |
private var priorFirstPosition = RecyclerView.NO_POSITION | |
/** | |
* Our private scroller | |
*/ | |
private lateinit var scroller: Scroller | |
/** | |
* Horizontal/vertical layout helper | |
*/ | |
private lateinit var orientationHelper: OrientationHelper | |
/** | |
* LTR/RTL helper | |
*/ | |
private var layoutDirectionHelper: LayoutDirectionHelper? = null | |
@Throws(IllegalStateException::class) | |
override fun attachToRecyclerView(recyclerView: RecyclerView?) { | |
if (recyclerView != null) { | |
this.recyclerView = recyclerView | |
val layoutManager = this.recyclerView.layoutManager as LinearLayoutManager | |
orientationHelper = when { | |
layoutManager.canScrollHorizontally() -> OrientationHelper.createHorizontalHelper(layoutManager) | |
layoutManager.canScrollVertically() -> OrientationHelper.createVerticalHelper(layoutManager) | |
else -> throw IllegalStateException("RecyclerView must be scrollable") | |
} | |
scroller = Scroller(this.recyclerView.context, sInterpolator) | |
initItemDimensionIfNeeded(layoutManager) | |
} | |
super.attachToRecyclerView(recyclerView) | |
} | |
// Called when the target view is available and we need to know how much more | |
// to scroll to get it lined up with the side of the RecyclerView. | |
override fun calculateDistanceToFinalSnap(layoutManager: RecyclerView.LayoutManager, targetView: View): IntArray { | |
val out = IntArray(2) | |
initLayoutDirectionHelperIfNeeded(layoutManager) | |
if (layoutManager.canScrollHorizontally()) | |
out[0] = layoutDirectionHelper!!.getScrollToAlignView(targetView) | |
if (layoutManager.canScrollVertically()) | |
out[1] = layoutDirectionHelper!!.getScrollToAlignView(targetView) | |
if (snapBlockCallback != null) | |
if (out[0] == 0 && out[1] == 0) | |
snapBlockCallback!!.onBlockSnapped(layoutManager.getPosition(targetView)) | |
else | |
snapBlockCallback!!.onBlockSnap(layoutManager.getPosition(targetView)) | |
return out | |
} | |
// We are flinging and need to know where we are heading. | |
override fun findTargetSnapPosition( | |
layoutManager: RecyclerView.LayoutManager, | |
velocityX: Int, | |
velocityY: Int | |
): Int { | |
initLayoutDirectionHelperIfNeeded(layoutManager) | |
val lm = layoutManager as LinearLayoutManager | |
initItemDimensionIfNeeded(layoutManager) | |
scroller.fling( | |
0, | |
0, | |
velocityX, | |
velocityY, | |
Integer.MIN_VALUE, | |
Integer.MAX_VALUE, | |
Integer.MIN_VALUE, | |
Integer.MAX_VALUE | |
) | |
return when { | |
velocityX != 0 -> layoutDirectionHelper!!.getPositionsToMove(lm, scroller.finalX, itemDimension) | |
velocityY != 0 -> layoutDirectionHelper!!.getPositionsToMove(lm, scroller.finalY, itemDimension) | |
else -> RecyclerView.NO_POSITION | |
} | |
} | |
// We have scrolled to the neighborhood where we will snap. Determine the snap position. | |
override fun findSnapView(layoutManager: RecyclerView.LayoutManager): View? { | |
// Snap to a view that is either | |
// 1) toward the bottom of the data and therefore on screen, or | |
// 2) toward the top of the data and may be off-screen. | |
val snapPos = calcTargetPosition(layoutManager as LinearLayoutManager) | |
val snapView = if (snapPos == RecyclerView.NO_POSITION) null else layoutManager.findViewByPosition(snapPos) | |
if (snapView == null) Log.d(TAG, "<<<<findSnapView is returning null!") | |
Log.d(TAG, "<<<<findSnapView snapos=$snapPos") | |
return snapView | |
} | |
override fun createScroller(layoutManager: RecyclerView.LayoutManager): LinearSmoothScroller? { | |
return if (layoutManager !is RecyclerView.SmoothScroller.ScrollVectorProvider) null | |
else object : LinearSmoothScroller(recyclerView.context) { | |
override fun onTargetFound(targetView: View, state: RecyclerView.State, action: Action) { | |
val snapDistances = calculateDistanceToFinalSnap(recyclerView.layoutManager!!, targetView) | |
val dx = snapDistances[0] | |
val dy = snapDistances[1] | |
val time = calculateTimeForDeceleration(max(abs(dx), abs(dy))) | |
if (time > 0) action.update(dx, dy, time, sInterpolator) | |
} | |
override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics): Float = | |
MILLISECONDS_PER_INCH / displayMetrics.densityDpi | |
} | |
} | |
/** | |
* Set a [SnapBlockCallback] to get informed, when the recyclerView snaps to a block. | |
*/ | |
fun setSnapBlockCallback(callback: SnapBlockCallback) { | |
snapBlockCallback = callback | |
} | |
private fun initLayoutDirectionHelperIfNeeded(layoutManager: RecyclerView.LayoutManager) { | |
if (layoutDirectionHelper == null) | |
if (layoutManager.canScrollHorizontally()) | |
layoutDirectionHelper = LayoutDirectionHelper() | |
else if (layoutManager.canScrollVertically()) | |
// RTL doesn't matter for vertical scrolling for this class. | |
layoutDirectionHelper = LayoutDirectionHelper(false) | |
} | |
// Does the heavy lifting for findSnapView. | |
private fun calcTargetPosition(layoutManager: LinearLayoutManager): Int { | |
val snapPos: Int | |
initLayoutDirectionHelperIfNeeded(layoutManager) | |
val firstVisiblePos = layoutManager.findFirstVisibleItemPosition() | |
if (firstVisiblePos == RecyclerView.NO_POSITION) | |
return RecyclerView.NO_POSITION | |
initItemDimensionIfNeeded(layoutManager) | |
if (firstVisiblePos >= priorFirstPosition) { | |
// Scrolling toward bottom of data | |
val firstCompletePosition = layoutManager.findFirstCompletelyVisibleItemPosition() | |
snapPos = if (firstCompletePosition != RecyclerView.NO_POSITION && firstCompletePosition % blocksize == 0) | |
firstCompletePosition | |
else | |
roundDownToBlockSize(firstVisiblePos + blocksize) | |
} else { | |
// Scrolling toward top of data | |
snapPos = roundDownToBlockSize(firstVisiblePos) | |
// Check to see if target view exists. If it doesn't, force a smooth scroll. | |
// SnapHelper only snaps to existing views and will not scroll to a non-existent one. | |
// If limiting fling to single block, then the following is not needed since the | |
// views are likely to be in the RecyclerView pool. | |
if (layoutManager.findViewByPosition(snapPos) == null) { | |
val toScroll = layoutDirectionHelper!!.calculateDistanceToScroll(layoutManager, snapPos) | |
recyclerView.smoothScrollBy(toScroll[0], toScroll[1], sInterpolator) | |
} | |
} | |
priorFirstPosition = firstVisiblePos | |
return snapPos | |
} | |
private fun initItemDimensionIfNeeded(layoutManager: RecyclerView.LayoutManager) { | |
if (itemDimension != 0) | |
return | |
val child = layoutManager.getChildAt(0) ?: return | |
if (layoutManager.canScrollHorizontally()) { | |
itemDimension = child.width | |
blocksize = getSpanCount(layoutManager) * (recyclerView.width / itemDimension) | |
} else if (layoutManager.canScrollVertically()) { | |
itemDimension = child.height | |
blocksize = getSpanCount(layoutManager) * (recyclerView.height / itemDimension) | |
} | |
maxPositionsToMove = blocksize * maxFlingBlocks | |
} | |
private fun getSpanCount(layoutManager: RecyclerView.LayoutManager): Int = | |
(layoutManager as? GridLayoutManager)?.spanCount ?: 1 | |
private fun roundDownToBlockSize(trialPosition: Int): Int = trialPosition - trialPosition % blocksize | |
private fun roundUpToBlockSize(trialPosition: Int): Int = roundDownToBlockSize(trialPosition + blocksize - 1) | |
/** | |
* Helper class that handles calculations for LTR and RTL layouts. | |
*/ | |
private inner class LayoutDirectionHelper { | |
/** | |
* Is the layout an RTL one? | |
*/ | |
private val mIsRTL: Boolean | |
constructor() { | |
mIsRTL = ViewCompat.getLayoutDirection(recyclerView) == ViewCompat.LAYOUT_DIRECTION_RTL | |
} | |
constructor(isRTL: Boolean) { | |
mIsRTL = isRTL | |
} | |
/** | |
* Calculate the amount of scroll needed to align the target view with the layout edge. | |
*/ | |
fun getScrollToAlignView(targetView: View): Int = if (mIsRTL) | |
orientationHelper.getDecoratedEnd(targetView) - recyclerView.width | |
else | |
orientationHelper.getDecoratedStart(targetView) | |
/** | |
* Calculate the distance to final snap position when the view corresponding to the snap | |
* position is not currently available. | |
* | |
* @param layoutManager LinearLayoutManager or descendent class | |
* @param targetPos - Adapter position to snap to | |
* @return int[2] {x-distance in pixels, y-distance in pixels} | |
*/ | |
fun calculateDistanceToScroll(layoutManager: LinearLayoutManager, targetPos: Int): IntArray { | |
val out = IntArray(2) | |
val firstVisiblePos = layoutManager.findFirstVisibleItemPosition() | |
if (layoutManager.canScrollHorizontally()) { | |
if (targetPos <= firstVisiblePos) // scrolling toward top of data | |
out[0] = if (mIsRTL) { | |
val lastView = layoutManager.findViewByPosition(layoutManager.findLastVisibleItemPosition()) | |
orientationHelper.getDecoratedEnd(lastView) + (firstVisiblePos - targetPos) * itemDimension | |
} else { | |
val firstView = layoutManager.findViewByPosition(firstVisiblePos) | |
orientationHelper.getDecoratedStart(firstView) - (firstVisiblePos - targetPos) * itemDimension | |
} | |
} | |
if (layoutManager.canScrollVertically() && targetPos <= firstVisiblePos) { | |
// scrolling toward top of data | |
out[1] = layoutManager.findViewByPosition(firstVisiblePos)?.let { firstView -> | |
firstView.top - (firstVisiblePos - targetPos) * itemDimension | |
} | |
?: throw IllegalStateException("layoutManager.findViewByPosition($firstVisiblePos) does not find any view!") | |
} | |
return out | |
} | |
/** | |
* Calculate the number of positions to move in the RecyclerView given a scroll amount | |
* and the size of the items to be scrolled. Return integral multiple of mBlockSize not | |
* equal to zero. | |
*/ | |
fun getPositionsToMove(llm: LinearLayoutManager, scroll: Int, itemSize: Int): Int { | |
var positionsToMove: Int | |
positionsToMove = roundUpToBlockSize(abs(scroll) / itemSize) | |
if (positionsToMove < blocksize) | |
// Must move at least one block | |
positionsToMove = blocksize | |
else if (positionsToMove > maxPositionsToMove) | |
// Clamp number of positions to move so we don't get wild flinging. | |
positionsToMove = maxPositionsToMove | |
if (scroll < 0) | |
positionsToMove *= -1 | |
if (mIsRTL) | |
positionsToMove *= -1 | |
return if (layoutDirectionHelper!!.isDirectionToBottom(scroll < 0)) { | |
// Scrolling toward the bottom of data. | |
roundDownToBlockSize(llm.findFirstVisibleItemPosition()) + positionsToMove | |
} else roundDownToBlockSize(llm.findLastVisibleItemPosition()) + positionsToMove | |
// Scrolling toward the top of the data. | |
} | |
fun isDirectionToBottom(velocityNegative: Boolean): Boolean = | |
if (mIsRTL) velocityNegative else !velocityNegative | |
} | |
/** | |
* Implement this interface and call [setSnapBlockCallback] with it to get informed, when the recyclerView snaps | |
* to a certain block. | |
*/ | |
interface SnapBlockCallback { | |
fun onBlockSnap(snapPosition: Int) | |
fun onBlockSnapped(snapPosition: Int) | |
} | |
companion object { | |
// Borrowed from ViewPager.java | |
private val sInterpolator = Interpolator { input -> | |
var t = input | |
// _o(t) = t * t * ((tension + 1) * t + tension) | |
// o(t) = _o(t - 1) + 1 | |
t -= 1.0f | |
t * t * t + 1.0f | |
} | |
private const val MILLISECONDS_PER_INCH = 100f | |
private const val TAG = "BlockSnapHelper" | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment