11
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Kotlin, LiveData, coroutine なんかを使って初めてのAndroidアプリを作る(4)Databindingと画面遷移

Last updated at Posted at 2019-06-22

前回の続きです。

今回の目標

  • データセットを扱う(Dataクラスの作成)
  • Data Bindingを使う
  • Fragmentを使う
  • Activity遷移を覚える

本当はRoomまでやりたかったのですが、Coroutineも絡んできてしまってボリュームが増えすぎるので、それは次回に回します。

今回は画面周りの実装がメインなので、楽しい、はず^^;

1. データセットを扱う

これまでは、記録しているデータはInt型カウント数値1つだけでした。
もう少し複雑なデータセットにしてみようと思います。とはいえ、「歩数計記録アプリ」という目標があるので、必要なのはあと日付くらいですね。

  • 歩行記録データクラス
    • 日付
    • 歩数

個人的には、「よく歩いた/全然歩かなかった/普通」みたいな感じでその日の感想を選択式で入れられるようにしようかな。あとはお天気とか?犬の散歩は雨だと行けないから・・・

  • 歩行記録データクラス
    • 日付(必須) : "yyyy/MM/dd"
    • 歩数(必須) : Int
    • 達成度(任意) : Enum(Default=NORMAL, GOOD, BAD)
    • 天気(任意) : Enum(Default=FINE, RAIN, CLOUD, HOT, COLD, SNOW...)

他に何か思いつく物があれば任意で追加して下さい。

(1) データクラスを作成する

Kotlinには、その名もズバリ、data classというキーワードがあります。通常のclass定義と何が違うかというと、

  • equals()/hashCode()を自動生成してくれる
  • toString()を自動生成してくれる
  • copy()を自動生成してくれる

など、自動で色々内部的に作ってくれて使うことができます。ただし制約もあって、**「派生できない/継承できない」**というのもあります。データ設計の際にはこの辺りは要注意ですね。

個人的には、toString()の自動生成が助かりますね。デバッガーでbreakポイントを貼ったときに、値が確認しやすくなります。Java時代は自前で書くの結構大変でしたから。

早速、上でざっくり設計した歩行記録データクラスをコードに起こしてみましょう。

1. パッケージを追加

まず、新しくdataというパッケージを追加します。

パッケージルートを右クリックして、[New]-[Package]と選択肢、dataと入力して下さい。

kotlin_04_001.png

2. データクラスを追加

新しくできたdataパッケージ下に、新規Kotlinクラスを追加します。
クラス名はStepCountLogとしましょうか。ファイルを新規作成して、以下のように記述します。

StepCountLog.kt
enum class LEVEL {
    NORMAL, GOOD, BAD,
}

enum class WEATHER {
    FINE, RAIN, CLOUD, SNOW, COLD, HOT,
}

data class StepCountLog(
    val date: String,
    val step: Int,
    val level: LEVEL = LEVEL.NORMAL,
    val weather: WEATHER = WEATHER.FINE
)
  • enum class LEVELが、達成度を表すenumクラスです。
  • enum class WEATHERが、天気を表すenumクラスです。
    • Javaにあったenumとそれほど大きな違いはありません。少なくとも、この連載で使っていく分には、複雑な使い方をしていませんので、特に難しいことは無いかと思います。
  • StepCountLogクラスは、プライマリコンストラクタのみ定義しています。
    • 基本的なメソッドを自動で作ってくれるので、データクラスはほとんどの場合、操作のない宣言だけのものになります。

コンストラクタの引数、val level: LEVEL = LEVEL.NORMALval weather: WEATHER = WEATHER.FINEの部分は、デフォルト値の設定です。Kotlinでは、引数を省略できます。省略した場合は、コンストラクタで指定されているデフォルト値が渡されることになります。
省略可能な引数は、省略不可能なすべての引数より、後ろに宣言されていなくてはなりません。
こういう形は出来ない、ということですね。

data class StepCountLog(
    val date: String,
    val level: LEVEL = LEVEL.NORMAL,
    val step: Int,
    val weather: WEATHER = WEATHER.FINE
)

上記のコードだと、クラス宣言の部分では特にエラーは出ないのですが、インスタンス化するコードのところでエラーが出ます。

StepCountLogクラスのインスタンス化(Javaでいうnewする)は次のように書けます。

val data1 = StepCountLog("2019/06/11", 123, LEVEL.GOOD, WEATHER.RAIN)

// LEVELはNORMAL, WEATHERはFINEが渡る
val data2 = StepCountLog("2019/06/11", 123) 

// 引数を1つだけ省略
val data3 = StepCountLog("2019/06/11", 123, LEVEL.GOOD) 

val data4 = StepCountLog("2019/06/11", 123, level = LEVEL.GOOD) 

data4の宣言を見てお気づきの通り、Kotlinでは、引数を渡すときにはエイリアスを指定することができます。エイリアスとは、まあ早い話、コンストラクタの宣言で書いた、引き数名を指定して、引数が渡せる、ということです。なので引数には分かりやすい名前を付けておく方が良いでしょう。
なお、エイリアス指定方式は、すべての関数で使用できます。

エイリアス指定の便利なところは、引数の順番を自由に書けるところでしょうか。
通常、関数の引数は、宣言の順番通りに渡さなければなりませんが、エイリアスを使うと、その順番を任意に出来るのです。

// 引数の順番を任意にする
val data = StepCountLog(step=123, level = LEVEL.GOOD, date = "2019/06/11")

これを利用して、こんな書き方も出来ます。

val data = StepCountLog("2019/06/11", 123, weather = WEATHER.RAIN)

省略可能な引数のうち、後に宣言されたweatherのみ、指定してます。levelはデフォルト値が渡されます。本当に必要なパラメータだけ渡せるので、便利ですね。

まあ、個人的には、行が長くなるので、エイリアスはあまり使いませんが(汗)
(※1行80文字でコードを長く書いてきた人間なので^^;)

3. ViewModelで扱うデータ型を変更する

さて、データクラスを作ったので、ViewModelで扱う型も変えていきましょう。

  • MainViewModelのLiveDataの型を変更する

    • Int型のリストにしていたのを、StepCountLogのリストにする

      MainViewModel.kt
        val stepCountList = MutableLiveData<MutableList<StepCountLog>>()
      
  • MainViewModeladdStepCountの引数の型も、StepCountLogにする

MainViewModel.kt
    @UiThread
    fun addStepCount(stepLog: StepCountLog) {
        val list = stepCountList.value ?: return
        list.add(stepLog)
        stepCountList.value = list
    }
  • InputDialogFramentaddStepCountを呼んでいる箇所を変更する

    • 今は入力値を選択出来ないので、levelとweatherはいったんデフォルト値が渡るようにします。日付も後で選べるようにしますが、今今は今日の日付、にしておきます。

      InputDialogFragment.kt
            val step = view.editStep.text.toString()
            val date = getDateStringYMD(Calendar.getInstance().time)
            viewModel.addStepCount(StepCountLog(date, step.toInt()))
      

    getDateStringYMDは下記のような関数です。

InputDialogFragment.kt
    private fun getDateStringYMD(time:Date):String{
        val fmt = SimpleDateFormat("yyyy/MM/dd", Locale.JAPAN)
        return fmt.format(time)
    }
  • LogRecyclerAdapterのリストの型も、StepCountLogに変更
class LogRecyclerAdapter(private var list: List<StepCountLog>) : RecyclerView.Adapter<LogRecyclerAdapter.LogViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogViewHolder {
        val rowView = LayoutInflater.from(parent.context).inflate(R.layout.item_step_log, parent, false)
        return LogViewHolder(rowView)
    }

    fun setList(newList: List<StepCountLog>) {
        list = newList
        notifyDataSetChanged()
    }
// 後は同じ

ここまでで、いったんビルド&実行してみましょう。ビルドが通らないときは、一度Clean&Rebuildしてみてください。どちらも、[Run]メニューにあります。

動いたけど、なんか変だって?

kotlin_04_002.png

べ、別に、Adapterの表示を設定するところを直すのを忘れたわけじゃ無いんだからね!
わ、わざとに決まってるでしょ!!
「ホラ、レイアウト変更しなきゃね!」てやりたかったからだよ!!

まあ、原因は、お察しの通り、LogRecyclerAdapterの表示データをセットする以下のところですね。

    override fun onBindViewHolder(holder: LogViewHolder, position: Int) {
        holder.textCount.text = if (position < list.size) list[position].toString() else ""
    }

TextViewに、list[position].toString()を渡してます。ここが、StepCountLog#toString()の呼び出しになり、自分ではこの関数は作っていませんが、前述の通り、data classですので、自動的に生成されています。
data classtoString()は、このようにメンバーの値をテキストで読みやすい形で出してくれるという代物なのです。
なぜデバッグで便利かというと、ブレークポイントを貼ってデバッガーで値を見るときに、自動的にこの形になっていると見やすいんですね。

例えば、こんな所にブレークポイントを貼っておき、アプリを実行して「登録」ボタンを押します。

kotlin_04_003.png

ブレークポイントで止まりますね。その時、[Valiables]のstepCountで[toString]-[View]とクリックすると、こんな風に値を確認することができます。

kotlin_04_004.png

[Variables]のところで、stepLogの左にある矢印をクリックして、要素を展開すれば中を見ることも出来ますが、ネストの深いデータ構造だと、矢印のクリック回数が増えて非常に面倒です。文字列で一瞥できた方が、便利なときもあるのです。

ということで、toString()の自動生成のありがたみを痛感したところで(?)、レイアウトをちゃんと対応していきましょう(汗)

(2) レイアウトを変更する

変えなければならないレイアウトは以下です。

  • リストの各行(アイテム)のレイアウト(item_step_log.xml)
  • 入力時のレイアウト(dialog_input.xml)

InputDialogFragmentについては、今回、新しく入力項目が増えたので、今回から、ダイアログ表示をやめて、画面遷移にしようと思います。

また、RecyclerViewは、databindingというのを使って行くと便利なのでそちらを使って行きます。

1. アイテムレイアウトを変更する

まずは、RecyclerViewのアイテムのレイアウトを変えましょう。日付、レベル、天気が表示出来るようにします。

  • 画像の準備

    レベル、天気は、こんな感じで、vector画像を用意しました。ほとんどは、AndroidStudioのクリップセットから作成可能ですが、一部は、フリー素材を使っています(参考ページ参照)

    kotlin_04_005.png

    サンプル画像はこんな感じです。

    kotlin_05_006.png
  • item_step_log.xmlのレイアウトを、日付、レベル画像、天気画像を表示するように変更する
    私はこんなレイアウトにしました。

    kotlin_04_006.png
こちらはサンプルxmlです。
item_step_log.xml
<?xml version="1.0" encoding="utf-8"?>
<?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.logitem.LogInputFragment">

    <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/dateTextView"
            tools:text="2019/06/11"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="8dp"
            app:layout_constraintTop_toTopOf="parent"
            android:layout_marginTop="8dp"/>

    <TextView
            android:text=""
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/stepTextView"
            android:textSize="24sp"
            android:textColor="#0B0A0A"
            tools:text="12345"
            app:layout_constraintStart_toEndOf="@+id/levelImageView"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toBottomOf="@+id/weatherImageView"/>
    <TextView
            android:text="@string/label_log_suffix"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintStart_toEndOf="@+id/stepTextView"
            app:layout_constraintBottom_toBottomOf="parent"
            android:id="@+id/suffixTextView"
            android:layout_marginStart="8dp"
            android:elevation="0dp"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toTopOf="@+id/stepTextView"/>
    <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            tools:src="@drawable/ic_cloud_gley_24dp"
            android:id="@+id/weatherImageView"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="@+id/dateTextView"
            app:layout_constraintStart_toEndOf="@+id/dateTextView"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"/>
    <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            tools:src="@drawable/ic_sentiment_neutral_green_24dp"
            android:id="@+id/levelImageView"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="8dp"
            app:layout_constraintBottom_toBottomOf="parent"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toBottomOf="@+id/dateTextView"/>
</androidx.constraintlayout.widget.ConstraintLayout>
  • LogRecyclerAdapterを修正する
    override fun onBindViewHolder(holder: LogViewHolder, position: Int) {
        if (position >= list.size) return
        val stepCountLog = list[position]
        holder.textCount.text = stepCountLog.step.toString()
        holder.textDate.text = stepCountLog.date
        when (stepCountLog.level) {
            LEVEL.GOOD -> holder.level.setImageResource(R.drawable.ic_sentiment_very_satisfied_pink_24dp)
            LEVEL.BAD -> holder.level.setImageResource(R.drawable.ic_sentiment_dissatisfied_black_24dp)
            else -> holder.level.setImageResource(R.drawable.ic_sentiment_neutral_green_24dp)
        }
        when (stepCountLog.weather) {
            WEATHER.CLOUD -> holder.weather.setImageResource(R.drawable.ic_cloud_gley_24dp)
            WEATHER.RAIN -> holder.weather.setImageResource(R.drawable.ic_iconmonstr_umbrella_1)
            WEATHER.HOT -> holder.weather.setImageResource(R.drawable.ic_flare_red_24dp)
            WEATHER.COLD -> holder.weather.setImageResource(R.drawable.ic_iconmonstr_weather_64)
            WEATHER.SNOW -> holder.weather.setImageResource(R.drawable.ic_grain_gley_24dp)
            else -> holder.weather.setImageResource(R.drawable.ic_wb_sunny_yellow_24dp)
        }
    }

    class LogViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val textCount = itemView.stepTextView!!
        val textDate = itemView.dateTextView!!
        val level = itemView.levelImageView!!
        val weather = itemView.weatherImageView!!
    }

LogViewHolderクラスで保持するViewを増やし、LogRecyclerAdapter#onBindViewHolderでデータクラスの値に対応したそれぞれの値を入れたり、アイコン画像を引っ張ってきたりしています。

しかし・・・、when節でdrawableリソースのidを引っ張ってきているところが、なんか気になります。もっと綺麗に、短く書けないでしょうか?

enumクラスでなんとか返せないかな?というアプローチで考えると、実は、こんな書き方が出来ます。

enum class LEVEL(val drawableRes: Int) {
    NORMAL(R.drawable.ic_sentiment_neutral_green_24dp),
    GOOD(R.drawable.ic_sentiment_very_satisfied_pink_24dp),
    BAD(R.drawable.ic_sentiment_dissatisfied_black_24dp),
}
enum class WEATHER(val drawableRes: Int) {
    FINE(R.drawable.ic_wb_sunny_yellow_24dp),
    RAIN(R.drawable.ic_iconmonstr_umbrella_1),
    CLOUD(R.drawable.ic_cloud_gley_24dp),
    SNOW(R.drawable.ic_grain_gley_24dp),
    COLD(R.drawable.ic_iconmonstr_weather_64),
    HOT(R.drawable.ic_flare_red_24dp)
}

どちらも、Int型のdrawableResという「付加情報」を付けて、初期化しています。

これを使うと、LogRecyclerAdapter#onBindViewHolderはこんなにシンプルになります。

    override fun onBindViewHolder(holder: LogViewHolder, position: Int) {
        if (position >= list.size) return
        val stepCountLog = list[position]
        holder.textCount.text = stepCountLog.step.toString()
        holder.textDate.text = stepCountLog.date
        holder.level.setImageResource(stepCountLog.level.drawableRes)
        holder.weather.setImageResource(stepCountLog.weather.drawableRes)
    }

すっきりしましたね。

もっとも、ただの列挙型、特にこの列挙型は、MVVMのModel部分に相当するStepCountLogというdataクラスで使われるものですので、そのクラスの情報に、R.drawableクラスという表示データに関わる情報を持たせることに、設計として「気持ち悪い」と感じる人もいらっしゃると思います。場合によってはそのせいで、UnitTestが書きづらくなったりすることもありますし。その辺りは、設計思想だったり、現場の文化だったりでも方針は変わってきますので、柔軟に対応していきましょう。

今回は勉強ということもあり、enumの付加情報について学べる良い機会ということで、取り上げてみました。

2. Data Binding

(1) data bindingとは

Androidには、Data Bindingという便利なライブラリがあります。何をしてくれるかというと、レイアウトのウィジェット(View)と、表示するデータを、xml上でバインドしておくと、ViewHolderクラスでやっていたような「値をビューにセットするだけ」のコードを、ごっそりコードから削除することが出来る、という代物です。

だいたい、ViewHolderに表示する物って、あるクラスの情報まるっとだったり、その一部だったり、要するに、まとめて渡せたら便利なことが多くない?それ実現できない?ってのをやってくれるのが、Data Bindingライブラリです。

公式ドキュメントはこちら

CodeLabsを日本語訳してみた拙記事もありますので、よければそちらも見てみて下さい。

とにかく使って行きましょう。
必要な初期化は、android{}ノードに、下記を追記するだけです。

app/build.gradle
android{
    ...
    dataBinding {
        enabled true
    }  
    ...
}

dependenciesに追加する記述はありません。

(2) アイテムレイアウトのdata binding

早速、アイテムレイアウトを、data bindingのものに変えましょう。

1. レイアウトファイル全体をdata binding向けにする

data binding向けのレイアウトファイルにするには、レイアウト全体を、<layout>タグで囲む必要があります。

ルート要素のタグ内にカーソルを合わせ、マウスをホバリングさせると表示される黄色い(オレンジ?)電球アイコンをクリックすると、[Convert to data binding layout]とやると、楽です。

kotlin_04_021.png

こんな感じになるはずです。

item_step_log.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>

    </data>
    <androidx.constraintlayout.widget.ConstraintLayout
 ...
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

<data>タグに、レイアウト変数というのを定義します。挿入したいデータを外部から指定する場合の、渡される型、クラスなどを名前付きで指定します。

今回は、StepCountLogクラスを渡して使いたいので、こう書きます。

item_step_log.xml
    <data>
        <variable name="stepLog" 
                  type="jp.les.kasa.sample.mykotlinapp.data.StepCountLog"/>
    </data>

2. バインドするデータのセット

各ビューに、stepLogのどの値を使うかを設定していきます。

  • dateTextViewのtextには、stepLog.date
  • stepTextViewのtextには、stepLog.step(※ただしInt型なので文字列に変更して)
  • weatherImageViewのsrcには、stepLog.weather.drawableRes
  • levelImageViewのsrcには、stepLog.level.drawableRes

レイアウト式というのを使います。レイアウト式は、@{}で書きます。
各設定は次のようになります。

        <TextView
                android:id="@+id/dateTextView"
                android:text="@{stepLog.date}"
        <TextView
                android:id="@+id/stepTextView"
                android:text="@{Integer.toString(stepLog.step)}"
        <ImageView
                android:id="@+id/weatherImageView"
                android:src="@{stepLog.weather.drawableRes}"
        <ImageView
                android:id="@+id/levelImageView"
                android:src="@{stepLog.level.drawableRes}"

レイアウトxmlファイルへの設定はこれで終わりです。ここまでで、いったんBuildしておいてください。

3. LogRecyclerAdapterを書き換える

  • レイアウトのインフレートをdata bindingを使ったものに書き換える
    • LogRecyclerAdapter#onCreateViewHolderで、レイアウトをinflateしている部分を、Data Binding用に変更する
    • LogViewHolderのコンストラクタの引数の型を、Bindingオブジェクトに変更する
    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)
    }
    class LogViewHolder(val binding: ItemStepLogBinding)
                   : RecyclerView.ViewHolder(binding.root)

ItemStepLogBindingのimport候補が出ない場合は、xmlのgenerateに失敗しています。Buildをやり直してみて下さい。[generatedJava]に、ItemStepLogBindingというのが出来ているはずです。出来ていない場合は、xmlファイルの記述で何か間違えているか、どこかのKotlinコードが変になっていますので、よく確認して下さい。

  • 最後に、レイアウトで表示するのに必要な、バインドするデータを渡します。
    • LogRecyclerAdapter#onBindViewHolderを書き換える
    override fun onBindViewHolder(holder: LogViewHolder, position: Int) {
        if (position >= list.size) return
        holder.binding.stepLog = list[position]
    }

stepLogがどこから来たかというと、xmlのここです。

    <data>
        <variable name="stepLog"
                  type="jp.les.kasa.sample.mykotlinapp.data.StepCountLog"/>
    </data>

<variable>タグのname属性に指定した名前の変数が、Data Bindingライブラリによって生成され、binding.stepLogのようにアクセスすることが出来るようになります。

だいぶコードがスッキリしましたね。実行してみて下さい。

(3) もっとdata binding(Binding Adapter)

CodeLabsを日本語訳してみたとき、便利な方法を学習したので紹介します。

BindingAdapterというのを自作する方法です。

値によって画像を変えるのには、こちらが適していそうだと感じました。enumクラスの付加情報を勉強のために使いましたが、こちらのほうが、モデルから表示情報を削除出来、data bindingも活用した良い方法なので、知っておくと役に立つと思います。

1. BindingAdapterを作成する

  • BindingAdapters.ktというファイルを作り、以下の関数を作る
BindingAdapters.kt
   @BindingAdapter("android:src")
   fun setImageLevel(view: ImageView, level: LEVEL) {
       val res =
           when (level) {
               LEVEL.GOOD -> R.drawable.ic_sentiment_very_satisfied_pink_24dp
               LEVEL.BAD -> R.drawable.ic_sentiment_dissatisfied_black_24dp
               else -> R.drawable.ic_sentiment_neutral_green_24dp
           }
       view.setImageResource(res)
   }
   
   @BindingAdapter("android:src")
   fun setImageWeather(view: ImageView, level: WEATHER) {
       val res =
           when (level) {
               WEATHER.RAIN -> R.drawable.ic_iconmonstr_umbrella_1
               WEATHER.CLOUD -> R.drawable.ic_cloud_gley_24dp
               WEATHER.SNOW -> R.drawable.ic_grain_gley_24dp
               WEATHER.COLD -> R.drawable.ic_iconmonstr_weather_64
               WEATHER.HOT -> R.drawable.ic_flare_red_24dp
               else -> R.drawable.ic_wb_sunny_yellow_24dp
           }
       view.setImageResource(res)
   }

やってることはだいたいわかるかと思いますが、次のような感じです。

  • "android:src"という属性に対し、特定の引数の型が指定されたら、その型に応じて、setImageLevelsetImageWeatherが呼び出される(@BindingAdapterアノテーションにより、その辺は適切にコードが自動生成されます)
  • 中身は、以前LogRecyclerAdapter#onBindViewHolderでやっていたのと一緒で、enumクラスの値に応じて、ImageViewにセットすべきdrawableのリソースidを振り分けて、それをsetImageResourceにセット。when節が値を返せるところが、Kotlin的なポイントでしょうか。ifもそうでしたね。

2. ImageViewのバインディングを変更する

レイアウトxmlのlevelImageView, weatherImageViewのバインドしている値の指定を変更します。

item_step_log.xml
        <ImageView
                android:id="@+id/weatherImageView"
                android:src="@{stepLog.weather}"
...
        />
        <ImageView
                android:id="@+id/levelImageView"
                android:src="@{stepLog.level}"
...

3. enumクラスを元に戻す

enum class LEVEL {
    NORMAL,
    GOOD,
    BAD,
}

enum class WEATHER {
    FINE,
    RAIN,
    CLOUD,
    SNOW,
    COLD,
    HOT,
}

BindingAdapter、便利そうなので覚えておくと、かなり開発効率がよくなりそうです。

(4) メインレイアウト(LiveData)のdata binding

さて、CodeLabをちらっと見て頂けた方なら、LiveDataをdata bindingする方法があることに気付いたと思います。

それを、RecyclerViewでもやってみようと思います。

1. RecyclerViewにもdata bindingを適用する

  • activity_main.xmlをdata binding用レイアウトに変更する
    • <data>タグに指定するのは、name=viewmodeltype=(フルパッケージ名).MainViewModel
  • app:itemsという属性でリストList<StepCountLog>を受け取る、BindingAdapter用の関数を作る
  • MainActivityでdata bindingの設定をする
    • レイアウト設定方法をDataBindingUtil#setContentViewにする
    • bindingオブジェクトのライフサイクルオーナーに自分を設定する
    • bindingオブジェクトのレイアウト変数viewmodelに、MainViewModelをセットする

全部やるとこんな感じになります。

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">

        <androidx.recyclerview.widget.RecyclerView
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:id="@+id/log_list"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                android:layout_marginTop="8dp"
                android:layout_marginStart="8dp"
                android:layout_marginEnd="8dp"
                android:layout_marginBottom="8dp"
                app:items="@{viewmodel.stepCountList}"/>

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
BindingAdapters.kt
@BindingAdapter("app:items")
fun setLogItems(view: RecyclerView, logs: List<StepCountLog>?) {
    val adapter = view.adapter as LogRecyclerAdapter? ?: return

    logs?.let {
        adapter.setList(logs)
    }
}

nullチェックがしつこいですが、タイミングによってはnullがありうるので、きちんとチェックしておきます。

2. MainActivityでBindingオブジェクトにライフサイクルオーナーとViewModelをセットする

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

        val binding: ActivityMainBinding
                = DataBindingUtil.setContentView(this, R.layout.activity_main)

        viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)

        binding.lifecycleOwner = this
        binding.viewmodel = viewModel

        // RecyclerViewの初期化
        log_list.layoutManager = LinearLayoutManager(this)
        adapter = LogRecyclerAdapter(viewModel.stepCountList.value!!)
        log_list.adapter = adapter
        // 区切り線を追加
        val decor = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
        log_list.addItemDecoration(decor)

        InputDialogFragment().show(supportFragmentManager, INPUT_TAG)
    }
  • val binding: ActivityMainBinding = DataBindingUtil.setContentView(this, R.layout.activity_main)は、DataBindingでレイアウトを指定して、bindingオブジェクトを取得する定型的なコードです

  • binding.lifecycleOwner = thisで、ライフサイクルオーナーを指定しています

  • binding.viewmodel = viewModelで、レイアウトxmlの<data>タグのname属性に指定した名前のレイアウト変数viewmodelに、作成したViewModelのインスタンスを渡しています

ViewModelをobserveしているコードが無くなりました。Data Bindingの方でよしなにobserveしてくれているのですね。

実行すると、ちゃんと追加が反映されているかと思います。

3. ダイアログをやめて画面遷移にする(Activity遷移)

さて、お気づきでしょうが、せっかくログクラスにデータが増えているのに(天気、レベル)、それを入力する箇所がありません。
ということで、入力画面を変えていきます。

ダイアログでそのままやっても良いのですが、今後いろいろ肉付けしていく際に必要そうなので、別のActivityを作ってやっていこうと思います。

今回は、Fragmentの作成もやってみましょう。

おさらいですが、Fragmentとは、Activity(1画面)の上に何枚でも(多分上限はありそうですが)重ねられる、スクリーン(あるいはレイヤーと言った方が分かりやすいかな?)みたいなものです。

(1) 新しいActivityクラスを作成する

1. 新しいパッケージを作る

ついでなので、パッケージを増やしましょう。

[app]-[java]下にある、パッケージ名のトップで右クリックして、[New]-[Package]とし、
kotlin_04_030.png

activity.logitemと入力します。

kotlin_04_031.png

パッケージングにはいろいろ流儀があると思いますので、現場やチームでのルールに従いましょう。
私は最近は画面ごとに分けています。今回も、Activityが増えるごとに、activityパッケージ下に増やしていくイメージで作りました。

2. LogItemActivityを作成する

Activiyを新規作成します。

  • パッケージ[activity.logitem]で右クリックし、[New]-[Activity]-**[Basic Activity]**と選択
kotlin_04_032.png
  • ActivityNameにLogItemActivityと入力し、あとは図の通りにして[Finish]
kotlin_04_033.png

LogItemActivity.ktファイルと、activity_log_item.xml、そしてcontent_log_item.xmlが作成され、ファイルが開きます。また、[manifests]下にあるAndroidManifest.xmlを開くと、<activity>タグが追加されているのが分かります。
それと、app/build.gradleにもdependenciesが1行追加されています。

app/build.gradle
implementation 'com.google.android.material:material:1.0.0-beta01'

これは、AppBarLayoutというのに必要なので自動で追加されました。気になる人は、最新バージョンにしておきましょう。(2019/06/19現在、"1.1.0-alpha07"が最新版みたいです)

  • activity_log_item.xml

MainActivityにもActionBarは出ています(上部の緑色?の部分。この色はランダムに決まる可能性があるので、違うかも)が、このクラスは実は、もうあまり推奨されていません。多分、標準の(MainActivityについている)ActionBarは、マテリアルデザインに対応していないとかで、対応しているSupportActionBarを使えということだったと思います。それを使うためのレイアウトは、基本的にはこのような構成になります。

CoordinatorLayoutの中に、AppBarLayoutがあり、更にその中に、Toolbarがある、という階層構造になっています。
Acvitiyのメインコンテンツは、CoordinatorLayoutの中、AppBarLayoutの下に定義していきますが、

activity_log_item.xml
<include layout="@layout/content_log_item"/>

このように、別のレイアウトで定義した物をincludeする形が圧倒的に多いです。もちろん、直接その部分にレイアウトを書いていっても良いのですが、レイアウトファイルがゴチャゴチャするのを避ける為にも、なるべくこの形にした方が良いのではないかと思っています。

includeを使うと、細々としたレイアウトセットを別ファイルに作っておき、組み合わせて使えるようにもなったりするので、レイアウトファイルの再利用性が高まります。ただもちろん、これはデメリットもあって、1つの画面だけレイアウトを変えたくても、他の画面も影響を受けてしまう、ということもあるので、あまり分割/再利用しすぎるのも問題になりますので、要注意です。

3. レイアウトファイルを修正

FloatingActionButtonは不要なので、ごっそり削除して下さい。

activity_log_item.xml
<!-- <com.google.android.material.floatingactionbutton.FloatingActionButton
            android:id="@+id/fab"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="bottom|end"
            android:layout_margin="@dimen/fab_margin"
            app:srcCompat="@android:drawable/ic_dialog_email"/>
-->
  • content_log_item.xml

ルート要素に、app:layout_behavior="@string/appbar_scrolling_view_behavior"という属性が定義されています。これが無いと、ActionBarの下にViewが潜り込んでしまうので、必ず指定をして下さい。

そして、FrameLayoutConstraintLayout内に1つ作り、idをlogitem_containerとします。

content_log_item.xml
    <FrameLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent" 
            android:id="@+id/logitem_container" 
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintStart_toStartOf="parent" 
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent">
    </FrameLayout>

Activityのレイアウトはここまでで完了です。値を入力する項目を作ってないって?それはFragmentのレイアウトでやるのです。
Activityに必要なのは、ActionBarに関わるレイアウトと、Fragmentをはめ込む「枠」としてのLayoutが1つあれば、ほとんどの場合、充分です。

  • LogItemActivity.kt

いまはonCreateがあるだけですね。

ひとまず、fabボタンのコードは不要なので削除します。
LogItemActivity.ktのコードはこうなります。

LogItemActivity.kt
class LogItemActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_log_item)
        setSupportActionBar(toolbar)
    }
}

setSupportActionBar(toolbar)が新しいですかね。
前述の通り、SupportActionBarに、toolbarとidを付けたToolbarオブジェクトを指定しています。
これにより、supportActionBarを介して、タイトルを表示したりアイコンを表示したりすることが出来るようになります。

4. Fragmentを追加するコードを実装

LogItemActivity#onCreateに下記のコードを追記します。

LogItemActivity.kt
    if(savedInstanceState==null){
        supportFragmentManager.beginTransaction()
            .replace(R.id.logitem_container, LogInputFragment.newInstance())
            .commitNow()
    }

このコードでやっているのは、supportFragmentManagerというActivityのFragmentを管理するマネージャーに(その名の通りですね)、LogInputFragmentのインスタンスを作って、R.id.logitem_containerというidのViewとreplaceする、という内容です。

以前、DialogFragmentを表示するときは、DialogFragment#showを使いましたが、Fragmentを表示するには、本来、supportFragmentManagerに対して設定を行っていきます。まずbeginTransaction()してから、replace/add等をして、最後にcommitNow()commit()をする、としなければなりません。DialogFragment#showは、内部でその流れをやってくれているということになります。

なお、LogInputFragmentクラスはまだ作っていないので、現時点ではコンパイルエラーになります。

(2) Fragmentを作成する

次はFragmentを作っていきます。

1. Fragmentを新規作成する

  • パッケージ[activity.logitem]を選んで、右クリックメニューから、[New]-[Fragment]-[Fragment (Blank)]を選択
kotlin_04_034.png
  • Fragment Nameに、InputLogFragmentとし、あとは図の通りの設定にして、[Finish]
kotlin_04_035.png
  • 不要なコードは削除しておく

    下記コードを削除

InputFragment.kt
	// TODO: Rename parameter arguments, choose names that match
	// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER
	private const val ARG_PARAM1 = "param1"
	private const val ARG_PARAM2 = "param2"

InputFragment.ktはいまはこれだけの状態のはずです。

InputFragment.kt

class LogInputFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_log_input, container, false)
    }
}

androidx.fragment.app.Fragmentを継承しています。これは必ずこのクラスにします。

onCreateViewは、LayoutInflatercontainer(nullable)と、savedInstanceState(nullable)を受け取ります。

LayoutInflaterはこれまでにも出てきたのでお分かりと思いますが、レイアウトxmlファイルを実際にViewオブジェクトにインスタンス化している、と思えば良いと思います。

containerは、親のViewGroupです。例えば、今回は、activity_log_item.xmlR.id.logitem_containerというidを指定してFragmentをセットしているので、ここに渡ってくるのは、FrameLayoutの実体ということになります。nullableになってるけど、nullが来ることは無い気がするんですがね・・・

さて、Bundle?savedInstanceStateは、Activityのコードでも出てきました。onCreateに追加したコードですね。

LogItemActivity.kt
        if(savedInstanceState==null){
            supportFragmentManager.beginTransaction()
                .replace(R.id.logitem_container, LogInputFragment.newInstance())
                .commitNow()
        }

Activityのライフサイクルを覚えているでしょうか?Activityが裏に回ったとき(onPaused以降)、Activityクラスのオブジェクトは、実はメモリから解放されてしまうことがあります。AndroidOSが、メモリが足りなくなると、バックグラウンドにあるオブジェクトのメモリを解放して賄おうとするからです。
その状態で、アプリをもう一度起動しようとすると、AndroidOSはなるべく前回の状態を復元しようとしてくれます。そんなとき、savedInstanceStateに、保存してあったデータが渡ってきます。

ということで、savedInstanceState==nullという条件は、実は、復元すべきデータが無いとき、つまり**「最初にActivityが作成されたとき」**を意味しています。

ちなみに、復元が必要なデータは、自動で保存してくれるわけでは無く、Activity#onSaveInstanceStateで自分で保存するコードを書いた物だけが渡ってきます。
このデータには容量制限があるので、やたらめったらデカいデータは、別の保存/復元方法を考えた方が良いでしょうね。

FragmentにもonSaveInstanceStateがあります。Activity同様、バックグラウンドなどから復帰したときに復元されて欲しいデータをセットしておくと、onCreateViewsavedInstanceStateはnullではなく、セットしておいたデータのセットが入ってきます。

バックグラウンドに行ったが、メモリが解放されることが無かった場合は、ActivityやFragmentの再作成は行われません(=onCreate等は呼ばれない)。その場合、画面が復帰したときはonResume等から呼ばれます。

onSaveInstanceStateが呼ばれるもう1つ重要なタイミングとしては、「画面回転」が挙げられます。端末を縦にしたり横にしたりしたときですね。この場合は、画面の再作成は、必ず行われます。(行わせないようにする設定は一応ありますが、このアプリでは使いません)

面倒なので、スマホ向けのアプリだと、「縦画面固定」や、ゲーム等は「横画面固定」にしているものがほとんどだと思いますが、今回は、ViewModelにせっかく対応しているので、固定にはしないで実装を進めたいと思います。
ただし、縦画面向け、横画面向けに、レイアウトの変更が必要になる場合があるので、その際には、代替リソースを使って、レイアウトxmlファイルを分けて対応していきましょう。
(今回も最終的に横画面向きのレイアウトを作ってあります。Githubにpushしたプロジェクトで、res/layout-landを参照して下さい。)

2. LogInputFragmentをインスタンス化するメソッドを作成

Fragmentのクラスが出来たので、それをインスタンス化するメソッドを作ります。
LogItemActivityで、LogInputFragment.newInstance()と呼び出していたのを覚えていますか?このメソッドを作りましょう。

LogInputFragment.kt
    companion object {
        fun newInstance(): LogInputFragment {
            val f = LogInputFragment()
            return f
        }
    }

※inlineに出来るよ、という警告は無視して良いです。

companion object{}の説明はしたっけ?した気もするな・・・(汗)
Javaでいうクラスの静的メンバ、メソッドは、companion object{}の中に定義します。

今やっているのは、単にLogInputFragmentをnewしているだけですが、後々、ここに初期値などのデータを渡すこともあるかも知れない・・・ということでこの形で作っておく方が便利です。

さて、これでいったんビルドは通るようになりました。
実行・・・しても、変化はありません。当然です、LogItemActivityに遷移するコードを書いていません!

ということで、次はActvitiyからActivityへの遷移を実装します。

(3) MainActivityからの遷移を実装する

1. InputDialogFragmentは不要なので、削除する

  • クラスファイル、レイアウトファイル、MainActivityでshowしているコード、すべて削除する

    MainActivity.kt
    
        // このコードは削除
        // InputDialogFragment().show(supportFragmentManager, INPUT_TAG)
    

2. 追加メニューが押されたときの処理を、Activity遷移に変更する

MainActivity.kt
override fun onOptionsItemSelected(item: MenuItem?): Boolean {
        item?.let {
            return when (it.itemId) {
                R.id.add_record -> {
                    val intent = Intent(this, LogItemActivity::class.java)
                    startActivityForResult(intent, REQUEST_CODE_LOGITEM)
                    true
                }
                else -> false
            }
        }
        return false
    }

Activityを起動するときには、Intentを使い、startActivitystartActivityForResultというメソッドを呼びます。Intentオブジェクトの作り方はいくつか引数のパターンがありますが、よく必要なのは、「自分のアプリパッケージ内の特定のActivityクラスを開く」なので、上記のパターンを非常に多く使うことになります。引数の1つめはPackageContext、2つめは「リクエストコード」と言われるものです。startActivityForResultメソッドを通して起動したActivityが終了して自分に戻ってくると、そのActivityからの戻り値やデータを受け取ることが出来ます。startActivityは特にデータのやりとりが不要な場合に使います。今回は、後で値を受け取るので、startActivityForResultの方を使います。

REQUEST_CODE_LOGITEMは次のようにしました。

MainActivity.kt
companion object {
    const val REQUEST_CODE_LOGITEM = 100
}

そういえば、INPUT_TAGは不要ですのでこれも削除しておきます。

実行してみて下さい。"+"ボタンタップで、新しい画面に遷移しましたか?
端末の戻るボタンを押すと戻りましたか?

これで行き来できるようになりましたね。

3. ActionBar(Toolbar)に戻るボタンを表示して処理をする

遷移した画面からは、左上に戻るボタンを表示して、そこをタップしても戻れるようにしましょう。

  • 戻るボタン表示にする

    下記コードをLogItemActivity#onCreate内の、setSupportActionBar(toolbar)の後に追記します。

LogItemActivity.kt
    supportActionBar?.setDisplayHomeAsUpEnabled(true)
  • 戻るボタンがタップされたときの処理を実装する

    下記メソッドをLogItemActivity.ktに追加します。

LogItemActivity.kt

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        when (item.itemId) {
            android.R.id.home -> {
                onBackPressed()
                return true
            }
        }
        return super.onOptionsItemSelected(item)
    }

面白いことに(?)、左上の「←」ボタンは、オプションの一部なんですね。そしてメニューアイテムIDは、android.R.id.homeが付いているようです。

onBackPressed()メソッドは、Activityクラスが元々持っているメソッドで、基本的には自分をfinish()するだけです。別の動作をさせたいときは、このメソッドをオーバーライドすると楽です。(少し古いサンプルだと、キーイベントでbackキーを拾って奪うようなコードを見かけますが、そんな必要はもう無いです。)

これで実行すると、ActionBarの左側に←アイコンが表示され、タップすると戻るようになりました。

kotlin_04_036.png


愚痴ここから===

ところで、よく、アプリを終了しようとして戻るボタン押すと、わざわざ「終了しますか?」って聞いていくるアプリがありますが、私、アレ嫌いなんですよね・・・もっと酷いのだと、戻るボタンの動作を無効にしていて、戻るボタンではアプリを終了できないのさえ、あります。そういうアプリは直ぐアンインストールしちゃいます。が、その仕様を企画から要求されたときには・・・ジレンマをご想像下さい^^; Googleさんも、「ユーザーの意図を妨げる、無効にするような処理」はやっちゃだめと言っているのにねえ・・・

==愚痴ここまで


4. 入力画面のレイアウトを作る

fragment_log_input.xmlを編集していきます。
とりあえず、既にあるTextViewは不要なので、削除します。

こんな見た目はどうでしょうか?(デザインセンスが無いのは目を瞑って下さい汗)

kotlin_04_040.png

階層はこんな感じになります。

kotlin_04_041.png
xmlは参考までにこちら。
fragment_log_input.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.logitem.LogInputFragment">

    <TextView
            android:text="@string/label_date"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/label_date"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="32dp"
            app:layout_constraintTop_toTopOf="parent"
            android:layout_marginTop="16dp"/>
    <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/text_date"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="32dp"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toBottomOf="@+id/label_date"
            android:layout_marginBottom="8dp"
            app:layout_constraintBottom_toBottomOf="@+id/button_date"
            android:textSize="18sp"
            tools:text="2999/99/99"/>
    <Button
            android:text="@string/label_select_date"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/button_date"
            app:layout_constraintStart_toEndOf="@+id/text_date"
            android:layout_marginStart="8dp"
            app:layout_constraintTop_toBottomOf="@+id/label_date"/>
    <TextView
            android:text="@string/label_step_count"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/label_step_count"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toBottomOf="@+id/button_date"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="32dp"/>
    <EditText
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:inputType="numberSigned"
            android:ems="10"
            android:id="@+id/edit_count"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="32dp"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toBottomOf="@+id/label_step_count"
            android:hint="@string/hint_edit_step"
            android:singleLine="true"
            android:textAlignment="textEnd"
            android:importantForAutofill="no" tools:targetApi="o"/>
    <TextView
            android:text="@string/label_level"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/label_level"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toBottomOf="@+id/edit_count"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="32dp"/>
    <RadioGroup
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="32dp"
            android:layout_marginTop="8dp"
            android:orientation="horizontal"
            app:layout_constraintTop_toBottomOf="@+id/label_level"
            android:id="@+id/radio_group"
            app:layout_constraintEnd_toEndOf="parent"
            android:layout_marginEnd="32dp">
        <RadioButton
                android:text="@string/level_normal"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/radio_normal"/>
        <ImageView
                android:src="@drawable/ic_sentiment_neutral_green_24dp"
                android:layout_width="wrap_content"
                android:layout_height="match_parent"
                android:id="@+id/imageView"/>
        <RadioButton
                android:text="@string/level_good"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/radio_good"
                android:layout_marginLeft="8dp"/>
        <ImageView
                android:src="@drawable/ic_sentiment_very_satisfied_pink_24dp"
                android:layout_width="wrap_content"
                android:layout_height="match_parent"
                android:id="@+id/imageView2"/>
        <RadioButton
                android:text="@string/level_bad"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/radio_bad"
                android:layout_marginLeft="8dp"/>
        <ImageView
                android:src="@drawable/ic_sentiment_dissatisfied_black_24dp"
                android:layout_width="wrap_content"
                android:layout_height="match_parent"
                android:id="@+id/imageView3"/>
    </RadioGroup>
    <TextView
            android:text="@string/label_weather"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/label_weather"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="32dp"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toBottomOf="@+id/radio_group"/>

    <Spinner
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:minWidth="180dp"
            android:id="@+id/spinner_weather"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toBottomOf="@+id/label_weather"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="32dp"
            android:entries="@array/array_weathers"/>
    <Button
            android:text="@string/resist"
            android:layout_height="wrap_content"
            android:layout_width="match_parent"
            android:id="@+id/button_resist"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="8dp"
            app:layout_constraintEnd_toEndOf="parent"
            android:layout_marginEnd="8dp"
            app:layout_constraintTop_toBottomOf="@+id/spinner_weather"
            android:layout_marginTop="24dp"/>
</androidx.constraintlayout.widget.ConstraintLayout>

RadioGroupRadioButtonSpinnerが新しいですかね。

RadioGroupRadioButtonはその名の通り、ラジオボタンを管理するグループと、ラジオボタンのアイテムです。今回は、android:orientation="horizontal"を指定してるので、横に並びますが、デフォルトは縦に並びます。
また、RadioGroupは恐らくLinearLayoutの派生なので、RadioButtonでなくても他の子Viewを並べられます。
ただ、通常、RadioButtonを配置していくと、android:layout_weight="1"という属性がデフォルトで入ります。
これが本来は都合が良いのですが(並べた要素の数に応じて均等な幅にしてくれる)、今回はアイコン画像を差し込みたかったのもあり、その属性を消去しています。動作が気になる方は、入れてみてどうなるか見てみて下さい。

Spinnerは、いわゆるドロップダウンボックスです。固定の要素をポップアップでリスト表示して選べるあれです。
要素は、今回は完全に固定なので、android:entries="@array/array_weathers"と属性で指定しています。要素が変動する場合は、プログラム上から設定することになります。

array/array_weathersが新しいリソースの定義の仕方になりますかね。これは、arrays.xmlというファイルをres/values下に作って以下のよう記述してあります。

arrays.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string-array name="array_weathers">
        <item>晴れ</item>
        <item></item>
        <item>曇り</item>
        <item></item>
        <item>寒い</item>
        <item>暑い</item>
    </string-array>
</resources>

このarraysは、後でenumクラス(WEATHER)と付き合わせることになるので、enumクラスでの定義順と同じ並びになっている必要があります。

(4) 入力データの受け渡し

入力画面が出来たので、登録ボタンが押されたときに、その値を収集して、MainActivityに返すようにしてみましょう。
データの受け渡しには、ViewModel(LiveData)を使って行きます。data bindingは今回ちょっと使いませんが、使えないことはないはずなので興味ある方はチャレンジしてみて下さい。

1. ViewModelを作成する

クラス名は、LogItemViewModelとでもしましょうか。パッケージは、activity.logitemとしました。

LogItemViewModel.kt
import androidx.annotation.UiThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import jp.les.kasa.sample.mykotlinapp.data.StepCountLog

class LogItemViewModel : ViewModel() {

    private val _stepCountLog = MutableLiveData<StepCountLog>()

    val stepCountLog = _stepCountLog as LiveData<StepCountLog>

    @UiThread
    fun changeLog(data :StepCountLog){
        _stepCountLog.value = data
    }
}

さて、今回はちょっとLiveDataの取り扱いに小技を入れてみました。stepCountLogをそのままMutableLiveDataとしても良いのですが、それではどこで誰が変更しようとするか分かりづらくなります。changeLogという関数を介してしか、変更できないようにしておくと、[Find Usage]とか、[Grep]検索とかで、非常に探し出しやすくなります。・・・というのはかなり強引に考えた理由なのですが、Googleさんがこうしなさいと言っているみたいです。

ということで、Mutableな_stepCountLogをprivate変数とし、誰でも参照できるpublicなstepCountLogは、読み取り専用としました。

changeLogは新しいStepCountLog型のオブジェクトを受け取り、それをそのままLiveDataの値として発行します。setValueを直接使っていて、postValueとしていないため、この関数は、必ずUIメソッドから呼ばれる必要があります。そのため、@UiThreadアノテーションを付けています。

2. LogItemViewModelを使うようにする

LogItemActivityと、LogInputFragmentで、LogItemViewModelを使うようにします。

LogItemActivity.kt

    lateinit var viewModel: LogItemViewModel
    
    override fun onCreate(savedInstanceState: Bundle?) {
...
        viewModel = ViewModelProviders.of(this).get(LogItemViewModel::class.java)
    }
LogInputFragment.kt

    lateinit var viewModel: LogItemViewModel
    
    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        viewModel = ViewModelProviders.of(activity!!).get(LogItemViewModel::class.java)
    }

ほとんど同じですが、Fragmentの方は、少し初期化のタイミングが違います。onCreateViewでも良さそうな気がするけど、AndroidStudioが[Fragment + LiveData]でFragmentを新規作成したときに生成するコードが、ここで初期化していたのでそれを踏襲することにします。

やってることはもう分かりますね。ActivityとViewModelを共有したいので、ViewModelProviders.ofにはactivityを渡しています。(むしろ共有しないパターンがこのアプリでは出てこない気がしてきた)

3. データをMainActivityに戻す処理を実装する

  • ラジオボタンやスピナーの選択状態から、enum値を取り出す関数を作る

LogInputFragmentに、下記のようなprivate関数を作ります。

LogInputFragment.kt
    private fun levelFromRadioId(checkedRadioButtonId: Int): LEVEL {
        return when (checkedRadioButtonId) {
            R.id.radio_good -> LEVEL.GOOD
            R.id.radio_bad -> LEVEL.BAD
            else -> LEVEL.NORMAL
        }
    }

    private fun weatherFromSpinner(selectedItemPosition: Int): WEATHER {
        return WEATHER.values()[selectedItemPosition]
    }

weatherFromSpinnerが少し見慣れないコードでしょうか。といっても、Javaのenumと同様、values()で配列で取得できるので、それを利用して、「選択したスピナーの位置」をindexとして配列から値を取ってきて返していると言うだけのコードです。
ここで、配列の順番と、スピナーの各アイテムの位置が一致してないとこの手法は使えないので、arraysリソース定義のところで、「一致させる必要がある」と書きました。
当然、要素が途中に増えたりすると面倒なこと(挿入位置の間違いや片方の挿入漏れの発生等)になるので、頻繁に改修が入るようなときには要注意ですね。

  • 登録ボタンにクリックリスナーを登録する

onCreateViewで、登録ボタンに対してクリックリスナーを登録します。
onCreateViewの全体はこのようになります。

LogInputFragment.kt
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        val contentView = inflater.inflate(R.layout.fragment_log_input, container, false)

        contentView.radio_group.check(R.id.radio_normal)
        contentView.text_date.text = getDateStringYMD(Calendar.getInstance().time)

        contentView.button_resist.setOnClickListener {
            val dateText = text_date.text.toString()
            val stepCount = edit_count.text.toString().toInt()
            val level = levelFromRadioId(radio_group.checkedRadioButtonId)
            val weather = weatherFromSpinner(spinner_weather.selectedItemPosition)
            val stepCountLog = StepCountLog(dateText, stepCount, level, weather)

            viewModel.changeLog(stepCountLog)
        }

        return contentView
    }

ビューの子要素にアクセスしたいので、ルートのViewをcontentView変数に入れ、最終的にそれをreturnしています。

contentView.radio_group.check(R.id.radio_normal)は、初期値のラジオボタンを選択状態にしています。未選択だと都合が悪いので。

contentView.text_date.text = getDateStringYMD(Calendar.getInstance().time)は、日付を今日の日付で初期化しています。
日付を選ぶ実装は少し後に入れます。getDateStringYMD関数は、以前InputDialogFramentにあったものを、Util.ktファイルに移して、Calendarクラス拡張関数にしてみました。

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

fun クラス名.新しい関数宣言で、あるクラスに対して、追加の関数を定義することが出来ます。これが拡張関数です。
元のクラスを変更したり、わざわざ派生クラスを作る必要も無く、新しい関数を作れるので、とても便利です。
これにより、あたかも最初からCalendarクラスにgetDateStringYMDというメソッドが用意されていたかのように、利用できるわけです。

ちゃんとthisでそのオブジェクトのメンバーやメソッドにアクセスできます。thisは省略可能ですが、敢えて分かりやすく書いておきました。
あたかもクラスのメソッドのように使えると書きましたが、privateprotectなメンバーにはアクセスできないようです。

contentView.button_resist.setOnClickListener {...}が、登録ボタンにクリックリスナーを登録するコードです。ラムダになっています。やっていることは単純なので分かりますね。各Viewから値を取得し、レベルと天気については、先ほど作った関数からenum値に変換し、最終的にStepCountLogの新しいインスタンスを作って、ViewModelに変更を掛けています。

(data bindingにしたらここもかなりスッキリしそうですね)

尚、contentView.text_dateとしている箇所と、ラムダの中ではtext_dateと直接アクセスしていたりする違いについてですが、onCreateViewを抜けるまでは、直接アクセスするtext_dateのほうは、まだnullなんですね。だってonCreateViewが返したViewオブジェクトを使って初めてFragmentが作られますから。この時点ではまだFragmentにViewはセットされていないんです。
なのでonCreateView内では、ルートのViewからアクセスしています。

import文をよく見ると、以下の2つがあることが分かるとかと思いますが、

import kotlinx.android.synthetic.main.fragment_log_input.*
import kotlinx.android.synthetic.main.fragment_log_input.view.*

下にあるのがcontentView.text_dateとアクセスするためのパッケージで、上にあるのが、直接アクセスするためのパッケージです。どちらも自動生成されたものです。

4. Activityでデータ変更を検知して呼び出し元に戻す

LogItemActivityで、LogItemViewModel.stepCountLogを監視しておき、変更がかかったら呼び出し元のActivityに値を返し、自分は終了するようにします。

  • LiveDataを監視するコード
LogItemActivity.kt
        viewModel.stepCountLog.observe(this, Observer {
            val dataIntent = Intent()
            dataIntent.putExtra("data", it)
            setResult(RESULT_OK, dataIntent)
            finish()
        })

Activityが呼び出し元にデータを返す常套手段は、setResultにリザルトコードを指定したり、付加情報を付けたIntentを返す方法です。

dataIntent.putExtra("data", it)で、dataIntentのExtraデータのキー名"data"として、it、すなわちStepLogCountのオブジェクトを入れています。
その後、setResult(RESULT_OK, dataIntent)で、RESULT_OKというリザルトコードと共に、dataIntentを結果として設定後、自分自身をfinish()しています。
これで、呼び出し元のActivityが、リザルトコードとdatIntentを受け取ることが出来ます。

・・・dataIntent.putExtra("data", it)でエラーになっているって?
今からそこを直します。

その前に、"data"のべた書きが気になるので、companion objectに定数で定義しておきます。

LogItemActivity.kt
class LogItemActivity : AppCompatActivity() {

    companion object {
        const val EXTRA_KEY_DATA = "data"
    }
}
LogItemActivity.kt
     dataIntent.putExtra(EXTRA_KEY_DATA, it)

5. StepLogCountをSerializableにする

Intent#putExtraは色んな型を受け取れるようオーバーロードされた関数がたくさんあります。
頑張ってStepLogCountの要素1つずつ、putExtra(String)とかでチマチマやっても良いですが、ちゃんとクラスで対応しておく方が後々楽です。

クラスごとIntentのExtraに設定できるようにするには、2つのアプローチがあります。

  1. Serializableにする
  2. Parcelableにする

Serializableは、Javaにもあるデータの直列化ですね。
Parcelableは、フルパッケージはandroid.os.Parcelableとなっていて、Androidならではの形式になります。

今回は、Serializableにします。というのも、その方が圧倒的に簡単だし(Parcelableにするとコーディング量が増える)、Parcelableのメリットは「アプリ同士で外部連携が可能」という部分くらいかなと思っているので、今回はSerializableで充分だと思います。

クラスをSerializableにするには、そのメンバーもすべてSerializableである必要があるので、Bitmap何かを渡したいときには注意が必要です。今回は、String, Int, そしてEnumが対象ですが、EnumクラスはSerializableに対応しているので、そこも問題ありません。

早速Serializableにしましょう。

Serializableをimplementするだけですね。

data class StepCountLog(
    val date: String,
    val step: Int,
    val level: LEVEL = LEVEL.NORMAL,
    val weather: WEATHER = WEATHER.FINE
) : Serializable

これで入力画面からデータを戻すところは出来ました。

6. 戻されたデータをMainActivityで処理する

入力画面からの結果を受け取って処理するコードを書いていきます。

  • Activityの終了結果を受け取る
MainActivity.kt
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {

        when (requestCode) {
            REQUEST_CODE_LOGITEM -> {
                onNewStepCountLog(resultCode, data)
                return
            }
        }
        
        super.onActivityResult(requestCode, resultCode, data)
    }

自分がstartActivityForResultで起動したActivityが終了したことは、onActivityResultで受け取ります。
上記のコードは、requestCode==REQUEST_CODE_LOGITEMだったとき、onNewStepCountLogメソッドを呼び出しています。
when{}節がネストするのは嫌いなので、private関数に分けました。

onNewStepCountLogは次のようになります。

MainActivity.kt
   private fun onNewStepCountLog(resultCode: Int, data: Intent?) {
        when (resultCode) {
            RESULT_OK -> {
                val log = data!!.getSerializableExtra(LogItemActivity.EXTRA_KEY_DATA) as StepCountLog
                viewModel.addStepCount(log)
            }
        }
    }

resultCode==RESULT_OKだったときのみ、dataから"data"キーのExtraデータをSerializableで取り出し、それをStepCountLogにキャストしています。?を付けていないので、この時変数lognon-nullです。万が一nullになる場合(data==nullか、Extraデータに該当キーのデータが無い、nullがセットされているなど)は、キャストに失敗するため、例外でクラッシュします。しかしnullは実装上あり得てはいけないので、ここでは!!で強行しています。
が、もし万全を期すなら、

                val log = data?.getSerializableExtra(LogItemActivity.EXTRA_KEY_DATA) as StepCountLog?
                log?.let{
                    viewModel.addStepCount(log)
                }

これくらいやれば、万全でしょう。

後は、これまで見てきたように、その値をViewModelに渡してリストに追加すれば、完了です。

実行してみましょう。
天気、レベルのアイコンが、ちゃんと入力画面で選んだ内容に変わっているはずです。

(5) 日付を選択出来るようにする

最後に、「日付を選ぶ」ボタンを押したときに、日付が選べるようにします。
CalendarViewというそのままズバリの物があるので、使いましょう。

1. DateSelectDialogFragmentクラスを作って表示する

InputDialogFragmentを作ったように、DateSelectDialogFragmentクラスを、AlertDialogで表示するように作ります。
レイアウトは特に不要で、CalendarViewオブジェクトをそのままsetViewします。

また、CalendarViewには、「今日」を初期値で選択状態にします。
CalendarView#setDateで初期値が設定できます。

button_dateが押されたときに、DateSelectDialogFragmentを表示します。

2. 選択した日付をセットするLiveDataを定義

LogItemViewModelに、選んだ日付を受け渡せるLiveDataを定義し、DateSelectDialogFragmentのポジティブボタンが押されたときに変更するようにします。

CalendarViewでの日付変更イベントリスナーは、CalendarView#setOnDateChangeListenerで設定します。

3. そのLiveDataをobserveし、変更をTextViewに反映する

そして、LogInputFragmentでそのLiveDataをobserveし、変更時にtext_dateに反映するようにします。せっかく作ったCalendarクラスの拡張関数getDateStringYMD()を使いましょう。

ここまでの復習です。まずは上記をヒントに作ってみましょう。

サンプルはこちら。
DateSelectDialogFragment.kt
import android.app.Dialog
import android.os.Bundle
import android.widget.CalendarView
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.ViewModelProviders
import java.util.*

/**
 * 日付選択ダイアログ
 **/
class DateSelectDialogFragment : DialogFragment() {

    // CalendarViewで選択している日付の保存
    private val selectDate = Calendar.getInstance()
    
    // CalendarView
    lateinit var calendarView: CalendarView

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val viewModel = ViewModelProviders.of(activity!!).get(LogItemViewModel::class.java)

        // AlertDialogで作成する
        val builder = AlertDialog.Builder(requireContext())

        // CalendarViewのインスタンス生成
        calendarView = CalendarView(requireContext())
        // 初期値(今日)をセット
        calendarView.date = selectDate.timeInMillis

        // 選択している日付が変わったときのイベントリスナー
        calendarView.setOnDateChangeListener { _, year, month, dayOfMonth ->
            selectDate.set(year, month, dayOfMonth)
        }

        // AlertDialogのセットアップ
        builder.setView(calendarView)
            .setNegativeButton(android.R.string.cancel, null)
            .setPositiveButton(android.R.string.ok) { _, _ ->
                // ポジティブボタンでVieModelに最後に選択した日付をセット
                viewModel.dateSelected(selectDate)
            }
        return builder.create()
    }
}
LogItemViewModel.kt
class LogItemViewModel : ViewModel() {

    // StepCountLogデータ(Activityに戻す用)
    private val _stepCountLog = MutableLiveData<StepCountLog>()
    val stepCountLog = _stepCountLog as LiveData<StepCountLog>

    // 選択日付
    private val _selectDate = MutableLiveData<Calendar>()
    val selectDate = _selectDate as LiveData<Calendar>

    /**
     * StepCountLogデータのセット(すべてのデータの登録完了)
     */
    @UiThread
    fun changeLog(data: StepCountLog) {
        _stepCountLog.value = data
    }

    /**
     * 選択した日付のセット
     */
    @UiThread
    fun dateSelected(selectedDate: Calendar) {
        _selectDate.value = selectedDate
    }
}
LogInputFragment.kt
class LogInputFragment : Fragment() {

    companion object {
        const val DATE_SELECT_TAG = "date_select"

        fun newInstance(): LogInputFragment {
            val f = LogInputFragment()
            return f
        }
    }

    lateinit var viewModel: LogItemViewModel

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        val contentView = inflater.inflate(R.layout.fragment_log_input, container, false)

        contentView.radio_group.check(R.id.radio_normal)
        contentView.text_date.text = Calendar.getInstance().getDateStringYMD()

        contentView.button_resist.setOnClickListener {
            val dateText = text_date.text.toString()
            val stepCount = edit_count.text.toString().toInt()
            val level = levelFromRadioId(radio_group.checkedRadioButtonId)
            val weather = weatherFromSpinner(spinner_weather.selectedItemPosition)
            val stepCountLog = StepCountLog(dateText, stepCount, level, weather)

            viewModel.changeLog(stepCountLog)
        }

        // 日付を選ぶボタンで日付選択ダイアログを表示
        contentView.button_date.setOnClickListener {
            val fgm = fragmentManager ?: return@setOnClickListener // nullチェック
            DateSelectDialogFragment().show(fgm, DATE_SELECT_TAG)
        }

        return contentView
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        viewModel = ViewModelProviders.of(activity!!).get(LogItemViewModel::class.java)

        // 日付の選択を監視
        viewModel.selectDate.observe(this, Observer {
            text_date.text = it.getDateStringYMD()
        })
    }

...

ビルド、実行してみてください。

4. 入力値の検証を行う

日付を変更して、登録ボタンを押すと、・・・あれ?クラッシュすることがある?
そんなときは、Logcatの出番です。どこで落ちているか、分かりましたか?

2019-06-20 03:24:11.471 29889-29889/jp.les.kasa.sample.mykotlinapp E/AndroidRuntime: FATAL EXCEPTION: main
    Process: jp.les.kasa.sample.mykotlinapp, PID: 29889
    java.lang.NumberFormatException: For input string: ""
        at java.lang.Integer.parseInt(Integer.java:533)
        at java.lang.Integer.parseInt(Integer.java:556)
        at jp.les.kasa.sample.mykotlinapp.activity.logitem.LogInputFragment$onCreateView$1.onClick(LogInputFragment.kt:46)
        at android.view.View.performClick(View.java:5657)
        at android.view.View$PerformClick.run(View.java:22314)
        at android.os.Handler.handleCallback(Handler.java:751)
        at android.os.Handler.dispatchMessage(Handler.java:95)
        at android.os.Looper.loop(Looper.java:241)
        at android.app.ActivityThread.main(ActivityThread.java:6223)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:865)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:755)
    
    
    --------- beginning of system

こんなスタックトレースが吐かれているはずです。
例外はjava.lang.NumberFormatException: For input string: ""。空文字""が渡ったのがダメだったようです。
原因箇所は、LogInputFragment.kt:46とのことですから、LogInputFragment.ktファイルの46行目です。クリックできるようになっていますので、クリックするとその行に飛んでくれます。便利。

val stepCount = edit_count.text.toString().toInt()で落ちています。どうやら、空文字""がtoInt()に渡ってはいけないようです。
どうしましょうか?強制的に0にするか・・・

値が未入力だったら、登録できないようにするのは、どうでしょう?

はいということで、入力データのバリデーション(varidation/検証)を行うようにしましょう。検証エラーだったら、メッセージを表示して、登録できないことをユーザーに知らせます。

今回の場合、空文字でなければ良いので、チェックはこうなります。

    if(edit_count.text.toString()==0){
        // エラーメッセージを表示
    }else{
        // 登録処理
    }

それと、未来日付もNGにしましょうか。
こんなチェック関数を用意しました。logInputValidationはグローバルな関数にしています。理由は後でJUnitでテストしやすいようにです^^;

class LogInputFragment : : Fragment() {
...
    private fun validation(): Int? {
        val selectDate = viewModel.selectDate.value?.clearTime()
        return logInputValidation(today, selectDate!!, edit_count.text.toString())
    }
}

fun logInputValidation(
    today: Calendar, selectDate: Calendar,
    stepCountText: String?
): Int? {
    if (today.before(selectDate)) {
        // 今日より未来はNG
        return R.string.error_validation_future_date
    }
    // ステップ数が1文字以上入力されていること
    if (stepCountText.isNullOrEmpty()) {
        return R.string.error_validation_empty_count
    }
    return null
}

clearTimeという関数は、Calendarクラスの拡張関数で次のように定義しました。

Util.kt
fun Calendar.clearTime() : Calendar{
    set(Calendar.HOUR_OF_DAY, 0)
    set(Calendar.MINUTE, 0)
    set(Calendar.SECOND, 0)
    set(Calendar.MILLISECOND, 0)
    return this
}

LogInputFragmenttodayを今日で初期化し、同時にclearTime()しておきます

LogInputFragment.kt
    private val today = Calendar.getInstance().clearTime()

なぜ時間をクリアしているか分かりますか?

logInputValidationメソッドで、Calendar#beforeを使ってますが、この関数は当然ながら、ミリ秒まで比較します。
年月日までの比較で良いので(というかミリ秒まで比較すると都合が悪い)、不要なところはクリアしているというわけです。

最後に、登録部分の処理を書き換えます。

LogInputFragment.kt
        contentView.button_resist.setOnClickListener {
            validation()?.let {
                val fgm = fragmentManager ?: return@setOnClickListener
                ErrorDialog.Builder().message(it).create().show(fgm, null)
                return@setOnClickListener
            }

            val dateText = text_date.text.toString()
            val stepCount = edit_count.text.toString().toInt()
            val level = levelFromRadioId(radio_group.checkedRadioButtonId)
            val weather = weatherFromSpinner(spinner_weather.selectedItemPosition)
            val stepCountLog = StepCountLog(dateText, stepCount, level, weather)

            viewModel.changeLog(stepCountLog)
        }
  • ErrorDialog.Builder().message(it).create().show(fgm, null)

ErrorDialogクラスはこんな感じで作りました。最近、Builderかますのがマイブームです。

ErrorDialog.kt
class ErrorDialog : DialogFragment() {

    class Builder() {
        private var message: String? = null
        private var messageResId: Int = R.string.error

        fun message(message: String): Builder {
            this.message = message
            return this
        }

        fun message(resId: Int): Builder {
            this.messageResId = resId
            return this
        }

        fun create(): ErrorDialog {
            val d = ErrorDialog()
            d.arguments = Bundle().apply {
                if (message != null) {
                    putString(KEY_MESSAGE, message)
                } else {
                    putInt(KEY_RESOURCE_ID, messageResId)
                }
            }
            return d
        }
    }

    companion object {
        const val KEY_MESSAGE = "message"
        const val KEY_RESOURCE_ID = "res_id"
    }

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        // AlertDialogで作成する
        val builder = AlertDialog.Builder(requireContext())

        // メッセージの決定
        val message =
            when {
                arguments!!.containsKey(KEY_MESSAGE) -> arguments!!.getString(KEY_MESSAGE)
                else -> requireContext().getString(
                    arguments!!.getInt(KEY_RESOURCE_ID)
                )
            }
        // AlertDialogのセットアップ
        builder.setMessage(message)
            .setNeutralButton(R.string.close, null)
        return builder.create()

    }
}

カレンダーで未来の日付を選択したり、ステップ数を入力しないで登録ボタンを押して下さい。
エラーメッセージは、strings.xmlにお好きに定義して下さい。

kotlin_04_050.png

kotlin_04_051.png

4. テスト

今回テストはこんな内容でしょうか。

  • 表示テストを修正する

    • 追加メニューボタンでダイアログでは無くActivityが起動する
    • RecyclerViewのレイアウト変更に合わせたテストの修正
  • 新しい表示テスト

    • 登録画面の表示テスト
    • 登録画面で日付選択ボタンでダイアログが表示される
      • ダイアログのテスト
    • 登録画面でステップ数を入力したときのテスト
    • 登録画面でラジオボタンを押したときのテスト
    • 登録画面でスピナーを押したときのテスト
    • 登録画面で登録ボタンを押したときのテスト
  • validationのテスト

結構ボリュームがありますね。頑張りましょう。

(1) MainActivityのテストの変更

androidTest向けに書きます。Robolectric向けは、Githubにはpushしてあります。
下記をapp/build.gradleのdependenciesに追加しておく必要があるかも知れません。

app/build.gradle
    androidTestImplementation 'org.assertj:assertj-core:3.2.0'

また、テストをビルド時に

   > Error while dexing.
     The dependency contains Java 8 bytecode. Please enable desugaring by adding the following to build.gradle

というようなエラーが出た場合は、これは"dependenciesがJava8を使ってるから有効にしなさい"というメッセージなので、その下にある指示の通り、app/build.gradleandroid{}内に、下記を追記して下さい。

app/build.gradle
android{
...
    compileOptions {
        sourceCompatibility 1.8
        targetCompatibility 1.8
    }
}

1. 不要なテストを削除

以下のテストは不要なので削除します。

  • inputDialogFragmentShown
  • inputStep

また、addRecordMenuIconは、最初のEspresso.pressBack()が不要になったので削除しておきます。

2. 画面遷移のテスト

addRecordMenuを変更して、画面遷移のテストにします。

MainActivityTestI.kt
    @Test
    fun addRecordMenu() {
        // ResultActivityの起動を監視
        val monitor = Instrumentation.ActivityMonitor(
            LogItemActivity::class.java.canonicalName, null, false)
        getInstrumentation().addMonitor(monitor)

        // 追加メニューをクリック
        onView(
            Matchers.allOf(withId(R.id.add_record), withContentDescription("記録を追加"))
        ).perform(click())

        // ResultActivityが起動したか確認
        val resultActivity = getInstrumentation().waitForMonitorWithTimeout(monitor, 1000L)
        assertThat(monitor.hits).isEqualTo(1)
        assertThat(resultActivity).isNotNull()

        // 端末戻るボタンで終了を確認
        Espresso.pressBack()
        assertThat(resultActivity.isFinishing).isTrue()
    }

Activityが起動したかどうかのテストは、ActivityMonitorを使ってカウントが増えたことと、その結果のresultActivityがnullでないことで確認するのが王道・・・かな?
取り敢えず起動したことだけを確認したい場合は、これで十分かと思います。

尚、上記テストコードをそのままRobolectric向けで実行すると、resultActivityがnullになってしまいテストが失敗します。
hit==1にはなっているのに・・・
原因・回避方法を探っていますがまだ分かっていないので、分かったら追記します。
今は取り敢えず、androidTestで進めていきます。

3. 新しいRecyclerViewレイアウト

addRecordListも、レイアウトが変わったのでビルドエラーになっていると思います。直していきましょう。

MainActivityTestI.kt

    @Test
    fun addRecordList() {
        // 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))
        }
        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        // リストの表示確認
        var index = 0

        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 = 1
        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
    }

mainActivity.runOnUiThread {}の部分は、ViewModelのaddStepCount@UiThreadを付けた上でLiveDataのpostValueではなくsetValueを使っているので、UIスレッドから更新する必要があるためにこうしています。
(※ViewModelを更新できるルールがあるはずなのですが、どうにもimportが出来ず、今回は使うのを断念しました。また解決次第、追記します)

InstrumentationRegistry.getInstrumentation().waitForIdleSync()で、UIの更新が終わるのを待っています。

尚、Robolectric版の場合は、このUIスレッドからviewModelを更新しなければならない、という部分はなぜか見逃されるので、以下のように書いてもテストは通りますが、もしこれがRobolectricのバグで、将来直されたら通過しなくなる可能性は有ります。

        val mainActivity = activityRule.activity
        mainActivity.viewModel.addStepCount(StepCountLog("2019/06/13", 12345, LEVEL.GOOD))
        mainActivity.viewModel.addStepCount(StepCountLog("2019/06/19", 666, LEVEL.BAD, WEATHER.RAIN))

        // リストの表示確認
   ...

// @formatter:off// @formatter:onは、フォーマッターで改行されて縦に長くなって見づらかったので解除してみました。この二つで囲むと、formatterを部分的に無効にすることが出来ますが、設定で有効にしておく必要があります。

[Preferences]-[Editor]-[Code Style]とし、下記図の部分にチェックを入れ、[OK]すると有効になります。

kotlin_04_052.png

withDrawableというのも自作関数です。
やっているのは、ImageViewに設定されているカレントのDrawableと、指定のリソースidのDrawableが、内部的に同じBitmapかをチェックしています。

class DrawableMatcher(private val expectedId: Int) : TypeSafeMatcher<View>(View::class.java) {
    private var resourceName: String? = null

    override fun matchesSafely(target: View): Boolean {
        if (target is ImageView) {
            if (expectedId < 0) {
                return target.drawable == null
            }
            val resources = target.getContext().resources
            val expectedDrawable = resources.getDrawable(expectedId, null)
            resourceName = resources.getResourceEntryName(expectedId)

            if (expectedDrawable == null) {
                return false
            }

            var drawable = target.drawable
            if (drawable is StateListDrawable) {
                drawable = drawable.getCurrent()
            }

            val bitmap = drawable.toBitmap()
            val otherBitmap = expectedDrawable.toBitmap()
            return bitmap.sameAs(otherBitmap)
        }
        return false
    }


    override fun describeTo(description: Description) {
        description.appendText("with drawable from resource id: ")
        description.appendValue(expectedId)
        if (resourceName != null) {
            description.appendText("[")
            description.appendText(resourceName)
            description.appendText("]")
        }
    }
}

fun withDrawable(resourceId: Int): Matcher<View> {
    return DrawableMatcher(resourceId)
}


どこに書いてもいいですが、atPositionOnViewと併せて、EspressoUtils.ktとかにまとめておいてもいいかも知れませんね。

(2) 登録画面のテスト

新しく追加した画面のテストをしましょう。Fragmentごとにテストするという手もあるようなのですが、今はまだ1つしかないので、いったんActivityのテストとして作成します。

なお、LogItemActivityのActivityRuleは次のようにします。

LogItemActivityTest.kt
    @get:Rule
    val activityRule = ActivityTestRule(LogItemActivity::class.java, false, false)

ActivityTestRuleコンストラクタの2つめの引数は、initialTouchModeに対するBoolean値です。デフォルトはfalseです。
3つめの引数は、launchActivityに対するBoolean値です。デフォルトはtrueで、trueだと、自動的にActivityが起動されます。でも、自動で起動されたら困る場合もあります。例えば、先に設定ファイルやデータベースの値を、テストケースごとに設定したい場合などです。LogItemActivityはいずれ他のFragmentを起動する場合を想定しているため、あらかじめ自動で起動せず、こちらで設定を終えた後で手動で起動するようにしておきます。

Activityを起動するコードは次のようになります。

    activity = activityRule.launchActivity(null)

launchActivityの引数はIntent型です。Activityを起動するIntentを指定できますが、ExtraDataが特にない場合は、nullでOKです。

上記を踏まえ、テストを書いていってみましょう。

1. 起動直後の表示のテスト

各Viewの初期値のセットを確認しましょう。

2. 日付選択ボタンでダイアログが表示されるテスト

以前あったInputDialogFragmentに対してやっていたのと同じ感じで、該当のDialogが表示されていることを確認したいですが、CalendarViewが中々に特殊なので、ちょっと別のアプローチが必要です。
SupportFragmentManagerで、今追加されているFragmentが全部取れるので、そのクラスのオブジェクトからCalendarViewを直接参照して確認します。

3. ステップ数を入力したときのテスト

値がちゃんと置き換わることを確認すれば良いでしょう。

4. ラジオボタンを押したときのテスト

Aを押せばB,Cは非選択になること、Bを押せばA,Cは非選択になること、Cを押せば・・・というのを確認しましょう。
ラジオボタンがチェック状態かどうかは、isChecked()でチェック出来ます。

5. スピナーを押したときのテスト

初期値とは別の任意の要素を選択したとき、表示が変わるかどうかをチェックしましょう。
可能であれば、必要な要素が全部ポップアップされるかも確認しましょう。スクロールすると厄介ですが、取り敢えずそんなに数はないのでまだ大丈夫かと。

6. 登録ボタンを押したときのテスト

値をそれぞれセットして、戻りのIntentにExtraDataとして正しく設定されているか確認しましょう。

7. エラーメッセージの表示テスト

いわゆる"異常系"のテストです。
「日付が未来」「ステップ数が未入力」のとき、エラーメッセージが表示されていることも確認しましょう。
validation関数のUnitテスト自体も書きましたが、その「戻り」を正確に判定して、正しいエラーメッセージを出しているか、のUIのテストとしては、やはり必要です。

8. 実装結果

すべてを実装したandroidTest用のサンプルはこちら。
LogItemActivityTestI.kt
import android.app.Activity
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
import jp.les.kasa.sample.mykotlinapp.*
import jp.les.kasa.sample.mykotlinapp.activity.logitem.LogItemActivity.Companion.EXTRA_KEY_DATA
import jp.les.kasa.sample.mykotlinapp.data.LEVEL
import jp.les.kasa.sample.mykotlinapp.data.StepCountLog
import jp.les.kasa.sample.mykotlinapp.data.WEATHER
import jp.les.kasa.sample.mykotlinapp.espresso.withDrawable
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.data.Offset
import org.hamcrest.Matchers.not
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.util.*

@RunWith(AndroidJUnit4::class)
class LogItemActivityTestI {
    @get:Rule
    val activityRule = ActivityTestRule(LogItemActivity::class.java, false, false)

    lateinit var activity: LogItemActivity

    /**
     *   起動直後の表示のテスト<br>
     *   LogInputFragmentの初期表示をチェックする
     */
    @Test
    fun logInputFragment() {
        activity = activityRule.launchActivity(null)

        // 日時ラベル
        onView(withText(R.string.label_date)).check(matches(isDisplayed()))
        // 日付
        val today = Calendar.getInstance().getDateStringYMD()
        onView(withText(today)).check(matches(isDisplayed()))
        // 日付選択ボタン
        onView(withText(R.string.label_select_date)).check(matches(isDisplayed()))
        // 歩数ラベル
        onView(withText(R.string.label_step_count)).check(matches(isDisplayed()))
        // 歩数ヒント
        onView(withHint(R.string.hint_edit_step)).check(matches(isDisplayed()))
        // 気分ラベル
        onView(withText(R.string.label_level)).check(matches(isDisplayed()))
        // 気分ラジオボタン
        onView(withText(R.string.level_normal)).check(matches(isDisplayed()))
        onView(withText(R.string.level_good)).check(matches(isDisplayed()))
        onView(withText(R.string.level_bad)).check(matches(isDisplayed()))
        onView(withDrawable(R.drawable.ic_sentiment_neutral_green_24dp)).check(matches(isDisplayed()))
        onView(withDrawable(R.drawable.ic_sentiment_very_satisfied_pink_24dp)).check(matches(isDisplayed()))
        onView(withDrawable(R.drawable.ic_sentiment_dissatisfied_black_24dp)).check(matches(isDisplayed()))
        // 天気ラベル
        onView(withText(R.string.label_step_count)).check(matches(isDisplayed()))
        // 天気スピナー
        onView(withId(R.id.spinner_weather)).check(matches(isDisplayed()))
        // 登録ボタン
        onView(withText(R.string.resist)).check(matches(isDisplayed()))
    }

    /**
     * 日付選択ボタンでダイアログが表示されるテスト
     */
    @Test
    fun selectDate() {
        activity = activityRule.launchActivity(null)

        val today = Calendar.getInstance()

        // 日付選択ボタン
        onView(withText(R.string.label_select_date)).perform(click())

        // CalendarViewは特殊で、OSバージョンで表示される物が異なるため、
        // 内容の確認は難しい(表示されているはずの文字列で見つけられない。もしかしたら文字列じゃ無く画像なのかも)
        // なので、直接SupportFragmentManagerから今持っているFragmentでTAGを条件にDialogFragmentを探しだし、
        // そこからCalendarViewのインスタンスを得ている
        val fragment = activity.supportFragmentManager.findFragmentByTag(LogInputFragment.DATE_SELECT_TAG)
                as DateSelectDialogFragment
        // 初期選択時間が、起動前に取得した時間と僅差であることの確認
        assertThat(fragment.calendarView.date).isCloseTo(today.timeInMillis, Offset.offset(1000L))
    }

    /**
     * 選択を変更してキャンセルしたときに表示が変わっていないこと
     */
    @Test
    fun selectDate_cancel() {
        activity = activityRule.launchActivity(null)

        val today = Calendar.getInstance()

        // 日付選択ボタン
        onView(withText(R.string.label_select_date)).perform(click())

        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        // CalendarViewは特殊で、OSバージョンで表示される物が異なるため、
        // 内容の確認は難しい
        // なので、直接SupportFragmentManagerから今持っているFragmentでTAGを条件にDialogFragmentを探しだし、
        // そこからCalendarViewのインスタンスを得ている
        val fragment = activity.supportFragmentManager.findFragmentByTag(LogInputFragment.DATE_SELECT_TAG)
                as DateSelectDialogFragment
        val newDate = today.clone() as Calendar
        newDate.add(Calendar.DAY_OF_MONTH, -1) // 未来はNGなので一つ前に
        // 日付を選んだ動作も書けないので、クリックされるときに変わるはずのselectDateを無理矢理上書き。
        // そのため、selectDate関数のアクセス修飾子を、テスト向けにはpublicになるように変更してある
        fragment.selectDate.set(newDate.getYear(), newDate.getMonth(), newDate.getDay())

        // ボタンのクリックはEspressoで書ける
        onView(withText(android.R.string.cancel)).perform(click())

        // 新しい日付は表示されていない
        onView(withText(newDate.getDateStringYMD())).check(doesNotExist())
        // 当日のまま
        onView(withText(today.getDateStringYMD())).check(matches(isDisplayed()))
    }

    /**
     * 選択を変更してOKしたときに表示が変わっていること
     */
    @Test
    fun selectDate_ok() {
        activity = activityRule.launchActivity(null)

        val today = Calendar.getInstance()

        // 日付選択ボタン
        onView(withText(R.string.label_select_date)).perform(click())

        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        // CalendarViewは特殊で、OSバージョンで表示される物が異なるため、
        // 内容の確認は難しい
        // なので、直接SupportFragmentManagerから今持っているFragmentでTAGを条件にDialogFragmentを探しだし、
        // そこからCalendarViewのインスタンスを得ている
        val fragment = activity.supportFragmentManager.findFragmentByTag(LogInputFragment.DATE_SELECT_TAG)
                as DateSelectDialogFragment
        val newDate = today.clone() as Calendar
        newDate.add(Calendar.DAY_OF_MONTH, -1) // 未来はNGなので一つ前に
        // 日付を選んだ動作も書けないので、クリックされるときに変わるはずのselectDateを無理矢理上書き。
        // そのため、selectDate関数のアクセス修飾子を、テスト向けにはpublicになるように変更してある
        fragment.selectDate.set(newDate.getYear(), newDate.getMonth(), newDate.getDay())

        // ボタンのクリックはEspressoで書ける
        onView(withText(android.R.string.ok)).perform(click())

        // 新しい日付になっていること
        onView(withId(R.id.text_date)).check(matches(withText(newDate.getDateStringYMD())))
    }

    /**
     * ステップ数を入力したときのテスト
     */
    @Test
    fun editCount() {
        activity = activityRule.launchActivity(null)

        onView(withId(R.id.edit_count)).check(matches(isDisplayed()))
            .perform(replaceText("12345"))

        onView(withId(R.id.edit_count)).check(matches(withText("12345")))

        // 取り敢えず再入力も
        onView(withId(R.id.edit_count)).check(matches(isDisplayed()))
            .perform(replaceText("4444"))

        onView(withId(R.id.edit_count)).check(matches(withText("4444")))
    }

    /**
     * ラジオグループの初期状態
     */
    @Test
    fun levelRadioGroup() {
        activity = activityRule.launchActivity(null)

        // 初期選択状態
        onView(withId(R.id.radio_normal)).check(matches(isDisplayed()))
            .check(matches(isChecked()))
        onView(withId(R.id.radio_good)).check(matches(isDisplayed()))
            .check(matches(not(isChecked())))
        onView(withId(R.id.radio_bad)).check(matches(isDisplayed()))
            .check(matches(not(isChecked())))
    }

    /**
     * ラジオボタン[GOOD]を押したときのテスト
     */
    @Test
    fun levelRadioButtonGood() {
        activity = activityRule.launchActivity(null)

        onView(withId(R.id.radio_good)).perform(click())

        // 選択状態
        onView(withId(R.id.radio_normal)).check(matches(isDisplayed()))
            .check(matches(not(isChecked())))
        onView(withId(R.id.radio_good)).check(matches(isDisplayed()))
            .check(matches(isChecked()))
        onView(withId(R.id.radio_bad)).check(matches(isDisplayed()))
            .check(matches(not(isChecked())))
    }

    /**
     * ラジオボタン[BAD]を押したときのテスト
     */
    @Test
    fun levelRadioButtonBad() {
        activity = activityRule.launchActivity(null)

        onView(withId(R.id.radio_bad)).perform(click())

        // 選択状態
        onView(withId(R.id.radio_normal)).check(matches(isDisplayed()))
            .check(matches(not(isChecked())))
        onView(withId(R.id.radio_good)).check(matches(isDisplayed()))
            .check(matches(not(isChecked())))
        onView(withId(R.id.radio_bad)).check(matches(isDisplayed()))
            .check(matches(isChecked()))
    }

    /**
     * スピナーを押したときのテスト
     */
    @Test
    fun weatherSpinner() {
        activity = activityRule.launchActivity(null)

        // 初期表示
        onView(withText("晴れ")).check(matches(isDisplayed()))

        onView(withId(R.id.spinner_weather)).perform(click())

        // リスト表示を確認
        onView(withText("晴れ")).check(matches(isDisplayed()))
        onView(withText("雨")).check(matches(isDisplayed()))
        onView(withText("曇り")).check(matches(isDisplayed()))
        onView(withText("雪")).check(matches(isDisplayed()))
        onView(withText("寒い")).check(matches(isDisplayed()))
        onView(withText("暑い")).check(matches(isDisplayed()))

        // 初期値以外を選択
        onView(withText("雨")).perform(click())

        onView(withText("晴れ")).check(doesNotExist())
        onView(withText("雨")).check(matches(isDisplayed()))
    }

    /**
     * 登録ボタン押下のテスト:正常
     */
    @Test
    fun resistButton_success() {
        activity = activityRule.launchActivity(null)

        val today = Calendar.getInstance().apply {
            set(Calendar.YEAR, 2019)
            set(Calendar.MONTH, 5)
            set(Calendar.DAY_OF_MONTH, 20)
        }
        activity.runOnUiThread {
            activity.viewModel.dateSelected(today)
        }
        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        onView(withId(R.id.edit_count)).check(matches(isDisplayed()))
            .perform(replaceText("12345"))
        onView(withId(R.id.radio_good)).perform(click())

        onView(withId(R.id.spinner_weather)).perform(click())
        onView(withText("曇り")).perform(click())

        onView(withId(R.id.button_resist)).check(matches(isDisplayed()))
            .perform(click())

        assertThat(activityRule.activityResult.resultCode).isEqualTo(Activity.RESULT_OK)
        assertThat(activityRule.activityResult.resultData).isNotNull()
        val data = activityRule.activityResult.resultData.getSerializableExtra(EXTRA_KEY_DATA)
        assertThat(data).isNotNull()
        assertThat(data is StepCountLog).isTrue()
        val expectItem = StepCountLog("2019/06/20", 12345, LEVEL.GOOD, WEATHER.CLOUD)
        assertThat(data).isEqualToComparingFieldByField(expectItem)
    }


    /**
     * 登録ボタン押下のテスト:未来日付エラー
     */
    @Test
    fun resistButton_error_futureDate() {
        activity = activityRule.launchActivity(null)

        val next = Calendar.getInstance().addDay(1)
        activity.runOnUiThread {
            activity.viewModel.dateSelected(next)
        }
        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        onView(withId(R.id.edit_count)).check(matches(isDisplayed()))
            .perform(replaceText("12345"))

        onView(withId(R.id.button_resist)).check(matches(isDisplayed()))
            .perform(click())

        onView(withText(R.string.error_validation_future_date)).check(matches(isDisplayed()))
    }

    /**
     * 登録ボタン押下のテスト:カウント未入力エラー
     */
    @Test
    fun resistButton_error_emptyCount() {
        activity = activityRule.launchActivity(null)

        val today = Calendar.getInstance()
        activity.runOnUiThread {
            activity.viewModel.dateSelected(today)
        }
        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        onView(withId(R.id.button_resist)).check(matches(isDisplayed()))
            .perform(click())

        onView(withText(R.string.error_validation_empty_count)).check(matches(isDisplayed()))
    }
}

// そのため、selectDate関数のアクセス修飾子を、テスト向けにはpublicになるように変更してあるのコメント部分についてですが、具体的に以下のような記述になっています。

DateSelectDialogFragment.kt
    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    val selectDate = Calendar.getInstance()!!

本来はprivateでいいのだけど、テストではアクセスしたいのでpublicにしたい・・・という時に疲れる技です。

Robolectric用は、Githubに上がっているブランチqiita_04にpushしてあります。ご興味ある方はチェックしてみて下さい。

※両方のテストを書いているので、工数が半端なくなってきた・・・
※取り敢えず分かったことは、結構アプローチを変えないと行けないテストが多く、まだまだテストコードの完全共有にはほど遠いな、と感じています。

(3) validationのテスト

1. Calendarの拡張関数のテスト

Util.ktに追加したCalendarクラスの拡張関数2本のテストを追加しましょう。

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

    @Test
    fun calendar_clearTime() {
        val cal = Calendar.getInstance()
        // 時間関連が0にならないようにセット
        cal.set(Calendar.HOUR, 1)
        cal.set(Calendar.MINUTE, 10)
        cal.set(Calendar.SECOND, 20)
        cal.set(Calendar.MILLISECOND, 300)
        // 0でないことの確認
        assertThat(cal.get(Calendar.HOUR)).isNotEqualTo(0)
        assertThat(cal.get(Calendar.MINUTE)).isNotEqualTo(0)
        assertThat(cal.get(Calendar.SECOND)).isNotEqualTo(0)
        assertThat(cal.get(Calendar.MILLISECOND)).isNotEqualTo(0)

        cal.clearTime()
        // 0になっていることの確認
        assertThat(cal.get(Calendar.HOUR)).isEqualTo(0)
        assertThat(cal.get(Calendar.MINUTE)).isEqualTo(0)
        assertThat(cal.get(Calendar.SECOND)).isEqualTo(0)
        assertThat(cal.get(Calendar.MILLISECOND)).isEqualTo(0)
    }

Githubのプロジェクトには、Util.ktに他にも便宜上追加した関数があり、それらのテストもUtilTestに追加してあります。

2. validation関数のテスト

logInputValidationはpublicなグローバル関数でなので、そのままJUnitのテストとして書けば良いでしょう。
特に難しいことは無いと思いますが、正常系だけで無く、異常系も閾値をしっかりテストしましょう。

3. MainActivityに登録画面からの戻りが反映されるかのテスト

データが登録画面から返ってきたときに表示に反映されるかの確認をします。
このテストはMainActivityTestでonActivityResultのテストとして実装しますが、onActivityResultprotectedで、Javaのprotectedと違い、同じパッケージでも呼び出せないので、androidTestではかなり強引にやります。

他のテスト同様に、登録画面を起動する手順を踏むと、resultActivityが取れますから、resultActivity#setResult()してresultActiviy.finish()してしまいます。
そうすれば後は表示内容を確認すれば良いです。

MainActivityTest.kt
    @Test
    fun onActivityResult() {
        val resultData = Intent().apply {
            putExtra(EXTRA_KEY_DATA, StepCountLog("2019/06/19", 666, LEVEL.BAD, WEATHER.SNOW))
        }

        val monitor = Instrumentation.ActivityMonitor(
            LogItemActivity::class.java.canonicalName, null, false
        )
        getInstrumentation().addMonitor(monitor)

        // 登録画面を起動
        onView(
            Matchers.allOf(withId(R.id.add_record), withContentDescription("記録を追加"))
        ).perform(click())

        val resultActivity = getInstrumentation().waitForMonitorWithTimeout(monitor, 500L)
        resultActivity.setResult(Activity.RESULT_OK, resultData)
        resultActivity.finish()

        // 反映を確認
        val 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_grain_gley_24dp),R.id.weatherImageView)))
            // @formatter:on

    }

Robolectricでは、resultActivityが取れないので、shadowクラスを使った書き方しか分かりませんでした。Githubにpushしてあるので、サンプルを参照してみて下さい。

espresso-intentsライブラリを使って、書けないかなと頑張ったのですが、上手くいきませんでした・・・

(4) ViewModelのテスト

1. MainViewModelのテストの修正

MainViewModel.ktのテストを修正します。
LiveDataの型が変わったのでその対応ですね。

MainViewModel.kt
    @Test
    fun addStepCount() {
        viewModel.addStepCount(StepCountLog("2019/06/21", 123))
        viewModel.addStepCount(StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT))

        assertThat(viewModel.stepCountList.value)
            .isNotEmpty()

        val list = viewModel.stepCountList.value as List<StepCountLog>
        assertThat(list.size).isEqualTo(2)
        assertThat(list[0]).isEqualToComparingFieldByField(StepCountLog("2019/06/21", 123))
        assertThat(list[1]).isEqualToComparingFieldByField(StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT))
    }

2. LogItemViewModelのテスト

MainViewModelと同等なテストを作れば良いでしょう。

LogItemViewModelTest.kt
class LogItemViewModelTest {
    @get:Rule
    val rule: TestRule = InstantTaskExecutorRule()

    lateinit var viewModel: LogItemViewModel

    @Before
    fun setUp() {
        viewModel = LogItemViewModel()
    }

    @Test
    fun init() {
        Assertions.assertThat(viewModel.stepCountLog.value)
            .isNull() // 初期化したときはnull
    }

    @Test
    fun changeLog() {
        viewModel.changeLog(StepCountLog("2019/06/21", 12345, LEVEL.BAD, WEATHER.COLD))

        assertThat(viewModel.stepCountLog.value)
            .isEqualToComparingFieldByField(StepCountLog("2019/06/21", 12345, LEVEL.BAD, WEATHER.COLD))
    }

    @Test
    fun dateSelected() {
        var date = Calendar.getInstance()
        date.set(Calendar.YEAR, 2019)
        date.set(Calendar.MONDAY, 5)
        date.set(Calendar.DAY_OF_MONTH, 15)
        date = date.clearTime()
        viewModel.dateSelected(date)

        assertThat(viewModel.selectDate.value!!.getYear())
            .isEqualTo(2019)
        assertThat(viewModel.selectDate.value!!.getMonth())
            .isEqualTo(5)
        assertThat(viewModel.selectDate.value!!.getDay())
            .isEqualTo(15)
    }

まとめ

data classの使い方を覚えたり、data binding、そしてFragmentの使い方、Activity同士の遷移の仕方を学びました。
結構なボリュームでしたね。

ここまでの状態のプロジェクトをGithubにpushしてあります。
https://github.com/le-kamba/qiita_pedometer/tree/feature/qiita_04

次回予告

今は起動するとデータが消えてしまいます。再起動したら、前回入力した値は表示されていたいですよね。
つまりデータを永続化しようというお話になります。
ま、早い話、Room使ってデータベースに保存していきましょ。

あ、でもその前に一つ、番外編を挟むかも知れません。

参考ページなど

11
13
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?