はじめに
RecyclerViewでsmoothScrollToPositionを使った際、中途半端な位置でしか停止してくれなかったので、調べたときのメモです。
前提
LinearLayoutManagerを使用
スクロール方向は縦
やりたかったこと
スクロールの停止位置は、必ず一番上にしたい
デフォルトの挙動
スクロールする方向によって、上になったり下になったり…
内部実装の調査
smoothScrollToPosition
よく使われるLinearLayoutManagerの中の実装はこんな感じです。
@Override
public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state, int position) {
LinearSmoothScroller linearSmoothScroller = new LinearSmoothScroller(recyclerView.getContext());
// scrollerにスクロールのtargetPositionを設定
linearSmoothScroller.setTargetPosition(position);
// scrollerを使ってスクロール実行
startSmoothScroll(linearSmoothScroller);
}
LinearSmoothScroller
はSmoothScroller
を継承しています。
SmoothScroller
SmoothScroller
はabstractクラスで、その中にonTargetFound
というmethodがあります。
public abstract static class SmoothScroller {
...(略)
/**
* Called when the target position is laid out. This is the last callback SmoothScroller
* will receive and it should update the provided {@link Action} to define the scroll
* details towards the target view.
* @param targetView The view element which render the target position.
* @param state Transient state of RecyclerView
* @param action Action instance that you should update to define final scroll action
* towards the targetView
*/
protected abstract void onTargetFound(View targetView, State state, Action action);
...(略)
}
SmoothScroller
はいくつかのcallbackがあるのですが、このcallbackは一番最後に通知されるもので、targetのViewがレイアウト内に入ってきたときにcallされます。ここで何の処理をするのかというと、引数で渡ってきたaction
に対してupdateを行って位置の微調整を行いなさい、というような趣旨のコメントが書いてあります。
それでは、具体的にLinearSmoothScroller
ではどのような実装になっているか確認します。
LinearSmoothScroller
@Override
protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
final int dx = calculateDxToMakeVisible(targetView, getHorizontalSnapPreference());
final int dy = calculateDyToMakeVisible(targetView, getVerticalSnapPreference());
final int distance = (int) Math.sqrt(dx * dx + dy * dy);
final int time = calculateTimeForDeceleration(distance);
if (time > 0) {
action.update(-dx, -dy, time, mDecelerateInterpolator);
}
}
必要なx,y方向のスクロール量を計算し、action.update
を実行しています。計算するcalculateDx/DyToMakeVisible
の実装の中身は泥臭いのでコメントだけ見ると、このようになっています。
/**
* Calculates the vertical scroll amount necessary to make the given view fully visible
* inside the RecyclerView.
*
* @param view The view which we want to make fully visible
* @param snapPreference The edge which the view should snap to when entering the visible
* area. One of {@link #SNAP_TO_START}, {@link #SNAP_TO_END} or
* {@link #SNAP_TO_ANY}.
* @return The vertical scroll amount necessary to make the view visible with the given
* snap preference.
*/
public int calculateDyToMakeVisible(View view, int snapPreference) {
第一引数のview全体が見えるようになるまでに必要なスクロール量を計算します、とあります。第二引数はviewを最終的に表示させたい位置を示し、以下の3つから選択します。
- SNAP_TO_START
- 左または上に寄せる
- SNAP_TO_END
- 右または下に寄せる
- SNAP_ANY
- 表示領域内であればどこでもOK
第二引数を決定するgetVerticalSnapPreference
を見るとこのようになっています。
protected int getVerticalSnapPreference() {
return mTargetVector == null || mTargetVector.y == 0 ? SNAP_TO_ANY :
mTargetVector.y > 0 ? SNAP_TO_END : SNAP_TO_START;
}
スクロースする方向によって、SNAP_TO_START
とSNAP_TO_END
を切り替えています。つまり、今回の目的で言えば、このsnapPreferenceを修正(override)してあげれば実現できることになります。
結論
説明文は長くなってしまいましたが、実際に実装すべきコードはデフォルトのLinearSmoothScroller
を少しだけ拡張するだけで充分ということがわかりました。
class CustomLayoutManager(context: Context) : LinearLayoutManager(context) {
override fun smoothScrollToPosition(recyclerView: RecyclerView, state: RecyclerView.State?, position: Int) {
val linearSmoothScroller = object : LinearSmoothScroller(recyclerView.context) {
override fun getVerticalSnapPreference(): Int = if (reverseLayout) SNAP_TO_END else SNAP_TO_START
}
linearSmoothScroller.targetPosition = position
startSmoothScroll(linearSmoothScroller)
}
}
ちなみにこの例ではreverseLayout
だけ考慮していますが、本来はスクロール方向(vertical/horizontal)も考慮した実装にするのが汎用的だと思います。