ListViewでスクロール位置が失われないようにする方法

ListViewを使った画面から他の画面へ遷移し、バックキーで戻ってくると、
スクロール位置が失われて一番上までもどってきてしまうことへの対処方法を紹介します。

イメージはこんな感じです。

|対処前|対処後|
|---|---|
|![修正前.gif](https://qiita-image-store.s3.amazonaws.com/0/149859/02413344-dbf7-9027-6039-54e1716aea32.gif)|![after.gif](https://qiita-image-store.s3.amazonaws.com/0/149859/5dd4681c-73b9-265c-8f5a-6c7ecbdd98f0.gif)
|

なお今回はFragment間の画面遷移での話になります。Fragmentはサポートライブラリのものを使用しています。

# 対処前のコード（スクロール位置がリセットされる）
```java:ListViewFragment.java（対処前）
public class ListViewFragment extends Fragment {

    private DataModel mDataModel = new DataModel();
    private ArrayAdapter<String> mAdapter;

    @Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        return inflater.inflate(R.layout.fragment_list_view, container, false);
    }

    @Override
    public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        ListView listView = (ListView) view.findViewById(R.id.list_view);

        mAdapter = new ArrayAdapter<>(
                getContext(),
                R.layout.view_list_item,
                new ArrayList<>());

        // listViewのsetting
        listView.setAdapter(mAdapter);
        listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
            @Override
            public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
                openItemFragment(mAdapter.getItem(position));
            }
        });

        // Listに使用するデータはサーバーから取得することが多いと思うので、その状況を再現するため遅延実行
        new Handler(Looper.getMainLooper()).post(() -> {
            mAdapter.addAll(mDataModel.getKeyList());
        });
    }

    public void openItemFragment(String text) {
        ItemFragment fragment = ItemFragment.createInstance(
                text, mDataModel.getImageResource(text));

        // 画面遷移
        getFragmentManager().beginTransaction()
                .addToBackStack(null)
                .replace(R.id.fragment_container, fragment)
                .commit();
    }

}
```
上記コードのポイントは

* `onViewCreated`で全ての初期化処理をしている。
* データ取得を遅延実行することでサーバー間通信時と近い状況を再現している。  
※AndroidAnnotationsの`@UiThread`を[参考](#参考)にしました
* アイテムをタッチしたら別のFragmentに`replace`（画面遷移）している。

といった感じです。(自作クラスが幾つかまじってますが、本題とは関係ないので末尾の[おまけ](#おまけ)で紹介します)

このコードだと、画面遷移後にバックキーで戻ってくるとスクロール位置が失われます。
なので失われないように修正します。

# 修正後のコード（スクロール位置が保持される）
主にAdapter周りの初期化処理のタイミングを変更します。変更箇所にはコメントを追加してます。

```java:ListViewFragment.java(対処後)
public class ListViewFragment extends Fragment {

    private DataModel mDataModel = new DataModel();
    private ArrayAdapter<String> mAdapter;

    @Override
    public void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        /*onViewCreatedから移動してきました。*/
        mAdapter = new ArrayAdapter<>(
                getContext(),
                R.layout.view_list_item,
                new ArrayList<>());
        new Handler(Looper.getMainLooper()).post(() -> {
            mAdapter.addAll(mDataModel.getKeyList());
        });
    }

    @Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        return inflater.inflate(R.layout.fragment_list_view, container, false);
    }

    @Override
    public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        ListView listView = (ListView) view.findViewById(R.id.list_view);

        /*adapter初期化処理をonCreteに移動しました。*/

        listView.setAdapter(mAdapter);
        listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
            @Override
            public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
                openItemFragment(mAdapter.getItem(position));
            }
        });

        /*データ取得処理をonCreteに移動しました。*/
    }

    public void openItemFragment(String text) {
        ItemFragment fragment = ItemFragment.createInstance(
                text, mDataModel.getImageResource(text));

        getFragmentManager().beginTransaction()
                .addToBackStack(null)
                .replace(R.id.fragment_container, fragment)
                .commit();
    }

}
```
**`onViewCreated`内にあったadapter生成処理とデータ取得処理を`onCreate`に移動しました。**

## なぜこれでなおるのか
一番の元凶は以下の部分です。

```java
    new Handler(Looper.getMainLooper()).post(() -> {
            mAdapter.addAll(mDataModel.getKeyList());
    });
```
この処理のせいでスクロール位置がリセットされてしまっていました。
そもそも、バックキーで戻ってきたときは、実は毎回初期化の途中で復元処理が動いて一度スクロール位置が復元されているんです。
しかし、データを取りに行く処理が遅延実行なので、復元されたあとにリスト更新に関わる処理が呼ばれ、スクロール位置がリセットされてしまうようです。
なので**遅延実行をやめると（Handlerでpostしないと）スクロール位置はリセットされません。**不思議ですね。

ただ、遅延実行やめたら意味ないので、今回は代わりにデータ取得処理を`onCreate`に移動させることで、バックキーで戻ってきた時にはこの処理が実行されないように変更しました。

逆に考えると、対処前の「`onViewCreated`以降に再度データをサーバーに取りに行くような実装」さえしていなければ勝手に復元してもらえるんです。
しかし、それでもダメなパターンもあります。それはListViewをxmlに書かずにjavaでnewしてaddしている場合ですね。試していませんが多分ダメだと思います。
こういったことをしていなければ適切に動いてくれる親切な作りに最初からなっているみたいです。

## 対処方法まとめ
今回の対処方法を一言にまとめます。

**データ取得の処理をonCreateに持って行く**

インスタンスが残っている間は新しく取りに行かず、使いまわそうっていうことです。
ただ、これだと当然ながらバックキーで戻ってきた時にリストの中身が最新でない可能性があります。
個人的には、バックキーで戻ってきたんだから情報も前の状態でいいと思うんですが、そこは仕様と相談することになると思うので利用する場合は注意してください。

ちなみに、２回目のデータ取得処理を防ぐだけなら`onViewCreated`で`adapter`のnullチェックとかでもいいかもしれません。

# その他の方法
なお他の簡単？な方法として「`FragmentTransaction`の`replace`ではなく`add`を使う」という手もあります。
その場合Fragmentの生存に関わる部分とかが変化するので注意しなければいけませんが。

# RecyclerViewの場合はどうか
気になってRecyclerViewも同様に確認してみたのですが、RecyclerViewの方はLayoutManagerをセットし直さない限りはスクロール位置が復元されるようでした（データの再取得は問題なさそう）。
ただこっちは自作クラスがどうしても増えるのでこの記事に詳細はのせていません。もしかしたらまた別の記事でも作って公開するかもしれません。

# おまけ
今回自作した他のファイルを記載します。
[DataModel.java](#datamodeljava)
[R.layout.fragment_list_view.xml（ListViewFragmentのレイアウトファイル)](#listviewfragmentのレイアウトファイル)
[R.layout.view_list_item.xml（リストビュー内の１行分のView)](#リストビュー内の１行分のview)
[ItemFragment.java（遷移先のItemFragment)](#遷移先のitemfragment)

## DataModel.java
表示するデータは今回ここで生成しています。

```java:DataModel.java
public class DataModel {

    final private Map<String, Integer> masterData;

    public DataModel() {
        // 並び順を残したいのでLinkedHashMapで
        Map<String, Integer> masterData = new LinkedHashMap<>();
        masterData.put("子年", R.drawable.eto_mark01_nezumi);
        masterData.put("丑年", R.drawable.eto_mark02_ushi);
        masterData.put("寅年", R.drawable.eto_mark03_tora);
        masterData.put("卯年", R.drawable.eto_mark04_usagi);
        masterData.put("辰年", R.drawable.eto_mark05_tatsu);
        masterData.put("巳年", R.drawable.eto_mark06_hebi);
        masterData.put("午年", R.drawable.eto_mark07_uma);
        masterData.put("未年", R.drawable.eto_mark08_hitsuji);
        masterData.put("申年", R.drawable.eto_mark09_saru);
        masterData.put("酉年", R.drawable.eto_mark10_tori);
        masterData.put("戌年", R.drawable.eto_mark11_inu);
        masterData.put("亥年", R.drawable.eto_mark12_inoshishi);

        this.masterData = masterData;
    }

    public List<String> getKeyList() {
        List<String> keyList = new ArrayList<>();
        for(Map.Entry<String, Integer> e : this.masterData.entrySet()){
            keyList.add(e.getKey());
        }
        return keyList;
    }

    @DrawableRes
    public int getImageResource(String text){
        return this.masterData.get(text);
    }
}
```
多くの場合、リストに表示するデータはサーバーとかから受け取る実装になると思うので、本来必要ないんですが、サーバー間通信の処理を入れるとそっちの記述も気になりそうだったので簡易的にこのようなものを用意しました。

## ListViewFragmentのレイアウトファイル
```xml:R.layout.fragment_list_view.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              xmlns:app="http://schemas.android.com/apk/res-auto"
              android:layout_width="match_parent"
              android:layout_height="match_parent"
              android:orientation="vertical">

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="#3F51B5"
        android:text="十二支一覧"
        android:textColor="#FFFFFF"
        android:textSize="40sp"/>

    <ListView
        android:id="@+id/list_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</LinearLayout>
```
いろいろ直書きなのは見逃してください。

## リストビュー内の１行分のView

```xml:R.layout.view_list_item.xml
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
          android:layout_width="match_parent"
          android:layout_height="100dp"
          android:gravity="center_vertical"
          android:paddingLeft="20dp"
          android:textSize="30sp"/>
```
簡略化のためTextView１本です。
本当はデフォルトで用意されている。`android.R.layout.simple_list_item_1`を利用したかったんですが、それだと高さが低くて１２アイテムだとほとんどスクロールできなかったのでやむなく自前で用意しました。

## 遷移先のItemFragment
```java:ItemFragment.java
public class ItemFragment extends Fragment{

    private static final String BUNDLE_KEY_TEXT = "bundleKeyText";
    private static final String BUNDLE_KEY_IMAGE = "bundleKeyImage";

    public static ItemFragment createInstance(String text, @DrawableRes int imageResource){
        Bundle bundle = new Bundle();
        bundle.putString(BUNDLE_KEY_TEXT,text);
        bundle.putInt(BUNDLE_KEY_IMAGE,imageResource);

        ItemFragment itemFragment = new ItemFragment();
        itemFragment.setArguments(bundle);
        return itemFragment;
    }

    @Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        return inflater.inflate(R.layout.fragment_item, container, false);
    }

    @Override
    public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        TextView textView = (TextView) view.findViewById(R.id.text);
        ImageView imageView = (ImageView) view.findViewById(R.id.image);

        Bundle bundle = getArguments();
        textView.setText(bundle.getString(BUNDLE_KEY_TEXT));
        imageView.setImageResource(bundle.getInt(BUNDLE_KEY_IMAGE));
    }
}
```
テキストと画像のリソースIDもらって表示するだけです。レイアウトもいたってシンプルで以下のようになっています。

```xml:R.layout.fragment_item.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:layout_width="match_parent"
              android:layout_height="match_parent"
              android:orientation="vertical">

    <TextView
        android:id="@+id/text"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="#3F51B5"
        android:textColor="#FFFFFF"
        android:paddingLeft="20dp"
        android:textSize="30sp"/>

    <ImageView
        android:id="@+id/image"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>

</LinearLayout>
```

# 参考
[AndroidAnnotationsでよく利用するアノテーション](http://qiita.com/yamagen0915/items/51800fd6bb448be4e1de)