Kotlin, LiveData, coroutine なんかを使って初めてのAndroidアプリを作る(8)ViewPager編

[前回](https://qiita.com/kasa_le/items/e372c9e18b60f4b2f430)の続きです。


# 今回の目標

リストの表示を1ヶ月1ページにします。
そしていわゆる`ViewPager`を使い、左右にスワイプすることで月を移動できるようにします。

※カレンダー風の表示は、また次回に回します。

ViewPagerは、Androidでは使えないとお話にならない機能だと思うので、是非覚えて下さい。


# 1ヶ月1ページにする

## (1) レイアウトの変更

今表示してる「年月」を表示する部分を追加します。
完成イメージはこんな感じです。

![qiita08_01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/00eaa98f-2fce-f1fd-871a-932794b1673a.png)

<details><summary>レイアウトxmlのサンプルはこちら</summary><div>

```activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools">
    <data>
        <variable name="viewmodel"
                  type="jp.les.kasa.sample.mykotlinapp.MainViewModel"/>
    </data>
    <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            tools:context=".MainActivity">

        <TextView
                android:id="@+id/textViewYM"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:elevation="2dp"
                android:padding="8dp"
                android:textColor="@android:color/black"
                android:textSize="18sp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent"
                tools:text="2020年 2月" />

        <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/log_list"
                android:layout_width="match_parent"
                android:layout_height="0dp"
                android:layout_marginTop="8dp"
                android:layout_marginEnd="8dp"
                android:layout_marginBottom="8dp"
                app:items="@{viewmodel.stepCountList}"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintLeft_toLeftOf="parent"
                app:layout_constraintTop_toBottomOf="@id/textViewYM" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
```
</div></details>


ここはさほど難しくないですかね。
`RecyclerView`の`layout_height`を`match_parent`にしているとダメなので、そこだけ要注意です。

## (2) MonthlyPageFragmentの作成

今`MainActivity`は`Fragment`を持たずベタにリスト表示をしていますが、のちのちFragmentで表示していくようになるので、今のうちに一度Fragmentを使うように変えます。
これは勿論後で`ViewPager`を入れるときに同時にやっても良いのですが、前もって月表示Fragmentの動作、表示を確認しておく、という意味で先にやっておきます。

### 1. MonthlyPageFragmentのレイアウトの作成

`fragment_monthly_page.xml`に`activity_main.xml`の内容をそのままコピーします。
そして、`activity_main.xml`を次のように書き変えます。

```activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".activity.main.MainActivity">

    <FrameLayout
            android:id="@+id/main_container"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            android:layout_marginEnd="8dp"
            android:layout_marginBottom="8dp">
    </FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
```

Databindingが不要になったので外してあります。

### 2. MonthlyPageFragmentクラスの作成

その前に、`activity`パッケージ下に`main`を作って、以下を移動させておきます。

- MainActivity
- MainViewModel

ファイルが増えてくるのでパッケージ直下に増えていくのが嫌だったので`activity`にまとめました。
ただ、好みやチームの方針があると思うので、その辺は臨機応変に。

さて、`MonthlyPageFragment`を`main`パッケージ下に *[New]-[Kotlin-File/Class]* で作成したら、次のものを移植してきます。

- `DIALOG_TAG_DELETE_CONFIRM`, `DIALOG_BUNDLE_KEY_DATA`変数(companion object)
- `viewModel`, `adapter`変数
- `onItemClick`関数
- `onLongItemClick`関数
- `onConfirmResult`関数
- `LogRecyclerAdapter`クラス

また、以下の変更が必要です。

- `MainViewModel`のインジェクトは、`by sharedViewModel`で
  - `MainActivity`と共有のViewModelインスタンスにしたいため
- `onCreate`は`onCreateView`に移植
- `DataBindingUtil`によるレイアウトの初期化は、`DataBindingUtil.inflate()`を使う
- `startActivityForResult`を自前で呼ばず、`activity?.startActivityForResult`としてActivityから起動させる
  - `onActivityResult`の処理を`MainAcitivity`側に残しておくため
  - `Fragment`からも`startActivity/startActivityForResult`可能だが、そうするとFragment側の`onActivityResult`をオーバーライドして処理する必要が出る
- `ConfirmDialog.Builder`に`setTarget(this)`をする
  - コールバックリスナーをFragmentで受け取るために必要


ほとんど`MainActivity`にあった処理を持ってくるだけなので、難しいところは無いと思います。
上記の点に注意して、作ってみて下さい。

<details><summary>全体コードのサンプルはこちら</summary><div>

```MonthlyPageFragment.kt
class MonthlyPageFragment : Fragment(),
    LogRecyclerAdapter.OnItemClickListener
    , ConfirmDialog.ConfirmEventListener {
    	
    companion object {
        const val DIALOG_TAG_DELETE_CONFIRM = "delete_confirm"
        const val DIALOG_BUNDLE_KEY_DATA = "data"
    }


    val viewModel by sharedViewModel<MainViewModel>()
    lateinit var adapter: LogRecyclerAdapter

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        val binding: FragmentMonthlyPageBinding = DataBindingUtil.inflate(
            layoutInflater, R.layout.fragment_monthly_page, container, false
        )

        binding.lifecycleOwner = this
        binding.viewmodel = viewModel

        // RecyclerViewの初期化
        binding.logList.layoutManager = LinearLayoutManager(context)
        adapter = LogRecyclerAdapter(this)
        binding.logList.adapter = adapter
        // 区切り線を追加
        val decor = DividerItemDecoration(context, DividerItemDecoration.VERTICAL)
        binding.logList.addItemDecoration(decor)

        return binding.root
    }

    override fun onItemClick(data: StepCountLog) {
        val intent = Intent(context, LogItemActivity::class.java)
        intent.putExtra(LogItemActivity.EXTRA_KEY_DATA, data)
        activity?.startActivityForResult(
            intent,
            MainActivity.REQUEST_CODE_LOGITEM
        )
    }

    override fun onLongItemClick(data: StepCountLog) {
        // ダイアログを表示
        val dialog = ConfirmDialog.Builder()
            .message(R.string.message_delete_confirm)
            .data(Bundle().apply {
                putSerializable(MainActivity.DIALOG_BUNDLE_KEY_DATA, data)
            })
            .target(this)
            .create()
        dialog.show(
            requireFragmentManager(),
            MainActivity.DIALOG_TAG_DELETE_CONFIRM
        )
    }

    override fun onConfirmResult(which: Int, bundle: Bundle?, requestCode: Int) {
        when (which) {
            DialogInterface.BUTTON_POSITIVE -> {
                // 削除を実行
                val stepCountLog =
                    bundle?.getSerializable(MainActivity.DIALOG_BUNDLE_KEY_DATA) as StepCountLog?
                viewModel.deleteStepCount(stepCountLog!!)
            }
        }
    }
}

class LogRecyclerAdapter(private val listener: OnItemClickListener) :
    RecyclerView.Adapter<LogRecyclerAdapter.LogViewHolder>() {

    interface OnItemClickListener {
        fun onItemClick(data: StepCountLog)
        fun onLongItemClick(data: StepCountLog)
    }

    private var list: List<StepCountLog> = emptyList()

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogViewHolder {
        val binding: ItemStepLogBinding = DataBindingUtil.inflate(
            LayoutInflater.from(parent.context),
            R.layout.item_step_log, parent, false
        )
        return LogViewHolder(
            binding
        )
    }

    fun setList(newList: List<StepCountLog>) {
        list = newList
        notifyDataSetChanged()
    }

    override fun getItemCount() = list.size

    override fun onBindViewHolder(holder: LogViewHolder, position: Int) {
        if (position >= list.size) return
        val data = list[position]
        holder.binding.stepLog = data
        holder.binding.logItemLayout.setOnClickListener {
            listener.onItemClick(data)
        }
        holder.binding.logItemLayout.setOnLongClickListener {
            listener.onLongItemClick(data)
            return@setOnLongClickListener true
        }
    }

    class LogViewHolder(val binding: ItemStepLogBinding) : RecyclerView.ViewHolder(binding.root)
}
```

</div></details>



### 3. MainActivityの変更

`MainActivity`が`MonthlyPageFragment`を使うようにします。
`MonthlyPageFragment`に移したメソッドを削除して、Databinding用のコードも削除します。

`onCreate`は次のようになります。それと、使わなくなった定数も削除して良いかと思います。

```MainActivity.kt
class MainActivity : AppCompatActivity() {

    companion object {
        const val REQUEST_CODE_LOGITEM = 100
        const val REQUEST_CODE_SHARE_TWITTER = 101

        const val RESULT_CODE_DELETE = 10
    }

    val viewModel by viewModel<MainViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContentView(R.layout.activity_main)

        supportFragmentManager.beginTransaction().replace(
            R.id.main_container, MonthlyPageFragment()
        ).commit()
    }
  }
```

`onCreate`がスッキリしましたね。
オプションメニュー関係と、`onActivityResult`関係の処理だけが残っているはずです。

実行してみて下さい。
追加や、データの編集、削除など、問題なく行えるでしょうか？
自分で動かしてみる手動テストより、せっかくテストを作ってきたのですから、テストを実行して確認しましょう。

### 4. 年月表示の受け渡し

`MainViewModel`に表示する**年月**情報を渡して、表示に反映させましょう。

まず、`MainViewModel`に対応するLiveDataと、それを外からセットする関数を用意します。

```MainViewModel.kt
    // 表示する年月
    private val _dataYearMonth = MutableLiveData<String>()
    val dataYearMonth :LiveData<String> = _dataYearMonth
    
    fun setYearMonth(yearMonth: String){
        _dataYearMonth.postValue(yearMonth)
    }
```

`setYearMonth`は、今はUIスレッドからしか呼ばれませんが、のちのちワーカースレッドから呼ばれる可能性もあるので、`postValue`を使っておくようにしています。

次はDatabindingの設定です。

```fragment_monthly_page.xml
        <TextView
                android:id="@+id/textViewYM"
                ...(略)
                app:yearMonth="@{viewmodel.dataYearMonth}"
```

`app:yearMonth`は例によって`BindingAdapters.kt`に次のように用意しました。

```BindingAdapters.kt
@BindingAdapter("yearMonth")
fun setDataYearMonth(view: TextView, yearMonth: String?) {
    if(yearMonth==null) return
    val date = yearMonth.split('/')
    val str = view.context.getString(R.string.year_month_label, date[0], Integer.valueOf(date[1]))
    view.text = str
}
```

`MainViewModel`にセットする年月は、データベースの検索に使うことも考慮して、`yyyy/MM`の形とする仕様にしました。そのままでも良いのですが、好みで日本語表記の「yyyy年 M月」に変換して表示するようにしています。

`R.string.year_month_label`は次の通り。

```values.xml
<string name="year_month_label">%1$s年 %2$d月</string>
```

この二つでやっていることは、`"yyyy/MM"`で渡ってきた文字列を`/`で`"yyyy"`と`"MM"`に分けて、`"yyyy"`はそのまま文字列としてリソース文字列の引数1に入れ、`"MM"`は一度`Integer`にしてリソース文字列の引数2に入れています。一度`Integer`にしているのは、`"02"`などの場合の`0`を削除したいからです。

`Formatter`を使っても良いのかも知れませんが、単純なのでこのようにしました。

最後に、`MonthlyPageFragment`の初期化時に年月をセットしてやります。

```MonthlyPageFragment.kt
    override fun onCreateView(...){
    	...
    	
        viewModel.setYearMonth("2020/02")

        return binding.root
    }
```

実行してみましょう。
年月をコード上で変えてみると、表示もちゃんと変わるはずです。


## (3) データを1ヶ月分取得して表示する

さて、今は全データ表示していますので、これを「指定月」のものだけ表示するようにしていきます。

### 1. LogDaoの修正

まずは指定の範囲でデータを抽出できるよう、`LogDao`に関数を追加する必要があります。

アプローチはいくつかあるとは思いますが、ここでは、**from年月**と**to年月**を渡すことで、*from以上to未満*の日付であるデータを抽出するクエリーにしてみましょう。

例えば、`2020/01`のデータは、`from="2020/01/01"`、`to="2020/02/01"`とすることで、1ヶ月分が抽出できますね。

```LogDao.kt
    @Query("SELECT * from log_table WHERE date>= :from AND date < :to ORDER BY date DESC")
    fun getRangeLog(from: String, to: String): LiveData<List<StepCountLog>>
```

返す値は、リストに変更があれば反映されて欲しいので、`LiveData`にしておきます。


### 2. LogRepositoryの修正

`LogRepository`は上記の`getRangeLog`のラッパーを作るだけです。

```LogRepository.kt
    @WorkerThread
    fun searchRange(from: String, to: String): LiveData<List<StepCountLog>>{
        return logDao.getRangeLog(from, to)
    }
```

`suspend`関数にしていないのは理由があるのですが、いったん置いておきます。
`LogRepository`のテストに追加しましょう。


```LogRepositoryTest.kt
    @Test
    fun searchRange(){
        runBlocking {
            repository.insert(StepCountLog("2019/07/31", 12345))
            repository.insert(StepCountLog("2019/08/01", 12345))
            repository.insert(StepCountLog("2019/08/30", 12345))
            repository.insert(StepCountLog("2019/08/31", 12345, LEVEL.GOOD, WEATHER.CLOUD))
            repository.insert(StepCountLog("2019/09/01", 123, LEVEL.BAD, WEATHER.RAIN))
            repository.insert(StepCountLog("2019/12/31", 1111, LEVEL.BAD, WEATHER.RAIN))
            repository.insert(StepCountLog("2019/01/01", 1111)) // 古いデータ
            repository.insert(StepCountLog("2020/01/01", 11115))
            repository.insert(StepCountLog("2020/02/29", 29))
            repository.insert(StepCountLog("2020/02/28", 28))
            repository.insert(StepCountLog("2020/03/01", 31))
        }

        val data6 = repository.searchRange("2019/06/01", "2019/07/01")
        data6.observeForever {
            assertThat(it).isEmpty()
        }

        val data8 = repository.searchRange("2019/08/01", "2019/09/01")
        data8.observeForever {
            assertThat(it).isNotEmpty()
            assertThat(it!!.size).isEqualTo(3)
            assertThat(it[2]).isEqualToComparingFieldByField(
                StepCountLog("2019/08/01", 12345)
            )
            assertThat(it[1]).isEqualToComparingFieldByField(
                StepCountLog("2019/08/30", 12345)
            )
            assertThat(it[0]).isEqualToComparingFieldByField(
                StepCountLog("2019/08/31", 12345, LEVEL.GOOD, WEATHER.CLOUD)
            )
        }

        // 月またぎ、年またぎ
        val data12 = repository.searchRange("2019/12/01", "2020/02/01")
        data12.observeForever {
            assertThat(it).isNotEmpty()
            assertThat(it!!.size).isEqualTo(2)
            assertThat(it[1]).isEqualToComparingFieldByField(
                StepCountLog("2019/12/31", 1111, LEVEL.BAD, WEATHER.RAIN)
            )
            assertThat(it[0]).isEqualToComparingFieldByField(
                StepCountLog("2020/01/01", 11115)
            )
        }

        // 閏月
        val data2 = repository.searchRange("2020/02/01", "2020/03/01")
        data2.observeForever {
            assertThat(it).isNotEmpty()
            assertThat(it!!.size).isEqualTo(2)
            assertThat(it[1]).isEqualToComparingFieldByField(
                StepCountLog("2020/02/28", 28)
            )
            assertThat(it[0]).isEqualToComparingFieldByField(
                StepCountLog("2020/02/29", 29)
            )
        }
    }
```

月初と月末の日付をデータに入れて、閾値(しきいち)テストをちゃんとします。
関数の仕様としては何ヶ月分でもOKなので、2ヶ月分、年またぎで取れるかのテストもしています。
なんだったら今年はちょうど閏年だし、閏月のテストもしておきます。

テストを実行し、通過するのを確認しましょう。

続いて、変数`allLogs`の定義は不要になったので削除します。ただ、テストでは全件検索がまだ欲しいので、関数化します。

```LogRepository.kt
//    メンバー変数は削除
//    val allLogs: LiveData<List<StepCountLog>> = logDao.getAllLogs()

    @WorkerThread
    fun allLogs():List<StepCountLog>{
        return logDao.getAllLogs()
    }
```

今のところテストで使うだけなので、`LiveData`を返すのもやめました。
これに伴い、`allLogs`を使っていた箇所を修正します。

`allLogs`はメンバー変数では無く、関数としたので、それに合わせて書き変えます。

```LogRepositoryTest.kt
        val items = repository.allLogs()
```

また、戻り値は`LiveData`でもなくなったので、`observe`する必要も無くなっています。
これらを踏まえて、書き直しましょう。
単純な変更なので、答え(?)は、ブランチにアップしてあるソースコードで確認して下さい。

これで`LogRepositoryTest`が通過するようになれば準備は終わりです。

### 3. MainViewModelに機能を追加

まず、`MainViewModel`も`allLogs`を参照しているので書き変えないとビルドが通りませんね。
`stepCountList`の定義を次のように変更したいところです。

```MainViewModel.kt
val stepCountList = repository.searchRange(from, to)
```

んー、でも、`from`と`to`って、直ぐには決まらないですよね。外から貰わないと・・・
ただ、`_dataYearMonth`が決まれば、自動的に決まりますね？

ということは、`_dataYearMonth`がセットされるときに同時に呼んでやれば良いでしょうかね？

```MainViewModel.kt
    fun setYearMonth(yearMonth: String) {
        _dataYearMonth.postValue(yearMonth)
        stepCountList = repository.searchRange(from, to)
    }
```

あれ、でも、`stepCountList`は`val`だから再代入できないと怒られてしまいます。
じゃあ、`var`にしなくちゃダメか？

実は、**あるLiveDataに変更があったときに、その値を元に別のLiveDataを更新する**ということが出来ます。
`MediatorLiveData`と言われるものです。
詳細は[公式サイト](https://developer.android.com/reference/android/arch/lifecycle/MediatorLiveData)に譲るとして、ここでは更に便利な、`Transformations`というのを使います。
これは、1個のSourceだけを参照する`MediatorLiveData`と考えれば良いかと思います。(実際に`Transformations`の[ソースコード](https://android.googlesource.com/platform/frameworks/support/+/refs/heads/androidx-master-dev/lifecycle/lifecycle-livedata/src/main/java/androidx/lifecycle/Transformations.java?source=post_page---------------------------%2F%2F%2F&autodive=0%2F%2F%2F%2F%2F%2F%2F)を見るとまさしくそのような実装になっているのが分かります)

`stepCountList`の定義はこうなります。

```MainViewModel.kt
    // データリスト
    val stepCountList: LiveData<List<StepCountLog>> =
        Transformations.switchMap(_dataYearMonth) {
            val ymd = getFromToYMD(it)
            repository.searchRange(ymd.first, ymd.second)
        }
```

`Transformations.switchMap`は、`LiveData`を返すときに使います。`Transformations.map`という関数もあり、こちらは**値**を返す場合に使います。

上のコードは、`_dataYearMonth`に変更があったら、from日付とto日付を求めて、`repository.searchRange`を呼び出し、その戻りの`LiveData`を`stepCountList`が参照している、ということになります。

先ほど、`searchRange`をsuspend関数にしなかった理由を後回しにしましたが、実は、ここでこのように使いたかったからです。suspend関数にしていると、Transformationsの中で呼べないんですね。厳密には、拡張ライブラリ(ktx)を使えば出来るようなんですが今回はバージョンの問題で使わないので。

なお、愚直に`MediatorLiveData`でやるとすると、サンプルのようなコードになるかと思います。

<details><summary>サンプルコードはこちら</summary><div>

```kotlin
    val stepCountList: LiveData<List<StepCountLog>> by lazy {
        val liveData = MediatorLiveData<List<StepCountLog>>()
        liveData.addSource(_dataYearMonth){
            val ymd = getFromToYMD(it)
            repository.searchRange(ymd.first, ymd.second)
        }
        return@lazy liveData
    }
```

</div></details>


`getFromToYMD`は次のように定義しました。

```MainViewModel.kt
    fun getFromToYMD(yyyyMM: String): Pair<String, String> {
        val formatter = SimpleDateFormat("yyyy/MM", Locale.JAPAN)
        val from = Calendar.getInstance()
        from.time = formatter.parse(yyyyMM)
        from.set(Calendar.DATE, 1)
        from.clearTime()
        val to = from.clone() as Calendar
        to.add(Calendar.MONTH, 1)

        return Pair(from.getDateStringYMD(), to.getDateStringYMD())
    }
```

クエリー用のfromとto日付を取得する関数です。
`yyyy/MM`でフォーマットされている日付文字列を受け取ると、その月の1日と、その翌月1日の日付文字列を、`yyyy/MM/dd`のフォーマットで返す、というものです。

`Pair`というのが出てきました。以前ちらっと触れたのを覚えているでしょうか？
[第6回](https://qiita.com/kasa_le/items/a20763135ce61b15c270)の記事でした。

これこそ、型を定義するまでもないので、使ってみました。要素が2個しかないので配列やリストにするまでもないし。

まずはこの`getFromToYMD`関数のテストを作って確認しましょう。

```MainViewModelTest.kt
    @Test
    fun getFromToYMD(){
        val pair = viewModel.getFromToYMD("2020/01")
        assertThat(pair.first).isEqualTo("2020/01/01")
        assertThat(pair.second).isEqualTo("2020/02/01")

        val pair2 = viewModel.getFromToYMD("2020/12")
        assertThat(pair2.first).isEqualTo("2020/12/01")
        assertThat(pair2.second).isEqualTo("2021/01/01")
    }
```

こちらも閾値確認として、年またぎデータも確認しています。

なお、`MainViewModelTest`の`addStepCount`と`deleteStepCount`のテストも、次の行の追加が必要です。

```MainViewModelTest.kt
        viewModel.stepCountList.observeForever(listObserver)
        viewModel.setYearMonth("2019/06") // <- 追加
```

(どうでもいいけどこのテストを最初に書いたのがもう半年以上前だと知って愕然としています)

理由は分かりますか？

`MainViewModel#stepCountList`は、`Transformations.switchMap()`で値が代入されるので、`_dataYearMonth`がセットされるまでは値が入らないんです。だから単に`addStepCount`をしただけでは`stepCountList`が更新されず、`LiveData#onChanged`が飛んでこないため、`listObserver.await()`で必ずタイムアウトしてしまうんですね。

うっかり私も嵌まっていましたが、原因が分かれば至極当然な理由でした(汗)

`androidTest`の方にある、`ViewModelTestI.kt`も同様に修正して、通るようになればOKなんですが、`init`テストだけは、下記のコードを修正する必要があります。

```ViewModelTestI.kt
        viewModel.stepCountList.observeForTesting {
            assertThat(viewModel.stepCountList.value)
                .isNull()  // isEmpty()から変更
        }
```

理由は先ほどと同じで、初期化段階では`stepCountList.value`は`null`だからですね・・・

ちなみに、このチェック、Robolectric版の`ViewModelTest`では書いてなかったですが(以前はスレッド問題でコルーチンのテストが出来なかったから)、同じように書くことが出来るようになっているので、今後のメンテの負担も考えて、`androidTest`版の方はもう削除しても良いかも知れませんね。

とりあえずアプリの動作確認をしてみましょう。
先月分、今月分と、任意の日付でデータを登録してみてください。
リストには「今月分」だけ表示されているでしょうか？

過去のデータを見る手段が無くなってしまいましたが、ViewPagerに対応するまでの辛抱です(笑)

## (4) 年月表示のUIテスト

忘れていたわけではありませんよ(汗)

まずは、単純に日付表示ラベルが正しいかのテストです。

```MainActivityTestI.kt

    @Test
    fun showDateLabel(){
        val mainActivity = activityRule.activity

        mainActivity.runOnUiThread {
            mainActivity.viewModel.setYearMonth("2020/02")
        }
        getInstrumentation().waitForIdleSync()

        onView(withId(R.id.textViewYM)).check(matches(isDisplayed()))
            .check(matches(withText("2020年 2月")))
    }
}
```

特に難しいところは無いですね。
Robolectric版も同じように行けます。


## (5)その他のUIテストの修正

あとは、他のテストで`setYearMonth`で日付をセットしておく必要があります。
単純な修正なのでコードは載せませんが、答え(?)はブランチにアップしてあるソースコードで確認して下さい。

それと、せっかくなので指定月以外は表示されていないテストも追加しましょう。

```MainActivityTestI.kt
    @Test
    fun showList() {
        // ViewModelのリストに直接追加
        val mainActivity = activityRule.activity

        mainActivity.runOnUiThread {
            mainActivity.viewModel.addStepCount(StepCountLog("2019/06/13", 12345, LEVEL.GOOD))
            mainActivity.viewModel.addStepCount(StepCountLog("2019/06/19", 666, LEVEL.BAD, WEATHER.RAIN))
            mainActivity.viewModel.addStepCount(StepCountLog("2019/05/31", 333, LEVEL.NORMAL, WEATHER.HOT)) // 追加
            mainActivity.viewModel.setYearMonth("2019/06")
        }
        getInstrumentation().waitForIdleSync()
        
        onView(withId(R.id.log_list)).check(matches(hasItemCount(2)))
        ....
   }
```

`showList`のテストで、2019/6以外のデータを追加して、リストには2件の2019/6データしか無いことを確認しています。
`hasItemCount`は、`EspressoUtil.kt`に次のように定義しました。

```EspressoUtil.kt
object RecyclerViewMatchers {
    fun hasItemCount(itemCount: Int): Matcher<View> {
        return object : BoundedMatcher<View, RecyclerView>(
            RecyclerView::class.java) {

            override fun describeTo(description: Description) {
                description.appendText("has $itemCount items")
            }

            override fun matchesSafely(view: RecyclerView): Boolean {
                return view.adapter!!.itemCount == itemCount
            }
        }
    }
}
```

これで、月単位の表示をするFragmentの準備が出来ました。

# ViewPager

さて、このままでは、いわゆる過去ログが見られません。
過去に遡って見られるように、左(から右)にスワイプしたらどんどん月を遡っていけるようにしましょう。
これは、実は一般的な`ViewPager`の使い方とは逆の進行方向になります。
通常は左端がデフォルトで、右(から左)にスワイプして進めていくのが基本的な仕様なのですが、カレンダーという性質上、逆にするしかありませんね。

## (1) レイアウトの変更

### 1. 依存関係の追加

最初に、ViewPagerのライブラリを追加します。
`ViewPager2`というのがJetpackになって追加されたので、こちらを使ってみようと思います。
旧来の`ViewPager`をご存じで、何が変わったか知りたい人は[公式ページ](https://developer.android.com/reference/androidx/viewpager2/widget/ViewPager2)などを参考にして下さい。

```app/build.gradle
dependencies{
   ...
    // ViewPager2
    implementation 'androidx.viewpager2:viewpager2:1.0.0'
}
```

### 2. MainActivityのレイアウトの変更

`FrameLayout`を削除し、代わりに`ViewPager2`を入れます。

```activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".activity.main.MainActivity">

    <androidx.viewpager2.widget.ViewPager2
            android:id="@+id/viewPager"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            android:layout_marginEnd="8dp"
            android:layout_marginBottom="8dp"
            android:orientation="horizontal"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
```

### 3. MainActivityの初期化の変更

`onCreate`でFragmentをセットしているコードはいったん削除します。

```MainActivity.kt
//        supportFragmentManager.beginTransaction().replace(
//            R.id.main_container, MonthlyPageFragment()
//        ).commit()
```

## (2) Adapterクラス

続いて、ViewPagerにAdapterをセットしていきます。
`ViewPager2`では、`RecyclerAdapter`を使うこともできるんですが、`Fragment`を使いたいので、`FragmentStateAdapter`を使います。

```MainActivity.kt
class MonthlyPagerAdapter(
    fragmentActivity: FragmentActivity,
    private val items: List<String>
) : FragmentStateAdapter(fragmentActivity) {

    override fun getItemCount(): Int = items.size
    override fun createFragment(position: Int) = MonthlyPageFragment.newInstance(items[position])
}
```

`MonthlyPagerAdapter`はリストを受け取り、その数だけページを表示します。
基底クラスの`FragmentStateAdapter`は`FragmentActivity`を必要とするのでコンストラクタで渡します。

ページ数はいったん固定にしておきます。

```MainActivity.kt
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContentView(R.layout.activity_main)

        val list = listOf("2019/10", "2019/11", "2019/12", "2020/01", "2020/02")

        viewPager.adapter = MonthlyPagerAdapter(this, list)
        viewPager.setCurrentItem(list.size - 1, false)
    }
```

listは今は仮です。後でDBから取るようにします。取り敢えずViewPagerの動作確認をしましょう。

`viewPager.setCurrentItem(list.size - 1, false)`は、

- ViewPagerの`list.size - 1`=最終ページをカレントに指定する
- アニメーションをしない

という設定です。

ViewPagerは通常は左端がデフォルトで、(右から)左にスワイプして進めていくのが基本的な仕様だと書きましたが、アプリの特性上、「一番新しい月」が表示されて欲しいし、過去に遡る操作は(左から)右にスワイプしていくのが自然です。
なので、最初に表示するページを最終ページにする設定をしているのです。この時、アニメーションするのが見えてしまうと変なので、二つ目の引数に`false`を渡してアニメーションしないように指定しています。

アニメーションしても良いようにカレントアイテムを変更するときは、Kotlinではプロパティアクセスで`viewPager.currentItem = list.size - 1`とすることが出来ます。

※`ViewPager2`は`Right-to-Left(RTL)`レイアウトをサポートしたそうなので、レイアウトxmlに`android:layoutDirection="rtl"`とすれば、`setCurrentItem`で最後のページをカレントにする設定をしなくても良さそうに思ったのですが、試しに使ってみたところ、中のレイアウトまで全部RTLになってしまうようで、今回の用途には使えませんでした。

## (3) ViewModelの修正

これで起動して動作してみましょう。
スワイプしてページが切り替わるでしょうか？
2019/10から5ヶ月分のデータを登録してみましょう。

・・・なんか動作が変ではないでしょうか？
同じ表示が何ヶ月も続いてしまったりしませんか？

これは`MainViewModel`が共有されてしまっているからですね。

```MonthlyPageFragment.kt
val viewModel by sharedViewModel<MainViewModel>()
```

ここを`val viewModel by viewModel<MainViewModel>()`とすれば、FragmentごとにViewModelのインスタンスが作成されるはずなので、共有されなくなるはず。

しかし、それだと、`MainViewModel`に、`MainActivity`で使うものと、`MonthlyPageFragment`で使うものとがマージされたような状態になってしまっています。

これを改善するため、`MonthlyPageViewModel`を作って、お互いに不要なものは削除してしまいましょう。

### 1. MainViewModelの変更

```MainViewModel.kt
class MainViewModel(
    app: Application,
    val repository: LogRepository
) : AndroidViewModel(app) {

    fun addStepCount(stepLog: StepCountLog) = viewModelScope.launch(Dispatchers.IO) {
        repository.insert(stepLog)
    }

    fun deleteStepCount(stepLog: StepCountLog) = viewModelScope.launch(Dispatchers.IO) {
        repository.delete(stepLog)
    }
}
```

`MainViewModel`は、ログの追加と削除関数だけになりました。

### 2. MonthlyPageViewModelの追加

一方、`MonthlyPageViewModel`は、こうなります

```MonthlyPageViewModel.kt
class MonthlyPageViewModel(
    app: Application,
    val repository: LogRepository
) : AndroidViewModel(app) {

    // 表示する年月
    private val _dataYearMonth = MutableLiveData<String>()
    val dataYearMonth: LiveData<String> = _dataYearMonth

    // データリスト
    val stepCountList: LiveData<List<StepCountLog>> =
        Transformations.switchMap(_dataYearMonth) {
            val ymd = getFromToYMD(it)
            repository.searchRange(ymd.first, ymd.second)
        }

    fun setYearMonth(yearMonth: String) {
        _dataYearMonth.postValue(yearMonth)
    }

    fun deleteStepCount(stepLog: StepCountLog) = viewModelScope.launch(Dispatchers.IO) {
        repository.delete(stepLog)
    }

    /**
     * クエリー用のfromとto日付を取得する
     * @param yyyyMM `yyyy/MM`の形の日付
     * @return <yyyy/MM/01, yyyy/(MM+1)/01>のPair
     */
    fun getFromToYMD(yyyyMM: String): Pair<String, String> {
        val formatter = SimpleDateFormat("yyyy/MM", Locale.JAPAN)
        val from = Calendar.getInstance()
        from.time = formatter.parse(yyyyMM)
        from.set(Calendar.DATE, 1)
        from.clearTime()
        val to = from.clone() as Calendar
        to.add(Calendar.MONTH, 1)

        val formatter2 = SimpleDateFormat("yyyy/MM/dd", Locale.JAPAN)
        val fromStr = formatter2.format(from.time)
        val toStr = formatter2.format(to.time)
        return Pair(fromStr, toStr)
    }
}
```

ほとんどが`MainViewModel`からの移動です。削除関数だけは、こちらも持つことになります。

### 3. Koinモジュールの修正

さて、ViewModelを追加したので、Koinのモジュール群も修正する必要があります。

```modules.kt
val viewModelModule = module {
    viewModel { MainViewModel(androidApplication(), get()) }
    viewModel { MonthlyPageViewModel(androidApplication(), get()) }
    ...
}
```

### 4. MonthlyPageFragmentの修正

`MonthlyPageFragment`でViewModelをインジェクトするコードも変更します。

```MonthlyPageFragment.kt
    val viewModel by viewModel<MonthlyPageViewModel>()
```

### 4. レイアウトファイルの修正

忘れてはならないのが、Databindingをしているレイアウトファイルの修正です。バインドするデータの型を、`MainViewModel`から`MonthlyPageViewModel`に変更するのを忘れてはなりません。

```fragment_monthly_page.xml
    <data>

        <variable
                name="viewmodel"
                type="jp.les.kasa.sample.mykotlinapp.activity.main.MonthlyPageViewModel" />
    </data>
```

これで実行してみて下さい。
ちゃんと月ごとにページの表示が変わるはずです！

追加や削除も試してみましょう。

なお、「`MainActivity`と`MonthlyPageFragment`の両方で削除が出来るのが気持ち悪い」「`LogItemActivity`の起動は`MonthlyPageFragment`から行い、起動後の戻り処理も`MonthlyPageFragment`でやるべき」など、いろいろ設計思想はあると思います。

その場合、`MonthlyPageFragment`で`activity?.startActivityForResult()`と`Activity`を経由して起動しているのをやめて、`Fragment`自身の`startActivityForResult()`を呼び出すようにして、`MainActivity`内にある`onActivityResult`をごっそり`MonthlyPageFragment`に移動すれば良いかと思います。そうすると、`MainActivity`はかなりスッキリしますね。

## (4) ページ数を取得する

さて、最後のステップです。
今はページリストを固定で渡してしまっているので、ちゃんとデータから引っ張ってくるようにしましょう。

ところで、`ViewPager`のページは何ページ用意すれば良いでしょうか？
無限に遡れる、無限に未来が見られる、としても良いのですが、ここでは、~~面倒なので~~ 有限ページ数とするため、以下の仕様としようと思います。

- 最も古いデータの年月まで遡れる
- 来月以降は表示出来ない(未来のデータは登録できないからそもそも不要)

この辺りは、「ログも取るけど、予定も書き込めるようにしたいんだ！」などという要望、仕様によって決めていく必要があるでしょう。
今回は「ログアプリ」という点に特化し、未来のデータは登録が出来ない仕様なのだからそもそもページを作らないこととします。

ということで、「最も古いデータ」から「当月」までページを作れば良さそうです。

どうアプローチするのが良いのか悩ましいですが、以下のような手順でどうでしょう。

- 一番古い日付のデータを取得する
- 現在日付を取得する
- その間の年月を展開してリストにする

### 1. 一番古い日付のデータを取得する

ここはDao, Repositoryクラスの範疇ですね。

```LogDatabase.kt
@Dao
interface LogDao {
    ....
    
    @Query("SELECT date from log_table ORDER BY date limit 1")
    fun getOldestDate(): LiveData<String>
}
```
こんなクエリーでどうでしょうか。

取得するのは`date`カラムのみ。その際、`date`カラムを昇順でソートした上で(`ORDER BY`に`DESC`を指定すると降順、昇順は`ASC`ですが、デフォルトで`ASC`なので省略しています)、`limit=1`すなわち1件だけ取得することで、一番古い日付を取得しています。

返すのは`LiveData`です。古いデータが登録されたら、ページを追加してやらないといけないので。

`LogRepository`は上記をラップした関数を追加します。

```LogRepository.kt
    @WorkerThread
    fun getOldestDate(): LiveData<String>{
        return logDao.getOldestDate()
    }
```

こういうときは単体テストでサクッと確認しましょう。

```LogRepositoryTest.kt
    @Test
    fun getOldestDate(){
        runBlocking {
            repository.insert(StepCountLog("2019/08/30", 12345))
            repository.insert(StepCountLog("2019/09/01", 12345))
            repository.insert(StepCountLog("2019/09/22", 12345))
            repository.insert(StepCountLog("2019/10/10", 12345))
            repository.insert(StepCountLog("2019/10/13", 12345))
            repository.insert(StepCountLog("2019/01/13", 12345))
            repository.insert(StepCountLog("2020/02/03", 12345))
            repository.insert(StepCountLog("2019/02/03", 12345))
            repository.insert(StepCountLog("2020/02/04", 12345))
        }

        val date = repository.getOldestDate()
        date.observeForever{
            assertThat(it).isNotEmpty()
            assertThat(it).isEqualTo("2019/01/13")
        }
    }
```

わざとデータの登録順をめちゃくちゃにしたうえで、一番古い日付が取れているかのテストになっています。

### 2. テストサイズの指定

さて、先ほど作ったテストを実行しようとすると・・・
`MainViewModel`の仕様が変わったので、`MainViewModelTest`や`MainAcvitiyTest`のコンパイルエラーのためビルドが通りません。
一時的にコメントアウトするなどしておくのが良いでしょう。これらのテストについては、後段でやります。

### 3. ページリストを作成する

ページリストとは、このアプリの場合、表示する年月の範囲を展開した`yyyy/MM`の文字列のリストです。
これは`MainViewModel`で持ちます。

まずは、Databaseから取ってこられる`一番古い日付`を監視します。

```MainViewModel.kt
    // 一番古いデータの年月
    private val oldestDate = repository.getOldestDate()
```

で、この日付と、「当月」(アプリを実行している今日の日付)の間の年月をリスト化しようと思います。
先ほども使った、`Transforamtions.swicthMap`の出番ですね！

```MainViewModel.kt
    // ページ
    val pages = Transformations.switchMap(oldestDate) {
        val liveData = MutableLiveData<List<String>>()
        val today = Calendar.getInstance().clearTime()
        liveData.value = makePageList(it, today)
        return@switchMap liveData
    }
    
    fun makePageList(from: String?, to: Calendar): List<String> {
        val formatter = SimpleDateFormat("yyy/MM/dd", Locale.JAPAN)

        to.set(Calendar.DATE, 1)
        to.clearTime()

        if (from == null) {
            return listOf(to.getDateStringYM())
        }

        val date = Calendar.getInstance()
        date.time = formatter.parse(from)
        date.clearTime()
        date.set(Calendar.DATE, 1)

        val list = mutableListOf<String>()

        // 今の年月を超えるまで月を足し続ける
        while (!date.after(to)) {
            list.add(date.getDateStringYM())
            date.add(Calendar.MONTH, 1)
        }
        return list
    }
```

`makePageList`は、こんなステップです。

- **`to`のカレンダー**の時間をクリアして、さらに**日**を1日セット
- `from`から`Calendar`インスタンスを作って、時間をクリア、**日**を1日にセット
- **今日**を超えるまで、**月**を1ヶ月ずつ足しながら、`yyyy/MM`のフォーマットで日付を取得してリストに追加

`getDateStringYM`は例によって拡張関数です。

```Util.kt
fun Calendar.getDateStringYM(): String {
    val fmt = SimpleDateFormat("yyyy/MM", Locale.JAPAN)
    return fmt.format(time)
}
```

```UtilTest.kt
    @Test
    fun calendar_getDateStringYM() {
        val cal = Calendar.getInstance()
        cal.set(2020, 9 - 1, 11) // 月だけはindex扱いなので、実際の月-1のセットとしなければならない
        assertThat(cal.getDateStringYM()).isEqualTo("2020/09")
    }
```

これで、一番古い日付から、当月までの年月文字列のリストが出来ました。
これを、`ViewPager2`の`Adapter`にセットしてやれば良いですね。
当然、セットするタイミングは、`pages`を監視しておいて、変更があったときです。

```MainActivity.kt
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        viewModel.pages.observe(this, Observer { list ->
            list?.let {
                viewPager.adapter = MonthlyPagerAdapter(this, it)
                viewPager.setCurrentItem(it.size - 1, false)
            }
        })
    }
```

これで、一番古いデータがある月から、今月までのページが見られるようになりました！

なお、`activity_main.xml`をDatabinding化して出来ないかも試したのですが、Fragmentの再作成がおかしなことになったので、使わない方を採用しています。

※恐らく、Adapter内でのFragmentのキャッシュ利用が影響していると思います。ViewPagerは、`offscreenPageLimit`でキャッシュするページを変更できますが、どうしても**0**にはセットできないのです。ViewPager2になってもこの仕様は変わらないようです。

※`DiffUtil`を上手く使えばDatabinding版でも上手くいくのかも知れません。


# テスト

最後に、テストを修正します。

ViewModel周りが大きく変わりましたね。
Activityもテストが大きく変わります。

## (1) リファクタリング

まず、`MainActivity`と`MainViewModel`をパッケージ移動したので、テストの方も移動しましょう。
Robolectric版の`test`下と、InstrumentationTest版の`androidTest`下を両方やるのをお忘れなく。

なお、私は今回から、ViewModelのテストは`Robolectric`オンリーで実装していくことにします。
なので、`androidTest`下にあった`MainViewModelTestI.kt`は削除です。

## (2) MainViewModelのテスト

このクラスのテストは、`init`、`add/deleteStepCoount`と`makePageList`の関数のテストだけになるかと思います。
詳細は割愛します。
参考ソースはブランチにアップしてあるソースコードでご確認ください。

## (3) MonthlyPageViewModelのテスト

`MonthlyPageViewModelTest`クラスを作成します。
このクラスのテストは、以前`MainViewModelTest`にあったものをそのまま持ってこられますね。
そして、`setYearMonth`関数のテストを追加するだけかと思います。

```MonthlyPageViewModel.kt
    @Test
    fun setYearMonth() {
        val dateObserver = TestObserver<String>()
        viewModel.dataYearMonth.observeForever(dateObserver)
        viewModel.setYearMonth("2019/06")

        dateObserver.await()

        assertThat(viewModel.dataYearMonth.value).isEqualTo("2019/06")
    }
```

LiveDataにpostされた値が一致するかの単純なテストです。
他はほとんどコピペというか移動するだけなので、参考ソースはブランチにアップしてあるソースコードでご確認ください。

## (4) MainAcvitiyのテスト

ここからがめちゃくちゃ時間かかった部分です。結論を出すまでに延べ24時間くらい試行錯誤しました(泣)

### 1. Robolectric版(断念)

とりあえず`addRecordMenu`と`addRecordMenuIcon`以外はコメントアウトした状態でいったん流そうとしたら、エラーが(泣)

```
java.lang.Exception: Main looper has queued unexecuted runnables. This might be the cause of the test failure. You might need a shadowOf(getMainLooper()).idle() call.

	at org.robolectric.android.internal.AndroidTestEnvironment.checkStateAfterTestFailure(AndroidTestEnvironment.java:470)
	at org.robolectric.RobolectricTestRunner$HelperTestRunner$1.evaluate(RobolectricTestRunner.java:548)
	at org.robolectric.internal.SandboxTestRunner$2.lambda$evaluate$0(SandboxTestRunner.java:252)
	at org.robolectric.internal.bytecode.Sandbox.lambda$runOnMainThread$0(Sandbox.java:89)
	at java.util.concurrent.FutureTask.run(FutureTask.java:266)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at java.lang.Thread.run(Thread.java:748)
Caused by: java.lang.IllegalStateException: Layout state should be one of 100 but it is 10
	at androidx.recyclerview.widget.RecyclerView$State.assertLayoutStep(RecyclerView.java:12371)

    ....(略)....

	at org.robolectric.android.fakes.RoboMonitoringInstrumentation.startActivitySync(RoboMonitoringInstrumentation.java:42)
	at androidx.test.rule.ActivityTestRule.launchActivity(ActivityTestRule.java:358)
	at jp.les.kasa.sample.mykotlinapp.activity.main.MainActivityTest.setUp(MainActivityTest.kt:63)
	at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
	at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
	at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
	at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:24)
	at org.junit.internal.runners.statements.RunAfters.evaluate(RunAfters.java:27)
	at androidx.test.rule.ActivityTestRule$ActivityStatement.evaluate(ActivityTestRule.java:531)
	at org.junit.rules.TestWatcher$1.evaluate(TestWatcher.java:55)
	at org.junit.rules.RunRules.evaluate(RunRules.java:20)
	at org.robolectric.RobolectricTestRunner$HelperTestRunner$1.evaluate(RobolectricTestRunner.java:546)
```

`ViewPager2`を入れる前までは動いていたはずなので、どうやら相性が悪いのか・・・
でも`ViewPager2`の中身は`RecyclerView`で、`RecyclerView`はログデータのリスト表示でもうずっと使ってきていたし・・・
Fragmentに移ったからおかしくなったのでしょうか？
いやいや、[その他のUIテストの修正](#5その他のuiテストの修正)ではFragment化したRecyclerViewでテスト通ってました。

ひとまず`IllegalStateException: Layout state should be one of 100 but it is 10`でググると、

- `@LooperMode(LooperMode.Mode.PAUSED) `を使う とか、
- `ShadowLooper.pauseMainLooper()`を使う とか、

といったアドバイスが見られますが、そうするとLiveDataの監視をしている実際の`Activity`のコードの中でスレッドが間違っていると怒られてしまいます。

他のActivityのテストは変わらず通るので、`ViewPager2`+`Fragment化`で何か起こってしまったようです。
もしかしたら、旧来版の`ViewPager`だと大丈夫だったりするかも知れませんが、テストのためにアプリ側の本実装を変えるというのもおかしなものです。Instrumentation版は動くのだし。
ということで、今後、MainActivityのRobolectric版テストはいったん諦めることにします。

ファイルも削除しておきますね・・・

無念・・・

もし、「この方法で出来たよ」という報告がありましたら、是非コメントで教えて下さい！

### 3. Instrumentation版テストの修正

`androidTest`フォルダ下にあるテストのことです。
こちらはこれまで通り、大丈夫なようなので、コンパイルが通らなくなっているテストを直していきます。

ちょっと前に`setYearMonth`するコードを追加していますすが、

- 関数自体は`MonthlyPageViewModel`のものになっていること
- アプリは起動すると**今月**を表示しようとする

というあたりで、**作成するデータの日付に注意**する必要が出てきています。
テストを起動すると、テストを実行している「年月」のデータを表示しようとしてしまうので、作成するデータが固定だと問題が出てくるわけです。

でも、テストデータの日付を、テストを実行している当月にするようなコードは分かりづらくなりそうなのでやりたくないですね。

せっかく**Koinでモジュールをモックする方法**を学んだので、**Calendarを提供するモジュールを作ってモック化する**というのはどうでしょうか？

例えば、こんなクラスをモジュールに追加します。

```modules.kt
// カレンダークラスで現在日付を持つInstance取得を提供するプロバイダ
interface CalendarProviderI{
    val now: Calendar
}

class CalendarProvider : CalendarProviderI{
    override val now: Calendar
        get() = Calendar.getInstance().clearTime()
}

val providerModule = module {
    factory { CalendarProvider() as CalendarProviderI }
}

// モジュール群
val appModules = listOf(viewModelModule, daoModule, repositoryModule, providerModule)
```

interface `CalendarProviderI`を定義して、実際にアプリで使うのはそれを派生した`CalendarProvider`とします。
Kotlinでは、このように**変数をオーバーライド**することが出来るんですね。
`get() = Calendar.getInstance().clearTime()`は、変数`now`の`getter`を定義しているコードになります。

テスト用にはこうします。
ひとまずこのクラスのテストデータは"2019/06"のものになっているので、常にこの月が返るようにします。
"日にち"は何日でもいいのですが、テストを実行する日にちが"31"だと、6月は31日が存在しないのでちょっとまずいかも知れません。
ということで、任意の日にちにしておきます。

```androidTest/.../MockModules.kt
// カレンダークラスで現在日付を持つInstance取得を提供するプロバイダのテスト用
class TestCalendarProvider : CalendarProviderI {
    override val now: Calendar
        get() {
            val cal = Calendar.getInstance().clearTime()
            cal.set(Calendar.YEAR, 2019)
            cal.set(Calendar.MONTH, 6-1) // 月は0 based index
            cal.set(Calendar.DATE, 28)
            return cal
        }
}

// テスト用にモックするモジュール
val testMockModule = module {
    ...

    single(override = true){
        TestCalendarProvider() as CalendarProviderI
    }
}
```

`MainViewModel`にこの`CalendarProviderI`がインジェクトされるようにします。
**`interface`の方を型に指定する**のを間違えないように。

```MainViewModel.kt
class MainViewModel(
    app: Application,
    val repository: LogRepository,
    val calendarProvider: CalendarProviderI
) : AndroidViewModel(app) {
```

今日の日付を取っているところをこのprovider経由に変えます。

```MainViewModel.kt
liveData.value = makePageList(it, calendarProvider.now)
```

`MainViewModel`のコンストラクタ引数が増えたので、Koinモジュールの方も修正します。

```modules.kt
viewModel { MainViewModel(androidApplication(), get(), get()) }
```

これでどうでしょうか？
今後、テストを作るときには"2019/06"のデータを作らなきゃいけない、という制約には気をつけないとダメですが、テストデータがテストを実行する日付によって変わるよりは、ずっといいかと思います。
願わくば、モックモジュールの方に、使用する日付を指定できると良いのだけど、ちょっとやり方が分かりませんでした。
どこかにstatic変数用意しちゃうとかなら思いつくんだけど、それでいいのだろうか？？と・・・


さて、過去に作ったテストは、これでパスするようになったかと思います。

続いて、ViewPager周りのテストですが、

- ページ数が合っていること
- 最初に表示されているのが最終ページであること

の確認をしていきましょう。

なお、`ViewPager`を左右にスワイプするテストは`Espresso`にもアクションが用意されているけど、これ、`ViewPager2`でも使えるのかな？？？
中身が`RecyclerView`に変わったからもしかしたら同じでいけるかも？ということで、先ほど作った`RecyclerView`のアイテム数をチェックする`Matcher`を改造して、`ViewPager2`用にしてみます。

```EspressoUtils.kt
object ViewPagerMatchers {
    fun hasItemCount(itemCount: Int): Matcher<View> {
        return object : BoundedMatcher<View, ViewPager2>(
            ViewPager2::class.java
        ) {

            override fun describeTo(description: Description) {
                description.appendText("has $itemCount items")
            }

            override fun matchesSafely(view: ViewPager2): Boolean {
                return view.adapter!!.itemCount == itemCount
            }
        }
    }

    fun isCurrent(index: Int): Matcher<View> {
        return object : BoundedMatcher<View, ViewPager2>(
            ViewPager2::class.java
        ) {

            override fun describeTo(description: Description) {
                description.appendText("is $index index is current")
            }

            override fun matchesSafely(view: ViewPager2): Boolean {
                return view.currentItem == index
            }
        }
    }
}
```

ついでにカレントページインデックスをチェックするMatcherも作っておきました。

テストはこう実装しました。

```MainActivityTestI.kt
    @Test
    fun pages(){
        // 最初にデータ投入
        val mainActivity = activityRule.activity

        mainActivity.runOnUiThread {
            // @formatter:off
            mainActivity.viewModel.addStepCount(StepCountLog("2019/06/13", 12345, LEVEL.GOOD))
            mainActivity.viewModel.addStepCount(StepCountLog("2018/12/19", 666, LEVEL.BAD, WEATHER.RAIN))
            // @formatter:on
        }
        getInstrumentation().waitForIdleSync()

        // ページ数が正しいかのテスト(2018/12〜2019/06までの7ページあるはず
        onView(withId(R.id.viewPager)).check(matches(ViewPagerMatchers.hasItemCount(7)))

        // 今表示されているのが2019/06かどうかのテスト
        onView(withText("2019年 6月")).check(matches(isCompletelyDisplayed()))

        // currentPageのチェック
        onView(withId(R.id.viewPager)).check(matches(ViewPagerMatchers.isCurrent(6)))
    }
```

実行してみます。おお、動きました。パスしました！

単純だけどスワイプもテストしておきましょうか。Espressoの`ViewActions.swipe`系が`ViewPager2`でも使えるかどうかの実験も兼ねて。

```MainActivityTestI.kt
   @Test
    fun swipe(){
        // 最初にデータ投入
        val mainActivity = activityRule.activity

        mainActivity.runOnUiThread {
            // @formatter:off
            mainActivity.viewModel.addStepCount(StepCountLog("2019/06/13", 12345, LEVEL.GOOD))
            mainActivity.viewModel.addStepCount(StepCountLog("2018/12/19", 666, LEVEL.BAD, WEATHER.RAIN))
            // @formatter:on
        }
        getInstrumentation().waitForIdleSync()

        // currentPageのチェック
        onView(withId(R.id.viewPager)).check(matches(ViewPagerMatchers.isCurrent(6)))
        // 左にスワイプしてカレントページインデックスのチェック
        onView(withId(R.id.viewPager)).perform(swipeLeft())
        getInstrumentation().waitForIdleSync()
        onView(withId(R.id.viewPager)).check(matches(ViewPagerMatchers.isCurrent(5)))

        // 右にスワイプしてカレントページインデックスのチェック
        onView(withId(R.id.viewPager)).perform(swipeRight())
            .check(matches(ViewPagerMatchers.isCurrent(6)))
    }
```

うーん、スワイプしませんね・・・
仕方ないのでググってみます。まだ新しいせいか、そんなに情報が無いのですが・・・
こんなページが引っかかりました。

https://github.com/android/views-widgets-samples/tree/master/ViewPager2

ViewPager2のサンプルの中に、テストも作っておられて、なんとカスタムマッチャーまで作成されています。
これは使えそうかな？と、その中の`ViewPagerActions.kt`を読んでいくと、でも使っているのはやっぱり`swipeLeft/Right`です。

さらに追っていくと、どうやら、`ViewPagerIdleWatcher.kt`のコメントをざっと流し読むと、Espressoでの`ViewPager2`のアイドリングステータスの監視が合致しないようです。
それを解消するためのクラスと言うことなので、ライセンスは**Apache-2.0**のようですから、こちらを遠慮無く利用させていただきましょう。
ソースコードをGithub上でコピーしてペタッと貼ってもよいですし(ライセンス表記を消さないこと)、ソースコードをzipでDLしてきて、以下のファイルを任意の`androidTest/`下のフォルダに置けば良いですね。

- ViewPagerActions.kt
- ViewPagerIdleWatcher.kt

ただし、パッケージ名を自分のアプリのフォルダ構成に合わせるのには注意してくださいね。

スワイプしてインデックスをチェックする部分のコードはこうなります。

```MainActivityTestI.kt
        // 左からスワイプしてカレントページインデックスのチェック
        onView(withId(R.id.viewPager)).perform(swipePrevious())

        val idleWatcher = ViewPagerIdleWatcher(mainActivity.viewPager)
        idleWatcher.waitForIdle()
        onIdle()
        onView(withId(R.id.viewPager)).check(matches(ViewPagerMatchers.isCurrent(5)))
```

パスしました！右からスワイプも同じように直して、このテストは完成です。

ところで、`pressBack()`を使っているところで、importエラーみたいなのが出るかも知れません。
その場合は、`androidx.test.espresso.Espresso`の方にする必要があります。

あるいは、`addRecordMenu`テストで失敗するかも知れません。

特に**optimize import**をどこかで有効にしていると、import文が下記のようになってしまっている場合があります。

```kotlin
import androidx.test.espresso.action.ViewActions.pressBack
```

こうなっているとちゃんと動かないので、

```kotlin
import androidx.test.espresso.Espresso.pressBack
```

とし、`ViewActions.pressBack`の方は削除しましょう。
あるいは、安全に、`Espresso.pressBack()`を使うよう、コードの方を修正するかですね。

## (5) MonthlyPageFratmentのテスト作成

さて、ここからがかなり試行錯誤でハマってしまった部分です。

このシリーズみたいに、ちょこちょこ作っては機能変えてというアジャイル的な作り方だと、テストを作り込んでおくと、テストの修正に時間が取られて、かえってプロジェクトスピードは落ちます。なのでそのプロジェクトの性質に合わせてテストをどのタイミングで作っていくかは考えた方が良いですね。
(・・・別にテストも一緒に作り始めて後悔しているわけじゃないんだからねっっ)

### 1. FragmentTest導入(半分失敗)

せっかくだから`FragmentScenario`に移行したいので試したのですが、なんだか変でした。

かいつまんで言うと、一応同じコードでテストは動いたのですが、InstrumentationTestの方で、ちゃんとUIが表示されない。
[公式には](https://developer.android.com/training/basics/fragments/testing?hl=ja)

> グラフィカル フラグメントをテストする場合、そのフラグメントはユーザーにも表示される

とあるので、表示されるものと思っているのですが、いざ実行して、画面が表示されているべきタイミングにブレークポイントを貼ってみると、こんな画面しか表示されていませんでした。

![device-2020-02-09-122724.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/df27b041-167f-d712-b9d0-0274318d9d04.png)

空のActivityにアタッチされるのは分かるけど、Fragmentのレイアウトは表示されないの？
謎です。

---
もしかしたら、`グラフィカル フラグメント`と言っているのは、レイアウトを持っていて表示に使うFragmentのことで、`非グラフィカルフラグメント`とは、UIを持たないで便宜的に差し込んでいるFragmentのことを言っているのかなあ？という気がしてきました。
Androidのライブラリにも、いわゆるUIを持たないでステータス管理のようなことだけをするFragmentがあったはずで、そういう役割のFragmentを指して`非グラフィカルフラグメント`と言っているのかな？と。
だとすれば、

> そのフラグメントはユーザーにも表示される

の部分は、実は、

> そのフラグメントはユーザーにも表示される(ただし人間の目に見えるとは言ってない)

ってことなのかー！？

こういうときは、英語の原文を読むのが大切です。

> If you're testing a graphical fragment, it's also visible to users, so you can evaluate information about its UI elements using Espresso UI tests.

うーん、そんなにおかしな日本語訳ではないですね。

*it's also visible to users*の係り方の問題かな？

「グラフィカルフラグメントは、(ユーザーに見える)UIを持っているんだから、Espresso UI testsが使えるよ」

と言っているだけで、**「Fragment Scenarioで起動したFragmentがテスト実行中に本当に表示されるとは言ってない」**ってことなんでしょうね。
私の読解力の問題だったのか・・・？！

---

InstrumentationTestでは、ちゃんと画面を自分でも確認した上で、テストの実装がOKだと判定したいので、ちょっとこれは個人的には使えません。

また、それ以外にも、FragmentTestにすると以下のような不具合もありました。

- `Activity`を起動した`Intent`が取れない
- `RecyclerView`に対して、`perform(click())`は動くのに、`perform(longClick())`でエラーを吐く

なので、

- Robolectic版はFragmentTestで書けるものだけ
- InstrumentationTestは通常通りAcrivityRuleでやる(=`MainActivityTestI`に残す)

とすることにします。
(まあ、本来は、`AcrivityRule`よりも`AcrivityScenario`を使って行くべきなのでしょうが)

まだまだ新しい`ViewPager2`を使ってしまったことの弊害でしょうか。
新しいものを採用すると、こういうリスクもあると言うことですね。

### 2. 依存関係の追加

`dependencies`に下記のように追加します。

```app/build.gradle
    // FragmentTest
    testImplementation 'androidx.test:core:1.2.0'
    androidTestImplementation 'androidx.test:core:1.2.0'
    // Robolectric用,test向けではなくdebug向けに必要
    debugImplementation "androidx.fragment:fragment-testing:1.2.1"
    debugImplementation 'androidx.test:core:1.2.0'
    // 無くてもアプリの実行は問題ないがテストがビルド出来ない
    debugImplementation "androidx.legacy:legacy-support-core-ui:1.0.0"
    debugImplementation "androidx.legacy:legacy-support-core-utils:1.0.0"
```

`androidx.legacy:legacy-support-core-ui:1.0.0`と`androidx.legacy:legacy-support-core-utils:1.0.0`は以前からも書いていたのですが、

```gradle
    implementation "androidx.legacy:legacy-support-core-ui:1.0.0"
    implementation "androidx.legacy:legacy-support-core-utils:1.0.0"
```

と実アプリのビルドにも含まれるようにしていました。が、無くてもアプリの実行は問題なかったので、削除してみたら、コメントにあるとおり、InstrumentationTestがビルドできなくなってしまいました。これはアプリ側のapkに含まれていないとダメなんだろうなってことで、debugビルドだけ(testで実行される対象がdebugビルドだから)含まれるようにしました。

なんだか気持ち悪い構成ですが。


### 3. FragmentTestの実装

FragmentTestで書けたのは、次の二つだけでした。

- showDateLabel
- showList

よって、`MainActivityTestI`からは上記二つのテストは除外しています。

FragmentTestの基本形は、

- 必要なら`fragmentArgs`を作って
- `launchFragmentInContainer<Fragmentクラス>(fragmentArgs)`でFragmentを起動する

というものになります。`MonthlyPageFragmentTest`ならこうなりますね。

```kotlin
       val fragmentArgs = Bundle().apply {
            putString(MonthlyPageFragment.KEY_DATE_YEAR_MONTH, "2020/02")
        }
        val scenario = launchFragmentInContainer<MonthlyPageFragment>(
            fragmentArgs
        )
```

`launchFragmentInContainer`は`FragmentScenario<Fragmentクラス>`を返します。この`FragmentScenario`は、Fragmentクラスの実体を使いたいときにこんな風に出来ます。

```kotlin
        scenario.onFragment { fragment ->  
            fragment.xxxx()
        }
```

Fragmentの関数だったり(当然publicなものだけ)を直接触れるんで、まあ少しは便利でしょうか。

これを使って修正を加えれば、このクラスのRobolectric版テストは完成です。

`activityTestRule`とかは不要なので削除します。

`MonthlyPageFragmentTest`クラスの全コードは、こちらからご確認下さい。

<details><summary>全テストコードサンプル</summary><div>

```MonthlyPageFragmentTest.kt
import android.os.Bundle
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.fragment.app.testing.launchFragmentInContainer
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import jp.les.kasa.sample.mykotlinapp.*
import jp.les.kasa.sample.mykotlinapp.data.*
import jp.les.kasa.sample.mykotlinapp.di.mockModule
import kotlinx.coroutines.runBlocking
import org.assertj.core.api.Assertions.assertThat
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.koin.core.context.loadKoinModules
import org.koin.test.AutoCloseKoinTest
import org.koin.test.get
import org.koin.test.inject
import org.robolectric.annotation.Config

@RunWith(AndroidJUnit4::class)
@Config(
    qualifiers = "xlarge-port",
    shadows = [ShadowAlertDialog::class, ShadowAlertController::class]
)
class MonthlyPageFragmentTest : AutoCloseKoinTest() {

    @get:Rule
    val rule: TestRule = InstantTaskExecutorRule()

    private val repository: LogRepository by inject()

    @Before
    fun setUp() {
        loadKoinModules(mockModule)
        get<LogRoomDatabase>().clearAllTables()

    }

    @After
    fun tearDown() {
        get<LogRoomDatabase>().clearAllTables()
    }

    @Test
    fun showDateLabel() {
        val fragmentArgs = Bundle().apply {
            putString(MonthlyPageFragment.KEY_DATE_YEAR_MONTH, "2020/02")
        }
        launchFragmentInContainer<MonthlyPageFragment>(fragmentArgs)

        onView(withId(R.id.textViewYM)).check(matches(isDisplayed()))
            .check(matches(withText("2020年 2月")))
    }

    @Test
    fun showList() {

        // repositoryに直接追加
        runBlocking {
            // @formatter:off
            repository.insert(StepCountLog("2019/06/13", 12345, LEVEL.GOOD))
            repository.insert(StepCountLog("2019/06/19", 666, LEVEL.BAD, WEATHER.RAIN))
            repository.insert(StepCountLog("2019/05/30", 612, LEVEL.NORMAL, WEATHER.CLOUD))
            // @formatter:on
        }
        val allLogs = repository.allLogs()
        assertThat(allLogs.size).isEqualTo(3)

        val fragmentArgs = Bundle().apply {
            putString(MonthlyPageFragment.KEY_DATE_YEAR_MONTH, "2019/06")
        }
        launchFragmentInContainer<MonthlyPageFragment>(fragmentArgs)

        // リストの表示確認
        onView(withId(R.id.log_list)).check(matches(RecyclerViewMatchers.hasItemCount(2)))

        // リスト項目の確認
        var index = 1

        onView(withId(R.id.log_list))
            // @formatter:off
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
            .check(matches(atPositionOnView(index, withText("12345"), R.id.stepTextView)))
            .check(matches(atPositionOnView(index, withText("2019/06/13"), R.id.dateTextView)))
            .check(matches(atPositionOnView(index, withDrawable(R.drawable.ic_sentiment_very_satisfied_pink_24dp), R.id.levelImageView)))
            .check(matches(atPositionOnView(index,
                        withDrawable(R.drawable.ic_wb_sunny_yellow_24dp),R.id.weatherImageView)))
            // @formatter:on
        index = 0
        onView(withId(R.id.log_list))
            // @formatter:off
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
            .check(matches(atPositionOnView(index, withText("666"), R.id.stepTextView)))
            .check(matches(atPositionOnView(index, withText("2019/06/19"), R.id.dateTextView)))
            .check(matches(atPositionOnView(index,
                withDrawable(R.drawable.ic_sentiment_dissatisfied_black_24dp),R.id.levelImageView)))
            .check(matches(atPositionOnView(index,
                        withDrawable(R.drawable.ic_iconmonstr_umbrella_1),R.id.weatherImageView)))
        // @formatter:on
    }
}
```
</div></details>


最後に、すべてのテストが通過するのを確認しておきましょう。

だいたいOKなんですが・・・

クラス全体、あるいはパッケージ全体を実行すると、私の環境では(最近AndroidStudioを3.5.3にアップデートしました。あと使っている実機はPixel3a OS10です)、特定のテスト(`MainActivityTestI#onActivityResult_Add`や`onActivityResult_Edit`)が失敗しやすくなっています。タイミングの問題だと思いますが、全部パスすることもあるのと、個別に実行すれば必ず成功するので、今は目をつぶります。あと、**デバッグ実行**もなぜか一括実行が出来ずに、1件だけテストした後応答しなくなってしまいます。これも`ViewPager2`のせいなんでしょうか？時間があったら、旧来の`ViewPager`に置き換えて実験してみます。


# まとめ

データベースから、範囲を絞ってデータを抽出出来るようにしました。
`ViewPager2`を使用して、月ごとの表示をページングすることが出来るようになりました。
`LiveData`を監視して変更する`MediatorLiveData`、それを簡単に実装できる`Transformations.switchMap()`の使い方を学びました。
テストでは、（Robolectric版を挑戦した場合)`FragmentScenario`の使い方も覚えました。

ここまでの状態のプロジェクトをGithubにpushしてあります。

https://github.com/le-kamba/qiita_pedometer/tree/feature/qiita_08


# 予告

いよいよ、カレンダー風のグリッド表示に挑戦します。

 
# 参考ページなど

- MediatorLiveDataとTransformationsでViewModelを効果的に使う
  https://www.koheiando.com/tech/android/298
  
- How to count RecyclerView items with Espresso
  https://stackoverflow.com/questions/36399787/how-to-count-recyclerview-items-with-espresso
  
- ViewPager2を簡単に使ってみる
  https://qiita.com/chohas/items/7efe9828c3308145b13e
  
# おまけ(ViewPager実験結果)

ということで、ViewPager2を旧来の`ViewPager`に置き換えて実験しましたが、テストが失敗しやすくなっているのは変わらずでした。

また、Robolectric版で`RecyclerView`のアイテムへの`longClick`がエラーになってしまうのも同じでした。
ViewPagerはRobolectricとは相性が悪そうですね・・・DialogFragmentが取れない点など、Activityのレイアウト直下にないFragmentとは、相性が悪いのかも知れません。

一応以下のブランチにアップしてありますので、興味があれば覗いてみて下さい。
なお、旧来の`VeiwPager`は`androidx.appcompat:appcompat`モジュールに含まれているので、特に依存関係に追加する必要はありません。

https://github.com/le-kamba/qiita_pedometer/tree/feature/qiita_08_sub/viewpager_1

それと、こちらは**Databinding + ViewModel**が出来たので、それも入っています。

見て頂くと分かりますが、`ViewPager`向けの`FragmentStatePagerAdapter`では、`notifyDataSetChanged`されたときに、作成済みのFragmentをキャッシュから作らせない方法があり、それが`getItemPosition`で`PagerAdapter.POSITION_NONE`を返すこと、です。
`ViewPager2`ではキャッシュ済のFragmentを使わないように指定する方法が見つからないので、しばらくは**Databinding + ViewModel**は諦めないとだめそうです。

`ViewPager`は、**ページ数が変わること、順番が変わることをあまり想定していない**ようですね(そりゃそうだ)。