RecyclerViewをFragmentPagerAdapter風にする


## やりたいこと

- 水平方向で1ページずつ表示する
- スワイプで左右に移動する
- ページの境界線にスナップする

## やってみたこと

- LinearLayoutManagerでできるだけシンプルにする
- 1ページの大きさを画面の幅に合わせる
- スクロールに加速度がつくので、速さを抑える
- スナップするようにする

## 構成

- ImageAdapter: 1枚の画像を画面の幅に合わせて表示するアダプタ
- HorizontalSnapLayoutManager: レイアウトマネージャ
- MainActivity: アクティビティ

## ImageAdapter

1ページ毎に表示するためには、このadapterが生成するviewの幅を画面の幅にする必要があるが、RecyclerView自体の幅は大きいので、レイアウトXMLでmatch_parentを指定してもうまくいかない。そのため、生成時に幅に合わせるようにした。
幅をぴったりにすることでスナップの処理も単純になる。

```java:ImageAdapter.java
public class ImageAdapter extends RecyclerView.Adapter<ImageAdapter.ViewHolder> {
    private LayoutInflater mLayoutInflater;
    private List<Integer> mDataList;

    public ImageAdapter(Context context, List<Integer> dataList) {
        super();
        mDataList = dataList;
        mLayoutInflater = LayoutInflater.from(context);
    }

    @Override
    public ImageAdapter.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        View v = mLayoutInflater.inflate(R.layout.list_image, parent, false);

        // resize
        ViewGroup.LayoutParams params = v.getLayoutParams();
        params.width = parent.getMeasuredWidth();
        params.height = parent.getMeasuredHeight();
        v.setLayoutParams(params);

        return new ViewHolder(v);
    }

    @Override
    public int getItemCount() {
        return mDataList.size();
    }

    @Override
    public void onBindViewHolder(ViewHolder holder, int position) {
        holder.image.setImageResource(mDataList.get(position));
    }

    static class ViewHolder extends RecyclerView.ViewHolder {
        ImageView image;

        public ViewHolder(View v) {
            super(v);
            image = (ImageView) v.findViewById(R.id.imageView);
        }
    }
}
```

## HorizontalSnapLayoutManager

何もいじらないとスクロール量が大きくすべっていくので、抑えるようにしてみた。
scrollHorizontallyByイベントでスクロール量を決める仕組みになっている。
与えられた移動係数dxから移動量travelを計算する必要がある。まじめに計算するのは大変そうなので、親クラスにtravalを計算させて、上限と下限を設定してみた。これで速度が抑えられた。
スクロールが止まったあと、スナップさせるときにはこの上限下限を外す。

```java:HorizontalSnapLayoutManager.java
public class HorizontalSnapLayoutManager extends LinearLayoutManager {
    final static int MAX_VELOCITY = 100;
    private boolean mSnapping;

    public boolean isSnapping() { return mSnapping; }
    public void setSnapping(boolean snapping) { mSnapping = snapping; }

    public HorizontalSnapLayoutManager(Context context) {
        super(context, LinearLayoutManager.HORIZONTAL, false);
    }

    @Override
    public int scrollHorizontallyBy(int dx, final RecyclerView.Recycler recycler, RecyclerView.State state) {
        int travel = super.scrollHorizontallyBy(dx, recycler, state);
        if (!isSnapping()) {
           travel = Math.max(-MAX_VELOCITY, travel);
           travel = Math.min(MAX_VELOCITY, travel);
        }
        return travel;
    }
}
```

## MainActivity

RecyclerViewのスクロール完了でスナップする。直前の方向を保存しておき、スクロールする方向に1/4以上進んでいた場合はその方向にスナップするようにしてみた。

```java:MainActivity.java
public class MainActivity extends ActionBarActivity {
    final static Integer ResourceIds[] = { R.drawable.earth, R.drawable.lunar_module, R.drawable.columbia };
    final static int SNAP_THRESHOLD = 4;

    private HorizontalSnapLayoutManager mLayoutManager;
    private RecyclerView mRecyclerView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        mLayoutManager = new HorizontalSnapLayoutManager(this);
        mRecyclerView = (RecyclerView) findViewById(R.id.recyclerview);
        mRecyclerView.setLayoutManager(mLayoutManager);
        mRecyclerView.setHasFixedSize(true);
        mRecyclerView.setAdapter(new ImageAdapter(this, Arrays.asList(ResourceIds)));
        mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
            private int mDirection;

            @Override
            public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
                mDirection = dx;
            }

            @Override
            public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
                if (newState == RecyclerView.SCROLL_STATE_IDLE) {
                    onScrollStopped(mDirection);
                }
            }
        });
    }

    void onScrollStopped(int direction) {
        int latter_pos = mLayoutManager.findLastVisibleItemPosition();
        if (latter_pos == RecyclerView.NO_POSITION) {
            return;
        }

        if (mLayoutManager.isSnapping()) {
            // snapping is finished
            mLayoutManager.setSnapping(false);
            return;
        }

        View a = mLayoutManager.getChildAt(0);      // former
        View b = mLayoutManager.getChildAt(1);      // latter

        int diff_a = a != null ? Math.abs(mLayoutManager.getDecoratedLeft(a)) : 0;
        int diff_b = b != null ? Math.abs(mLayoutManager.getDecoratedLeft(b)) : 0;
        if (diff_a > 0 && diff_b > 0) {
            int snap_to;
            if (direction < 0) {
                // left
                snap_to = (latter_pos > 0 && diff_a < diff_b * SNAP_THRESHOLD) ? (latter_pos - 1) : latter_pos;
            } else {
                snap_to = (latter_pos > 0 && diff_a * SNAP_THRESHOLD < diff_b) ? (latter_pos - 1) : latter_pos;
            }

            mLayoutManager.setSnapping(true);
            mRecyclerView.smoothScrollToPosition(snap_to);
        }
    }
}
```

## list_image.xml

1ページに表示するレイアウト。とりあえず画像だけにしたがもちろん何を置いてもよい。

```xml:list_image.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent" android:layout_height="fill_parent">
    <ImageView
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:id="@+id/imageView"
        android:scaleType="centerCrop" />
</FrameLayout>

```

## ソースコード

一式ここに置きました。
https://github.com/firewood/horizontal_snappy