4
4

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アプリを作る(9)グリッド表示編

Last updated at Posted at 2020-02-18

前回の続きです。

今回の目標

いよいよ、カレンダー風のグリッド表示に挑戦します。
グリッド表示も、Androidでは覚えておくべき機能の1つでしょうね。
とはいえ、RecyclerViewを使えば、それほど変更は無く出来るはずです。

GridViewGridLayoutというのはありますが、今はRecyclerViewGridLayoutManagerを使う方法が推奨になっています。

グリッド表示

まずは単純にグリッド表示をしてみましょう。

こんなレイアウトにしようと思います。
なお、AndroidのGridViewでは、格子状に罫線を入れるのは結構至難の業です。
これくらいだったら簡単にできますが、

qiita09_00.png

左端と上端に罫線が無いのお分かりでしょうか?
これを出そうとすると、途端に難しくなるんです。(あと、要素の幅を固定しないと縦の罫線が大量に出てしまうというのもあります)

なので、このアプリでは、背景枠画像そのものを用意するという手段で乗り切ろうと思います。

(1) 背景枠画像の用意

背景画像は角丸になるようにShapeで用意しました。

cell_white.xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <stroke
            android:width="1dp"
            android:color="#4DB6AC" />
    <solid
            android:color="#FFFFFFFF" />
    <corners android:radius="8dp" />
</shape>

(2) セルのレイアウトファイルの作成

こんなレイアウトにしてみます。

qiita09_01.png

レイアウトファイルのサンプルはこちら
item_cell.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>

        <import type="android.view.View" />

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

    <androidx.constraintlayout.widget.ConstraintLayout
            android:id="@+id/logItemLayout"
            android:layout_width="48dp"
            android:layout_height="65dp"
            android:layout_margin="4dp"
            android:background="@drawable/cell_nonactive"
            android:clickable="true"
            android:focusable="true"
            android:foreground="?android:attr/selectableItemBackground">

        <TextView
                android:id="@+id/dayTextView"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="3dp"
                android:layout_marginTop="4dp"
                android:text="30"
                android:textSize="10dp"
                android:textStyle="bold"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />

        <TextView
                android:id="@+id/stepTextView"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="3dp"
                android:layout_marginTop="3dp"
                android:text="@{Integer.toString(stepLog.step)}"
                android:textColor="#0B0A0A"
                android:textSize="8dp"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@+id/dayTextView"
                tools:text="88888" />

        <TextView
                android:id="@+id/suffixTextView"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:elevation="0dp"
                android:text="@string/label_step"
                android:textSize="8dp"
                android:visibility="@{stepLog!=null ? View.VISIBLE : View.GONE}"
                app:layout_constraintBottom_toBottomOf="@+id/stepTextView"
                app:layout_constraintStart_toEndOf="@+id/stepTextView"
                app:layout_constraintTop_toTopOf="@+id/stepTextView" />

        <ImageView
                android:id="@+id/weatherImageView"
                android:layout_width="12dp"
                android:layout_height="12dp"
                android:layout_marginStart="2dp"
                android:src="@{stepLog.weather}"
                android:visibility="@{stepLog!=null ? View.VISIBLE : View.GONE}"
                app:layout_constraintBottom_toBottomOf="@+id/dayTextView"
                app:layout_constraintStart_toEndOf="@+id/dayTextView"
                app:layout_constraintTop_toTopOf="@+id/dayTextView"
                tools:src="@drawable/ic_cloud_gley_24dp" />

        <ImageView
                android:id="@+id/levelImageView"
                android:layout_width="12dp"
                android:layout_height="12dp"
                android:layout_marginEnd="3dp"
                android:src="@{stepLog.level}"
                android:visibility="@{stepLog!=null ? View.VISIBLE : View.GONE}"
                app:layout_constraintBottom_toBottomOf="@+id/stepTextView"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toEndOf="@+id/suffixTextView"
                app:layout_constraintTop_toTopOf="@+id/suffixTextView"
                tools:src="@drawable/ic_sentiment_neutral_green_24dp" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

日にちの部分はひとまず固定で表示しています。
StepCountLogからも取れますが、今後を考えるとその値を使わない別の方法にするので。

(3) グリッドレイアウトに変更

1. レイアウトマネージャーの変更

今はLinearLayoutManagerを使ってリスト表示していますが、これをGridLayoutManagerに変更することでグリッド表示に出来ます。

MonthlyPageFragment.kt

    override fun onCreateView(...){
		....
        // RecyclerViewの初期化
        binding.logList.layoutManager = GridLayoutManager(context, 7)
		....

LinearLayoutManagerを指定していた所をGridLayoutManagerに変えています。二つ目の引数で7を渡していますが、これは、いわゆるカラム数です。

これだけ!

表示してみましょう。

今は存在するデータだけを表示しているので、カレンダーぽくないですが、ちゃんとグリッド表示出来ているはずです。

(4) データ取得ソート順を変更

さて、カレンダー式表示にするなら、データは日付が古い順に持っている方が良いでしょうね。
ということで、現在、searchRangeで降順を指定しているのを昇順に変更します。

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

ORDER BY句に今まで指定していたDESCは降順を指定するものでした。昇順はASCですが、デフォルト値なので省略しています。

(5) カレンダー式表示にする

もっとカレンダーぽくしていきます。
カレンダーなので、ログデータがない日もセルが表示されてないとおかしいですね。
その対応をしていきます。

1. セルデータクラスの用意

新たなデータ型かPairを使って、日にち+StepCountLogの表示セットを用意した方が良いでしょうね。Databindingのことも考えて、ここは新たなdata classを作ることにします。

CalendarCellData.kt
data class CalendarCellData(val calendar:Calendar,
                            val stepCountLog: StepCountLog?)

Cendarクラスはjava.utilの方をimportして下さいよ。(間違えないと思いますが)
stepCountLogはデータがない日はnullになるため、nullableとして?が付いています。

2. 1ヶ月分のセルデータの用意

ところで、1ヶ月分のカレンダーには何週分、つまり何行表示すればよいでしょうか?
実は、1ヶ月に最大で6週間があり得るんです。
週数に応じて行数を変動する方法もありますが面倒なのでここは固定ですべてのページで6週分表示することにします。
なので、1ヶ月分のセルデータは、7*6=42日分必要になります。

取り敢えずCalendarCellDataを42日分作って表示してみましょう。
データはMonthlyPageViewModelに用意します。

  • そのページの表示年月
  • その月のデータリスト

これらが確定したら、セルデータを作れますね?
またまた、Transformationsの出番です。が、今回は、mapを使います。

MonthlyPageViewModel.kt
    // カレンダーのセルデータ
    val cellData: LiveData<List<CalendarCellData>> =
        Transformations.map(stepCountList) {
            createCellData(dataYearMonth.value!!, it)
        }

前回、「mapは値を返す」と書きました。 「ラムダの中で、値を返す」 という意味でした。自分で見返しててちょっと混乱したので、今回、自分の中で確認の意味も兼ねてmapを使ってみました。
実は、前回作ったMainViewModel#pagesも、switchMapではなくmapでもっと簡単に書けました。
訂正しておきます。

MainViewModel.kt
    // ページ
    val pages = Transformations.map(oldestDate) {
        makePageList(it, calendarProvider.now)
    }

さて、本題に戻して、createCellDataは次のように作りました。

MonthlyPageViewModel.kt
    fun createCellData(yyyyMM: String, stepCountList: List<StepCountLog>): List<CalendarCellData> {
        val formatter = SimpleDateFormat("yyyy/MM", Locale.JAPAN)
        val cal = Calendar.getInstance()
        cal.time = formatter.parse(yyyyMM)
        cal.set(Calendar.DATE, 1)

        val list = mutableListOf<CalendarCellData>()
        for (i in 1..42) {
            cal.set(Calendar.DATE, i)
            list.add(CalendarCellData(cal.clone() as Calendar, null))
        }

        return list
    }

for (i in 1..42)が新しいでしょうか。
i in 1..42はKotlinのRange変数と呼ばれるものです。見てだいたいわかると思いますが、1以上42以下の範囲で、iを1ずつ増やします。for (i=1; i<=42; i++)と同じですね。

この関数では、表示年月を貰い、その月の1日から42日分のCalendarCellDataを作っています。
本当は日付が合致するセルデータにはstepCountListの値を入れていかないと行けないのですが、今はいったんnullをセットしておきます。1件もデータが無い月がログの途中にあれば、その月もちゃんと表示されないと行けませんからね。

3. Databindingの変更

RecyclerViewとそのアイテムで表示するデータを、CalendarCellDataに変更していきます。
以下の場所で変更が必要です。

  • fragment_monthly_page.xmlitem_cell.xmlで指定しているバインドデータ型
    • <variable>タグの中です
  • fragment_monthly_page.xmlitem_cell.xmlでアクセスしている各データ
    • @{}でアクセスしている変数をそれぞれ書き変える必要があります
  • LogRecyclerAdapterでバインドしているデータ
    • onBindViewHolder()の中です
  • Binding Adapterの型の変更
    • nullableに変更しなければならないものがあります
  • セルの日にち表示用のBinding Adapterの作成

各ヒントに従って、変更してみて下さい。
変更漏れなどがあると、ビルドエラーが出ます。レイアウトxml内のDatabinding周りのエラーは分かりづらいですが、ちゃんと出ていますので、良く読んで見つけて下さい。

例えば、

e: /workspace/github/qiita_pedometer/app/build/generated/source/kapt/debug/jp/les/kasa/sample/mykotlinapp/DataBinderMapperImpl.java:23: エラー: シンボルを見つけられません
import jp.les.kasa.sample.mykotlinapp.databinding.ItemCellBindingImpl;
                                                 ^
  シンボル:   クラス ItemCellBindingImpl
  場所: パッケージ jp.les.kasa.sample.mykotlinapp.databinding
e: [kapt] An exception occurred: android.databinding.tool.util.LoggedErrorException: Found data binding error(s):

[databinding] {"msg":"Could not find identifier \u0027stepLog\u0027\n\nCheck that the identifier is spelled correctly, and that no \u003cimport\u003e or \u003cvariable\u003e tags are missing.","file":"/workspace/github/qiita_pedometer/app/src/main/res/layout/item_cell.xml","pos":[{"line0":66,"col0":31,"line1":66,"col1":37}]}

こういったビルドエラーが出たとき、「エラー: シンボルを見つけられません」に目がいきがちですが、本当の原因は、その下の**[databinding]**の所に出ています。
文字のエスケープがおかしくて分かりづらいですが、stepLogCheck that the identifier is spelled correctlyと言われているので、スペルミスじゃないかと言われているのが分かります。検出された場所も、item_cell.xml","pos":[{"line0":66とあるので、item_cell.xmlの66行目付近を見てみます。

item_cell.xml
<ImageView
                android:id="@+id/weatherImageView"
                android:layout_width="12dp"
                android:layout_height="12dp"
                android:layout_marginStart="2dp"
                android:src="@{stepLog.weather}"

おっと、修正漏れしてました。(行数は目安で、合っていないことがあるようですw)

item_cell.xml
 <variable
                name="cellData"
                type="jp.les.kasa.sample.mykotlinapp.data.CalendarCellData" />

バインディングデータを上記のように変更してたので、正しくは、こうですね。

item_cell.xml
             android:src="@{cellData.stepCountLog.weather}"

ビルドが通ったら、実行してみましょう。
おや、データが無いはずなのに、いろいろ表示されてしまっていますね。
データが無いときには、歩数や天気アイコンなどは非表示にしたいですね。

天気アイコンと、満足度アイコン(そう呼んでたっけ?忘れてしまった^^;)は、Binding Adapterの中でやればよいですね。
nullが渡ってきたら、visibilityGONEにしてやればいいです。
例として、満足度アイコンのを出しておきます。

BindingAdapters.kt
@BindingAdapter("android:src")
fun setImageLevel(view: ImageView, level: LEVEL?) {
    if(level==null){
        view.visibility = View.GONE
        return
    }
    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.visibility = View.VISIBLE
    view.setImageResource(res)
}

さて、歩数の方はどうしましょうか?Binding Adapterを用意していません。
わざわざBinding Adapterを用意して出来なくもないですが・・・

visibilityを、Databindingで出来ないものでしょうか?

はい、出来ます!

item_cell.xml
      android:visibility="@{cellData.stepCountLog!=null ? View.VISIBLE : View.GONE}"

こんな風に書けます。
Javaの三項演算子みたいですね。というか多分そのままですね。

セルデータのstepCountLognullでなければ表示し、それ以外はGONE、つまり存在を消してしまっています。INVISIBLEとの違いですが、INVISIBLEは単に表示されなくしますが、View自体は実はレイアウト階層にちゃんと存在します。GONEにすると、高さや幅が0にされます。なので、「非表示にしたときにそのViewがあったはずの場所に、次のViewが詰められて表示する」ということが起こります。スペースは残したい場合、INVISIBLEを使うことが多いですが、実際にはViewは作られてしまっていて、見えていないだけ、ということに注意はする必要があります。

実はこのままではちゃんと動きません。Databindingで使用するデータ型として、Viewを宣言してやる必要があります。

item_cell.xml
    <data>
        <import type="android.view.View" /> <!-- 追加 -->
        <variable
                name="cellData"
                type="jp.les.kasa.sample.mykotlinapp.data.CalendarCellData" />
    </data>

View.GONEView.INVISIBLEにアクセスするために、importが必要なんですね。

実行してみましょう。
(まさか、「歩」を消すのを忘れてないよねえ?:grin:)

だいぶカレンダーぽくなりましたが、まだ変です。
そう、一日(ついたち)が必ず一番最初に表示されているのは変ですね。
ここはやはり、左から日曜日〜土曜日として、曜日にあった日にちを表示すべきです。(月曜日始まりがお好きな方もいるかと思いますが、取り敢えず日曜日始まりにします。)
つまり、その月の一日が何曜日かに合わせて、表示をずらさなければなりません。
でも、前月の日にちも表示されているのが通常のカレンダーです。

ということで、その月の一日がある週の日曜日の日付を求める必要があります。

4. そのページの初日を求める

その月の一日がある週の日曜日の日付を求めるには、どうすれば良いでしょう?
一日の曜日はCalendar.get(Calendar.DAY_OF_WEEK)で取れます。
ということは、日曜日になるまで日付を遡ると、そのページの初日が取れることになります。

createCellDataを修正します。

MonthlyPageViewModel.kt
    fun createCellData(yyyyMM: String, stepCountList: List<StepCountLog>): List<CalendarCellData> {
        val formatter = SimpleDateFormat("yyyy/MM", Locale.JAPAN)
        val cal = Calendar.getInstance()
        cal.time = formatter.parse(yyyyMM)
        cal.set(Calendar.DATE, 1)

        // 日曜日になるまで日付を遡る
        var dw = cal.get(Calendar.DAY_OF_WEEK)
        while (dw != Calendar.SUNDAY) {
            cal.add(Calendar.DATE, -1)
            dw = cal.get(Calendar.DAY_OF_WEEK)
        }

        val list = mutableListOf<CalendarCellData>()
        for (i in 1..42) {
            list.add(CalendarCellData(cal.clone() as Calendar, null))
            cal.add(Calendar.DATE, 1)
        }

        return list
    }

実行してみましょう。
だいぶカレンダーらしくなりましたね。

5. ログデータの取得範囲を変更する

ログデータが存在する日に表示を反映させていきましょう。
まず、前月と翌月のセルも表示しているので、この部分のデータも取ってきてあげる必要が出ます。

幸い、LogDao#getRangeLogfromtoyyyy/MM/ddを受け付けます。特に**その月の一日(ついたち)**と限定しているわけではないんです。なので、2019/12/29から2019/02/08としたって動いてくれます。

ということは、MonthlyPageViewModel#getFromToYMDで求めている日付の範囲を、カレンダーの42日分を求めたときに出した「そのページの表示初日」と「その42日後」にして、createCellDataの方は、その時出した「初日」をどこかに保持しといて、そこから42日分データを作れば良いという流れになります。

全体でこんな風に書けるかと思います。

MonthlyPageViewModel.kt
    // データリスト
    val stepCountList: LiveData<List<StepCountLog>> =
        Transformations.switchMap(_dataYearMonth) {
            val ymd = getFromToYMD(it)
            firstDayInPage = ymd.first // ページ初日を保持
            repository.searchRange(ymd.first.getDateStringYMD(), ymd.second.getDateStringYMD())
        }

    private lateinit var firstDayInPage :Calendar

    // カレンダーのセルデータは、データリストが取れてからにする
    val cellData: LiveData<List<CalendarCellData>> = Transformations.map(stepCountList) {
        createCellData(firstDayInPage, it)
    }
    
    ...
    
    fun getFromToYMD(yyyyMM: String): Pair<Calendar, Calendar> {
        val formatter = SimpleDateFormat("yyyy/MM", Locale.JAPAN)
        val from = Calendar.getInstance()
        from.time = formatter.parse(yyyyMM)
        from.set(Calendar.DATE, 1)
        from.clearTime()
        // 日曜日になるまで日付を遡る
        var dw = from.get(Calendar.DAY_OF_WEEK)
        while (dw != Calendar.SUNDAY) {
            from.add(Calendar.DATE, -1)
            dw = from.get(Calendar.DAY_OF_WEEK)
        }
        // 42日後にする
        val to = from.addDay(42)

        return Pair(from, to)
    }

    fun createCellData(from: Calendar, stepCountList: List<StepCountLog>): List<CalendarCellData> {

        val cal = from.clone() as Calendar
        val list = mutableListOf<CalendarCellData>()
        for (i in 1..42) {
            list.add(CalendarCellData(cal.clone() as Calendar, null))
            cal.add(Calendar.DATE, 1)
        }

        return list
    }

Calendarクラスのインスタンスの扱いに注意が必要です。
Calendar#addDayを拡張関数で作っていますが、これは新しいインスタンスに日付を加算して返します。
一方、add(Calendar.DATE, 1)は、そのインスタンスの日付を加算します。
(自分で拡張関数を作っておいてちょっと混乱したのは内緒w)

6. ログデータを反映する

さて、いよいよ、ログデータを反映します。
ログデータは昇順になっているので、順番にこう見ていくのはどうでしょう?

  • 日付が一致するか?
    • 一致するならそのデータをセルデータにセット
    • ログデータのindexを後ろにずらす
実装サンプルはこちら。
MonthlyPageViewModel.kt
    fun createCellData(from: Calendar, logs: List<StepCountLog>): List<CalendarCellData> {

        val cal = from.clone() as Calendar
        val list = mutableListOf<CalendarCellData>()
        var index = 0
        for (i in 1..42) {
            val log =
                if (logs[index].date == cal.getDateStringYMD()) {
                    logs[index++]
                } else {
                    null
                }
            list.add(CalendarCellData(cal.clone() as Calendar, log))
            cal.add(Calendar.DATE, 1)
        }

        return list
    }

List#findを使っても良いですが、データは昇順に並んでいることを考えると、毎回全件検索するのは効率が悪いし、常に先頭と比べて一致すればindexを下げていけば良いかなと・・・

もっとスマートな方法があるかもしれません。アイデアある方は是非コメントで教えて下さい。

(6) 前月・翌月を分かりやすくする

前月と翌月が分かりやすくなるように、少しセルの背景色を変えましょうか?
そのためには、何か「表示月かどうか」が分かる手段が必要ですね。

Databindingに以下のようにすることを考えます。

item_cell.xml
    <data>

        <import type="android.view.View" />

        <variable
            name="cellData"
            type="jp.les.kasa.sample.mykotlinapp.data.CalendarCellData" />
        <variable
                name="month"
                type="int" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
            android:id="@+id/logItemLayout"
            android:layout_width="48dp"
            android:layout_height="65dp"
            android:layout_margin="4dp"
            android:background="@{cellData.calendar}"
            app:month="@{month}"
            ....

monthLogRecyclerAdapter#onBindViewHolderで渡します。

MonthlyPageFragment.kt
        holder.binding.month = month

さらにこれはどこから貰うかというと、コンストラクタで貰っておきましょうか。

MonthlyPageFragment.kt
class LogRecyclerAdapter(private val listener: OnItemClickListener, val month: Int) 

で、これを呼び出すところでは、Intentから表示年月を取り出して、ただしこれは年月yyyy/MMになっているので、うまいこと月だけもらえばいいですね。

MonthlyPageFragment.kt
    override fun onCreateView(
        val yearMonth = arguments!!.getString(KEY_DATE_YEAR_MONTH)!!

        // RecyclerViewの初期化
        binding.logList.layoutManager = GridLayoutManager(context, 7)
        adapter = LogRecyclerAdapter(this, Integer.valueOf(yearMonth.split('/')[1]))
        binding.logList.adapter = adapter

        viewModel.setYearMonth(yearMonth)

こんな感じで渡せるはずです。

さて、問題はBinding Adapterです。レイアウトxmlでは、次のようにしましたね。

item_cell.xml
            android:background="@{cellData.calendar}"
            app:month="@{month}"

これは、Binding Adapterに複数のパラメータを指定する方法で書けます。

BindingAdapters.kt
@BindingAdapter(value = ["android:background", "month"], requireAll = true)
fun setCellBackground(view: View, cellDate: Calendar, month:Int) {
    val m = cellDate.getMonth()
    if(m+1==month){
        view.setBackgroundResource(R.drawable.cell_nonactive)
    }else{
        view.setBackgroundResource(R.drawable.cell_nonactive_grey)
    }
}

["android:background", "month"]で指定した順番と、関数の引数の順番は一緒でなければなりません。
requireAll = trueは見ての通り、すべての要素の指定が必要、という意味です。
Calendarクラスの月は、生データだと0 based indexつまり0から始まっているので、1月=0、2月=1 ...となっています。なので比較する際、1足したものと比較しています。

cell_nonactive_grey.xmlは通常のセル背景用のshapeの色だけ設定を変えたものです。

さて、だいぶカレンダーらしくなりましたね!

(7) 今日をアクティブにする

何となく、今日は分かりやすくなっていて欲しいですよね。
セルの枠の色を変えることで、目立つようにします。

1. リソースの用意

ということで、またshapeをdrawableに用意します。

cell_active.xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <stroke
            android:width="2dp"
            android:color="#000000" />
    <solid android:color="#FFFFFF" />
    <corners android:radius="8dp" />
</shape>

黒い枠線にし、太く(2dpに)しました。

2. セルの枠の色を変える

Binding Adapterでやってしまいましょうか。
今日の日付を取る必要がありますが、ここはテストのことも考えて、CalendarProviderIを使いたいところです。

でも、Koinのby Injedct()は、KoinComponentを継承していないと使えません。
じゃあ、クラスにすれば良い?

BindingAdapters.kt
class CalendarCellBindingAdapter : KoinComponent {
  val calendarProvider: CalendarProviderI by inject()

    @BindingAdapter(value = ["android:background", "month"], requireAll = true)
    fun setCellBackground(view: View, cellDate: Calendar, month: Int) {
      ....
    }
}

一見良さそうに見えますが、Binding Adapterは、実はJavaで書くと、staticなメソッドでなければならないんです。

class CalendarCellBindingAdapter extends KoinComponent {
    static void setCellBackground(...) {
        ....
    }
}

恐らく、Databindingが内部で、BindingAdapterの関数を持つクラスを生成することをしないのでしょう。Javaではクラスに属さない関数は作れなかったので、こうなっています。

Kotlinでこれを書くと、こうなります。

class CalendarCellBindingAdapter : KoinComponent {
  companion object {
    val calendarProvider: CalendarProviderI by inject() // (a)

    @JvmStatic
    @BindingAdapter(value = ["android:background", "month"], requireAll = true)
    fun setCellBackground(view: View, cellDate: Calendar, month: Int) {
        val calendarProvider: CalendarProviderI by inject() // (b)
        ....
    }
  }
}

ところがこれだと、やっぱり(a)でも(b)でも、inject()が解決できないとコンパイルエラーが出ます。まあ、コンパニオンオブジェクト(Javaでいうstaticなもの)は実際にはKoinComponentのインスタンスには属さないものだし、拡張関数であることを考えると、KoinComponent.inject()みたいになっていて、コンパニオンオブジェクトには対応していないのでしょう。

どうしたものかとググっていたら、下記のページに参考になるコメントが付いていました。

有り難い!
ということで、このようにしました。

KoinUtils.kt
inline fun <reified T> byKoinInject(): T {
    return object : KoinComponent {
        val value: T by inject()
    }.value
}
BindingAdapters.kt
@BindingAdapter(value = ["android:background", "month"], requireAll = true)
fun setCellBackground(view: View, cellDate: Calendar, month: Int) {
    val calendarProvider:CalendarProviderI = byKoinInject()
    val now = calendarProvider.now
    val m = cellDate.getMonth()
    when {
        cellDate.equalsYMD(now) -> {
            view.setBackgroundResource(R.drawable.cell_active)
        }
        m + 1 == month -> {
            view.setBackgroundResource(R.drawable.cell_nonactive)
        }
        else -> {
            view.setBackgroundResource(R.drawable.cell_nonactive_grey)
        }
    }
}

条件が3つに増えたので、ifではなくてwhenに変更しました。AndroidStudioがif-elseで書いていると、「whenにしなさい」と怒ってサジェストしてくれるので、alt + Enter(Macの場合)等でそのまま**replace `if` with `when`**を選べば簡単です。

かなりカレンダーらしくなりました。あともう一歩です。

(8) 曜日ラベルを表示する

最後に、曜日のラベル(日〜土)を表示してあげましょう。

こんな風にしてみました。ここはもうお好みですね。

qiita09_02.png

曜日ラベルの部分だけを抜粋します。

        <LinearLayout
                android:id="@+id/weekOfDayLabels"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginStart="4dp"
                android:layout_marginEnd="4dp"
                android:background="#004D40"
                android:orientation="horizontal"
                android:paddingTop="4dp"
                android:paddingBottom="4dp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/textViewYM">

            <TextView
                    style="@style/DayOfWeekLabelStyle"
                    android:text="@string/sunday" />

            <TextView
                    style="@style/DayOfWeekLabelStyle"
                    android:text="@string/monday" />

            <TextView
                    style="@style/DayOfWeekLabelStyle"
                    android:text="@string/tuesday" />

            <TextView
                    style="@style/DayOfWeekLabelStyle"
                    android:text="@string/wednesday" />

            <TextView
                    style="@style/DayOfWeekLabelStyle"
                    android:text="@string/thursday" />

            <TextView
                    style="@style/DayOfWeekLabelStyle"
                    android:text="@string/friday" />

            <TextView
                    style="@style/DayOfWeekLabelStyle"
                    android:text="@string/saturday" />
        </LinearLayout>

LinearLayoutは、縦または横に子要素をひたすら並べるレイアウトです。

TextViewには、styleを指定する方法を採りました。同じスタイル(テキストの色やスタイル等)のTextViewを7個も並べるので、微調整したいときに7箇所も直さなければならず、結構手間です。
そこで、スタイルを作って共通で使う、ということが出来ます。

中身はこうなっています。

styles.xml
    <style name="DayOfWeekLabelStyle" parent="@android:style/TextAppearance.Medium">
            <item name="android:layout_width">wrap_content</item>
            <item name="android:layout_height">wrap_content</item>
            <item name="android:layout_weight">1</item>
            <item name="android:gravity">center_horizontal</item>
            <item name="android:textStyle">bold</item>
            <item name="android:textColor">#E0F2F1</item>
    </style>

TextAppearance.Mediumというスタイルは、TextViewにデフォルトでセットされるスタイルです。
それを親として、DayOfWeekLabelStyleというスタイルを定義しています。
レイアウトの属性を1つずつ、itemタグで指定していきます。見てお気づきと思いますが、nameには、レイアウトxmlファイルに指定する属性名を使います。無いものを入れるとAndroidStudioが怒って教えてくれるはずです。

今回は、layout_weightを使っているのがポイントになります。
これは、LinearLayoutの子要素を配置する際のウェイトを指定するもので、今回は全部に1を指定しているので、すべてが等幅になる、ということになります。
なお、LinearLayoutの属性にweight_sumというのがあります。これが指定されていると、すべての子要素のlayout_weightの合計がweight_sumにならないと、余った分の余白が出来上がります。
詳しくはこちらなどの解説に任せますが、簡単に子要素や余白との比率を指定できるので、割とよく使われる手段です。

ここまでで、やっとカレンダー式表示の完成です!お疲れ様でした^-^
私は最後に背景色など調整しました。皆さんも、お好みにどうぞ。
Fragmentの下部の余白も、好きに埋めましょう。
そのうち、広告を入れるのでもやりましょうかね?

ところで、カレンダーのセルに随分余白が多い気がします?
実は私、ここにもう少し他のデータを載せてアプリを公開しようかと思っています。
いつになるか分かりませんが、公開したら使ってみて頂けたら嬉しいです。
公開するには、デザインをもう少しどうにかしないとな(汗)

追加や削除の動作変更

カレンダー式表示が出来たので、追加や削除の導線等を次のように変更します。

  • 追加はセルタップから
    • ログデータが無いセルの場合、日付を初期値として渡せるようにする
    • 未来日付をタップしても反応しないようにする(※お好みで)
      • 今日の日付は、CalendarProviderIを使って
  • 削除は編集画面のみからにする(※お好みで)
    • セルのロングクリックを削除
    • 削除確認ダイアログ関連の処理を削除(せっかく作ったけど・・・^^;)

削除の導線を絞ったのは、ActivityとFragmentに同じ機能があるのが嫌だったのと、セルのロングクリックって結構誤操作起きやすいかなと思って、やめることにしました。残しておきたい方はそのままでも良いです。

そんなに難しくないと思うので、是非やってみてください。
参考コードは、ブランチにアップしてあるソースコードでご確認ください。

今までの縦のリスト表示も残したい方は、MainActivityのメニューを、「リスト⇔グリッド表示切替」ボタンに変更するというのも面白いかも知れませんね。

サンプルコードは作っていませんが、以下のようなアプローチでどうでしょうか?

  • 表示モード用のLiveDataMainViewModelに用意する
    • これを監視しておいて、表示を切り替える
  • リスト表示用とグリッド表示用のViewPagerのAdapterをそれぞれ用意する
    • Fragmentもそれぞれ用意
    • 上記の表示モードを表すLiveDataがセットされたらAdapterを差し替える
  • リスト表示時のデータ追加は、Floating Actionボタンにする
  • リスト表示時とグリッド表示時のデータはList#reversed()を用いて順序を逆にして使う

テスト

グリッド表示、カレンダー式表示に変わったので、ちょこちょこと修正箇所があります。

(1) Util系のテストの変更

Calendarクラスの拡張関数をいくつか追加したので、そのテストを追加します。
これまでのテストと同様に作れるはずなので、自力で頑張ってみてください。
ブランチにはアップしてあります。

(2) LogRepositoryTestの変更

searchRangeのソート順が変わっているので、その対応が必要です。

(3) MonthlyPageViewModelの変更

以下にヒントを出しますので、自力で書いてみてください。

  • 初期化テストにcellDataに関するチェックを追加
  • 削除に関するテストを削除
  • cellDataに関するテストを追加
    • _dataYearMonthがセットされたら更新されること
  • stepCountListに関するテストが無かったので追加
    • _dataYearMonthがセットされたら更新されること
  • firstDayInPageに関するテストを追加
    • _dataYearMonthがセットされたら更新されること
    • privateだとテスト関数からアクセスできないので、VisibleForTestingを使う
  • getFromToYMDテストの変更
    • 求める範囲が変わった
    • 返す値が変わった
  • createCellData関数のテストを追加

これまで書いてきたテストを参考に書けるかと思います。
詳細は、ブランチにアップしてあるソースコードで確認して下さい。

(4) MonthlyPageFragmentTestの変更

まず、showListのテストの変更が必要ですね。関数名もそぐわないので変えましょう。showPageとか?

1. セルのログデータ反映表示のテスト

アプローチとしては次のようにしたいと思います。

  • 先頭からいくつかと最後のセル表示のチェック
  • ログがある日とその前後のセル表示のチェック
    • 他の日付のセルに繰り返されたりしていないかの確認のため

セル表示のチェックは、ログがある日のチェックはこれまでのリスト表示の時のテストとほぼ同じになりますが、ログがない日のチェックもしなければなりません。

  • 日にちは表示されている
  • 歩数と"歩"のラベルは表示されていない
  • 天気、満足度アイコンは表示されていない

このようにならないといけませんね。
このうち、"表示されていない"のテストは、withEffectiveVisibility((ViewMatchers.Visibility.XXXX)でチェックできます。
XXXXの部分は、Viewクラスのvisibilityに指定できるのと同じものが使えます。つまり、VISIBLE, INVISIBLE, GONEですね。
今回、ログがない日は全部View.GONEにしているはずなので・・・?

上記をヒントに、変更してみて下さい。
例によって、ブランチにアップしてあるソースコードで確認出来ます。

なお、カスタムマッチャー(atPositionOnView)はそのまま使えます。indexの進みは、左から右、上から下に向かって進みます。まあ普通ですね。

一方、あるカスタムマッチャーは、実は修正が必要です。

2. DrawableMatcher(カスタムマッチャー)の修正

Drawableの一致チェックをするカスタムマッチャー、DrawableMatcherはこのように作っていました。

TestUtils.kt
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
    }
   ...

が、実は、RobolectricのBitmapはダミーの画像しか作成されず、Bitmap#sameAsが常に成功してしまうようになってしまっているんです。
このことには気付いていましたが、ViewPager化するまでは、UIのテストは必ずInstrumentation版にも同じテストを書いていて、そちらで通っていたのであまり気にしていませんでした。
しかし、MonthlyPageFragmentTestにあるテストはInstrumentation版には入れていないので、実際には、セットされているリソースが間違っていても検出できない、テストが不十分な状態でした。
(実際に、withDrawableを使っているところで、エラーになるはずのリソースIDを指定しても、テストを通過してしまうのが分かると思います)
これ、業務でやらかしてしまっていたら、「テスト書いてたのになんで検出できなかったんだ!」と相当上から絞られてしまうようなミスでした。リリース前に気付いて良かった!とここは開き直って対応を考えましょう。

Instrumentation版のMainActivityTestIに表示系のテストを移すことも検討したのですが、せっかくならFragmentのテストはFragmentのテストクラスに入れたい・・・と悩みながら、return bitmap.sameAs(otherBitmap)にブレークポイント貼っていろいろ変数の中身などを眺めているときに、ふと、以下のようなものが目に入りました。

qiita09_03.png

createdFromResIdなるものが使えそうですね?これはリソースIDに実際に付与されたInt型の整数です。
これなら、Bitmapが同じかのチェックではなく、同じリソースIDのリソースから作られたかという、もっと単純なテストが出来そうです。
ということで、早速書き変えてみます。

TestUtils.kt
    override fun matchesSafely(target: View): Boolean {
        if (target is ImageView) {
            if (expectedId < 0) {
                return target.drawable == null
            }
            
            var drawable = target.drawable
            if (drawable is StateListDrawable) {
                drawable = drawable.getCurrent()
            }

            return shadowOf(drawable).createdFromResId==expectedId
        }
        return false
    }

随分簡単な処理になりました。Robolectricがダミーにしてしまっている(Shadow化といいます)Drawableの、そのShadowデータには、Shadow.shadowOf()でアクセスしています。先ほど見た変数_robo_data_にアクセスしているものと思われます。

これでテストを、敢えてエラーになるはずのリソースIDに変更して、既存のテストをどれか実行してみて下さい。
エラーになればOK。

これで、Robolectric版でもDrawableのリソースIDが一致しているかのチェックが出来るようになりました。
ただ、注意しないと行けないのは、これはDrawableがリソースから作成された場合にしか使えないと言うことです。
ネットワーク越しにDLした画像のチェックとか、アプリ内で生成する系の画像のチェックは、諦めないといけません。
そういうテストが必要になったら、素直にInstrumentation版のテストに書いていきましょう。

3. セルの背景設定のテスト

セルの背景は、条件によって変わるはずですね。以下のパターンでチェックしましょう。

  • 表示月の前月
  • 表示月の翌月
  • 表示月の今日
  • 表示月の今日以外

「今日」を返すCalendarProviderIを、Robolectric版でもモックしてやるのが必要ですね。これもInstrumentation版を参考に出来ると思います。ただ、同じクラス名TestCalendarPrividerだとコンパイルできないので、別の名前にしてあげて下さい。

先ほど直したDrawableMatcherをふんだんに使うテストになると思います・・・が、実は、またまたマッチャーに修正が必要です。

TestUtils.kt
    override fun matchesSafely(target: View): Boolean {
        if (target is ImageView) {
            ....
        }
        return false
    }

ImageViewじゃなかったら常にfalseです!
セルの背景を指定しているのは、ルートのViewGroupにしている人がほとんどではないでしょうか?私の場合、ConstraintLayoutです。FrameLayoutをルートにしてImageViewを使った人はそのままでも大丈夫ですが、そうでない人は、Viewのバックグラウンドに指定したDrawableの一致もチェックできる関数にしなければなりません。
Viewのバックグラウンドには、View#backgroundでアクセスできます。

else文を追加して、その中で、target.backgroundに対するチェックをしてやればいいですね。チェックはImageViewのときのと同じことをすれば良いです。

もしくは、withBackgroundとして別の関数を作っても良いですね。

マッチャーの修正が終わったら、セルの背景テストを書いて、実行してみましょう。
個別にテストが通過したら、テストクラス全体も流しておきましょう。
Robolectric版はテストが早くて良いですね。初期化に時間がかかるので、個別に実行するぶんにはあまりメリット無いかもですが、まとめて流すときにはやはり威力を発揮します。
もう少しInstrumentation版とコードが共有できるようになると本当に助かるのですが・・・

(5) MainActivityのテストの変更

追加メニューや削除機能が無くなったので、そのへんのテストは削除ですね。

既存のテストは、日付ラベルに表示される文字列の仕様も変わっているので、それらの細かい修正が必要なのと、アイテムクリックの部分を少し工夫する必要があります。

  • 新規追加は、今日までのログがない日をクリック
  • 編集は、ログのある日をクリック

とアクション契機を変えないといけません。
とはいえ、ログデータは自分で用意しているわけですから、該当する日のIndexを計算して、そのセルをクリックするアクションを起こしてやれば良いですね。

これまでのテストに書いてきた内容で、直せるはずなので、やってみてください。

ただ、画面の表示に凄く時間がかかるようになっているのは、実際にアプリを触っていて気付いていましたが、特にLogItemActivityを起動して戻ってきたときに再表示が間に合っていないらしく、テストが失敗しやすいです。というかほぼ失敗します。デバッグ実行にしてブレークポイントを貼っておけば成功しますが。
どうやら、事前にデータがあるべきのテスト(削除、編集系)で、ViewModelの関数を通して追加しているのも動作を遅くしている原因ぽいので、「Activityが起動する前にデータを登録しておく」というのをやると、少し安定しました。
また、LogItemActivityから戻ったときは、LogRepository#allLogs()でデータ件数のチェック処理を挟んで更にwaitForIdleSyne()を入れたりして、やっと案定しました。

事前にデータを入れておくのは、以下のようなアプローチで考えれば良いかと思います。

  • LogRepositoryのインスタンスはby inject()で取得
  • @Beforeメソッドで、一括してactivityRule.launchActivity(Intent())としているのを、各テストメソッドの適切な位置に入れる
  • Activity#runOnUiThreadでしていたデータ追加処理を、runBloclingを使ってLogRepositoryクラスから行い、その後Activityを起動させる

(6) LogItemActivityのテストの変更

データ追加の際、初期設定日を渡すようにしましたね。その対応が必要です。
具体的には、Intentを渡していないで起動しているテストなどで軒並み失敗します。なのでそこを直してあげてください。
一度全部実行してみると、どのテストに対応が必要か分かります。
Robolectric版、Instrumentation版共に、ブランチにアップしてありますので必要があれば確認して下さい。

そうそう、MockCalendarProviderTestCalendarPrividerにアクセスしたいテストクラスの@Beforeメソッドで、loadKoinModules()してモジュールを上書きするのを忘れないで下さいね。

その他

Roomを導入したときに参考にしたページが古かったのか、使っているライブラリがJecpack(androidX)のものではありませんでした。
なので、dpendenciesを以下のように直しています。

app/build.gradle
    // Room components
    def room_version = "2.2.3"
    implementation "androidx.room:room-runtime:$room_version"
    kapt "androidx.room:room-compiler:$room_version"
    androidTestImplementation "androidx.room:room-testing:$room_version"
    testImplementation "androidx.room:room-testing:$room_version"

まとめ

RecyclerViewのレイアウトマネージャーを変更して、グリッド表示が出来るようになりました。
Binding Adapterに複数のパラメータを渡せるようになりました。また、Viewクラスをインポートすることも学びました。
KoinComponentの外でinjectする方法も覚えましたね。DIのライブラリを使って書くのがだんだん楽しくなってきたのではないでしょうか?

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

予告

2020/02/21 変更
CI(継続的インテグレーション)をやってみます。
そろそろローカルで全部テストするのが長くなってきましたのでね。
予定ではGithub Actionはやってみます。余力があれば、他も試してみるかも知れません。


以下は次々回予定に変更。

データをサーバーに保存します。そのためにはユーザーを一意に特定する必要があるので、ログイン機能が必要になってきます。
ということで、以下のFirebaseの機能を入れていきます。

  • 認証(Authentication)
  • Database(Cloud Firestore)

これで、アプリをアンインストールしたり機種変更したりしても、過去データを戻すことが出来ます。
ただ、これはやり方を間違えると、Firebaseにとんでもなく課金されてしまうので、注意が必要です。

参考ページなど

4
4
0

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?