2020年12月26日土曜日

Dispatchers.Main.immediate ってなに?

Dispatchers.Main は MainCoroutineDispatcher です。 package kotlinx.coroutines ... public actual object Dispatchers { ... @JvmStatic public actual val Main: MainCoroutineDispatcher get() = MainDispatcherLoader.dispatcher ... } Dispatchers.Main.immediate は MainCoroutineDispatcher に定義されており、Dispatchers.Main.immediate 自体も MainCoroutineDispatcher です。 package kotlinx.coroutines ... public abstract class MainCoroutineDispatcher : CoroutineDispatcher() { ... public abstract val immediate: MainCoroutineDispatcher ... } kotlinx-coroutines-android では HandlerDispatcher が MainCoroutineDispatcher を継承し、 package kotlinx.coroutines.android ... public sealed class HandlerDispatcher : MainCoroutineDispatcher(), Delay { ... public abstract override val immediate: HandlerDispatcher } HandlerContext が HandlerDispatcher を継承しています。 package kotlinx.coroutines.android ... internal class HandlerContext private constructor( private val handler: Handler, private val name: String?, private val invokeImmediately: Boolean ) : HandlerDispatcher(), Delay { ... @Volatile private var _immediate: HandlerContext? = if (invokeImmediately) this else null override val immediate: HandlerContext = _immediate ?: HandlerContext(handler, name, true).also { _immediate = it } override fun isDispatchNeeded(context: CoroutineContext): Boolean { return !invokeImmediately || Looper.myLooper() != handler.looper } ... } HandlerContext では immediate にセットされる HandlerContext は invokeImmediately プロパティが true になる、ということがわかります。

invokeImmediately は isDispatchNeeded() で使われます。isDispatchNeeded() の実装をみると、invokeImmediately が false のときは常に isDispatchNeeded() が true を返すことがわかります。また Looper.myLooper() != handler.looper のときも isDispatchNeeded() が true を返すことがわかります。つまり、invokeImmediately が true かつ Looper.myLooper() == handler.looper のときだけ isDispatchNeeded() は false を返します。

このことから、immediate にセットされる HandlerContext では、Looper.myLooper() が handler.looper と同じだと isDispatchNeeded() が false を返すということがわかります。

isDispatchNeeded() は coroutine を dispatch メソッドで実行するべきかどうか判定するときに呼ばれます。デフォルトは true を返すようになっています。 package kotlinx.coroutines ... public abstract class CoroutineDispatcher : AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor { ... public open fun isDispatchNeeded(context: CoroutineContext): Boolean = true ... }

つまり、(kotlinx-coroutines-android の) Dispatchers.Main.immediate はすでに UI スレッドにいる場合(現在の Looper.myLooper() が handler.looper と同じ場合)そのまますぐに実行される Dispatcher ということです。

例えば Dispatchers.Main を使った以下のコードだと class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) CoroutineScope(Dispatchers.Main).launch { println("1 : ${Thread.currentThread().name}") } println("2 : ${Thread.currentThread().name}") } } 出力は 2 が 1 より先になります。 I/System.out: 2 : main I/System.out: 1 : main CoroutineScope の dispatcher を Dispatchers.Main.immediate に変えると class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) CoroutineScope(Dispatchers.Main.immediate).launch { println("1 : ${Thread.currentThread().name}") } println("2 : ${Thread.currentThread().name}") } } 1 が先に出力されるようになります。 I/System.out: 1 : main I/System.out: 2 : main UI スレッドにいない場合はすぐには実行されず dispatch されます。 class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) CoroutineScope(Dispatchers.Default).launch { println("3 : ${Thread.currentThread().name}") CoroutineScope(Dispatchers.Main.immediate).launch { println("1 : ${Thread.currentThread().name}") } println("4 : ${Thread.currentThread().name}") } println("2 : ${Thread.currentThread().name}") } } I/System.out: 2 : main I/System.out: 3 : DefaultDispatcher-worker-2 I/System.out: 4 : DefaultDispatcher-worker-2 I/System.out: 1 : main

viewModelScope, lifecycleScope は dispatcher として Dispatchers.Main.immediate が指定されています。 val LifecycleOwner.lifecycleScope: LifecycleCoroutineScope get() = lifecycle.coroutineScope val Lifecycle.coroutineScope: LifecycleCoroutineScope get() { while (true) { ... val newScope = LifecycleCoroutineScopeImpl( this, SupervisorJob() + Dispatchers.Main.immediate ) ... } } val ViewModel.viewModelScope: CoroutineScope get() { ... return setTagIfAbsent(JOB_KEY, CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)) }



2020年12月24日木曜日

android:fontFamily="sans-serif-medium" の文字の太さを動的に変更する

android:fontFamily に "sans-serif" または "sans-serif-medium"、android:textStyle に "normal" または "bold" を指定したときに生成される Typeface の weight, style, isBold は次のようになります。 <TextView ... android:fontFamily="sans-serif" or "sans-serif-medium" android:textStyle="normal" or "bold" />
fontFamilytextStyleweightstyleisBold
sans-serifnormal4000false
sans-serifbold7001true
sans-serif-mediumnormal5000false
sans-serif-mediumbold8001true

Typeface の style は textStyle の設定が反映されます。style が 1 だと Typeface.BOLD, 2 だと Typeface.ITALIC, 3 だと Typeface.BOLD_ITALIC です。なので style が 1 のとき isBold が true になっています。

"sans-serif-medium" + normal はちょっと太いですが isBold は false です。


android:textStyle の値をプログラムから変更するには TextView.setTypeface() メソッドを使います。このメソッドには引数の異なる2種類があります。 public void setTypeface(@Nullable Typeface tf, @Typeface.Style int style) { if (style > 0) { if (tf == null) { tf = Typeface.defaultFromStyle(style); } else { tf = Typeface.create(tf, style); } setTypeface(tf); // now compute what (if any) algorithmic styling is needed int typefaceStyle = tf != null ? tf.getStyle() : 0; int need = style & ~typefaceStyle; mTextPaint.setFakeBoldText((need & Typeface.BOLD) != 0); mTextPaint.setTextSkewX((need & Typeface.ITALIC) != 0 ? -0.25f : 0); } else { mTextPaint.setFakeBoldText(false); mTextPaint.setTextSkewX(0); setTypeface(tf); } } public void setTypeface(@Nullable Typeface tf) { if (mTextPaint.getTypeface() != tf) { mTextPaint.setTypeface(tf); if (mLayout != null) { nullLayouts(); requestLayout(); invalidate(); } } } Typeface.Style を渡さない方では mTextPaint に Typeface をセットして invalidate しています。

Typeface.Style を渡す方では、最終的に setTypeface(@Nullable Typeface tf) を呼び出しています。

style が 0 より大きい場合、つまり bold, italic, bold_italic のいずれかのときは Typeface.defaultFromStyle() または Typeface.create() を使って style に対応する Typeface を生成します。 生成した Typeface が style に対応していない場合だけ FakeBold や TextSkewX がセットされます。

style が 0 (= Typeface.NORMAL)の場合、style に対応する Typeface を生成せず、渡された Typeface をそのままセットします。

これによりどういうことが起こるかと言うと、
"sans-serif-medium" + normal な TextView に setTypeface(tv.typeface, Typeface.BOLD) すると bold になるのに、
"sans-serif-medium" + bold な TextView に setTypeface(tv.typeface, Typeface.NORMAL) すると normal にならない!
のです。 mediumNormalTextView.setTypeface(mediumNormalTextView.typeface, Typeface.BOLD) mediumBoldTextView.setTypeface(mediumBoldTextView.typeface, Typeface.NORMAL)

これは "sans-serif-medium" + bold のときに生成される Typeface 自体が bold になっていて(FakeBold を使わない)、style が 0 (= Typeface.NORMAL)の場合は渡した bold な Typeface をそのままセットされてしまうからです。

これを防ぐには、Typeface.create() で Typeface.NORMAL を適用した Typeface を生成して setTypeface() に渡します。 val tf = Typeface.create(mediumBoldTextView.typeface, Typeface.NORMAL) mediumBoldTextView.setTypeface(tf, Typeface.NORMAL)



2020年12月21日月曜日

Jupyter notebooks + Kotlin で移動平均を描画する(その3): 移動平均を計算する

このエントリは Fintalk Advent Calendar 2020 の21日目です。
今年は3つも割当たっているので、Covid-19 のデータで件数とその移動平均をグラフに描画する、というのを3回シリーズでやりたいと思います。


移動平均(Moving Average)は簡単に言うと、時系列データで平均をとって滑らかにする方法です。

Wikipedia にはこう書いてあります。

移動平均()は、時系列データ(より一般的には時系列に限らず系列データ)を平滑化する手法である。音声や画像等のデジタル信号処理に留まらず、金融(特にテクニカル分析)分野、気象、水象を含む計測分野等、広い技術分野で使われる。有限インパルス応答に対するローパスフィルタ(デジタルフィルタ)の一種であり、分野によっては移動積分とも呼ばれる。

主要なものは、単純移動平均と加重移動平均と指数移動平均の3種類である。普通、移動平均といえば、単純移動平均のことをいう。

by https://ja.wikipedia.org/wiki/%E7%A7%BB%E5%8B%95%E5%B9%B3%E5%9D%87


ここに書いてあるとおり、主なものとして
  • 単純移動平均
  • 加重移動平均
  • 指数移動平均
があります。

それぞれ計算してみましょう。
前回CSVデータを操作して得られた日本の新規感染者の最初の20日間のデータを使ってみます。 A DataFrame: 318 x 2 date new_cases_double 1 2020-01-23 0 2 2020-01-24 0 3 2020-01-25 0 4 2020-01-26 2 5 2020-01-27 0 6 2020-01-28 3 7 2020-01-29 0 8 2020-01-30 4 9 2020-01-31 4 10 2020-02-01 5 11 2020-02-02 0 12 2020-02-03 0 13 2020-02-04 2 14 2020-02-05 1 15 2020-02-06 0 16 2020-02-07 0 17 2020-02-08 1 18 2020-02-09 0 19 2020-02-10 2 20 2020-02-11 1 わかりやすいように4日間の移動平均(4日移動平均)を計算してみます。

単純移動平均(Simple Moving Average)

単純移動平均は値をそのまま足して平均を計算する方法です。
例えば 01-26 の単純移動平均は 01-23 〜 01-26 までのデータ(0,0,0,2)を足して4で割るので 0.5 です。

01-26 の単純移動平均 = (01-23 + 01-24 + 01-25 + 01-26) / 4 = (0 + 0 + 0 + 2) / 4 = 0.5

01-27 の単純移動平均を計算するとき、01-24 〜 01-27 までのデータ(0,0,2,0)を足して4で割ってもいいのですが、すでに01-26 の単純移動平均が計算してあるなら

01-27 の単純移動平均
= (01-24 + 01-25 + 01-26 + 01-27) / 4
= (- 01-23 + (01-23 + 01-24 + 01-25 + 01-26) + 01-27) / 4
= - 01-23 / 4 + (01-23 + 01-24 + 01-25 + 01-26) / 4 + 01-27 / 4
= - 01-23 / 4 + 01-26 の単純移動平均 + 01-27 / 4
= 01-26 の単純移動平均 - 01-23 / 4 + 01-27 / 4

このように、01-26 の単純移動平均から古い値(01-23)を4で割った数を引いて、新しい値(01-27)を4で割った値を足せば求めることができます。

01-23 〜 01-25 の単純移動平均は4日間分のデータがないので計算できません。

A DataFrame: 318 x 2 date new_cases_double simple_moving_average 1 2020-01-23 0 2 2020-01-24 0 3 2020-01-25 0 4 2020-01-26 2 0.50 5 2020-01-27 0 0.50 6 2020-01-28 3 1.25 7 2020-01-29 0 1.25 8 2020-01-30 4 1.75 9 2020-01-31 4 2.75 10 2020-02-01 5 3.25 11 2020-02-02 0 3.25 12 2020-02-03 0 2.25 13 2020-02-04 2 1.75 14 2020-02-05 1 0.75 15 2020-02-06 0 0.75 16 2020-02-07 0 0.75 17 2020-02-08 1 0.50 18 2020-02-09 0 0.25 19 2020-02-10 2 0.75 20 2020-02-11 1 1.00 val newCases3 = newCases2.head(20) val values = newCases3["new_cases_double"].asDoubles() val size = values.size var sma = arrayOfNulls<Double?>(size) val n = 4 // calculate 01-26 var sum = 0.0 for(i in 0 until n) { sum += values[i]!! } sma[n-1] = sum/n // calculate 01-27 ~ for(i in n until values.size) { sma[i] = sma[i-1]!! - values[i-n]!!/n + values[i]!!/n } val newCases4 = newCases3.addColumn("simple_moving_average") { sma }
赤の単純移動平均の線が滑らかになっているのがわかります。


加重移動平均(Weighted Moving Average)

加重移動平均は各値に重みをつけたものを足して平均を計算する方法です。例えば線形加重移動平均(Linear Weighted Moving Average)だと、現在に最も近い日の重みが一番大きくなり、そこから過去に行くほど線形に(一定量ずつ)重みが減っていきます。

例えば 01-26 の加重移動平均は 01-23 〜 01-26 までのデータ(0,0,0,2)から次のように計算します。

現在に最も近い日の 01-26 のデータには 4 を掛けます。次に近い日の 01-25 のデータには 4 から 1 を引いた値を掛けます。その前の日は 4-2、その前の日は 4-3 を掛けます。
それを 4 + 3 + 2 + 1 = 10 で割ります。

01-26 の加重移動平均 = (01-23 * (4-3) + 01-24* (4-2) + 01-25 * (4-1) + 01-26 * (4-0)) / (4 + 3 + 2 + 1)
= (0 * 1 + 0 * 2 + 0 * 3 + 2 * 4) / 10 = 0.8
= (0 + 0 + 0 + 8) / 10 = 0.8

01-27 の加重移動平均には、単純移動平均と同じように 01-26 の加重移動平均を利用します。

01-27 の加重移動平均
= (01-24 * (4-3) + 01-25* (4-2) + 01-26 * (4-1) + 01-27 * (4-0)) / 10
= (- 01-23 - 01-24 - 01-25 - 01-26 + (01-23 * (4-3) + 01-24 * (4-2) + 01-25* (4-1) + 01-26 * (4-0)) + 01-27 * (4-0)) / 10
= (- (01-23 ~ 01-26の総和)/10) + 01-26 の加重移動平均 + 01-27 * 4 / 10
= 01-26 の加重移動平均 - (01-23 ~ 01-26の総和) / 10 + 01-27 * 4 / 10


このように、01-26 の加重移動平均に、01-23 ~ 01-26の総和を10で割った数を引いて、新しい値(01-27)に4を掛けて10で割った値を足せば求めることができます。

01-23 〜 01-25 の加重移動平均は4日間分のデータがないので計算できません。

val newCases3 = newCases2.head(20) val values = newCases3["new_cases_double"].asDoubles() val size = values.size var wma = arrayOfNulls<Double?>(size) var sums = arrayOfNulls<Double?>(size) val n = 4 val n2 = n*(n + 1)/2 // calculate 01-26 var sum = 0.0 var sum_wma = 0.0 for(i in 0 until n) { sum += values[i]!! sum_wma += values[i]!! * (i + 1) } sums[n-1] = sum wma[n-1] = sum_wma/n2 // calculate 01-27 ~ for(i in n until values.size) { sums[i] = sums[i-1]!! - values[i-n]!! + values[i]!! wma[i] = wma[i-1]!! - sums[i-1]!!/n2 + n * values[i]!!/n2 } val newCases5 = newCases4.addColumn("weighted_moving_average") { wma }
赤が単純移動平均、緑が加重移動平均です。


指数移動平均(Exponential Moving Average)

指数移動平均は加重移動平均のように各値に重みをつけたものを足して平均を計算する方法です。各値につける重みが指数関数的に減っていきます。

重みの減少度合いは平滑化係数と呼ばれる0~1の間の値をとる定数 α で決まり、αを時系列区間 N で表した場合 α = 2 / (N+1) となります。
最初の値での EMA は定義しません。2番目の値での EMA をどう設定するかにはいくつかの手法があるそうですが、ここでは単純に2番目の値とします。

3番目以降の場合の EMA の計算式はこうなります。

EMA_t = α * value_t + (1 - α) * EMA_(t-1)



α = 2 / (N + 1) = 2 / (4 + 1) = 0.4 として計算すると
01-24 の指数移動平均 : 0
01-25 の指数移動平均 : 0.4 * 0 + (1 - 0.4) * 0 = 0
01-26 の指数移動平均 : 0.4 * 2 + (1 - 0.4) * 0 = 0.8
01-27 の指数移動平均 : 0.4 * 0 + (1 - 0.4) * 0.8 = 0.48


val newCases3 = newCases2.head(20) val values = newCases3["new_cases_double"].asDoubles() val size = values.size var ema = arrayOfNulls<Double?>(size) var sums = arrayOfNulls<Double?>(size) val n = 4 val alpha = 2.0 / (n + 1) ema[0] = null ema[1] = values[1] // calculate 01-25 ~ for(i in 2 until values.size) { ema[i] = alpha * values[i]!! + (1 - alpha) * ema[i - 1]!! } val newCases6 = newCases5.addColumn("exponential_moving_average") { ema }
赤が単純移動平均、緑が加重移動平均、灰色が指数移動平均です。


移動平均は自分で実際に計算してグラフにするとよくわかると思うので、Kotlin じゃなくても、好きな言語でぜひやってみてください。


2020年12月14日月曜日

Jupyter notebooks + Kotlin で移動平均を描画する(その2): krangl で csv データを操作する

このエントリは Fintalk Advent Calendar 2020 の14日目です。
今年は3つも割当たっているので、Covid-19 のデータで件数とその移動平均をグラフに描画する、というのを3回シリーズでやりたいと思います。


krangl は R の dplyr と Python の pandas を参考にした Kotlin のライブラリで、関数スタイルのデータ操作APIがあります。pandas と同じように表形式のデータのフィルタリングや変換、集約などができます。

krangl も Kotlin Jupyter Kernel にバンドルされていて一緒にインストール&セットアップがされているので %use krangl だけで使えます。

ざっくりした使い方は krangl の examples を実際にやってみるのがいいと思います。下に notebook で実行した結果を貼っておきます。



CSVから読み込む時は readCSV() を使います。 val df = DataFrame.readCSV("path/to/file") Covid-19 のデータを読み込むと、数字が入っているカラムに空のところがあるのでDoubleのパースに失敗します。
なので、デフォルトの ColType として String を指定して読み込みます。 val df = DataFrame.readCSV("owid-covid-data.csv", colTypes = mapOf(".default" to ColType.String))
schema() とか head() とか使えます。

日本データだけ出してみましょう。
日付と新規感染者数のデータだけにします。
新規感染者数が空のデータを省きます。
新規感染者数のデータを Double に変換します。
あとは移動平均を計算してグラフにするだけです。それはまた次回〜


2020年12月7日月曜日

Jupyter notebooks + Kotlin で移動平均を描画する(その1): lets-plot-kotlin で線を描画する

このエントリは Fintalk Advent Calendar 2020 の7日目です。
今年は3つも割当たっているので、Covid-19 のデータで件数とその移動平均をグラフに描画する、というのを3回シリーズでやりたいと思います。
Jupyter notebooks + python でやる方法はいっぱい情報があると思うので、ここでは Kotlin でやっていきます。


Kotlin + Jupyter notebooks については去年の Fintalk Advent Calendar のエントリとして書きました。
Kotlin + Jupyter notebooks で給与の額面から手取りを求めてみる

時間が経ってるので、今回の Kotlin + Jupyter notebooks のセットアップ手順を載せておきます。 $ python3 -m venv venv $ source venv/bin/activate (venv) $ pip install --upgrade pip setuptools (venv) $ pip install numpy scipy matplotlib Pillow ipython[all] (venv) $ pip install jupyter 以前の kotlin kernel が残っていたので venv の方をみるように一度削除 (venv) $ jupyter kernelspec list (venv) $ jupyter kernelspec uninstall kotlin (venv) $ pip install kotlin-jupyter-kernel (venv) $ jupyter notebook

グラフの描画には lets-plot-kotlin を使います。
Kotlin Jupyter Kernel にバンドルされていて一緒にインストール&セットアップがされているので %use lets-plot だけで使えます。 lets_plot(data) でプロットを作成し、これにレイヤーを追加していきます。

lets_plot() に渡すデータの型は Map<*, *> です。

例えばこんな感じ。 val data = mapOf( "x" to listOf(0, 1, 2, 3), "y" to listOf(1, 5, 2, 4) ) これは表にするとこういうデータに対応しています。

xy
01
15
22
34


点を描画するレイヤーを追加するには geom_point() を使います。

lambda(この中の this は PointMapping) で x軸、y軸のデータを Map の key 名で指定します。 %use lets-plot val data = mapOf( "x" to listOf(0, 1, 2, 3), "y" to listOf(1, 5, 2, 4) ) lets_plot(data) + geom_point(data, size=5) { x = "x"; y ="y" }


線を描画するレイヤーを追加するには geom_line() を使います。


点と線両方描画したいなら、両方のレイヤーを追加すればOKです。


2020年12月1日火曜日

mutableStateOf() の 3 つの使い方

val mutableState: MutableState<String> = mutableStateOf("") val v = mutableState.value mutableState.value = "1"

destructuring declaration val (value: String, setValue: (String) -> Unit) = mutableStateOf("") val v = value setValue("1")

delegate var value: String by mutableStateOf("") val v = value value = "1"

2020年11月19日木曜日

MaterialAlertDialogBuilder のボタンの色を変更する

non-Bridge なテーマで MaterialAlertDialogBuilder を使うと、ダイアログのボタンの色は colorPrimary になります。

そのため colorPrimary に黒っぽい色を指定した DayNight テーマだと、Dark Mode のときにボタンの文字が見えないという状態になってしまいます。 <resources> <style name="Theme.MyApp" parent="Theme.MaterialComponents.DayNight.DarkActionBar"> <item name="colorPrimary">#212121</item> </style> </resources>




DayNight テーマの colorPrimary は変えずに Dark Mode のときだけダイアログのボタンの色を変えるには、materialAlertDialogTheme 属性を指定します。

res/values/themes.xml <resources> <style name="Theme.MyApp" parent="Theme.MaterialComponents.DayNight.DarkActionBar"> <item name="colorPrimary">#212121</item> <item name="materialAlertDialogTheme">@style/ThemeOverlay.MyApp.MaterialAlertDialog</item> </style> <style name="ThemeOverlay.MyApp.MaterialAlertDialog" parent="ThemeOverlay.MaterialComponents.MaterialAlertDialog" /> </resources> res/values-night-v8/themes.xml <resources> <style name="ThemeOverlay.MyApp.MaterialAlertDialog" parent="ThemeOverlay.MaterialComponents.MaterialAlertDialog"> <item name="colorPrimary">#ffffff</item> </style> </resources>




2020年11月12日木曜日

複数の nullable な mutable 変数の null チェック

nullable な mutable 変数 a と b があって、両方 non-null のとき何かしたいという場合、

これだとコンパイルエラーになる var a: String? = "Hello" var b: String? = "Android" fun main() { if (a != null && b != null) { println(a.length + b.length) // compile error! } } let だと入れ子になるからいまいち var a: String? = "Hello" var b: String? = "Android" fun main() { a?.let { a -> b?.let { b -> println(a.length + b.length) } } } ローカルの変数に入れれば OK var a: String? = "Hello" var b: String? = "Android" fun main() { val a = a val b = b if (a != null && b != null) { println(a.length + b.length) // ok } } vararg を使って、渡した値が全部 non-null だったら block を実行するというメソッドを用意すると、let っぽい感じで入れ子にせずに書ける var a: String? = "Hello" var b: String? = "Android" fun main() { doIfAllNotNull(a, b) { (a, b) -> println(a.length + b.length) } } private inline fun <T> doIfAllNotNull( vararg value: T?, block: (values: List<T>) -> Unit ) { val nonNullValues = value.filterNotNull() if (nonNullValues.size == value.size) { block(nonNullValues) } }

2020年11月11日水曜日

Kotlin メモ : vetoable

vetoable

変更を拒否(veto)するかどうかのコールバックを指定できる property delegate を返す。

fun main() { var value: Int by Delegates.vetoable(0) { property, oldValue, newValue -> newValue > 0 } println(value) // 0 value = 10 println(value) // 10 value = -1 println(value) // 10 }

2020年11月10日火曜日

ConstraintSet を使って Activity を再生成せずにレイアウトを切り替える

自分で縦横レイアウトを切り替えるので Manifest に設定を追加 <activity android:name=".MainActivity" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"> ... </activity> 縦画面用のレイアウトと、横画面用のレイアウトを用意する。
例としてここでは縦画面では上部に 16:9 で配置し、横画面では全画面に配置している。

縦画面用
res/layout/activity_main_port.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:id="@+id/constraintLayout" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <include android:id="@+id/container" layout="@layout/activity_main" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintDimensionRatio="16:9" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout> 横画面用
res/layout/activity_main_land.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:id="@+id/constraintLayout" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <include android:id="@+id/container" layout="@layout/activity_main" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout> activity_main_port.xml と activity_main_land.xml からそれぞれ ConstraintSet を構成する。
onConfigurationChanged() で画面の向きに応じて対応する ConstraintSet を ConstraintLayout に applyTo() する。 class MainActivity : AppCompatActivity() { private val constraintSetPort = ConstraintSet() private val constraintSetLand = ConstraintSet() private lateinit var constraintLayout: ConstraintLayout override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val layoutId = when (resources.configuration.orientation) { Configuration.ORIENTATION_LANDSCAPE -> R.layout.activity_main_land else -> R.layout.activity_main_port } setContentView(layoutId) constraintLayout = findViewById(R.id.constraintLayout) constraintSetPort.clone(this, R.layout.activity_main_port) constraintSetLand.clone(this, R.layout.activity_main_land) } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) val constraintSet = when (newConfig.orientation) { Configuration.ORIENTATION_LANDSCAPE -> constraintSetLand else -> constraintSetPort } constraintSet.applyTo(constraintLayout) } }





2020年11月5日木曜日

Android 11 では ACTION_CREATE_DOCUMENT と ACTION_OPEN_DOCUMENT には <queries> 指定が必要

Intent.ACTION_CREATE_DOCUMENT および Intent.ACTION_OPEN_DOCUMENT を startActivityForResult() で呼び出す以下のコードは、targetSdkVersion が 29 (Android 10)までは問題なく動きます。 private fun createDocument() { val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) .setType("*/*") .putExtra(Intent.EXTRA_TITLE, "log.txt") if (intent.resolveActivity(packageManager) != null) { startActivityForResult(intent, REQUEST_CODE_CREATE_DOCUMENT) } else { Toast.makeText( this, "Unable to resolve Intent.ACTION_CREATE_DOCUMENT", Toast.LENGTH_SHORT ) .show() } } private fun openDocument() { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) .setType("*/*") if (intent.resolveActivity(packageManager) != null) { startActivityForResult(intent, REQUEST_CODE_OPEN_DOCUMENT) } else { Toast.makeText( this, "Unable to resolve Intent.ACTION_OPEN_DOCUMENT", Toast.LENGTH_SHORT ) .show() } } しかし、targetSdkVersion を 30 (Android 11)にすると、intent.resolveActivity(packageManager) が null を返すようになってしまいます。

Android 11 では AndroidManifest.xml に以下の <queries> を指定すると動くようになります。 <manifest ...> ... <queries> <intent> <action android:name="android.intent.action.CREATE_DOCUMENT" /> <data android:mimeType="*/*" /> </intent> <intent> <action android:name="android.intent.action.OPEN_DOCUMENT" /> <data android:mimeType="*/*" /> </intent> </queries> ... </manifest>



2020年10月21日水曜日

DevFest 2020 で Material Design Components のカスタマイズについて話してきました。



資料はこちらです。



Android Studio 4.1 についてと、まとめの後のおまけにいくつかページを追加しています。
MDC をカスタマイズするときは便利だと思うので、是非参考にしてください。

2020年9月10日木曜日

Kotlin でスプレット演算子を使うと enum の values() に値を追加したリストを簡単に作れる

以下のような Rank クラスがあるとして、 enum class Rank { GOLD, SILVER, BRONZE } ランクなしという意味の null を含むリストを作りたいとします。

↓ 作りたいリスト val list: List<Rank?> = ... println(list) // [null, GOLD, SILVER, BRONZE] Rank の一覧は Rank.values() で取れるので val ranks: Array<Rank> = Rank.values() println(ranks.joinToString()) // GOLD, SILVER, BRONZE listOf(null) で null だけのリストを作って + で ranks と合わせたリストを作ることもできますが、 val list: List<Rank?> = listOf(null) + ranks スプレット演算子を使うと余計なリスト(= null だけのリスト)を作らずに済みます。 val list: List<Rank?> = listOf(null, *ranks)



2020年8月10日月曜日

ACTION_GET_CONTENT で複数の MIME type を指定したいときは EXTRA_MIME_TYPES を使う

画像を ACTION_GET_CONTENT で取りたいけれど、image/* ではなく image/png と image/jpeg だけにしたいという場合は EXTRA_MIME_TYPES を指定します。このとき type には */* を指定しておきます。 val intent = Intent(Intent.ACTION_GET_CONTENT) .setType("*/*") .putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/png", "image/jpeg")) ACTION_OPEN_DOCUMENT でも使えます。


2020年8月9日日曜日

アプリ内から Google Play にレビューを投稿できるようになりました。

In-App Review API は Java/Kotlin のほか、native code と Unity からも使えます。

In-App Review API のガイドラインを確認して利用しましょう。
例えば
  • レビューのUI(ドキュメントではカードと呼んでいる)を出す前に "Do you like the app?" のような意見を聞く質問を出してはいけない。
  • カードに変更を加えたり、上や周りにオーバーレイを出したり、カードを勝手に消してはいけない。
  • 過度に出してはいけない。quotaがあり、超えるとカードが出なくなる。
などがあります。

In-App Review API を使うには Google Play Core Library を追加します。 dependencies { ... implementation "com.google.android.play:core-ktx:1.8.1" } Review Flow を開始するには、ReviewManager の requestReviewFlow() を呼び出します。requestReviewFlow() の結果が成功だと ReviewInfo が取得できます。取得した ReviewInfo を使って ReviewManager の launchReviewFlow() を呼び出します。 val manager = ReviewManagerFactory.create(context) ... private fun startReviewFlow() { manager.requestReviewFlow() .addOnCompleteListener { request -> if (request.isSuccessful) { val reviewInfo = request.result manager.launchReviewFlow(activity, reviewInfo) .addOnCompleteListener { // レビューフローが終了した // ユーザーがレビューしたのかしてないのか、 // そもそもカードが表示されたのかどうかは取得できない // アプリの通常フローを進める } } else { // なにかしら問題があったということだが、 // 結果によらずアプリの通常フローを続ける } } } In-App Review API からは、ユーザーがレビューしたのかどうか、レビューのカードが表示されたのかどうかは取得できません。


In-App Review API の挙動をテストするにはアプリが Google Play に公開されている必要がありますが、製品版として公開されている必要はなく、internal test tracks や internal app sharing に公開することでテストできます。

また、Unit Test や Instrumentation Test 用に ReviewManager の Fake が用意されています。 val manager = FakeReviewManager(context)


2020年7月16日木曜日

WorkerFactory を使って WorkManager の Worker を生成する

WorkerFactory を使うので Worker のコンストラクタでは任意の引数を取れる。 class MyWorker( context: Context, params: WorkerParameters, private val api: MyApi, private val dataStore: DataStore ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { return try { val data = api.getData() dataStore.save(data) Result.success() } catch (e: Exception) { Timber.e(e) Result.failure() } } } WorkerFactory を用意する。 class MyWorkerFactory( private val api: MyApi, private val dataStore: DataStore ) : WorkerFactory() { override fun createWorker( appContext: Context, workerClassName: String, workerParameters: WorkerParameters ): ListenableWorker? { return if (workerClassName == MyWorker::class.java.name) { MyWorker(appContext, workerParameters, api, dataStore) } else { null } } } default initializer を削除するための記述を AndroidManifest に追加する。 <manifest ...> <application ...> ... <provider android:name="androidx.work.impl.WorkManagerInitializer" android:authorities="${applicationId}.workmanager-init" tools:ignore="ExportedContentProvider" tools:node="remove" /> </application> </manifest> Application で Configuration.Provider を実装し、getWorkManagerConfiguration() で返す Configuration で MyWorkerFactory を指定する。
直接 MyWorkerFactory を setWorkerFactory() に渡してもいいが、DelegatingWorkerFactory を使うと複数の Factory から構成させる Factory を作ることができる。 class MyApplication : Application(), Configuration.Provider { ... override fun getWorkManagerConfiguration(): Configuration { val api = appComponent.api() val dataStore = appComponent.dataStore() val delegatingWorkerFactory = DelegatingWorkerFactory().apply { addFactory(MyWorkerFactory(api, dataStore)) } return Configuration.Builder() .setWorkerFactory(delegatingWorkerFactory) .build() } } Subcomponent を使えば WorkerFactory と Worker 両方 Dagger に生成させることもできるし、なんなら Hilt には HiltWorkerFactory が用意されている。

参考

2020年7月14日火曜日

依存 module に無い buildType がある場合 matchingFallbacks を使う

proguard を有効にしたビルドで debuggable な処理をしたいとき、debug を引き継いで proguard を有効にした buildType (以下の minify) を追加したくなると思います。

app/build.gradle android { ... buildTypes { minify { initWith debug minifyEnabled true proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" } release { minifyEnabled true proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ... } } ... testBuildType "minify" } dependencies { implementation project(":api") ... } 依存している api モジュールに debug と release しか無い場合、このままだと Build Variants に minify を選んだときに gradle sync に失敗します。なぜなら api モジュールでは minify がないので debug と release のどちらを使えばいいかわからないからです。

api/build.gradle android { ... buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" } } } ...

これを解決するには matchingFallbacks を使います。依存モジュールに対応する buildType が無い場合、ここで指定した buildType が使われます。
ここでは debug を指定しているので、app で buildType に minify を選ぶと、api では buildType として debug が選択されます。

app/build.gradle android { ... buildTypes { minify { initWith debug minifyEnabled true proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" matchingFallbacks = ['debug'] // これを追加 } release { minifyEnabled true proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ... } } ... testBuildType "minify" } ...

参考

2020年6月24日水曜日

Android 11 での android:allowBackup の挙動変更

Android 11 で android:allowBackup の挙動が少し変わります。


behavior-changes-11#device-to-device-file-transfer

If your app targets Android 11, you can no longer disable device-to-device migration of your app's files using the allowBackup attribute. The system automatically allows this functionality.
However, you can still disable cloud-based backup and restore of your app's files by setting the allowBackup attribute to false, even if your app targets Android 11.


Android 11 をターゲットにしている(targetSdkVersion = 30+)アプリでは、android:allowBackup="false" にしても device-to-device migration を無効にすることはできません。ただし、android:allowBackup="false" にしていれば cloud-based backup and restore は無効にできます。

device-to-device migration とは何かというと、Google Pixel などの local device-to-device transfer をサポートしているデバイスに別のデバイスからケーブル経由でバックアップデータを転送することです。


Backup 関係のリソース

2020年6月22日月曜日

Android アプリの詳細設定画面を開く

Settings.ACTION_APPLICATION_DETAILS_SETTINGS を使います。

startActivity( Intent( Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", BuildConfig.APPLICATION_ID, null) // または // Uri.parse("package:${BuildConfig.APPLICATION_ID}") ) )




adb から試すなら $ adb shell $ am start -a android.settings.APPLICATION_DETAILS_SETTINGS -d package:[packageName]

2020年6月20日土曜日

Kotlin メモ : orEmpty()

?: "" をしてくれる String?.orEmpty() という拡張関数が用意されています。 val text: String? = ... val nonNullText: String = text.orEmpty()


2020年6月18日木曜日

Google Play の subscriptions policy の変更に対応するための参考リソース

2020/4/16 に Google Play の subscriptions policy が変更され、提供する subscription について透明性の高い情報を提供することが求められるようになりました。

subscriptions policy の変更についてのブログ
Android Developers Blog | Building user trust through more transparent subscriptions

例えば、価格や課金の頻度をちゃんと表示しましょうね、無料期間のあとは課金されることをちゃんと表示しましょうね、ということなのですが、とはいえ何がOKで何がNGなのか、何をすればいいのかこのブログではよくわからないので、関係するリソースを集めました。


チェックリスト
Subscriptions checklist

このチェックリストの最後のほうにある Learn more でいける
Academy for App Success | Set up subscriptions
の方がわかりやすいチェックリストがあります。ただし日本語だとチェックリストが出ないので、Profile で Preferred language を English にする必要があります。


↓ OK/NG の例があります(少しだけ)。
Developer Policy Center | Monetization and Ads | Subscriptions


OK/NG の例はこの動画が一番わかりやすいと思います。




おすすめは ↑ の動画を見た後に Academy for App Success | Set up subscriptions の英語版をやる、です。


2020年6月16日火曜日

WorkManager の CoroutineWorker, Worker をテストする

こういう CoroutineWorker があるとします。 class SendHelloWorker( private val context: Context, params: WorkerParameters ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { val name = inputData.getString("name") ?: return Result.failure() val api = (context.applicationContext as MyApplication) .appComponent .api() return try { api.sendHello("Hello $name") Result.success() } catch (e: Exception) { Result.failure() } } } まず CoroutineWorker や Worker をテストするには worker-testing artifact を使います。 testImplementation "androidx.work:work-testing:$work_version" CoroutineWorker のテストには TestListenableWorkerBuilder を使います。
TestListenableWorkerBuilder は version 2.1.0 から追加されています。

TestListenableWorkerBuilder の build() で Worker のインスタンスを取得し、doWork() を呼んで結果をチェックします。 class SendHelloWorkerTest { private lateinit var api: MyApi private lateinit var context: Context @Before fun setup() { api = mock() val appComponent: AppComponent = mock() whenever(appComponent.api()).thenReturn(api) val application = MyApplication() application.setAppComponent(appComponent) context = mock() whenever(context.applicationContext).thenReturn(application) } @Test fun doWork() { val worker = TestListenableWorkerBuilder<SendHelloWorker>( context, inputData = Data.Builder() .putString("name", "Android") .build() ) .build() runBlocking { val result = worker.doWork() assertThat(result).isEqualTo(ListenableWorker.Result.success()) verify(api).sendHello("Hello Android") } } @Test fun doWork_fail() { val worker = TestListenableWorkerBuilder<SendHelloWorker>( context, inputData = Data.Builder() .putString("name", "Android") .build() ) .build() whenever(api.sendHello("Hello Android")).thenThrow(RuntimeException()) runBlocking { val result = worker.doWork() assertThat(result).isEqualTo(ListenableWorker.Result.failure()) verify(api).sendHello("Hello Android") } } }

CoroutineWorker ではなく Worker をテストするときは TestListenableWorkerBuilder ではなく TestWorkerBuilder を使います。



参考 : https://developer.android.com/topic/libraries/architecture/workmanager/how-to/testing-worker-impl



2020年6月10日水曜日

Kotlin : Uri から ByteArray を取得する (Uri to ByteArray)

Kotlin の readBytes() 拡張関数を使うとすっきり。 val context: Context = ... val uri: Uri = ... val byteArray: ByteArray? = context.contentResolver .openInputStream(uri) ?.use { it.readBytes() }


2020年6月2日火曜日

androidx.test.ext:truth を使ったときに IllegalAccessError が出たらバージョンを 1.3.0 以降にする

androidx.test.ext.truth にある IntentSubject などを使うとき com.google.truth:truth:0.42 androidx.test.ext:truth:1.2.0 だと動くのですが、Truth のバージョンを以下のように 1.0.1 にすると com.google.truth:truth:1.0.1 androidx.test.ext:truth:1.2.0 java.lang.IllegalAccessError: tried to access method com.google.common.truth.Subject.actual()Ljava/lang/Object; from class androidx.test.ext.truth.content.IntentSubject

というエラーが出ます。

IntentSubject 内で Subject の actual() メソッドにアクセスしているのですが、これが 0.42 のときは protected だったのが package private に変わってアクセスできなくなったのが原因です。

そのため、この新しい Truth に対応した androidx.test.ext:truth のバージョンを使えば OK です。 com.google.truth:truth:1.0.1 androidx.test.ext:truth:1.3.0-rc01



2020年5月29日金曜日

Scroller を使う

Scroller というのは、スクロール時のアニメーションを実現するための x,y 位置を計算してくれるクラスです。

ScrollerOverScroller が用意されています。 OverScroller は行き過ぎて戻ってくるようなアニメーションができます。

Scroller にはアニメーションを開始するメソッドとして が用意されています。

使い方はこんな感じです。
  • 1. scroller.forceFinished() でアニメーションを止める
  • 2. scroller.fling() または scroller.startScroll() でアニメーションを開始する
  • 3. View.postInvalidateOnAnimation() を呼ぶ。これを呼ぶと View.computeScroll() が呼ばれる
  • 4. View.computeScroll() で scroller.computeScrollOffset() を呼ぶ。戻り値が true の場合アニメーションが終わっていないということ
  • 5. scroller.currX, scroller.currY を使って View の位置などを変える
setFriction() で摩擦を設定できます。デフォルトは ViewConfiguration.getScrollFriction() が設定されています。

class ScrollerSampleView : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( context, attrs, defStyleAttr ) private val size = (100 * resources.displayMetrics.density).toInt() private val targetView: View = View(context).apply { layoutParams = LayoutParams(size, size) setBackgroundColor(Color.RED) } private val textView: TextView = TextView(context).apply { layoutParams = LayoutParams( LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT ) } private val scroller = OverScroller(context) init { addView(targetView) addView(textView) } fun scroll(dx: Int, dy: Int, duration: Int, friction: Float) { scroller.setFriction(friction) // scroll の前に今のアニメーションを止める scroller.forceFinished(true) targetView.translationX = 0f targetView.translationY = 0f val startX = 0 val startY = 0 // アニメーションを開始 scroller.startScroll( startX, // scroll の開始位置 (X) startY, // scroll の開始位置 (Y) dx, // 移動する距離、正の値だとコンテンツが左にスクロールする (X) dy, // 移動する距離、正の値だとコンテンツが左にスクロールする (Y) duration // スクロールにかかる時間 [milliseconds] ) // これにより computeScroll() が呼ばれる postInvalidateOnAnimation() } fun fling(velocityX: Int, velocityY: Int, overX: Int, overY: Int, friction: Float) { scroller.setFriction(friction) // fling の前に今のアニメーションを止める scroller.forceFinished(true) targetView.translationX = 0f targetView.translationY = 0f val startX = 0 val startY = 0 val minX = 0 val maxX = 800 val minY = 0 val maxY = 800 // アニメーションを開始 scroller.fling( startX, // scroll の開始位置 (X) startY, // scroll の開始位置 (Y) velocityX, // fling の初速 [px/sec] (X) velocityY, // fling の初速 [px/sec] (Y) minX, // X の最小値. minX - overX まで移動し、minX 未満のところは overfling 中になる maxX, // X の最大値. maxX + overX まで移動し、maxX を超えたところは overfling 中になる minY, // Y の最小値. minY - overY まで移動し、minY 未満のところは overfling 中になる maxY, // Y の最大値. maxY + overY まで移動し、maxY を超えたところは overfling 中になる overX, // overfling の範囲 (X). overfling の範囲は両端に適用される overY // Overfling の範囲 (Y). overfling の範囲は両端に適用される ) // これにより computeScroll() が呼ばれる postInvalidateOnAnimation() } override fun computeScroll() { super.computeScroll() // computeScrollOffset() の戻り値が true == まだアニメーション中 if (scroller.computeScrollOffset()) { textView.text = """ currVelocity: ${scroller.currVelocity} currX: ${scroller.currX} currY: ${scroller.currY} startX: ${scroller.startX} startY: ${scroller.startY} finalX: ${scroller.finalX} finalY: ${scroller.finalY} isFinished: ${scroller.isFinished} isOverScrolled: ${scroller.isOverScrolled} """.trimIndent() targetView.translationX = scroller.currX.toFloat() targetView.translationY = scroller.currY.toFloat() // アニメーション中なので再度呼ぶ postInvalidateOnAnimation() } } } 速度を 1000 [px/sec], 2000 [px/sec], 3000 [px/sec], 4000 [px/sec]、摩擦を ViewConfiguration.getScrollFriction(), ViewConfiguration.getScrollFriction() / 2、overfling 範囲を 0, 200 で上記の fling() を呼んだ結果が次の動画です。





摩擦を半分にすると同じ速度でも遠くまで移動し、overfling 範囲をつけると行き過ぎて戻ってくるようになります。



2020年5月27日水曜日

Dagger に Fragment と FragmentFactory の生成をまかせる

Master of Dagger の改定版にも入れる予定です。ただいま鋭意執筆中です。もう少々お待ちください。


ViewModelFactory と同じような感じで FragmentFactory および Fragment の生成をまかせることができます。

オブジェクトグラフに MyApi があるとします。 @Module object AppModule { @Provides fun provideMyApi(): MyApi { ... } } これを引数にとる Fragment があります。Dagger に生成をまかせたいのでコンストラクタに @Inject をつけます。 class MainFragment @Inject constructor(private val api: MyApi) : Fragment() { ... } Fragment の Map Multibindings 用の MapKey を用意します。 @Target( AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER ) @Retention(AnnotationRetention.RUNTIME) @MapKey annotation class FragmentKey(val value: KClass<out Fragment>) 用意した MapKey を使って MainFragment を Multibindings に追加します。 @Module interface FragmentModule { @Binds @IntoMap @FragmentKey(MainFragment::class) fun bindMainFragment(fragment: MainFragment): Fragment } Fragment の Multibindings を引数に取る FragmentFactory を用意します。 class MyFragmentFactory @Inject constructor( private val providers: Map<Class<out Fragment>, @JvmSuppressWildcards Provider<Fragment>> ) : FragmentFactory() { override fun instantiate(classLoader: ClassLoader, className: String): Fragment { val found = providers.entries.find { className == it.key.name } ?: throw IllegalArgumentException("unknown model class $className") val provider = found.value try { @Suppress("UNCHECKED_CAST") return provider.get() } catch (e: Exception) { return super.instantiate(classLoader, className) } } } 用意した MyFragmentFactory を取得するためのメソッドを Component に用意します。 @Component(modules = [AppModule::class, FragmentModule::class]) interface AppComponent { fun fragmentFactory(): MyFragmentFactory } class MyApplication : Application() { lateinit var appComponent: AppComponent override fun onCreate() { super.onCreate() appComponent = DaggerAppComponent.builder() .build() } } supportFragmentManager.fragmentFactory に Component から取得した MyFragmentFactory をセットします。 class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) supportFragmentManager.fragmentFactory = (application as MyApplication).appComponent .fragmentFactory() setContentView(R.layout.activity_main) } } activity_main.xml <?xml version="1.0" encoding="utf-8"?> <fragment xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/mainFragment" android:name="net.yanzm.sample.MainFragment" android:layout_width="match_parent" android:layout_height="match_parent" />


2020年5月21日木曜日

VelocityTracker の使い方

VelocityTracker はタッチイベントの速度計算を簡単にするためのクラスです。Fling など速度がジェスチャーの構成要素になっているものに対して便利です。

VelocityTracker.obtain() でインスタンスを取得します。
addMovement(ev) で MotionEvent を追加し、速度を取得するときは computeCurrentVelocity(int units) または computeCurrentVelocity(int units, float maxVelocity) を呼んだ後に getXVelocity(), getYVelocity() を呼びます。
obtain() で取得したインスタンスは不要になった時点で recycle() を呼びましょう。

computeCurrentVelocity() で maxVelocity を渡さない場合は Float.MAX_VALUE が使われます。 computeCurrentVelocity() で渡す units は getXVelocity(), getYVelocity() で取得する velocity の単位になります。1 を指定した場合は pixels per millisecond、1000 を渡した場合は pixels per second になります。 class SimpleDragView : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( context, attrs, defStyleAttr ) private val targetView: View private var velocityTracker: VelocityTracker? = null init { val size = (100 * resources.displayMetrics.density).toInt() targetView = View(context).apply { layoutParams = LayoutParams(size, size).apply { gravity = Gravity.CENTER } setBackgroundColor(Color.RED) } addView(targetView) } private var lastVelocityX = 0f private var lastVelocityY = 0f override fun onTouchEvent(ev: MotionEvent): Boolean { when (ev.actionMasked) { MotionEvent.ACTION_DOWN -> { velocityTracker?.clear() velocityTracker = velocityTracker ?: VelocityTracker.obtain() velocityTracker?.addMovement(ev) } MotionEvent.ACTION_MOVE -> { velocityTracker?.let { it.addMovement(ev) val pointerId: Int = ev.getPointerId(ev.actionIndex) it.computeCurrentVelocity(1000) lastVelocityX = it.getXVelocity(pointerId) lastVelocityY = it.getYVelocity(pointerId) } } MotionEvent.ACTION_UP -> { velocityTracker?.let { ObjectAnimator .ofPropertyValuesHolder( targetView, PropertyValuesHolder.ofFloat( View.TRANSLATION_X, lastVelocityX / 4 ), PropertyValuesHolder.ofFloat( View.TRANSLATION_Y, lastVelocityY / 4 ) ) .apply { addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator?) { super.onAnimationEnd(animation) targetView.translationX = 0f targetView.translationY = 0f } }) } .setDuration(500) .start() } velocityTracker?.recycle() velocityTracker = null } MotionEvent.ACTION_CANCEL -> { velocityTracker?.recycle() velocityTracker = null } } return true } }


2020年5月7日木曜日

mockito-kotlin で lambda を mock + @RunWith(AndroidJUnit4::class) のときは work around が必要

以下のような @RunWith(AndroidJUnit4::class) を使わない Unit Test は問題なく動くのですが、 class HogeTest { @Test fun test() { val listener = mock<(Boolean) -> Unit>() ... verify(listener)(false) } } 次のように @RunWith(AndroidJUnit4::class) をつけるとエラーが発生します。 @RunWith(AndroidJUnit4::class) class HogeTest { @Test fun test() { val listener = mock<(Boolean) -> Unit>() ... verify(listener)(false) } }
org.mockito.exceptions.base.MockitoException:
ClassCastException occurred while creating the mockito mock :
class to mock : 'kotlin.jvm.functions.Function1', loaded by classloader : 'sun.misc.Launcher$AppClassLoader@18b4aac2'
created class : 'kotlin.jvm.functions.Function1$MockitoMock$1350680399', loaded by classloader : 'net.bytebuddy.dynamic.loading.MultipleParentClassLoader@7a2a2c83'
proxy instance class : 'kotlin.jvm.functions.Function1$MockitoMock$1350680399', loaded by classloader : 'net.bytebuddy.dynamic.loading.MultipleParentClassLoader@7a2a2c83'
instance creation by : ObjenesisInstantiator


この場合クッションになる interface を定義すると動きます。 @RunWith(AndroidJUnit4::class) class HogeTest { private interface Callback : (Boolean) -> Unit @Test fun test() { val listener = mock<Callback>() ... verify(listener)(false) } }

参考 : https://github.com/nhaarman/mockito-kotlin/issues/272



2020年3月18日水曜日

Kotlin メモ : repeat

repeat

指定回数だけ action を実行します。 val count = supportFragmentManager.backStackEntryCount repeat(count) { supportFragmentManager.popBackStack() }



2020年3月16日月曜日

Drag を実装する その2 : GestureDetector

GestureDetector を使うと onScroll() で移動距離を教えてくれる。ただし、GestureDetector は ACTION_UP や ACTION_CANCEL を通知してくれないのが難点である。 class SimpleDragView : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( context, attrs, defStyleAttr ) private val targetView: View private val gestureDetector: GestureDetector init { val size = (100 * resources.displayMetrics.density).toInt() targetView = View(context).apply { layoutParams = LayoutParams(size, size) setBackgroundColor(Color.RED) } addView(targetView) gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { override fun onScroll( e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float ): Boolean { targetView.translationX -= distanceX targetView.translationY -= distanceY return true } override fun onDown(e: MotionEvent): Boolean { val pointerIndex = e.actionIndex val x = e.getX(pointerIndex) val y = e.getY(pointerIndex) if (!(x.toInt() in 0..width && y.toInt() in 0..height)) { return false } val left = targetView.translationX val right = left + targetView.width val top = targetView.translationY val bottom = top + targetView.height if (!(x in left..right && y in top..bottom)) { return false } return true } }) gestureDetector.setIsLongpressEnabled(false) } override fun onTouchEvent(ev: MotionEvent): Boolean { return gestureDetector.onTouchEvent(ev) || super.onTouchEvent(ev) } }


setIsLongpressEnabled(false) しないと下のように LongPress 判定されたときに onScroll() が呼ばれない。




assets 内のファイルの url

いつも忘れるのでメモっておく

src/main/assets/index.html の url は
file:///android_asset/index.html


2020年3月13日金曜日

Drag を実装する その1 : GestureDetector なし

Drag and scale | Android Developers (MotionEventCompat を使ってたりちょっと古い)を参考に変えたもの class SimpleDragView : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( context, attrs, defStyleAttr ) private var activePointerId = INVALID_POINTER_ID private var lastTouchX = 0f private var lastTouchY = 0f private val targetView: View init { val size = (100 * resources.displayMetrics.density).toInt() targetView = View(context).apply { layoutParams = LayoutParams(size, size) setBackgroundColor(Color.RED) } addView(targetView) } override fun onTouchEvent(ev: MotionEvent): Boolean { when (ev.actionMasked) { MotionEvent.ACTION_DOWN -> { val pointerIndex = ev.actionIndex val x = ev.getX(pointerIndex) val y = ev.getY(pointerIndex) if (!(x.toInt() in 0..width && y.toInt() in 0..height)) { return false } val left = targetView.translationX val right = left + targetView.width val top = targetView.translationY val bottom = top + targetView.height if (!(x in left..right && y in top..bottom)) { return false } lastTouchX = x lastTouchY = y activePointerId = ev.getPointerId(0) } MotionEvent.ACTION_MOVE -> { if (activePointerId == INVALID_POINTER_ID) { return false } val pointerIndex = ev.findPointerIndex(activePointerId) val x = ev.getX(pointerIndex) val y = ev.getY(pointerIndex) if (!(x.toInt() in 0..width && y.toInt() in 0..height)) { return false } val diffX = x - lastTouchX val diffY = y - lastTouchY targetView.translationX += diffX targetView.translationY += diffY lastTouchX = x lastTouchY = y } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { activePointerId = INVALID_POINTER_ID } MotionEvent.ACTION_POINTER_UP -> { if (activePointerId == INVALID_POINTER_ID) { return false } val pointerIndex = ev.actionIndex if (ev.getPointerId(pointerIndex) != activePointerId) { return false } val newPointerIndex = if (pointerIndex == 0) 1 else 0 val x = ev.getX(newPointerIndex) val y = ev.getY(newPointerIndex) if (!(x.toInt() in 0..width && y.toInt() in 0..height)) { activePointerId = INVALID_POINTER_ID return false } lastTouchX = x lastTouchY = y activePointerId = ev.getPointerId(newPointerIndex) } } return true } }


2020年3月11日水曜日

dialogCornerRadius でダイアログの角丸具合を指定する

Android Pie(API Level 28)から ?android:attr/dialogCornerRadius でダイアログの角丸具合を指定できるようになりましたが、AppCompat や MaterialComponents では ?attr/dialogCornerRadius としてバックポートされています。 <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar"> <item name="dialogCornerRadius">12dp</item> </style> (<item name="android:dialogCornerRadius">12dp</item> だと Android Pie(API Level 28)以降だけ角丸になります。)



<item name="dialogCornerRadius">12dp</item>

API Level 21



API Level 27



API Level 28





<item name="android:dialogCornerRadius">12dp</item>

API Level 21



API Level 27



API Level 28





2020年3月10日火曜日

Material Design Components for Android 1.1.0 でボタンのデフォルトカラーが colorAccent から colorPrimary に変わった

Theme.AppCompat.Light.DarkActionBar



Theme.MaterialComponents.Light.DarkActionBar (1.0.0)



Theme.MaterialComponents.Light.DarkActionBar (1.1.0)





何もしてないのに(MDC の version を 1.1.0 に上げたけど...) 色が!変わった!

ピンクはどこの色かというと colorAccent に指定している色です。では緑はどこの色かというと colorPrimary に指定している色です。

ボタン系のデフォルトカラーが 1.1.0 から colorPrimary に変わったようです。



AlertDialog のボタンの色は

?attr/materialAlertDialogTheme に指定されている
ThemeOverlay.MaterialComponents.MaterialAlertDialog

Base.ThemeOverlay.MaterialComponents.MaterialAlertDialog

Base.V14.ThemeOverlay.MaterialComponents.MaterialAlertDialog の <item name="buttonBarPositiveButtonStyle">@style/Widget.MaterialComponents.Button.TextButton.Dialog</item> <item name="buttonBarNegativeButtonStyle">@style/Widget.MaterialComponents.Button.TextButton.Dialog</item> <item name="buttonBarNeutralButtonStyle">@style/Widget.MaterialComponents.Button.TextButton.Dialog.Flush</item>
Widget.MaterialComponents.Button.TextButton.Dialog の <item name="android:textColor">@color/mtrl_text_btn_text_color_selector</item>
@color/mtrl_text_btn_text_color_selector <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:alpha="1.00" android:color="?attr/colorPrimary" .../> <item android:alpha="0.60" android:color="?attr/colorOnSurface" .../> <item android:alpha="1.00" android:color="?attr/colorPrimary" .../> <item android:alpha="0.38" android:color="?attr/colorOnSurface"/> </selector> あー、colorPrimary と colorOnSurface になったのねぇ。

ちなみに 1.0.0 のときの @color/mtrl_text_btn_text_color_selector では colorAccent 使ってます。 <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:color="?attr/colorAccent" android:state_enabled="true"/> <item android:color="@color/mtrl_btn_text_color_disabled"/> </selector>


ボタンの色を変えたいときは 「AlertDialog の Negative ボタンの文字色を変える」 と同じ感じでやればOK <style name="ThemeOverlay.MyApp.MaterialAlertDialog" parent="ThemeOverlay.MaterialComponents.MaterialAlertDialog"> <item name="buttonBarPositiveButtonStyle">@style/Widget.MyApp.Button.TextButton.Dialog</item> <item name="buttonBarNegativeButtonStyle">@style/Widget.MyApp.Button.TextButton.Dialog</item> </style> <style name="Widget.MyApp.Button.TextButton.Dialog" parent="Widget.MaterialComponents.Button.TextButton.Dialog"> <item name="android:textColor">#1565C0</item> </style> AlertDialog.Builder(this, R.style.ThemeOverlay_MyApp_MaterialAlertDialog) .setTitle("Title") .setMessage("Message") .setPositiveButton(android.R.string.ok, null) .setNegativeButton(android.R.string.cancel, null) .show()



2020年3月9日月曜日

Material Design Components for Android 1.1.0 から Checkbox で android:button を指定するなら app:useMaterialThemeColors="false" も必要(なことが多い)

Checkbox のマークをカスタマイズするときは android:button に drawable resource を指定します。

例えば以下のような drawable を用意して Checkbox の android:button に指定したのが次のスクリーンショットの下2つです。 <?xml version="1.0" encoding="utf-8"?> <layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <item android:bottom="7dp" android:left="7dp" android:right="7dp" android:top="7dp"> <selector> <item android:state_checked="true"> <shape android:shape="oval"> <size android:width="18dp" android:height="18dp" /> <stroke android:width="6dp" android:color="#6666ff" /> </shape> </item> <item> <shape android:shape="oval"> <size android:width="18dp" android:height="18dp" /> <stroke android:width="2dp" android:color="#cccccc" /> </shape> </item> </selector> </item> </layer-list> <CheckBox ... android:button="@drawable/checkbox" ... />



全く同じコードで Activity の theme を Material Design Components for Android 1.0.0 に変えたのが次のスクリーンショットです。



同じようになってますね。

全く同じコードで Activity の theme を Material Design Components for Android 1.1.0 に変えたのが次のスクリーンショットです。



なんということでしょう!android:button で指定した drawable resource が Material Design の theme color で tint されるようになりました。tint で使われる色は colorControlActivated と colorOnSurface です。

この挙動を止めるには app:useMaterialThemeColors="false" を指定します。 <CheckBox ... android:button="@drawable/checkbox" app:useMaterialThemeColors="false" ... />



tint されなくなりました!