前回の続きです。
今回の目標
リストの表示を1ヶ月1ページにします。
そしていわゆるViewPager
を使い、左右にスワイプすることで月を移動できるようにします。
※カレンダー風の表示は、また次回に回します。
ViewPagerは、Androidでは使えないとお話にならない機能だと思うので、是非覚えて下さい。
1ヶ月1ページにする
(1) レイアウトの変更
今表示してる「年月」を表示する部分を追加します。
完成イメージはこんな感じです。
レイアウト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>
ここはさほど難しくないですかね。
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
を次のように書き変えます。
<?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
にあった処理を持ってくるだけなので、難しいところは無いと思います。
上記の点に注意して、作ってみて下さい。
全体コードのサンプルはこちら
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)
}
3. MainActivityの変更
MainActivity
がMonthlyPageFragment
を使うようにします。
MonthlyPageFragment
に移したメソッドを削除して、Databinding用のコードも削除します。
onCreate
は次のようになります。それと、使わなくなった定数も削除して良いかと思います。
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と、それを外からセットする関数を用意します。
// 表示する年月
private val _dataYearMonth = MutableLiveData<String>()
val dataYearMonth :LiveData<String> = _dataYearMonth
fun setYearMonth(yearMonth: String){
_dataYearMonth.postValue(yearMonth)
}
setYearMonth
は、今はUIスレッドからしか呼ばれませんが、のちのちワーカースレッドから呼ばれる可能性もあるので、postValue
を使っておくようにしています。
次はDatabindingの設定です。
<TextView
android:id="@+id/textViewYM"
...(略)
app:yearMonth="@{viewmodel.dataYearMonth}"
app:yearMonth
は例によって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
は次の通り。
<string name="year_month_label">%1$s年 %2$d月</string>
この二つでやっていることは、"yyyy/MM"
で渡ってきた文字列を/
で"yyyy"
と"MM"
に分けて、"yyyy"
はそのまま文字列としてリソース文字列の引数1に入れ、"MM"
は一度Integer
にしてリソース文字列の引数2に入れています。一度Integer
にしているのは、"02"
などの場合の0
を削除したいからです。
Formatter
を使っても良いのかも知れませんが、単純なのでこのようにしました。
最後に、MonthlyPageFragment
の初期化時に年月をセットしてやります。
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ヶ月分が抽出できますね。
@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
のラッパーを作るだけです。
@WorkerThread
fun searchRange(from: String, to: String): LiveData<List<StepCountLog>>{
return logDao.getRangeLog(from, to)
}
suspend
関数にしていないのは理由があるのですが、いったん置いておきます。
LogRepository
のテストに追加しましょう。
@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
の定義は不要になったので削除します。ただ、テストでは全件検索がまだ欲しいので、関数化します。
// メンバー変数は削除
// val allLogs: LiveData<List<StepCountLog>> = logDao.getAllLogs()
@WorkerThread
fun allLogs():List<StepCountLog>{
return logDao.getAllLogs()
}
今のところテストで使うだけなので、LiveData
を返すのもやめました。
これに伴い、allLogs
を使っていた箇所を修正します。
allLogs
はメンバー変数では無く、関数としたので、それに合わせて書き変えます。
val items = repository.allLogs()
また、戻り値はLiveData
でもなくなったので、observe
する必要も無くなっています。
これらを踏まえて、書き直しましょう。
単純な変更なので、答え(?)は、ブランチにアップしてあるソースコードで確認して下さい。
これでLogRepositoryTest
が通過するようになれば準備は終わりです。
3. MainViewModelに機能を追加
まず、MainViewModel
もallLogs
を参照しているので書き変えないとビルドが通りませんね。
stepCountList
の定義を次のように変更したいところです。
val stepCountList = repository.searchRange(from, to)
んー、でも、from
とto
って、直ぐには決まらないですよね。外から貰わないと・・・
ただ、_dataYearMonth
が決まれば、自動的に決まりますね?
ということは、_dataYearMonth
がセットされるときに同時に呼んでやれば良いでしょうかね?
fun setYearMonth(yearMonth: String) {
_dataYearMonth.postValue(yearMonth)
stepCountList = repository.searchRange(from, to)
}
あれ、でも、stepCountList
はval
だから再代入できないと怒られてしまいます。
じゃあ、var
にしなくちゃダメか?
実は、あるLiveDataに変更があったときに、その値を元に別のLiveDataを更新するということが出来ます。
MediatorLiveData
と言われるものです。
詳細は公式サイトに譲るとして、ここでは更に便利な、Transformations
というのを使います。
これは、1個のSourceだけを参照するMediatorLiveData
と考えれば良いかと思います。(実際にTransformations
のソースコードを見るとまさしくそのような実装になっているのが分かります)
stepCountList
の定義はこうなります。
// データリスト
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
でやるとすると、サンプルのようなコードになるかと思います。
サンプルコードはこちら
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
}
getFromToYMD
は次のように定義しました。
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回の記事でした。
これこそ、型を定義するまでもないので、使ってみました。要素が2個しかないので配列やリストにするまでもないし。
まずはこのgetFromToYMD
関数のテストを作って確認しましょう。
@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
のテストも、次の行の追加が必要です。
viewModel.stepCountList.observeForever(listObserver)
viewModel.setYearMonth("2019/06") // <- 追加
(どうでもいいけどこのテストを最初に書いたのがもう半年以上前だと知って愕然としています)
理由は分かりますか?
MainViewModel#stepCountList
は、Transformations.switchMap()
で値が代入されるので、_dataYearMonth
がセットされるまでは値が入らないんです。だから単にaddStepCount
をしただけではstepCountList
が更新されず、LiveData#onChanged
が飛んでこないため、listObserver.await()
で必ずタイムアウトしてしまうんですね。
うっかり私も嵌まっていましたが、原因が分かれば至極当然な理由でした(汗)
androidTest
の方にある、ViewModelTestI.kt
も同様に修正して、通るようになればOKなんですが、init
テストだけは、下記のコードを修正する必要があります。
viewModel.stepCountList.observeForTesting {
assertThat(viewModel.stepCountList.value)
.isNull() // isEmpty()から変更
}
理由は先ほどと同じで、初期化段階ではstepCountList.value
はnull
だからですね・・・
ちなみに、このチェック、Robolectric版のViewModelTest
では書いてなかったですが(以前はスレッド問題でコルーチンのテストが出来なかったから)、同じように書くことが出来るようになっているので、今後のメンテの負担も考えて、androidTest
版の方はもう削除しても良いかも知れませんね。
とりあえずアプリの動作確認をしてみましょう。
先月分、今月分と、任意の日付でデータを登録してみてください。
リストには「今月分」だけ表示されているでしょうか?
過去のデータを見る手段が無くなってしまいましたが、ViewPagerに対応するまでの辛抱です(笑)
(4) 年月表示のUIテスト
忘れていたわけではありませんよ(汗)
まずは、単純に日付表示ラベルが正しいかのテストです。
@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
で日付をセットしておく必要があります。
単純な修正なのでコードは載せませんが、答え(?)はブランチにアップしてあるソースコードで確認して下さい。
それと、せっかくなので指定月以外は表示されていないテストも追加しましょう。
@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
に次のように定義しました。
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
をご存じで、何が変わったか知りたい人は公式ページなどを参考にして下さい。
dependencies{
...
// ViewPager2
implementation 'androidx.viewpager2:viewpager2:1.0.0'
}
2. MainActivityのレイアウトの変更
FrameLayout
を削除し、代わりにViewPager2
を入れます。
<?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をセットしているコードはいったん削除します。
// supportFragmentManager.beginTransaction().replace(
// R.id.main_container, MonthlyPageFragment()
// ).commit()
(2) Adapterクラス
続いて、ViewPagerにAdapterをセットしていきます。
ViewPager2
では、RecyclerAdapter
を使うこともできるんですが、Fragment
を使いたいので、FragmentStateAdapter
を使います。
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
を必要とするのでコンストラクタで渡します。
ページ数はいったん固定にしておきます。
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
が共有されてしまっているからですね。
val viewModel by sharedViewModel<MainViewModel>()
ここをval viewModel by viewModel<MainViewModel>()
とすれば、FragmentごとにViewModelのインスタンスが作成されるはずなので、共有されなくなるはず。
しかし、それだと、MainViewModel
に、MainActivity
で使うものと、MonthlyPageFragment
で使うものとがマージされたような状態になってしまっています。
これを改善するため、MonthlyPageViewModel
を作って、お互いに不要なものは削除してしまいましょう。
1. MainViewModelの変更
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
は、こうなります
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のモジュール群も修正する必要があります。
val viewModelModule = module {
viewModel { MainViewModel(androidApplication(), get()) }
viewModel { MonthlyPageViewModel(androidApplication(), get()) }
...
}
4. MonthlyPageFragmentの修正
MonthlyPageFragment
でViewModelをインジェクトするコードも変更します。
val viewModel by viewModel<MonthlyPageViewModel>()
4. レイアウトファイルの修正
忘れてはならないのが、Databindingをしているレイアウトファイルの修正です。バインドするデータの型を、MainViewModel
からMonthlyPageViewModel
に変更するのを忘れてはなりません。
<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クラスの範疇ですね。
@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
は上記をラップした関数を追加します。
@WorkerThread
fun getOldestDate(): LiveData<String>{
return logDao.getOldestDate()
}
こういうときは単体テストでサクッと確認しましょう。
@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から取ってこられる一番古い日付
を監視します。
// 一番古いデータの年月
private val oldestDate = repository.getOldestDate()
で、この日付と、「当月」(アプリを実行している今日の日付)の間の年月をリスト化しようと思います。
先ほども使った、Transforamtions.swicthMap
の出番ですね!
// ページ
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
は例によって拡張関数です。
fun Calendar.getDateStringYM(): String {
val fmt = SimpleDateFormat("yyyy/MM", Locale.JAPAN)
return fmt.format(time)
}
@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
を監視しておいて、変更があったときです。
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
関数のテストを追加するだけかと思います。
@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テストの修正では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を提供するモジュールを作ってモック化するというのはどうでしょうか?
例えば、こんなクラスをモジュールに追加します。
// カレンダークラスで現在日付を持つ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日が存在しないのでちょっとまずいかも知れません。
ということで、任意の日にちにしておきます。
// カレンダークラスで現在日付を持つ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
の方を型に指定するのを間違えないように。
class MainViewModel(
app: Application,
val repository: LogRepository,
val calendarProvider: CalendarProviderI
) : AndroidViewModel(app) {
今日の日付を取っているところをこのprovider経由に変えます。
liveData.value = makePageList(it, calendarProvider.now)
MainViewModel
のコンストラクタ引数が増えたので、Koinモジュールの方も修正します。
viewModel { MainViewModel(androidApplication(), get(), get()) }
これでどうでしょうか?
今後、テストを作るときには"2019/06"のデータを作らなきゃいけない、という制約には気をつけないとダメですが、テストデータがテストを実行する日付によって変わるよりは、ずっといいかと思います。
願わくば、モックモジュールの方に、使用する日付を指定できると良いのだけど、ちょっとやり方が分かりませんでした。
どこかにstatic変数用意しちゃうとかなら思いつくんだけど、それでいいのだろうか??と・・・
さて、過去に作ったテストは、これでパスするようになったかと思います。
続いて、ViewPager周りのテストですが、
- ページ数が合っていること
- 最初に表示されているのが最終ページであること
の確認をしていきましょう。
なお、ViewPager
を左右にスワイプするテストはEspresso
にもアクションが用意されているけど、これ、ViewPager2
でも使えるのかな???
中身がRecyclerView
に変わったからもしかしたら同じでいけるかも?ということで、先ほど作ったRecyclerView
のアイテム数をチェックするMatcher
を改造して、ViewPager2
用にしてみます。
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も作っておきました。
テストはこう実装しました。
@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
でも使えるかどうかの実験も兼ねて。
@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)))
}
うーん、スワイプしませんね・・・
仕方ないのでググってみます。まだ新しいせいか、そんなに情報が無いのですが・・・
こんなページが引っかかりました。
ViewPager2のサンプルの中に、テストも作っておられて、なんとカスタムマッチャーまで作成されています。
これは使えそうかな?と、その中のViewPagerActions.kt
を読んでいくと、でも使っているのはやっぱりswipeLeft/Right
です。
さらに追っていくと、どうやら、ViewPagerIdleWatcher.kt
のコメントをざっと流し読むと、EspressoでのViewPager2
のアイドリングステータスの監視が合致しないようです。
それを解消するためのクラスと言うことなので、ライセンスはApache-2.0のようですから、こちらを遠慮無く利用させていただきましょう。
ソースコードをGithub上でコピーしてペタッと貼ってもよいですし(ライセンス表記を消さないこと)、ソースコードをzipでDLしてきて、以下のファイルを任意のandroidTest/
下のフォルダに置けば良いですね。
- ViewPagerActions.kt
- ViewPagerIdleWatcher.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文が下記のようになってしまっている場合があります。
import androidx.test.espresso.action.ViewActions.pressBack
こうなっているとちゃんと動かないので、
import androidx.test.espresso.Espresso.pressBack
とし、ViewActions.pressBack
の方は削除しましょう。
あるいは、安全に、Espresso.pressBack()
を使うよう、コードの方を修正するかですね。
(5) MonthlyPageFratmentのテスト作成
さて、ここからがかなり試行錯誤でハマってしまった部分です。
このシリーズみたいに、ちょこちょこ作っては機能変えてというアジャイル的な作り方だと、テストを作り込んでおくと、テストの修正に時間が取られて、かえってプロジェクトスピードは落ちます。なのでそのプロジェクトの性質に合わせてテストをどのタイミングで作っていくかは考えた方が良いですね。
(・・・別にテストも一緒に作り始めて後悔しているわけじゃないんだからねっっ)
1. FragmentTest導入(半分失敗)
せっかくだからFragmentScenario
に移行したいので試したのですが、なんだか変でした。
かいつまんで言うと、一応同じコードでテストは動いたのですが、InstrumentationTestの方で、ちゃんとUIが表示されない。
公式には
グラフィカル フラグメントをテストする場合、そのフラグメントはユーザーにも表示される
とあるので、表示されるものと思っているのですが、いざ実行して、画面が表示されているべきタイミングにブレークポイントを貼ってみると、こんな画面しか表示されていませんでした。
空の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
に下記のように追加します。
// 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
は以前からも書いていたのですが、
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
ならこうなりますね。
val fragmentArgs = Bundle().apply {
putString(MonthlyPageFragment.KEY_DATE_YEAR_MONTH, "2020/02")
}
val scenario = launchFragmentInContainer<MonthlyPageFragment>(
fragmentArgs
)
launchFragmentInContainer
はFragmentScenario<Fragmentクラス>
を返します。このFragmentScenario
は、Fragmentクラスの実体を使いたいときにこんな風に出来ます。
scenario.onFragment { fragment ->
fragment.xxxx()
}
Fragmentの関数だったり(当然publicなものだけ)を直接触れるんで、まあ少しは便利でしょうか。
これを使って修正を加えれば、このクラスのRobolectric版テストは完成です。
activityTestRule
とかは不要なので削除します。
MonthlyPageFragmentTest
クラスの全コードは、こちらからご確認下さい。
全テストコードサンプル
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
}
}
最後に、すべてのテストが通過するのを確認しておきましょう。
だいたいOKなんですが・・・
クラス全体、あるいはパッケージ全体を実行すると、私の環境では(最近AndroidStudioを3.5.3にアップデートしました。あと使っている実機はPixel3a OS10です)、特定のテスト(MainActivityTestI#onActivityResult_Add
やonActivityResult_Edit
)が失敗しやすくなっています。タイミングの問題だと思いますが、全部パスすることもあるのと、個別に実行すれば必ず成功するので、今は目をつぶります。あと、デバッグ実行もなぜか一括実行が出来ずに、1件だけテストした後応答しなくなってしまいます。これもViewPager2
のせいなんでしょうか?時間があったら、旧来のViewPager
に置き換えて実験してみます。
まとめ
データベースから、範囲を絞ってデータを抽出出来るようにしました。
ViewPager2
を使用して、月ごとの表示をページングすることが出来るようになりました。
LiveData
を監視して変更するMediatorLiveData
、それを簡単に実装できるTransformations.switchMap()
の使い方を学びました。
テストでは、(Robolectric版を挑戦した場合)FragmentScenario
の使い方も覚えました。
ここまでの状態のプロジェクトをGithubにpushしてあります。
予告
いよいよ、カレンダー風のグリッド表示に挑戦します。
参考ページなど
MediatorLiveDataとTransformationsでViewModelを効果的に使う
https://www.koheiando.com/tech/android/298How to count RecyclerView items with Espresso
https://stackoverflow.com/questions/36399787/how-to-count-recyclerview-items-with-espressoViewPager2を簡単に使ってみる
https://qiita.com/chohas/items/7efe9828c3308145b13e
おまけ(ViewPager実験結果)
ということで、ViewPager2を旧来のViewPager
に置き換えて実験しましたが、テストが失敗しやすくなっているのは変わらずでした。
また、Robolectric版でRecyclerView
のアイテムへのlongClick
がエラーになってしまうのも同じでした。
ViewPagerはRobolectricとは相性が悪そうですね・・・DialogFragmentが取れない点など、Activityのレイアウト直下にないFragmentとは、相性が悪いのかも知れません。
一応以下のブランチにアップしてありますので、興味があれば覗いてみて下さい。
なお、旧来のVeiwPager
はandroidx.appcompat:appcompat
モジュールに含まれているので、特に依存関係に追加する必要はありません。
それと、こちらはDatabinding + ViewModelが出来たので、それも入っています。
見て頂くと分かりますが、ViewPager
向けのFragmentStatePagerAdapter
では、notifyDataSetChanged
されたときに、作成済みのFragmentをキャッシュから作らせない方法があり、それがgetItemPosition
でPagerAdapter.POSITION_NONE
を返すこと、です。
ViewPager2
ではキャッシュ済のFragmentを使わないように指定する方法が見つからないので、しばらくはDatabinding + ViewModelは諦めないとだめそうです。
ViewPager
は、ページ数が変わること、順番が変わることをあまり想定していないようですね(そりゃそうだ)。