前回の続きです。
今回の目標
いよいよ、カレンダー風のグリッド表示に挑戦します。
グリッド表示も、Androidでは覚えておくべき機能の1つでしょうね。
とはいえ、RecyclerView
を使えば、それほど変更は無く出来るはずです。
※GridView
やGridLayout
というのはありますが、今はRecyclerView
にGridLayoutManager
を使う方法が推奨になっています。
グリッド表示
まずは単純にグリッド表示をしてみましょう。
こんなレイアウトにしようと思います。
なお、AndroidのGridViewでは、格子状に罫線を入れるのは結構至難の業です。
これくらいだったら簡単にできますが、
左端と上端に罫線が無いのお分かりでしょうか?
これを出そうとすると、途端に難しくなるんです。(あと、要素の幅を固定しないと縦の罫線が大量に出てしまうというのもあります)
なので、このアプリでは、背景枠画像そのものを用意するという手段で乗り切ろうと思います。
(1) 背景枠画像の用意
背景画像は角丸になるようにShape
で用意しました。
<?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) セルのレイアウトファイルの作成
こんなレイアウトにしてみます。
レイアウトファイルのサンプルはこちら
<?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
に変更することでグリッド表示に出来ます。
override fun onCreateView(...){
....
// RecyclerViewの初期化
binding.logList.layoutManager = GridLayoutManager(context, 7)
....
LinearLayoutManager
を指定していた所をGridLayoutManager
に変えています。二つ目の引数で7
を渡していますが、これは、いわゆるカラム数です。
これだけ!
表示してみましょう。
今は存在するデータだけを表示しているので、カレンダーぽくないですが、ちゃんとグリッド表示出来ているはずです。
(4) データ取得ソート順を変更
さて、カレンダー式表示にするなら、データは日付が古い順に持っている方が良いでしょうね。
ということで、現在、searchRangeで降順を指定しているのを昇順に変更します。
@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
を作ることにします。
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
を使います。
// カレンダーのセルデータ
val cellData: LiveData<List<CalendarCellData>> =
Transformations.map(stepCountList) {
createCellData(dataYearMonth.value!!, it)
}
前回、「map
は値を返す」と書きました。 「ラムダの中で、値を返す」 という意味でした。自分で見返しててちょっと混乱したので、今回、自分の中で確認の意味も兼ねてmap
を使ってみました。
実は、前回作ったMainViewModel#pages
も、switchMap
ではなくmap
でもっと簡単に書けました。
訂正しておきます。
// ページ
val pages = Transformations.map(oldestDate) {
makePageList(it, calendarProvider.now)
}
さて、本題に戻して、createCellData
は次のように作りました。
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.xml
、item_cell.xml
で指定しているバインドデータ型-
<variable>
タグの中です
-
-
fragment_monthly_page.xml
、item_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]**の所に出ています。
文字のエスケープがおかしくて分かりづらいですが、stepLog
がCheck that the identifier is spelled correctly
と言われているので、スペルミスじゃないかと言われているのが分かります。検出された場所も、item_cell.xml","pos":[{"line0":66
とあるので、item_cell.xml
の66行目付近を見てみます。
<ImageView
android:id="@+id/weatherImageView"
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_marginStart="2dp"
android:src="@{stepLog.weather}"
おっと、修正漏れしてました。(行数は目安で、合っていないことがあるようですw)
<variable
name="cellData"
type="jp.les.kasa.sample.mykotlinapp.data.CalendarCellData" />
バインディングデータを上記のように変更してたので、正しくは、こうですね。
android:src="@{cellData.stepCountLog.weather}"
ビルドが通ったら、実行してみましょう。
おや、データが無いはずなのに、いろいろ表示されてしまっていますね。
データが無いときには、歩数や天気アイコンなどは非表示にしたいですね。
天気アイコンと、満足度アイコン(そう呼んでたっけ?忘れてしまった^^;)は、Binding Adapter
の中でやればよいですね。
null
が渡ってきたら、visibility
をGONE
にしてやればいいです。
例として、満足度アイコンのを出しておきます。
@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で出来ないものでしょうか?
はい、出来ます!
android:visibility="@{cellData.stepCountLog!=null ? View.VISIBLE : View.GONE}"
こんな風に書けます。
Javaの三項演算子みたいですね。というか多分そのままですね。
セルデータのstepCountLog
がnull
でなければ表示し、それ以外はGONE
、つまり存在を消してしまっています。INVISIBLE
との違いですが、INVISIBLE
は単に表示されなくしますが、View自体は実はレイアウト階層にちゃんと存在します。GONE
にすると、高さや幅が0にされます。なので、「非表示にしたときにそのViewがあったはずの場所に、次のViewが詰められて表示する」ということが起こります。スペースは残したい場合、INVISIBLE
を使うことが多いですが、実際にはViewは作られてしまっていて、見えていないだけ、ということに注意はする必要があります。
実はこのままではちゃんと動きません。Databindingで使用するデータ型として、View
を宣言してやる必要があります。
<data>
<import type="android.view.View" /> <!-- 追加 -->
<variable
name="cellData"
type="jp.les.kasa.sample.mykotlinapp.data.CalendarCellData" />
</data>
View.GONE
やView.INVISIBLE
にアクセスするために、importが必要なんですね。
実行してみましょう。
(まさか、「歩」を消すのを忘れてないよねえ?)
だいぶカレンダーぽくなりましたが、まだ変です。
そう、一日(ついたち)が必ず一番最初に表示されているのは変ですね。
ここはやはり、左から日曜日〜土曜日として、曜日にあった日にちを表示すべきです。(月曜日始まりがお好きな方もいるかと思いますが、取り敢えず日曜日始まりにします。)
つまり、その月の一日が何曜日かに合わせて、表示をずらさなければなりません。
でも、前月の日にちも表示されているのが通常のカレンダーです。
ということで、その月の一日がある週の日曜日の日付を求める必要があります。
4. そのページの初日を求める
その月の一日がある週の日曜日の日付を求めるには、どうすれば良いでしょう?
一日の曜日はCalendar.get(Calendar.DAY_OF_WEEK)
で取れます。
ということは、日曜日になるまで日付を遡ると、そのページの初日が取れることになります。
createCellData
を修正します。
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#getRangeLog
はfrom
とto
にyyyy/MM/dd
を受け付けます。特に**その月の一日(ついたち)**と限定しているわけではないんです。なので、2019/12/29
から2019/02/08
としたって動いてくれます。
ということは、MonthlyPageViewModel#getFromToYMD
で求めている日付の範囲を、カレンダーの42日分を求めたときに出した「そのページの表示初日」と「その42日後」にして、createCellData
の方は、その時出した「初日」をどこかに保持しといて、そこから42日分データを作れば良いという流れになります。
全体でこんな風に書けるかと思います。
// データリスト
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を後ろにずらす
実装サンプルはこちら。
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に以下のようにすることを考えます。
<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}"
....
month
はLogRecyclerAdapter#onBindViewHolder
で渡します。
holder.binding.month = month
さらにこれはどこから貰うかというと、コンストラクタで貰っておきましょうか。
class LogRecyclerAdapter(private val listener: OnItemClickListener, val month: Int)
で、これを呼び出すところでは、Intent
から表示年月を取り出して、ただしこれは年月yyyy/MM
になっているので、うまいこと月だけもらえばいいですね。
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では、次のようにしましたね。
android:background="@{cellData.calendar}"
app:month="@{month}"
これは、Binding Adapter
に複数のパラメータを指定する方法で書けます。
@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に用意します。
<?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
を継承していないと使えません。
じゃあ、クラスにすれば良い?
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()
みたいになっていて、コンパニオンオブジェクトには対応していないのでしょう。
どうしたものかとググっていたら、下記のページに参考になるコメントが付いていました。
有り難い!
ということで、このようにしました。
inline fun <reified T> byKoinInject(): T {
return object : KoinComponent {
val value: T by inject()
}.value
}
@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) 曜日ラベルを表示する
最後に、曜日のラベル(日〜土)を表示してあげましょう。
こんな風にしてみました。ここはもうお好みですね。
曜日ラベルの部分だけを抜粋します。
<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箇所も直さなければならず、結構手間です。
そこで、スタイルを作って共通で使う、ということが出来ます。
中身はこうなっています。
<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
のメニューを、「リスト⇔グリッド表示切替」ボタンに変更するというのも面白いかも知れませんね。
サンプルコードは作っていませんが、以下のようなアプローチでどうでしょうか?
- 表示モード用の
LiveData
をMainViewModel
に用意する- これを監視しておいて、表示を切り替える
- リスト表示用とグリッド表示用の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
はこのように作っていました。
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)
にブレークポイント貼っていろいろ変数の中身などを眺めているときに、ふと、以下のようなものが目に入りました。
createdFromResId
なるものが使えそうですね?これはリソースIDに実際に付与されたInt型の整数です。
これなら、Bitmapが同じかのチェックではなく、同じリソースIDのリソースから作られたかという、もっと単純なテストが出来そうです。
ということで、早速書き変えてみます。
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
をふんだんに使うテストになると思います・・・が、実は、またまたマッチャーに修正が必要です。
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版共に、ブランチにアップしてありますので必要があれば確認して下さい。
そうそう、MockCalendarProvider
やTestCalendarPrivider
にアクセスしたいテストクラスの@Before
メソッドで、loadKoinModules()
してモジュールを上書きするのを忘れないで下さいね。
その他
Roomを導入したときに参考にしたページが古かったのか、使っているライブラリがJecpack(androidX)のものではありませんでした。
なので、dpendenciesを以下のように直しています。
// 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にとんでもなく課金されてしまうので、注意が必要です。
参考ページなど
- 逆引きKotlin | ループ処理を行う
http://kotlin-rev-solution.herokuapp.com/site/loop/ - スタイルとテーマ
https://developer.android.com/guide/topics/ui/themes?hl=ja - ShadowBitmapDrawableTest | robolectic Github
https://github.com/robolectric/robolectric/blob/master/robolectric/src/test/java/org/robolectric/shadows/ShadowBitmapDrawableTest.java - Room | Android Developers
https://developer.android.com/jetpack/androidx/releases/room