RecyclerViewの長距離スムーズスクロールをスムーズにする

## TL;DR

- RecyclerViewのsmooth scrollの動作を変更するにはSmoothScrollerを実装する
- 全部自分で実装するのはかなり大変そうだが、既存の拡張だけでもそれなりに動作を変更できる

## はじめに

タイトルがなに言ってるんだこいつは状態ですが、移動距離の長くなるポジションで`RecyclerView#smoothScrollToPosition`すると下のGifアニメのように、ひたすら一定の速度で移動し続ける動作になります。

<img src="https://qiita-image-store.s3.amazonaws.com/0/53706/cf91a137-639f-a436-7c84-788a546d6190.gif" width=30% />

Twitter公式クライアントでは、タブをタップした時にTLの先頭まで戻れますが、移動距離が長くなるようなときにはsmooth scrollせずに一瞬で先頭が表示されます。
このような動作にしてもいいのですが、あまりにも味気ないので少しスクロールを見せた後、指定したポジションに移動する動きを実現するために少し調査したのでまとめます。

## RecyclerViewをsmooth scrollさせているのは誰か

`LinearLayoutManager#smoothScrollToPosition`の実装を見てみます。

```java
@Override
public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state,
        int position) {
    LinearSmoothScroller linearSmoothScroller =
            new LinearSmoothScroller(recyclerView.getContext());
    linearSmoothScroller.setTargetPosition(position);
    startSmoothScroll(linearSmoothScroller);
}
```

`LinearSmoothScroller`というやつのインスタンスを生成して`startSmoothScroll`に渡しているだけですね。
つまり、こいつがsmoothScrollの正体のようです。
`LinearLayoutManager`や`GridLayoutManager`ではこの`LinearSmoothScroller`を使っていますが、厳密には`RecyclerView#SmoothScroller`というabstract classを継承していればいいようです。

- [LinearSmoothScroller](https://github.com/android/platform_frameworks_support/blob/master/v7/recyclerview/src/android/support/v7/widget/LinearSmoothScroller.java)
- [SmoothScroller (RecyclerView.java#L10916-L11346))](https://github.com/android/platform_frameworks_support/blob/master/v7/recyclerview/src/android/support/v7/widget/RecyclerView.java#L10916-L11346)

## 長距離移動するときの動作

実際にRecyclerViewの内部動作を追おうとすると、いつまでたってもたどり着けなくなりそうなので適当なところにデバッグポイント張って調べてみます。
すると、`updateActionForInterimTarget`というメソッドで移動距離と時間等を計算しているのですが、指定のポジションに到達しなかった場合には、到達するまで繰り返し呼ばれているらしいことがわかります。

なので、こいつを拡張して`updateActionForInterimTarget`の２回目の呼び出しでsmoothScrollをやめて指定のポジションへ移動するようにしてみます。

## Smooth scrollの動きをカスタムする

いきなりですがコードです。

```kotlin
class OneTimeSmoothScroller(recyclerView: RecyclerView) : LinearSmoothScroller(recyclerView.context) {

    private var isScrolled: Boolean = false

    override fun updateActionForInterimTarget(action: Action) {
        if (isScrolled) {
            // 2回目は一気に指定位置までジャンプ
            action.jumpTo(targetPosition)
        } else {
            // 1回目は通常通りにスクロール
            super.updateActionForInterimTarget(action)
            isScrolled = true
        }
    }
}
```

これだけです。実際に動きを見てみます。

<img src="https://qiita-image-store.s3.amazonaws.com/0/53706/ad4a70e5-6c88-f249-4e50-07796b529791.gif" width=30% />

意外と想定していた動きをしてくれましたが、スクロール速度が一定なので若干ジャンプが唐突に感じます。
そこで、もう少し変更を加えてみます。

`LinearSmoothScroller`の実装を見てみると`updateActionForInterimTarget`の最後に次の記述があります。

```java
action.update((int) (mInterimTargetDx * TARGET_SEEK_EXTRA_SCROLL_RATIO)
        , (int) (mInterimTargetDy * TARGET_SEEK_EXTRA_SCROLL_RATIO)
        , (int) (time * TARGET_SEEK_EXTRA_SCROLL_RATIO), mLinearInterpolator);
```

どうやら

- 引数で渡された`action`に値を設定するとそれがsmooth scrollのアニメーションに反映される
- アニメーションに`LinearInterpolator`を使っている

ようです。
少し唐突感を和らげられるよう、`LinearInterpolator`を`AccelerateInterpolator`に変えてみます。

```kotlin
class OneTimeSmoothScroller(recyclerView: RecyclerView) : LinearSmoothScroller(recyclerView.context) {
    private var isScrolled: Boolean = false

    override fun updateActionForInterimTarget(action: Action) {
        if (isScrolled) {
            action.jumpTo(targetPosition)
        } else {
            super.updateActionForInterimTarget(action)
            action.duration *= 2  // 動きが分かりやすいよう時間を2倍にする
            action.interpolator = AccelerateInterpolator(1.5F)
            isScrolled = true
        }
    }
}
```

実際の動きはこのような感じになります。

<img src="https://qiita-image-store.s3.amazonaws.com/0/53706/326a840a-53a3-5b88-4126-18cbe76c754c.gif" width=30% />

Gifアニメだとわかりづらいですが、多少唐突感は軽減された感じになりました。

## etc

`action`にちょっと変更入れるだけで結構動きを変えられますが、`dx`と`dy`は簡単に変更しないほうが良さそう。
今回の例だと、一回の計算でたどり着く距離の時に指定した位置にスクロールが止まらなくなってしまう。

サンプルコードはこちら https://github.com/chibatching/long-distance-smooth-scroll-sample