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 |
---|---|