RecyclerViewのsmoothScrollToPositionの停止位置を修正する


## はじめに
RecyclerViewでsmoothScrollToPositionを使った際、中途半端な位置でしか停止してくれなかったので、調べたときのメモです。

#### 前提
LinearLayoutManagerを使用
スクロール方向は縦

#### やりたかったこと
スクロールの停止位置は、必ず一番上にしたい

#### デフォルトの挙動
スクロールする方向によって、上になったり下になったり…

## 内部実装の調査

#### smoothScrollToPosition
よく使われるLinearLayoutManagerの中の実装はこんな感じです。

```java:LinearLayoutManager.java
@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があります。

```java:SmoothScroller.java
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

```java:LinearSmoothScroller.java
@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`の実装の中身は泥臭いのでコメントだけ見ると、このようになっています。

```java:LinearSmoothScroller.java
/**
 * 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を最終的に表示させたい位置を示し、以下の３つから選択します。

- SNAP_TO_START
    - 左または上に寄せる
- SNAP_TO_END
    - 右または下に寄せる
- SNAP_ANY
    - 表示領域内であればどこでもOK

第二引数を決定する`getVerticalSnapPreference`を見るとこのようになっています。

```java:LinearSmoothScroller.java
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`を少しだけ拡張するだけで充分ということがわかりました。

```kotlin:CustomLayoutManager.kt
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)も考慮した実装にするのが汎用的だと思います。