【Android】Spinnerのアイテム初期選択状態を動的に変えるときの注意

# はじめに
AndroidのSpinnerの選択位置は、初期値0ですが、
これを動的に変えたい（前回選択していた位置を復元したい等）ときにちょっとハマったので備忘録として残しておきます。


# 前提知識
Spinnerは初回起動時に、onItemSelected()イベントが発生します。

初回起動というのは、具体的にSpinnerのインスタンスが生成され、アダプタやリスナーがセットされて画面に表示されるタイミングを指します。

ActivityやFragmentのonCreate()やonViewCreated()などでSpinnerのセットアップを行った直後が該当します。

これにより、アイテム選択が切り替わっていないのに、onItemSelected()内の処理が実行されてしまいます。

```java:MainFragment.java

private Spinner mSpinner;

@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
                         Bundle savedInstanceState) {
 
    mSpinner = (Spinner) view.findViewById(R.id.spinner);
    ArrayAdapter<String> adapter = new ArrayAdapter<String>(getContext(), android.R.layout.simple_spinner_item, arrGroupName);
    adapter.setDropDownViewResource(android.R.layout.simple_spinner_item);
    mSpinner.setAdapter(adapter);
    mSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
        @Override
        public void onItemSelected(AdapterView<?> adapterView, View view, int position, long id) {
            // onItemSelected()は画面表示時にも呼ばれてしまう
            Toast.makeText(requireContext(), "選択された項目: " + selectedItem, Toast.LENGTH_SHORT).show();
            
            // 以降は、アイテム選択が切り替わったときの処理（省略）
        }
 
        @Override
        public void onNothingSelected(AdapterView<?> adapterView) {
 
        }
    });
 
}
```

一般的な対策として、Focusable()を初回起動判定用フラグとして扱うようにすることで回避できます。

```java:MainFragment.java

private Spinner mSpinner;

@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
                         Bundle savedInstanceState) {
 
    mSpinner = (Spinner) view.findViewById(R.id.spinner);
    ArrayAdapter<String> adapter = new ArrayAdapter<String>(getContext(), android.R.layout.simple_spinner_item, arrGroupName);
    adapter.setDropDownViewResource(android.R.layout.simple_spinner_item);
    mSpinner.setAdapter(adapter);
    
    // 【New】初回動作の対応
    mSpinner.setFocusable(false);
    
    mSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
        @Override
        public void onItemSelected(AdapterView<?> adapterView, View view, int position, long id) {
            // 【New】初回の動作
            if (mSpinner.isFocusable() == false) {
                // 初回のonItemSelected()時は、何もしない
                mSpinner.setFocusable(true);
                return;
            }
            // 初回以降の動作
            Toast.makeText(requireContext(), "選択された項目: " + selectedItem, Toast.LENGTH_SHORT).show();
            
            // 以降は、アイテム選択が切り替わったときの処理（省略）
        }
 
        @Override
        public void onNothingSelected(AdapterView<?> adapterView) {
 
        }
    });
 
}
```

# ハマったところ
onItemSelected()の初回起動対策を行ったコードに対して、
**Spinnerが画面表示される際、初期選択状態を動的に変えたい**ときに問題が起こりました。
（今回やりたかったことは、画面表示時に前回選択していた位置を復元したい というものでした）

以下は、試しに書いてみたコードです。

```java:MainFragment.java

private Spinner mSpinner;

@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
                         Bundle savedInstanceState) {
 
    mSpinner = (Spinner) view.findViewById(R.id.spinner);
    ArrayAdapter<String> adapter = new ArrayAdapter<String>(getContext(), android.R.layout.simple_spinner_item, arrGroupName);
    adapter.setDropDownViewResource(android.R.layout.simple_spinner_item);
    mSpinner.setAdapter(adapter);

    // 【New】前回選択していたアイテムの選択位置を取得する処理（非同期で行われる）
    getPreviousSelectedPosition(new PositionCallback() {
       @Override
       public void onPositionRetrieved(int position) {
           if (position > 0) {
               mSpinner.setSelection(position);
           }

           // Spinnerのアイテム選択イベントリスナー設定を行う
           setupSpinnerListener();
       }
    });
 
}

// 【New】Spinnerのアイテム選択イベントリスナー設定
private void setupSpinnerListener() {
     // 初回動作の対応
    mSpinner.setFocusable(false);
    mSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
        @Override
        public void onItemSelected(AdapterView<?> adapterView, View view, int position, long id) {
            // 初回の選択イベントをスキップするための処理
            if (!mSpinner.isFocusable()) {
                mSpinner.setFocusable(true);
                return;
            }

            String selectedItem = (String) adapterView.getItemAtPosition(position);
            Toast.makeText(requireContext(), "選択された項目: " + selectedItem, Toast.LENGTH_SHORT).show();

            // 以降は、アイテム選択が切り替わったときの処理（省略）
        }

        @Override
        public void onNothingSelected(AdapterView<?> adapterView) {
            // 処理なし
        }
    });
}
```

## Spinnerの選択位置が0のときだけ、起動時のonItemSelected()が呼ばれなくなった
上記のコードで動作確認をすると、**Spinnerの選択位置が0のだけ、起動時のonItemSelected()が呼ばれなくなってしまいました**。

これにより、画面表示時に呼ばれるonItemSelected()イベントを回避するためのコードが実行されなくなってしまい、
アイテム選択を切り替えたときに必要な処理が走らなくなってしまいました。

```java:MainFragment.java
     // 初回動作の対応
    mSpinner.setFocusable(false);
    mSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
        @Override
        public void onItemSelected(AdapterView<?> adapterView, View view, int position, long id) {
            // 初回の選択イベントをスキップするための処理
            if (!mSpinner.isFocusable()) {
                /**
                 *【問題点】初回の選択イベントが走らなくなってしまい、
                 * アイテム選択を切り替えた時に、この分岐に入ってしまう。
                 * そのため、アイテム選択が切り替わったときの処理が走ってくれない。
                 */
                mSpinner.setFocusable(true);
                return;
            }

            // 以降は、アイテム選択が切り替わったときの処理（省略）
```

# setOnItemSelectedListener()でイベントを設定するタイミングが変わってしまったのが原因
前回選択していた位置を非同期で取得後にsetOnItemSelectedListener()を呼ぶように変更したのが原因でした。

前提知識にも書いていますが、

Spinnerで初回起動時にonItemSelected()イベントが発生するのは、
具体的にSpinnerのインスタンスが生成され、アダプタやリスナーがセットされて**画面に表示されるタイミング**です。

つまり、onResume()イベント発生後にonItemSelected()イベントが呼ばれる仕組みになっています。

前回選択していた位置の取得は非同期処理で行っているため、
**onResume()イベント以降にsetOnItemSelectedListener()を行うようになってしまいました。**

本来は、onResume()のタイミングでonItemSelected()が呼ばれるわけですが、
今回のケースでは、まだsetOnItemSelectedListener()を行っていないため、
起動時のonItemSelected()が呼ばれなかった というわけです。

## 前回選択していた位置が0以外の場合はonItemSelected()が呼ばれた
ちなみに、前回選択していた位置が0以外の場合はonItemSelected()が呼ばれました。

これは推測ですが、
**初期選択位置である0から、setSelection()で選択位置が変わったことによるものだと思われます。**
※今回のケースでは、初期値である0から変わらないケースだけ、onItemSelected()が呼ばれないため

```java:MainFragment.java
    // 【New】前回選択していたアイテムの選択位置を取得する処理（非同期で行われる）
    getPreviousSelectedPosition(new PositionCallback() {
       @Override
       public void onPositionRetrieved(int position) {
           if (position > 0) {
               // このタイミングで初期値である0から選択位置が変わるため、
               // onItemSelected()が呼ばれたと推測
               mSpinner.setSelection(position);
           }

           // Spinnerのアイテム選択イベントリスナー設定を行う
           setupSpinnerListener();
       }
    });
```


# 対策
初回起動時の対策として行っていたsetFocusable()の処理を、
**前回選択していたアイテムの選択位置が0以外のときだけ行うように修正しました。**
```
mSpinner.setFocusable(false); 
```

前回選択していたアイテムの位置が、
- 初期値から変更なし：起動時のonItemSelected()は呼ばれない
- 初期値から変更あり：起動時のonItemSelected()は呼ばれる

ので、初回起動判定として使用していたFocusable()のフラグは、
選択位置が初期値から変更があるときだけ設定するようにして対策できました。

## 対策したコード
```java:MainFragment.java

// 【New】Spinnerのアイテム選択イベントリスナー設定
private void setupSpinnerListener() {
    // ここから 対策コード
    if(mSpinner.getSelectedItemPosition() != 0) {
        // 初回動作の対応
        mSpinner.setFocusable(false);
    }
    // ここまで 対策コード
    
    mSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
        @Override
        public void onItemSelected(AdapterView<?> adapterView, View view, int position, long id) {
            // 初回の選択イベントをスキップするための処理
            if (!mSpinner.isFocusable()) {
                mSpinner.setFocusable(true);
                return;
            }

            String selectedItem = (String) adapterView.getItemAtPosition(position);
            Toast.makeText(requireContext(), "選択された項目: " + selectedItem, Toast.LENGTH_SHORT).show();

            // 以降は、アイテム選択が切り替わったときの処理（省略）
        }

        @Override
        public void onNothingSelected(AdapterView<?> adapterView) {
            // 処理なし
        }
    });
```

# 補足
ここで紹介した現象をまとめると、
onCreateView()イベントの外でsetOnItemSelectedListener()を実行したことで、onItemSelected()イベントを受け取れなくなったことになるのですが、

SharedPreferencesを用いて前回のアイテム選択位置を取得すれば、わざわざ非同期処理にする必要もなく今回の問題も発生しません。

僕の環境上の制約で、SharedPreferencesが使えなかっただけなので、
大抵の方は、SharedPreferencesやDataStoreを採用するのが良いと思います。

:::note warn

Googleは、SharedPreferencesの代替として[DataStore](https://developer.android.com/topic/libraries/architecture/datastore?hl=ja)の利用を強く推奨しています。

:::

# ソースコード全体

```java:MainFragment.java

private Spinner mSpinner;

@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
                         Bundle savedInstanceState) {
 
    mSpinner = (Spinner) view.findViewById(R.id.spinner);
    ArrayAdapter<String> adapter = new ArrayAdapter<String>(getContext(), android.R.layout.simple_spinner_item, arrGroupName);
    adapter.setDropDownViewResource(android.R.layout.simple_spinner_item);
    mSpinner.setAdapter(adapter);

    // 【New】前回選択していたアイテムの選択位置を取得する処理（非同期で行われる）
    getPreviousSelectedPosition(new PositionCallback() {
       @Override
       public void onPositionRetrieved(int position) {
           if (position > 0) {
               mSpinner.setSelection(position);
           }

           // Spinnerのアイテム選択イベントリスナー設定を行う
           setupSpinnerListener();
       }
    });

// 【New】Spinnerのアイテム選択イベントリスナー設定
private void setupSpinnerListener() {
    // ここから 対策コード
    if(mSpinner.getSelectedItemPosition() != 0) {
        // 初回動作の対応
        mSpinner.setFocusable(false);
    }
    // ここまで 対策コード
    
    mSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
        @Override
        public void onItemSelected(AdapterView<?> adapterView, View view, int position, long id) {
            // 初回の選択イベントをスキップするための処理
            if (!mSpinner.isFocusable()) {
                mSpinner.setFocusable(true);
                return;
            }

            String selectedItem = (String) adapterView.getItemAtPosition(position);
            Toast.makeText(requireContext(), "選択された項目: " + selectedItem, Toast.LENGTH_SHORT).show();

            // 以降は、アイテム選択が切り替わったときの処理（省略）
        }

        @Override
        public void onNothingSelected(AdapterView<?> adapterView) {
            // 処理なし
        }
    });
```