2018年11月4日日曜日

古い Mockito では Kotlin の suspend fun を override してくれないので 2.23.0 以降を使う

追記

mockito 2.23.0 で suspend fun のサポートが入った(Support mocking kotlin suspend functions compiled by Kotlin 1.3 (#1500))と教えていただいたので、試してみました。

2.23.0 の mockito なら以下のテストが成功しました!やったー! // このテストが 2.23.0 の mockito なら成功する! @Test fun test() { runBlocking { val mock = mock(Greeting::class.java).apply { `when`(hello()).thenReturn("Hello Android") } val counter = GreetingTextCounter(mock) assertThat(counter.count()).isEqualTo(13) } } もともと試した mockito のバージョンは 2.8.9 です。(なぜこのバージョンかというと iosched がこのバージョンを使っていたからです。特に深い意味はありません。)


以下は mockito 2.8.9 での動作です。

以下のような interface と class があるとします。 interface Greeting { fun hello(): String } class GreetingTextCounter(private val greeting: Greeting) { fun count(): Int { return greeting.hello().length } } Greeting のモックを用意して特定の文字列を hello() で返すようにすれば、GreetingTextCounter.count() のテストができます。 @Test fun test() { val mock = mock(Greeting::class.java).apply { `when`(hello()).thenReturn("Hello Android") } val counter = GreetingTextCounter(mock) assertThat(counter.count()).isEqualTo(13) } ここで Greeting.hello() と GreetingTextCounter.count() を suspend にします。
interface Greeting { suspend fun hello(): String } class GreetingTextCounter(private val greeting: Greeting) { suspend fun count(): Int { return greeting.hello().length } } すると、以下のテストは失敗します。 // このテストは失敗する @Test fun test() { runBlocking { val mock = mock(Greeting::class.java).apply { `when`(hello()).thenReturn("Hello Android") } val counter = GreetingTextCounter(mock) assertThat(counter.count()).isEqualTo(13) } } `when`().thenReturn() で hello() のときに "Hello Android" を返すように指定していても、GreetingTextCounter.count() のところで greeting.hello() が null を返してしまい、 java.lang.NullPointerException になります。

以下のように Greeting を実装した object を用意して hello() を override すればテストは成功します。 // このテストは成功する @Test fun test() { runBlocking { val mock = object : Greeting { override suspend fun hello() = "Hello Android" } val counter = GreetingTextCounter(mock) assertThat(counter.count()).isEqualTo(13) } } しかし使用しないメソッド以外も override しなければいけないのでよくありません。

そこで Delegation を活用し、使用しないメソッドは Mockito の mock に流すようにします。 // このテストは成功する @Test fun test() { runBlocking { val mock = object : Greeting by mock(Greeting::class.java) { override suspend fun hello() = "Hello Android" } val counter = GreetingTextCounter(mock) assertThat(counter.count()).isEqualTo(13) } } これで必要なメソッドだけ override し、テストも通るようになります。


2018年10月26日金曜日

FlexboxLayoutManager では CompoundDrawable の指定に relative 系の属性、メソッドは使わないほうがよい

compileSdkVersion 28, 27 で試しています(将来のリリースで修正されている可能性があります)。

FlexboxLayoutManager の問題ではなく、TextView の measure() 実装の問題です(Issue Tracker に登録しました)。
どういう問題かというと、setCompoundDrawablesRelativeWithIntrinsicBounds()setCompoundDrawablesWithIntrinsicBounds() で TextView の measure() の結果が異なり、setCompoundDrawablesRelativeWithIntrinsicBounds() だと measuredWidth/Height に CompoundDrawables のサイズが含まれないという問題です。 再現コードなどは上記の Issue に書いてあります。

FlexboxLayoutManager は MeasuredWidth/Height の値を使って View を配置しているため、TextView で setCompoundDrawablesRelativeWithIntrinsicBounds() や android:drawableStart, android:drawableEnd などで CompoundDrawable を指定すると、それを含まないサイズで配置され、文字が切れたり折り返して表示されてしまいます。



以下は RecyclerView + FlexboxLayoutManager の例です。



この例では、2行目は android:drawableLeft, android:drawableRight、4行目は android:drawableStart, android:drawableEnd を使っています。それ以外は同じです。セットされているテキストも両方 "Hello Android" です。

2行目用のレイアウト <?xml version="1.0" encoding="utf-8"?> <TextView xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/textView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:drawableLeft="@drawable/square" android:drawableRight="@drawable/square" /> 4行目用のレイアウト <?xml version="1.0" encoding="utf-8"?> <TextView xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/textView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:drawableEnd="@drawable/square" android:drawableStart="@drawable/square" /> android:drawableStart, android:drawableEnd を使った4行目では、CompoundDrawable のサイズが measuredWidth/Height に反映されず、その分文字の領域が小さくなって "Hello Android" の Android が切れたり、square の下のほうが切れたりしています。

このように CompoundDrawable を relative 系の属性、メソッドで指定すると問題があるため、setCompoundDrawablesWithIntrinsicBounds() や android:drawableLeft, android:drawableRight を使いましょう。



2018年9月15日土曜日

AlertDialog の Button の有効/無効を切り替える

AlertDialog の PositiveButton, NegativeButton, NeutralButton では、listener での実装によらずタップしたときに必ずダイアログが閉じます。

例えば AlertDialog でテキストを入力するようにして、未入力のときはボタンを押せないようにしたいとします。

AlertDialog の getButton() で Button インスタンスが取れるのでこれを利用します。
取得するボタンは BUTTON_POSITIVE, BUTTON_NEGATIVE, BUTTON_NEUTRAL で指定します。

あとは EditText に TextWatcher を追加して、テキストの変更時にボタンの isEnabled を変更します。

初回時 EditText が空ならボタンを disabled にしておかないといけません。
AlertDialog の getButton() は show() の前に呼ぶと NPE になるので注意が必要です。 val editText = EditText(this).apply { inputType = InputType.TYPE_CLASS_TEXT layoutParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT ).apply { val margin = (16 * resources.displayMetrics.density).toInt() marginStart = margin marginEnd = margin } } val frameLayout = FrameLayout(this).apply { addView(editText) } val dialog = AlertDialog.Builder(this) .setTitle("Title") .setMessage("Message") .setView(frameLayout) .setPositiveButton(android.R.string.ok, null) .create() editText.addTextChangedListener(object : TextWatcher { override fun afterTextChanged(s: Editable?) { } override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { } override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = !s.isNullOrBlank() } }) dialog.show() dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false