NetflixみたいなRecyclerView 【SnapHelper】

# Snappingとは
Androidでは、[SnapHelper](https://developer.android.com/reference/android/support/v7/widget/SnapHelper.html)というクラスを使ってRecyclerViewのSnappingを実現することができます。
スナッピング？と思う方もいると思いますが、スナッピングとはスクロール時に通常通りFlingさせるのではなく、特定のアイテムでスッと止まらせるような挙動です。
Netflixのホーム画面がSnappingを使用しています。

![ezgif.com-video-to-gif.gif](https://qiita-image-store.s3.amazonaws.com/0/93589/03bccf6e-fffa-3bfd-86de-a4068c24e409.gif)

# Snappingの実現方法

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

## RecyclerViewのスクロールState

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

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

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

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

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

### calculateDistanceToFinalSnap

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

```java
/**
* 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自身の中心の位置の差を返却すれば良いことになります。

```java

@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された時に呼ばれます。

```java

/**
* 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が３つごとにまとまってスクロールされているため、itemのpositionが1,4,7...で一番中心に近いViewを返却すれば良いことになります。

```java

@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を返却します。

```java
/**
 * 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を返却します。

```java

@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` を指定できます。
ぜひ見ていただけるとありがたいです。
そして、いいねと思った方はぜひスターを押していただけるとありがたいです！

https://github.com/TakuSemba/MultiSnapRecyclerView


|feature1: Gravity  |feature2: SnapCount  |
|:-:|:-:|
|<img src="https://github.com/TakuSemba/MultiSnapRecyclerView/raw/master/arts/gravity.gif" width="300" />  |<img src="https://github.com/TakuSemba/MultiSnapRecyclerView/raw/master/arts/snap_count.gif" width="300" />   |