NetflixみたいなRecyclerView 【SnapHelper】


Snappingとは

Androidでは、SnapHelperというクラスを使ってRecyclerViewのSnappingを実現することができます。
スナッピング?と思う方もいると思いますが、スナッピングとはスクロール時に通常通りFlingさせるのではなく、特定のアイテムでスッと止まらせるような挙動です。
Netflixのホーム画面がSnappingを使用しています。

Snappingの実現方法

今回はAndroidでのSnappingの実現方法について記述します。

RecyclerViewのスクロールState

RecyclerViewのスクロールには3つの状態があります。

  • SCROLL_STATE_IDLE
    (RecyclerViewがスクロールしていない状態。)

  • SCROLL_STATE_DRAGGING
    (RecyclerViewがドラッグされている状態)

  • SCROLL_STATE_SETTLING
    (RecyclerViewが最終ポジションまでアニメーションしている状態)

SnapHelper

次に本題のSnapHelperの内部を見ていきます。
SnapHelperは3つのAbstractメソッドが用意されています。
こちらをOverrideすることで意図したSnappingを実現することができます。

calculateDistanceToFinalSnap

calculateDistanceToFinalSnapはSnapさせたいView(targetView)をもとに最終ポジジョンまでの距離を返却します。

/**
* Override this method to snap to a particular point within the target view or the container
* view on any axis.
* <p>
* This method is called when the {@link SnapHelper} has intercepted a fling and it needs
* to know the exact distance required to scroll by in order to snap to the target view.
*
* @param layoutManager the {@link RecyclerView.LayoutManager} associated with the attached
*                      {@link RecyclerView}
* @param targetView the target view that is chosen as the view to snap
*
* @return the output coordinates the put the result into. out[0] is the distance
* on horizontal axis and out[1] is the distance on vertical axis.
*/
@SuppressWarnings("WeakerAccess")
@Nullable
public abstract int[] calculateDistanceToFinalSnap(@NonNull LayoutManager layoutManager, @NonNull View targetView);

targetViewがスナッピングさせたいViewです。
上のNetflixのような挙動を実現するには、targetViewが中心にSnapされているので、targetViewの中心とRecyclerView自身の中心の位置の差を返却すれば良いことになります。


@Override
int[] calculateDistanceToFinalSnap(@NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView) {
    int[] out = new int[2];
    if (layoutManager.canScrollHorizontally()) {
        out[0] = getDistance(layoutManager, targetView, OrientationHelper.createHorizontalHelper(layoutManager));
    } else {
        out[0] = 0;
    }

    if (layoutManager.canScrollVertically()) {
        out[1] = getDistance(layoutManager, targetView, OrientationHelper.createVerticalHelper(layoutManager));
    } else {
        out[1] = 0;
    }
    return out;
}

int getDistance(RecyclerView.LayoutManager layoutManager, View targetView, OrientationHelper helper) {
    final int childCenter = helper.getDecoratedStart(targetView) + (helper.getDecoratedMeasurement(targetView) / 2);
    final int containerCenter = layoutManager.getClipToPadding()
            ? helper.getStartAfterPadding() + helper.getTotalSpace() / 2
            : helper.getEnd() / 2;
    return childCenter - containerCenter;
}

findSnapView

findSnapViewでは、その名の通りSnapさせたいViewを返却します。ここで返却したViewが上記のcalculateDistanceToFinalSnapの引数として入っていきます。
このメソッドは、スクロール状態が SCROLL_STATE_IDLE になったときと、SnapHelperがRecyclerViewにAttachされた時に呼ばれます。


/**
* Override this method to provide a particular target view for snapping.
* <p>
* This method is called when the {@link SnapHelper} is ready to start snapping and requires
* a target view to snap to. It will be explicitly called when the scroll state becomes idle
* after a scroll. It will also be called when the {@link SnapHelper} is preparing to snap
* after a fling and requires a reference view from the current set of child views.
* <p>
 * If this method returns {@code null}, SnapHelper will not snap to any view.
*
* @param layoutManager the {@link RecyclerView.LayoutManager} associated with the attached
*                      {@link RecyclerView}
*
* @return the target view to which to snap on fling or end of scroll
*/
@SuppressWarnings("WeakerAccess")
@Nullable
public abstract View findSnapView(LayoutManager layoutManager);

上のNetflixのような挙動を実現するには、 itemが3つごとにまとまってスクロールされているため、itemのpositionが1,4,7...で一番中心に近いViewを返却すれば良いことになります。


@Override
View findSnapView(RecyclerView.LayoutManager layoutManager) {
    OrientationHelper helper = layoutManager.canScrollHorizontally()
            ? OrientationHelper.createHorizontalHelper(layoutManager)
            : OrientationHelper.createVerticalHelper(layoutManager);
    int childCount = layoutManager.getChildCount();
    View closestChild = null;
    int containerCenter = layoutManager.getClipToPadding()
            ? helper.getStartAfterPadding() + helper.getTotalSpace() / 2
            : helper.getEnd() / 2;
    int absClosest = Integer.MAX_VALUE;
    for (int i = 0; i < childCount; i++) {
        final View child = layoutManager.getChildAt(i);
        if (child == null) continue;
        if (getChildPosition(child, helper) % 3 != 1) continue;
        int childCenter = helper.getDecoratedStart(child) + (helper.getDecoratedMeasurement(child) / 2);
        int absDistance = Math.abs(childCenter - containerCenter);
        if (absDistance < absClosest) {
            absClosest = absDistance;
            closestChild = child;
        }
    }
    return closestChild;
}

findTargetSnapPosition

findTargetSnapPositionでは、targetViewのPositionを返却します。 一見findSnapViewと変わらないように見えますが、メソッドの呼ばれるタイミングが違います。
findTargetSnapPositionはRecyclerViewが SCROLL_STATE_SETTLING になった状態で呼ばれます。そのため、View自体が生成されていない可能性があるので、ViewではなくViewのPositionを返却します。

/**
 * Override to provide a particular adapter target position for snapping.
 *
 * @param layoutManager the {@link RecyclerView.LayoutManager} associated with the attached
 *                      {@link RecyclerView}
 * @param velocityX fling velocity on the horizontal axis
 * @param velocityY fling velocity on the vertical axis
 *
 * @return the target adapter position to you want to snap or {@link RecyclerView#NO_POSITION}
 *         if no snapping should happen
 */
public abstract int findTargetSnapPosition(LayoutManager layoutManager, int velocityX, int velocityY);

上のNetflixのような挙動を実現するには、スクロールの向きに応じてスナップされるViewのPositionwを返却します。


@Override
int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX, int velocityY) {
    boolean forwardDirection = layoutManager.canScrollHorizontally() ? velocityX > 0 : velocityY > 0;
    return forwardDirection ? previousClosestPosition + 3 : previousClosestPosition - 3;
}

previousClosestPositionはfindSnapViewないで、前回のSnapされたPositionをメンバー変数などに保持しておけば実現できます。

最後に

SnapHelperの使い方をつらつらと書きましたが、今回書いた内容をふんだんに使ってライブラリを作りました!
どの方向にSnapするのかの Gravity と スクロールするitemの数 SnapCount を指定できます。
ぜひ見ていただけるとありがたいです。
そして、いいねと思った方はぜひスターを押していただけるとありがたいです!

feature1: Gravity feature2: SnapCount