LoginSignup
42
31

More than 5 years have passed since last update.

NetflixみたいなRecyclerView 【SnapHelper】

Last updated at Posted at 2017-08-05

Snappingとは

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

ezgif.com-video-to-gif.gif

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
42
31
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
42
31