I have a RecyclerView
which is attached to a LinearSnapHelper
to snap to center item. When I scroll to the first or last items, these items are not ful
This issue happens when center of item which is next to the first/last is closer to the center of container. So, we should make some changes on snapping functionality to ignore this case. Since we need some fields in LinearSnapHelper
class, we can copy its source code and make change on findCenterView
method as following:
MyLinearSnapHelper.kt
/*
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.aminography.view.component
import android.support.v7.widget.LinearLayoutManager
import android.support.v7.widget.OrientationHelper
import android.support.v7.widget.RecyclerView
import android.support.v7.widget.SnapHelper
import android.view.View
/**
* Implementation of the [SnapHelper] supporting snapping in either vertical or horizontal
* orientation.
*
*
* The implementation will snap the center of the target child view to the center of
* the attached [RecyclerView]. If you intend to change this behavior then override
* [SnapHelper.calculateDistanceToFinalSnap].
*/
class MyLinearSnapHelper : SnapHelper() {
// Orientation helpers are lazily created per LayoutManager.
private var mVerticalHelper: OrientationHelper? = null
private var mHorizontalHelper: OrientationHelper? = null
override fun calculateDistanceToFinalSnap(
layoutManager: RecyclerView.LayoutManager, targetView: View): IntArray? {
val out = IntArray(2)
if (layoutManager.canScrollHorizontally()) {
out[0] = distanceToCenter(layoutManager, targetView,
getHorizontalHelper(layoutManager))
} else {
out[0] = 0
}
if (layoutManager.canScrollVertically()) {
out[1] = distanceToCenter(layoutManager, targetView,
getVerticalHelper(layoutManager))
} else {
out[1] = 0
}
return out
}
override fun findTargetSnapPosition(layoutManager: RecyclerView.LayoutManager, velocityX: Int,
velocityY: Int): Int {
if (layoutManager !is RecyclerView.SmoothScroller.ScrollVectorProvider) {
return RecyclerView.NO_POSITION
}
val itemCount = layoutManager.itemCount
if (itemCount == 0) {
return RecyclerView.NO_POSITION
}
val currentView = findSnapView(layoutManager) ?: return RecyclerView.NO_POSITION
val currentPosition = layoutManager.getPosition(currentView)
if (currentPosition == RecyclerView.NO_POSITION) {
return RecyclerView.NO_POSITION
}
val vectorProvider = layoutManager as RecyclerView.SmoothScroller.ScrollVectorProvider
// deltaJumps sign comes from the velocity which may not match the order of children in
// the LayoutManager. To overcome this, we ask for a vector from the LayoutManager to
// get the direction.
val vectorForEnd = vectorProvider.computeScrollVectorForPosition(itemCount - 1)
?: // cannot get a vector for the given position.
return RecyclerView.NO_POSITION
var vDeltaJump: Int
var hDeltaJump: Int
if (layoutManager.canScrollHorizontally()) {
hDeltaJump = estimateNextPositionDiffForFling(layoutManager,
getHorizontalHelper(layoutManager), velocityX, 0)
if (vectorForEnd.x < 0) {
hDeltaJump = -hDeltaJump
}
} else {
hDeltaJump = 0
}
if (layoutManager.canScrollVertically()) {
vDeltaJump = estimateNextPositionDiffForFling(layoutManager,
getVerticalHelper(layoutManager), 0, velocityY)
if (vectorForEnd.y < 0) {
vDeltaJump = -vDeltaJump
}
} else {
vDeltaJump = 0
}
val deltaJump = if (layoutManager.canScrollVertically()) vDeltaJump else hDeltaJump
if (deltaJump == 0) {
return RecyclerView.NO_POSITION
}
var targetPos = currentPosition + deltaJump
if (targetPos < 0) {
targetPos = 0
}
if (targetPos >= itemCount) {
targetPos = itemCount - 1
}
return targetPos
}
override fun findSnapView(layoutManager: RecyclerView.LayoutManager): View? {
if (layoutManager.canScrollVertically()) {
return findCenterView(layoutManager, getVerticalHelper(layoutManager))
} else if (layoutManager.canScrollHorizontally()) {
return findCenterView(layoutManager, getHorizontalHelper(layoutManager))
}
return null
}
private fun distanceToCenter(layoutManager: RecyclerView.LayoutManager,
targetView: View, helper: OrientationHelper): Int {
val childCenter = helper.getDecoratedStart(targetView) + helper.getDecoratedMeasurement(targetView) / 2
val containerCenter: Int = if (layoutManager.clipToPadding) {
helper.startAfterPadding + helper.totalSpace / 2
} else {
helper.end / 2
}
return childCenter - containerCenter
}
/**
* Estimates a position to which SnapHelper will try to scroll to in response to a fling.
*
* @param layoutManager The [RecyclerView.LayoutManager] associated with the attached
* [RecyclerView].
* @param helper The [OrientationHelper] that is created from the LayoutManager.
* @param velocityX The velocity on the x axis.
* @param velocityY The velocity on the y axis.
*
* @return The diff between the target scroll position and the current position.
*/
private fun estimateNextPositionDiffForFling(layoutManager: RecyclerView.LayoutManager,
helper: OrientationHelper, velocityX: Int, velocityY: Int): Int {
val distances = calculateScrollDistance(velocityX, velocityY)
val distancePerChild = computeDistancePerChild(layoutManager, helper)
if (distancePerChild <= 0) {
return 0
}
val distance = if (Math.abs(distances[0]) > Math.abs(distances[1])) distances[0] else distances[1]
return Math.round(distance / distancePerChild)
}
/**
* Return the child view that is currently closest to the center of this parent.
*
* @param layoutManager The [RecyclerView.LayoutManager] associated with the attached
* [RecyclerView].
* @param helper The relevant [OrientationHelper] for the attached [RecyclerView].
*
* @return the child view that is currently closest to the center of this parent.
*/
private fun findCenterView(layoutManager: RecyclerView.LayoutManager,
helper: OrientationHelper): View? {
// ----- Added by aminography
if (layoutManager is LinearLayoutManager) {
if (layoutManager.findFirstCompletelyVisibleItemPosition() == 0) {
return layoutManager.getChildAt(0)
} else if (layoutManager.findLastCompletelyVisibleItemPosition() == layoutManager.itemCount - 1) {
return layoutManager.getChildAt(layoutManager.itemCount - 1)
}
}
// -----
val childCount = layoutManager.childCount
if (childCount == 0) {
return null
}
var closestChild: View? = null
val center: Int = if (layoutManager.clipToPadding) {
helper.startAfterPadding + helper.totalSpace / 2
} else {
helper.end / 2
}
var absClosest = Integer.MAX_VALUE
for (i in 0 until childCount) {
val child = layoutManager.getChildAt(i)
val childCenter = helper.getDecoratedStart(child) + helper.getDecoratedMeasurement(child) / 2
val absDistance = Math.abs(childCenter - center)
/** if child center is closer than previous closest, set it as closest */
if (absDistance < absClosest) {
absClosest = absDistance
closestChild = child
}
}
return closestChild
}
/**
* Computes an average pixel value to pass a single child.
*
*
* Returns a negative value if it cannot be calculated.
*
* @param layoutManager The [RecyclerView.LayoutManager] associated with the attached
* [RecyclerView].
* @param helper The relevant [OrientationHelper] for the attached
* [RecyclerView.LayoutManager].
*
* @return A float value that is the average number of pixels needed to scroll by one view in
* the relevant direction.
*/
private fun computeDistancePerChild(layoutManager: RecyclerView.LayoutManager,
helper: OrientationHelper): Float {
var minPosView: View? = null
var maxPosView: View? = null
var minPos = Integer.MAX_VALUE
var maxPos = Integer.MIN_VALUE
val childCount = layoutManager.childCount
if (childCount == 0) {
return INVALID_DISTANCE
}
for (i in 0 until childCount) {
val child = layoutManager.getChildAt(i)
val pos = layoutManager.getPosition(child!!)
if (pos == RecyclerView.NO_POSITION) {
continue
}
if (pos < minPos) {
minPos = pos
minPosView = child
}
if (pos > maxPos) {
maxPos = pos
maxPosView = child
}
}
if (minPosView == null || maxPosView == null) {
return INVALID_DISTANCE
}
val start = Math.min(helper.getDecoratedStart(minPosView),
helper.getDecoratedStart(maxPosView))
val end = Math.max(helper.getDecoratedEnd(minPosView),
helper.getDecoratedEnd(maxPosView))
val distance = end - start
return if (distance == 0) {
INVALID_DISTANCE
} else 1f * distance / (maxPos - minPos + 1)
}
private fun getVerticalHelper(layoutManager: RecyclerView.LayoutManager): OrientationHelper {
if (mVerticalHelper == null || mVerticalHelper!!.layoutManager !== layoutManager) {
mVerticalHelper = OrientationHelper.createVerticalHelper(layoutManager)
}
return mVerticalHelper!!
}
private fun getHorizontalHelper(
layoutManager: RecyclerView.LayoutManager): OrientationHelper {
if (mHorizontalHelper == null || mHorizontalHelper!!.layoutManager !== layoutManager) {
mHorizontalHelper = OrientationHelper.createHorizontalHelper(layoutManager)
}
return mHorizontalHelper!!
}
companion object {
private const val INVALID_DISTANCE = 1f
}
}
I tried to implement a simple solution. Basically I checked if the first/last items are completely visible. If so, we don't need to perform the snap. See the solution below:
class CarouselSnapHelper : LinearSnapHelper() {
override fun findSnapView(layoutManager: RecyclerView.LayoutManager): View? {
val linearLayoutManager = layoutManager as? LinearLayoutManager
?: return super.findSnapView(layoutManager)
return linearLayoutManager
.takeIf { isValidSnap(it) }
?.run { super.findSnapView(layoutManager) }
}
private fun isValidSnap(linearLayoutManager: LinearLayoutManager) =
linearLayoutManager.findFirstCompletelyVisibleItemPosition() != 0 &&
linearLayoutManager.findLastCompletelyVisibleItemPosition() != linearLayoutManager.itemCount - 1
}
I know I am late but I want to suggest an simple solution written in Java code:
Create CustomSnapHelper
class:
public class CustomSnapHelper extends LinearSnapHelper {
@Override
public View findSnapView(RecyclerView.LayoutManager layoutManager) {
if(layoutManager instanceof LinearLayoutManager){
LinearLayoutManager linearLayoutManager = (LinearLayoutManager) layoutManager;
if(needToDoSnap(linearLayoutManager)==false){
return null;
}
}
return super.findSnapView(layoutManager);
}
public boolean needToDoSnap(LinearLayoutManager linearLayoutManager){
return linearLayoutManager.findFirstCompletelyVisibleItemPosition()!=0&&linearLayoutManager.findLastCompletelyVisibleItemPosition()!=linearLayoutManager.getItemCount()-1;
}
}
Attach an object of CustomSnapHelper
for recycler view:
CustomSnapHelper mSnapHelper = new CustomSnapHelper();
mSnapHelper.attachToRecyclerView(mRecyclerView);
I found a less invasive answer:
private class PagerSelectSnapHelper : LinearSnapHelper() {
override fun findSnapView(layoutManager: RecyclerView.LayoutManager): View? {
// Use existing LinearSnapHelper but override when the itemDecoration calculations are off
val snapView = super.findSnapView(layoutManager)
return if (!snapView.isViewInCenterOfParent(layoutManager.width)) {
val endView = layoutManager.findViewByPosition(layoutManager.itemCount - 1)
val startView = layoutManager.findViewByPosition(0)
when {
endView.isViewInCenterOfParent(layoutManager.width) -> endView
startView.isViewInCenterOfParent(layoutManager.width) -> startView
else -> snapView
}
} else {
snapView
}
}
private fun View?.isViewInCenterOfParent(parentWidth: Int): Boolean {
if (this == null || width == 0) {
return false
}
val parentCenter = parentWidth / 2
return left < parentCenter && parentCenter < right
}
}