2021年5月14日金曜日

inline class および value class で kotlinx.serialization (JSON) が動く組み合わせ

inline class および value class で kotlinx.serialization (JSON) が動く組み合わせを調べてみた


inline class のときのコード @Serializable inline class ItemId(val value: String) @Serializable data class Item(val id: ItemId, val name: String) fun main() { val item = Item(ItemId("1"), "Android") val json = Json.encodeToString(item) println(json) println(Json.decodeFromString<Item>(json)) } value class のときのコード @Serializable @JvmInline value class ItemId(val value: String) @Serializable data class Item(val id: ItemId, val name: String) fun main() { val item = Item(ItemId("1"), "Android") val json = Json.encodeToString(item) println(json) println(Json.decodeFromString<Item>(json)) }

Kotlin: 14.32, kotlinx.serialization: 1.1.0 + inline class

ビルドエラーになる

e: org.jetbrains.kotlin.backend.common.BackendException: Backend Internal error: Exception during file facade code generation

Kotlin: 1.4.32, kotlinx.serialization: 1.2.1 + inline class

ビルドエラーになる

e: org.jetbrains.kotlin.backend.common.BackendException: Backend Internal error: Exception during file facade code generation

Kotlin: 1.4.32, kotlinx.serialization: 1.1.0 + value class

@JvmInline が無いのでビルドエラーになる

Kotlin: 1.4.32, kotlinx.serialization: 1.2.1 + value class

@JvmInline が無いのでビルドエラーになる

Kotlin: 1.5.0, kotlinx.serialization: 1.1.0 + inline class

動く

Kotlin: 1.5.0, kotlinx.serialization: 1.2.1 + inline class

動く

Kotlin: 1.5.0, kotlinx.serialization: 1.1.0 + value class

動く

Kotlin: 1.5.0, kotlinx.serialization: 1.2.1 + inline class

動く


Kotlin を 1.5.0 にすれば kotlinx.serialization を 1.2 にしなくても inline class と value class 両方で動いた。


2021年5月9日日曜日

Jetpack Compose : Canvas Composable を使う

View の onDraw() で描画して Custom View を作る、というのを Compose でやりたいときは Canvas Composable を使います。

Canvas に渡す onDraw lamnda は Receiver が DrawScope になっています。
DrawScope からは描画エリアの大きさとして size: Size が取れます。

また、DrawScope は Density を継承しているので、Dp.toPx() とかも呼び出せます。 @Composable fun CircleProgress( progress: Int, modifier: Modifier, colorProgress: Color = MaterialTheme.colors.primary, colorBackground: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.12f) .compositeOver(MaterialTheme.colors.surface), strokeWidth: Dp = 8.dp, ) { Canvas(modifier = modifier) { val stroke = Stroke(width = strokeWidth.toPx(), cap = StrokeCap.Round) val diameter = min(size.width, size.height) - stroke.width val topLeft = Offset((size.width - diameter) / 2, (size.height - diameter) / 2) val circleSize = Size(diameter, diameter) drawArc( color = colorBackground, startAngle = -90f, sweepAngle = 360f, useCenter = false, style = stroke, topLeft = topLeft, size = circleSize, ) drawArc( color = colorProgress, startAngle = -90f, sweepAngle = 360f / 100 * progress, useCenter = false, style = stroke, topLeft = topLeft, size = circleSize, ) } } @Preview @Composable fun CircleProgressPreview() { var progress by remember { mutableStateOf(0) } val animateProgress by animateIntAsState(targetValue = progress, animationSpec = tween()) Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(16.dp) ) { Button(onClick = { progress = Random.nextInt(0, 101) }) { Text("Change Progress") } Spacer(modifier = Modifier.height(16.dp)) CircleProgress( progress = animateProgress, modifier = Modifier.size(120.dp) ) } } animate**AsState などでアニメーションも簡単にできます。



Jetpack Compose で Material Design の ToggleButton を作ってみた

Material Design の ToogleButton (https://material.io/components/buttons#toggle-button) を Jetpack Compose で作ってみた。

https://github.com/yanzm/ComposeToggleButton

こんな感じのやつ。


選択の処理は Modifier.toggleable() を使えば OK。 @Composable fun IconToggleButton( imageVector: ImageVector, contentDescription: String?, checked: Boolean, onCheckedChange: (Boolean) -> Unit, enabled: Boolean = true ) { CompositionLocalProvider( LocalContentColor provides contentColor(enabled = enabled, checked = checked), ) { Box( contentAlignment = Alignment.Center, modifier = Modifier .toggleable( value = checked, onValueChange = onCheckedChange, role = Role.RadioButton, ) .size(48.dp) ) { Icon( imageVector = imageVector, contentDescription = contentDescription, modifier = Modifier.size(24.dp) ) } } }

枠線などの描画は Modifier.drawWithContent() でやったが、これが面倒だった〜(特にRTL対応)。 private fun Modifier.drawToggleButtonFrame( ... ): Modifier = this.drawWithContent { ... // draw checked border drawPath( path = ..., color = checkedBorderColor, style = Stroke(strokeWidth), ) drawContent() }

Jetpack Compose : Modifier.triStateToggleable() で3状態ボタンを作る

Modifier.triStateToggleable() を使った3状態チェックボックスとして TriStateCheckbox が用意されています。 var state by remember { mutableStateOf(ToggleableState.On) } TriStateCheckbox(state = state, onClick = { state = when (state) { ToggleableState.On -> ToggleableState.Indeterminate ToggleableState.Indeterminate -> ToggleableState.Off ToggleableState.Off -> ToggleableState.On } })



Modifier.triStateToggleable() を使って独自の3状態ボタンも作ることができます。 var state by remember { mutableStateOf(ToggleableState.On) } Box( contentAlignment = Alignment.Center, modifier = Modifier.triStateToggleable( state = state, onClick = { state = when (state) { ToggleableState.On -> ToggleableState.Off ToggleableState.Off -> ToggleableState.Indeterminate ToggleableState.Indeterminate -> ToggleableState.On } }, role = Role.Checkbox, interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple( bounded = false, radius = 24.dp ) ) ) { Icon( imageVector = when (state) { ToggleableState.On -> Icons.Default.Favorite ToggleableState.Off -> Icons.Default.FavoriteBorder ToggleableState.Indeterminate -> Icons.Default.FavoriteBorder }, contentDescription = when (state) { ToggleableState.On -> "favorite on" ToggleableState.Off -> "favorite on" ToggleableState.Indeterminate -> "favorite indeterminate" }, tint = when (state) { ToggleableState.On -> MaterialTheme.colors.primary ToggleableState.Off -> MaterialTheme.colors.primary ToggleableState.Indeterminate -> MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) } ) }



Jetpack Compose : Modifier.toggleable() で独自チェックボックスを作る

Modifier.toggleable() を使います。 var checked by remember { mutableStateOf(false) } Box( contentAlignment = Alignment.Center, modifier = Modifier .toggleable( value = checked, onValueChange = { checked = it } ) .size(48.dp) ) { Icon( imageVector = if (checked) Icons.Default.Favorite else Icons.Default.FavoriteBorder, contentDescription = if (checked) "favorite on" else "favorite off", ) } Box + Modifier.toggleable() 部分は IconToggleButton として用意されているので、それを使うこともできます。 var checked by remember { mutableStateOf(false) } IconToggleButton( checked = checked, onCheckedChange = { checked = it }, ) { Icon( imageVector = if (checked) Icons.Default.Favorite else Icons.Default.FavoriteBorder, contentDescription = if (checked) "favorite on" else "favorite off", ) }



2021年5月2日日曜日

LazyColumn (LazyRow) の item 指定は index で頑張らなくていい

とある発表資料で見かけたのですが、LazyColumn (LazyRow)ではこういう index で頑張る方法は必要ありません。
RecyclerView がこういう頑張りをしないといけなかったので、こうやってしまう気持ちはわかります。

よくない例 @Composable fun DogList(list: List<Dog>) { LazyColumn { items(list.size + 1) { if (it == 0) { Header() } else { DogListItem(list[it - 1]) } } } }

どうするのが良いかというと、素直に Header() と list で item/items を分ければいいんです。items() には数字ではなく List<T> をとる拡張関数が用意されています。

よい例 @Composable fun DogList(list: List<Dog>) { LazyColumn { item { Header() } items(list) { dog -> DogListItem(dog) } } } また、itemsIndexed() を使うと index もとれるので、例えば dog.name の1文字目が変わったら区切りヘッダーを入れるというのもこんな感じで簡単に書けます(list は dog.name で sort されている前提)。 @Composable fun DogList(list: List<Dog>) { LazyColumn { itemsIndexed(list) { index, dog -> if (index == 0 || list[index - 1].name[0] != dog.name[0]) { NameDivider(dog.name[0]) } DogListItem(dog) } } }



2021年5月1日土曜日

Jetpack Compose : 角丸をパーセントで指定する

RoundedCornerShape() には Int または Float でパーセントを指定することができます。

RoundedCornerShape(50) // 50dp ではなく、50% ということ

@Composable fun Capsule() { Text( text = "Android", modifier = Modifier .padding(16.dp) .background( color = Color.LightGray, shape = RoundedCornerShape(50) ) .padding(vertical = 8.dp, horizontal = 16.dp) ) }

2021年4月28日水曜日

Jetpack Compose の BasicTextField の文字位置を中央揃え (centering) にする

TextAlign.Center を指定した TextStyle を渡します。 BasicTextField( ..., textStyle = TextStyle.Default.copy(textAlign = TextAlign.Center), ... ) BasicTextField( ..., textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), ... ) BasicTextField( ..., textStyle = MaterialTheme.typography.body1.copy(textAlign = TextAlign.Center), ... ) MDC の TextField と OutlinedTextField は innerTextField の位置が動かせないので、幅が innerTextField より大きい場合は左に寄った innerTextField の中で中央揃えになってしまいます。そのうち設定できるようになるのかもしれません。
@Composable fun CenteringTextField() { Column( modifier = Modifier .fillMaxSize() .padding(16.dp) ) { var text1 by remember { mutableStateOf("") } TextField( value = text1, onValueChange = { text1 = it }, textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), ) Spacer(modifier = Modifier.height(32.dp)) var text2 by remember { mutableStateOf("") } OutlinedTextField( value = text2, onValueChange = { text2 = it }, textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center) ) Spacer(modifier = Modifier.height(32.dp)) var text3 by remember { mutableStateOf("") } BasicTextField( value = text3, onValueChange = { text3 = it }, textStyle = TextStyle.Default.copy(textAlign = TextAlign.Center), decorationBox = { Box( contentAlignment = Alignment.Center, modifier = Modifier .background(Color.LightGray) .padding(16.dp) ) { it() } } ) Spacer(modifier = Modifier.height(32.dp)) var text4 by remember { mutableStateOf("") } BasicTextField( value = text4, onValueChange = { text4 = it }, textStyle = TextStyle.Default.copy(textAlign = TextAlign.Center), decorationBox = { Box( contentAlignment = Alignment.Center, modifier = Modifier .background(Color.LightGray) .padding(16.dp) ) { it() } }, modifier = Modifier.fillMaxWidth() ) } }

2021年4月22日木曜日

Modifier の width と requiredWidth と wrapContentWidth

↑の3番目について

wrapContentWith() を指定すると、そのコンテンツの計測したサイズが指定された minWidth よりも小さい場合、minWidth の幅の中に align(デフォルトでは Alignment.CenterHorizontal) で配置される。
↑の3番目では、"Hello" の横幅が指定された minWidth の 70.dp よりも小さいので、70.dp の幅の真ん中に Text が配置されている。

50.dp の Box の中に Text を配置している。

1番目は Text に width として 50.dp よりも大きい 100.dp を指定しているが、parent の maxWidth である 50.dp が利用される。

2番目は requiredWidth で 100.dp を指定しているので、parent の maxWidth によらずそれが利用される。

3番目は wrapContentWidth() を指定していて、かつ "Hello World" の幅が 50.dp よりも大きいが unbounded が false(デフォルトは false) なので parent の maxWidth が利用される。

4〜6番目は wrapContentWidth() を指定していて、かつ "Hello World" の幅が 50.dp よりも大きく unbounded が true なので "Hello World" の幅が利用され、align の指定にそって配置される。 Box( Modifier .size(50.dp) .background(Color.LightGray) ) { GrayText( "Hello Android", modifier = Modifier .width(100.dp) ) } Spacer(Modifier.height(24.dp)) Box( Modifier .size(50.dp) .background(Color.LightGray) ) { GrayText( "Hello Android", modifier = Modifier .requiredWidth(100.dp) ) } Spacer(Modifier.height(24.dp)) Box( Modifier .size(50.dp) .background(Color.LightGray) ) { GrayText( "Hello Android", modifier = Modifier .wrapContentWidth() ) } Spacer(Modifier.height(24.dp)) Box( Modifier .size(50.dp) .background(Color.LightGray) ) { GrayText( "Hello Android", modifier = Modifier .wrapContentWidth(align = Alignment.Start, unbounded = true) ) } Spacer(Modifier.height(24.dp)) Box( Modifier .size(50.dp) .background(Color.LightGray) ) { GrayText( "Hello Android", modifier = Modifier .wrapContentWidth(align = Alignment.CenterHorizontally, unbounded = true) ) } Spacer(Modifier.height(24.dp)) Box( Modifier .size(50.dp) .background(Color.LightGray) ) { GrayText( "Hello Android", modifier = Modifier .wrapContentWidth(align = Alignment.End, unbounded = true) ) }



2021年4月15日木曜日

Jetpack Compose の Composition Local ってなんなの?

Jetpack Compose には Composition Local という仕組みが用意されています。
これを使うとバケツリレー問題に対応することができます。

バケツリレー問題

例えば深いところの Composable で Context が必要だとします。 class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val context: Context = this setContent { MyAppTheme { MyApp(context) } } } } @Composable fun MyApp(context:Context) { TopScreen(context) } @Composable fun TopScreen(context:Context) { MyList(context) } @Composable fun MyList(context:Context) { // use context } この場合 Context を必要としているのは MyList ですが、MyList に Context を渡すために

MyApp -> TopScreen -> MyList

と Context をバケツリレーしています。

しかしこの方法では、途中の MyApp と TopScreen では Context を使わないにもかかわらず、受け渡しのためだけに引数に Context が必要になってしまいます。 その Composable で使わないものが引数にあると、何をその Compose で必要としているのかがわかりににくくなってしまいます。

Composition Local

Composition Local を使うと、深いところの Composable にデータを渡すことができます。
上のコードを Composition Local を使って書き換えるとこのようになります。
後ほどそれぞれを詳しく説明します。 private val MyLocalContext = staticCompositionLocalOf<Context> { error("No current Context") } class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val context: Context = this setContent { MyAppTheme { CompositionLocalProvider(MyLocalContext provides context) { MyApp() } } } } } @Composable fun MyApp() { TopScreen() } @Composable fun TopScreen() { MyList() } @Composable fun MyList() { val context:Context = MyLocalContext.current // use context } 実は Context を受け渡す Composition Local は Jetpack Compose の方で用意されています。それが LocalContext です。
LocalContext を使って書き換えるとこのようになります。
Context が欲しい Composable で LocalContext.current から Context のインスタンスが取得できます。 class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MyAppTheme { MyApp() } } } } @Composable fun MyApp() { TopScreen() } @Composable fun TopScreen() { MyList() } @Composable fun MyList() { val context:Context = LocalContext.current // use context }

用意されている Composition Local

LocalContext 以外にも LocalDensity, LocalConfiguration, LocalLifecycleOwner, LocalContentColor などが用意されています。


独自の Composition Local を作る

まず、受け渡したい値のホルダーになる ProvidableCompositionLocal を作ります。作るには staticCompositionLocalOf() か compositionLocalOf() を使います。 private val MyLocalColor: ProvidableCompositionLocal<Color> = compositionLocalOf<Color> { error("No current color") } staticCompositionLocalOf() および compositionLocalOf() に渡す lambda はデフォルト値のファクトリーです。値が与えられる前に取得しようとすると、このファクトリーが呼ばれます。

staticCompositionLocalOf() だと StaticProvidableCompositionLocal が生成され、compositionLocalOf() だと DynamicProvidableCompositionLocal が生成されます。

staticCompositionLocalOf() (StaticProvidableCompositionLocal) は滅多に変わらない値に使います。

例えば LocalContext や LocalLifecycleOwner は staticCompositionLocalOf() を使っていますが、LocalConfiguration は compositionLocalOf() を使っています。



ProvidableCompositionLocal の provides() または providesDefault() で 値を指定して ProvidedValue を取得します。
provides() および providesDefault() には infix がついているので、MyLocalColor provides Color.Black のように記述できます。

次に、取得した ProvidedValue を CompositionLocalProvider Composable に渡します。
CompositionLocalProvider の第1引数は vararg values: ProvidedValue<*> なので、複数渡すこともできます。 CompositionLocalProvider(MyLocalColor provides Color.Black) { MyApp() } こうすると、CompositionLocalProvider の content lambda 内の Composable では ProvidableCompositionLocal.current で指定された値を取得することができます。 @Composable fun MyApp() { TopScreen() } @Composable fun TopScreen() { MyList() } @Composable fun MyList() { val color:Color = MyLocalColor.current // Color.Black }

provides() で指定された値を上書きできます。 CompositionLocalProvider(MyLocalColor provides Color.Black) { Column { Text("Hello", color = MyLocalColor.current) // 文字色は Color.Black CompositionLocalProvider(MyLocalColor provides Color.Red) { Text("Hello", color = MyLocalColor.current) // 文字色は Color.Red } } } providesDefault() ではすでに指定された値がある場合は上書きしません。 CompositionLocalProvider(MyLocalColor provides Color.Black) { Column { Text("Hello", color = MyLocalColor.current) // 文字色は Color.Black CompositionLocalProvider(MyLocalColor providesDefault Color.Red) { Text("Hello", color = MyLocalColor.current) // 文字色は Color.Black のまま } } }

用意されている Composable での Composition Local の利用

Jetpack Compose で用意されている Composable では Composition Local がたくさん使われています。
ここでは一部を紹介します。

例えば string resource id を指定して文字列を取得する stringResource() では内部で Resources インスタンスを取得する resources() を使っています。この resources() では LocalContext から Context を取得しています。 @Composable @ReadOnlyComposable private fun resources(): Resources { LocalConfiguration.current return LocalContext.current.resources }

Text Composable ではデフォルトのスタイルとして LocalTextStyle から TextStyle を取得しています。また、文字色の指定がないときは LocalContentColor と LocalContentAlpha が使われます。 @Composable fun Text( text: AnnotatedString, ... style: TextStyle = LocalTextStyle.current ) { val textColor = color.takeOrElse { style.color.takeOrElse { LocalContentColor.current.copy(alpha = LocalContentAlpha.current) } } ... } よって CompositionLocalProvider で LocalTextStyle を上書きすると、Text のデフォルトスタイルが変わります。 CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.h3) { Text("Hello") // h3 }

Surface では LocalContentColor を contentColor で上書きしています。
contentColor はデフォルトでは color (背景色) から導出 (color が primary なら onPrimary, color が secondary から onSecondary など) しています。

これにより Surface の color を指定することで、内部に置かれた LocalContentColor を使っている Text などの色が自動で変わるようになっています。 @Composable fun Surface( ... color: Color = MaterialTheme.colors.surface, contentColor: Color = contentColorFor(color), ... content: @Composable () -> Unit ) { ... CompositionLocalProvider( LocalContentColor provides contentColor, LocalAbsoluteElevation provides absoluteElevation ) { Box( ... ) { content() } } }


2021年4月2日金曜日

Compose メモ : Icon + clickable より IconButton を使う

IconButton だと ripple がいい感じになります。 accesibility の処理も入っているし、ボタンの大きさが 48dp になるような指定も入っています。

上が Icon + clickable で 下が IconButton です。 Column(modifier = Modifier.padding(16.dp)) { Icon( imageVector = Icons.Filled.Favorite, contentDescription = "favorite", modifier = Modifier .clickable { } .padding(12.dp) .size(24.dp) ) Spacer(modifier = Modifier.height(48.dp)) IconButton(onClick = { /*TODO*/ }) { Icon( imageVector = Icons.Filled.Favorite, contentDescription = "favorite", modifier = Modifier .size(24.dp) ) } } IconButton は ripple がいい感じです。まるいし。



Compose メモ : 2つの色を重ねた色を作る

fun Color.compositeOver(background: Color): Color を使う。

例えば #232323 のグレーに透明度15%の白を重ねたときの色(不透明のグレー)を作りたい場合は、このようになる。 val gray = Color(0xFF232323) val white150 = Color.White.copy(alpha = 0.15f) val compositeColor = white150.compositeOver(gray) Card の背景は不透明じゃないと影が変になる。デフォルトでは Card の backgroundColor は MaterialTheme の surface なので、MaterialTheme の surface に半透明の色を指定しているときは、こんな感じで compositeOver で合成した色を backgroundColor に指定するとよい。 Card( backgroundColor = MaterialTheme.colors.surface.compositeOver(MaterialTheme.colors.background), ) { }
以下のコードでは、MaterialTheme の surface に透明度15%の白を指定しているので、左では影が変になっている。右は compositeOver を使って作った不透明の色(ベースの色が不透明のグレーだから不透明になる)を backgroundColor に指定しているので、意図通りの表示になっている。 MaterialTheme( colors = lightColors( background = Color(0xFF232323), surface = Color.White.copy(alpha = 0.15f) ) ) { Surface(color = MaterialTheme.colors.background) { Row( modifier = Modifier .padding(top = 8.dp) .fillMaxSize() ) { Card( modifier = Modifier .padding(start = 8.dp, bottom = 8.dp) .size(136.dp) ) { // 左のカード } Card( backgroundColor = MaterialTheme.colors.surface.compositeOver(MaterialTheme.colors.background), modifier = Modifier .padding(start = 8.dp, bottom = 8.dp) .size(136.dp) ) { // 右のカード } } } }



Compose メモ : Card を使う時は bottom に padding を入れないと影が切れる

Card の bottom に padding を入れていないとこのように影が切れてしまう。 Row( modifier = Modifier .horizontalScroll(rememberScrollState()) .padding(top = 16.dp) ) { repeat(10) { Card( modifier = Modifier .padding( start = 8.dp, // bottom padding がない ) .size(136.dp) ) { } } }


このように bottom に padding を入れると切れない。
(top はこのコードのように padding を入れていなくても影が出る) Row( modifier = Modifier .horizontalScroll(rememberScrollState()) .padding(top = 16.dp) ) { repeat(10) { Card( modifier = Modifier .padding( start = 8.dp, bottom = 8.dp, // 追加 ) .size(136.dp) ) { } } }



Compose メモ : baseline を基準とした余白

Modifier .paddingFromBaseline(top = 24.dp, bottom = 16.dp)

2021年3月29日月曜日

改行を入力させない EditText 用 InputFilter

class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val editText = findViewById<EditText>(R.id.editText) editText.filters = editText.filters + MyInputFilter() } } class MyInputFilter : InputFilter { override fun filter( source: CharSequence, start: Int, end: Int, dest: Spanned, dstart: Int, dend: Int ): CharSequence? { var i: Int = start while (i < end) { if (source[i] == '\n') { break } i++ } if (i == end) { return null } val filtered = SpannableStringBuilder(source, start, end) val start2 = i - start val end2 = end - start for (j in end2 - 1 downTo start2) { if (source[j] == '\n') { filtered.delete(j, j + 1) } } return filtered } }

2021年3月23日火曜日

Kotlin Serialization は sealed class も対応していて便利

Kotlin Serialization

plugins { ... id "org.jetbrains.kotlin.plugin.serialization" version "1.4.31" } dependencies { ... implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.1.0" } @Serializable data class Dog(val name: String, val age: Int, val sex: Sex, val kind: Kind) enum class Sex { MALE, FEMALE } @Serializable sealed class Kind { @Serializable object Hybrid : Kind() @Serializable data class PureBlood(val name: String) : Kind() } class DogTest { @Test fun list() { val dogs = listOf( Dog("White", 10, Sex.MALE, Kind.Hybrid), Dog("Black", 20, Sex.FEMALE, Kind.PureBlood("Husky")) ) val json = Json.encodeToString(dogs) println(json) // [{"name":"White","age":10,"sex":"MALE","kind":{"type":"net.yanzm.serialize.Kind.Hybrid"}},{"name":"Black","age":20,"sex":"FEMALE","kind":{"type":"net.yanzm.serialize.Kind.PureBlood","name":"Husky"}}] val decoded = Json.decodeFromString<List<Dog>>(json) assertThat(decoded).isEqualTo(dogs) } }

2021年3月9日火曜日

AppEngine に Ktor アプリをデプロイする

1. Google Cloud SDK をインストールする

https://cloud.google.com/sdk/docs/install

2. 認証 & プロジェクト選択

> gcloud init

3. Ktor アプリを作る

IntelliJ IDEA に Ktor plugin を入れて、New Project wizard から Ktor プロジェクトを作る

4. AppEngine の設定を追加する

build.gradle.kts ... plugins { ... // ↓ 追加 id("com.google.cloud.tools.appengine") version "2.2.0" // ↓ 追加 war } ... dependencies { ... // ↓ 追加 implementation("io.ktor:ktor-server-servlet:$ktor_version") // ↓ 追加 compileOnly("com.google.appengine:appengine:$appengine_version") } // ↓ 追加 appengine { deploy { projectId = "GCLOUD_CONFIG" version = "GCLOUD_CONFIG" } } // ↓ 追加 tasks.named("run") { dependsOn(":appengineRun") } settings.gradle.kts ... // https://stackoverflow.com/questions/48502220/how-to-configure-appengine-gradle-plugin-using-kotlin-dsl/48510049#48510049 // ↓ 追加 pluginManagement { repositories { gradlePluginPortal() google() } resolutionStrategy { eachPlugin { if (requested.id.id == "com.google.cloud.tools.appengine") { useModule("com.google.cloud.tools:appengine-gradle-plugin:${requested.version}") } } } } src/main/webapp/WEB-INF/web.xml <?xml version="1.0" encoding="ISO-8859-1" ?> <web-app xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd" version="3.0"> <!-- path to application.conf file, required --> <!-- note that this file is always loaded as an absolute path from the classpath --> <context-param> <param-name>io.ktor.ktor.config</param-name> <param-value>application.conf</param-value> </context-param> <servlet> <display-name>KtorServlet</display-name> <servlet-name>KtorServlet</servlet-name> <servlet-class>io.ktor.server.servlet.ServletApplicationEngine</servlet-class> <!-- required! --> <async-supported>true</async-supported> <!-- 100mb max file upload, optional --> <multipart-config> <max-file-size>304857600</max-file-size> <max-request-size>304857600</max-request-size> <file-size-threshold>0</file-size-threshold> </multipart-config> </servlet> <servlet-mapping> <servlet-name>KtorServlet</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> </web-app> src/main/webapp/WEB-INF/appengine-web.xml <?xml version="1.0" encoding="utf-8"?> <appengine-web-app xmlns="http://appengine.google.com/ns/1.0"> <threadsafe>true</threadsafe> <runtime>java8</runtime> </appengine-web-app>

5. ローカルで実行する

> ./gradlew appengineRun // 止めるとき > ./gradlew appengineStop

6. デプロイする

> ./gradlew appengineDeploy


参考

2021年3月5日金曜日

ViewPager2 で 遠くのページに smoothScroll するときは 3ページ前から作られる

最初 index : 0

タブクリックで index : 15 のページに移動

タブクリックで index : 4 のページに移動


したときの Fragment のライフサイクルの結果は次のようになります。 : onCreate : 0 : onCreate : 12 : onCreate : 13 : onCreate : 14 : onCreate : 15 : onDestroy : 0 : onDestroy : 12 : onCreate : 7 : onDestroy : 13 : onCreate : 6 : onCreate : 5 : onDestroy : 14 : onCreate : 4 : onDestroy : 15 : onDestroy : 7 index : 0 から index : 15 のページに移動するとき、index : 12 のページから作られていることがわかります。
同じように index : 15 から index : 4 のページに移動するときは index : 7 のページから作られていることがわかります。


この 3 ページ前からというロジックは ViewPager2 の setCurrentItemInternal() に実装されています。 void setCurrentItemInternal(int item, boolean smoothScroll) { ... // For smooth scroll, pre-jump to nearby item for long jumps. if (Math.abs(item - previousItem) > 3) { mRecyclerView.scrollToPosition(item > previousItem ? item - 3 : item + 3); // TODO(b/114361680): call smoothScrollToPosition synchronously (blocked by b/114019007) mRecyclerView.post(new SmoothScrollToPosition(item, mRecyclerView)); } else { mRecyclerView.smoothScrollToPosition(item); } }



2021年3月4日木曜日

ViewPager2 の offscreenPageLimit のデフォルト値は 1 ではない

ViewPager2 にも ViewPager と同様 offscreenPageLimit を指定することができます。

ViewPager2 の offscreenPageLimit のデフォルト値は 1 ではなく、ViewPager2.OFFSCREEN_PAGE_LIMIT_DEFAULT (= -1) です。

そのため FragmentStateAdapter を使っている場合、デフォルトでは隣の画面の Fragment はスワイプなどで表示が必要になるタイミングまで作成されません。
ViewPager と同じ挙動にするには、明示的に offscreenPageLimit に 1 を指定する必要があります。 val pager: ViewPager2 = ... pager.offscreenPageLimit = 1

2021年2月27日土曜日

ShapeDrawable + BitmapShader で elevation の影を出す

背景(background)の Drawable が ShapeDrawable の場合、elevation を指定すると影がでます。(1番目)
一方、BitmapDrawable では elevation を指定しても影がでません。(2番目)
しかし背景(background)の Drawable が BitmapDrawable でも ViewOutlineProvider を使うと影が出るようになります。(4番目) val provider = object : ViewOutlineProvider() { override fun getOutline(view: View, outline: Outline) { val radius = 32 * resources.displayMetrics.density outline.setRoundRect(0, 0, view.width, view.height, radius) } } findViewById<View>(R.id.frameLayout4).apply { outlineProvider = provider clipToOutline = true } ViewOutlineProvider を使わずに、ShapeDrawable と BitmapShader でも影がでるようにすることができます。(2番目) val r = 16 * resources.displayMetrics.density val shapeDrawable = ShapeDrawable( RoundRectShape(floatArrayOf(r, r, r, r, r, r, r, r), null, null) ) val bitmap = BitmapFactory.decodeResource(resources, R.drawable.image_round) shapeDrawable.shaderFactory = object: ShapeDrawable.ShaderFactory() { override fun resize(width: Int, height: Int): Shader { val bmp = bitmap.scale(width, height, false) return BitmapShader(bmp, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) } } findViewById<View>(R.id.frameLayout2).background = shapeDrawable



2021年2月25日木曜日

ViewPager2 でページを動的に追加・削除する

大事なポイントは FragmentStateAdapter を継承した Adapter で getItemId() と containsItem() を実装すること、notifyDataSetChanged() だとうまく動かないので DiffUtil.calculateDiff() を使うことです。
class ViewPager2Activity : AppCompatActivity() { private val binding by lazy { ActivityViewPager2Binding.inflate(layoutInflater) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) val list = listOf( PageType.MAIN, PageType.FAVORITE, PageType.SETTING, ) val list2 = listOf( PageType.MAIN, PageType.SETTING, ) val adapter = PagerAdapter(list, this) binding.pager.adapter = adapter TabLayoutMediator(binding.tabLayout, binding.pager) { tab, position -> tab.text = list[position].toString() }.attach() binding.checkbox.setOnCheckedChangeListener { _, isChecked -> adapter.updateList(if (isChecked) list else list2) } } } enum class PageType { MAIN, FAVORITE, SETTING } private class PagerAdapter( initial: List<PageType>, fragmentActivity: FragmentActivity ) : FragmentStateAdapter(fragmentActivity) { private val list = mutableListOf<PageType>() init { list.addAll(initial) } override fun getItemCount(): Int { return list.size } override fun createFragment(position: Int): Fragment { return PageFragment.newInstance(list[position]) } override fun getItemId(position: Int): Long { return list[position].ordinal.toLong() } override fun containsItem(itemId: Long): Boolean { return list.any { it.ordinal.toLong() == itemId } } fun updateList(newList: List<PageType>) { // notifyDataSetChanged() だとうまく動かない val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() { override fun getOldListSize(): Int { return list.size } override fun getNewListSize(): Int { return newList.size } override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { return list[oldItemPosition] == newList[newItemPosition] } override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { return list[oldItemPosition] == newList[newItemPosition] } }) list.clear() list.addAll(newList) diff.dispatchUpdatesTo(this) } } class PageFragment : Fragment() { private val color = Color.rgb(Random.nextInt(256), Random.nextInt(256), Random.nextInt(256)) override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { return TextView(inflater.context).apply { setBackgroundColor(color) text = (requireArguments().getSerializable("type") as PageType).toString() } } companion object { fun newInstance(type: PageType): PageFragment { return PageFragment().apply { arguments = bundleOf("type" to type) } } } }

2021年2月24日水曜日

ViewPager2

class MainActivity : AppCompatActivity() { private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) val adapter = PagerAdapter(this) binding.pager.adapter = adapter // MDC の TabLayout と組み合わせるときは TabLayoutMediator を使う // TabLayoutMediator の attach は ViewPager2 に adapter をセットした後に行う TabLayoutMediator(binding.tabLayout, binding.pager) { tab, position -> tab.text = adapter.getTitle(position) }.attach() } } private class PagerAdapter( fragmentActivity: FragmentActivity ) : FragmentStateAdapter(fragmentActivity) { override fun getItemCount(): Int { return ... } override fun createFragment(position: Int): Fragment { return PageFragment.newInstance(...) } fun getTitle(position: Int): String { return ... } }

2021年2月18日木曜日

縁取り TextView

縁の設定をして super.onDraw() を呼び、中の設定をして super.onDraw() を呼ぶ。
TextPaint.setColor() ではなく TextView.setTextColor() を使わないとうまく色が変わらない。 class OutlineTextView : AppCompatTextView { 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 outlineWidth = 10 * context.resources.displayMetrics.density override fun onDraw(canvas: Canvas?) { setTextColor(Color.RED) paint.apply { style = Paint.Style.FILL_AND_STROKE strokeWidth = outlineWidth } super.onDraw(canvas) setTextColor(Color.BLACK) paint.apply { style = Paint.Style.FILL strokeWidth = 0f } super.onDraw(canvas) } }




2021年2月12日金曜日

viewLifecycleOwnerLiveData を使って Fragment の onDestroyView() で自動で null がセットされる ViewBinding 用の property delegates を作る

ViewBinding のドキュメントでは Fragment で使う時の実装はこのようになっています。 class LoginFragment : Fragment() { private var _binding: FragmentLoginBinding? = null private val binding: FragmentLoginBinding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentLoginBinding.inflate(inflater, container, false) return binding.root } override fun onDestroyView() { super.onDestroyView() _binding = null } } Fragment はそのライフサイクル中にViewが破棄されることがあるので onDestroyView() で View への参照を外しておく必要があります。

Fragment.viewLifecycleOwnerLiveData で LiveData<LifecycleOwner?> が取れます」で紹介した viewLifecycleOwnerLiveData を使うと、onDestroyView() で null を代入する処理を自動でやってくれる ViewBinding 用の property delegates を作ることができます。

https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c を参考に変更を加えています。 class FragmentViewBindingDelegate<T : ViewBinding>( val fragment: Fragment, val viewBindingFactory: (View) -> T ) : ReadOnlyProperty<Fragment, T> { private var binding: T? = null private val viewLifecycleOwnerObserver = Observer<LifecycleOwner?> { if (it == null) { binding = null } } private val observer = object : DefaultLifecycleObserver { override fun onCreate(owner: LifecycleOwner) { fragment.viewLifecycleOwnerLiveData.observeForever(viewLifecycleOwnerObserver) } override fun onDestroy(owner: LifecycleOwner) { fragment.viewLifecycleOwnerLiveData.removeObserver(viewLifecycleOwnerObserver) fragment.lifecycle.removeObserver(this) } } init { if (fragment.lifecycle.currentState != Lifecycle.State.DESTROYED) { fragment.lifecycle.addObserver(observer) } } override fun getValue(thisRef: Fragment, property: KProperty<*>): T { val binding = binding if (binding != null) { return binding } val view = thisRef.view checkNotNull(view) { "Should get bindings when the view is not null." } return viewBindingFactory(view).also { this.binding = it } } } fun <T : ViewBinding> Fragment.viewBinding(viewBindingFactory: (View) -> T) = FragmentViewBindingDelegate(this, viewBindingFactory) これを使うと最初のコードはこうなります。 class LoginFragment : Fragment() { private val binding by viewBinding(FragmentLoginBinding::bind) override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { return inflater.inflate(R.layout.fragment_login, container, false) } }

2021年2月10日水曜日

Fragment.viewLifecycleOwnerLiveData で LiveData<LifecycleOwner?> が取れます

AndroidX の Fragment に getViewLifecycleOwnerLiveData() というメソッドがあります。 @NonNull public LiveData<LifecycleOwner> getViewLifecycleOwnerLiveData() { return mViewLifecycleOwnerLiveData; } これは LiveData<LifecycleOwner?> を返してくれます。
この LiveData には、onCreateView() が non-null な View を返した後に getViewLifecycleOwner() で取得できるのと同じ LifecycleOwner がセットされ、onDestroyView() が呼ばれた後に null がセットされます。

実際に試してみましょう。 class MainFragment : Fragment() { private val observer = Observer<LifecycleOwner?> { println("--- lifecycleOwner : $it") } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) println("onCreate") viewLifecycleOwnerLiveData.observeForever(observer) } override fun onAttach(context: Context) { super.onAttach(context) println("onAttach") } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { println("onCreateView") return TextView(inflater.context) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) println("onViewCreated") } override fun onStart() { super.onStart() println("onStart") } override fun onResume() { super.onResume() println("onResume") } override fun onPause() { super.onPause() println("onPause") } override fun onStop() { super.onStop() println("onStop") } override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) println("onActivityCreated") } override fun onDestroyView() { super.onDestroyView() println("onDestroyView") } override fun onDetach() { super.onDetach() println("onDetach") } override fun onDestroy() { super.onDestroy() println("onDestroy") viewLifecycleOwnerLiveData.removeObserver(observer) } } : onAttach : onCreate : onCreateView : --- lifecycleOwner : androidx.fragment.app.FragmentViewLifecycleOwner@6493c3f : onViewCreated : onActivityCreated : onStart : onResume : ----- detach ----- [ここで Activity から detach ] : onPause : onStop : onDestroyView : --- lifecycleOwner : null : ----- attach ----- [ここで Activity に attach ] : onCreateView : --- lifecycleOwner : androidx.fragment.app.FragmentViewLifecycleOwner@50fd6c : onViewCreated : onActivityCreated : onStart : onResume [ここで 画面回転 ] : onPause : onStop : onDestroyView : --- lifecycleOwner : null : onDestroy : onDetach : onAttach : onCreate : onCreateView : --- lifecycleOwner : androidx.fragment.app.FragmentViewLifecycleOwner@9f4c99a : onViewCreated : onActivityCreated : onStart : onResume [ここでバックキーを押して Activity を終了 ] : onPause : onStop : onDestroyView : --- lifecycleOwner : null : onDestroy : onDetach onCreateView() で TextView を返しているので onCreateView() の後に FragmentViewLifecycleOwner のインスタンスが流れてきて、onDestroyView() が呼ばれた後に null が流れてきています。


onCreateView() で null を返すと class MainFragment : Fragment() { ... override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { println("onCreateView") return null } ... } : onAttach : onCreate : onCreateView : onActivityCreated : onStart : onResume : ----- detach ----- : onPause : onStop : onDestroyView : --- lifecycleOwner : null : ----- attach ----- : onCreateView : onActivityCreated : onStart : onResume : onPause : onStop : onDestroyView : --- lifecycleOwner : null : onDestroy : onDetach : onAttach : onCreate : onCreateView : onActivityCreated : onStart : onResume : onPause : onStop : onDestroyView : --- lifecycleOwner : null : onDestroy : onDetach onCreateView() の後にはなにも流れてきませんが、onDestroyView() の後には null が流れてきます。



2021年2月4日木曜日

Animator メモ

single object, single property → ObjectAnimator val animator = ObjectAnimator.ofFloat(view, View.ALPHA, 0f, 1f) single object, multiple property, parallel → PropertyValuesHolder + ObjectAnimator val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 4f) val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 4f) val animator = ObjectAnimator.ofPropertyValuesHolder(view, scaleX, scaleY) single object, multiple property, sequential → ObjectAnimator + AnimatorSet val scaleX = ObjectAnimator.ofFloat(view, View.SCALE_X, 4f) val scaleY = ObjectAnimator.ofFloat(view, View.SCALE_Y, 4f) val set = AnimatorSet() set.playSequentially(scaleX, scaleY) multiple object, multiple property, parallel → ObjectAnimator +AnimatorSet val move = ObjectAnimator.ofFloat(view1, View.TRANSLATION_Y, 100f) val rotate = ObjectAnimator.ofFloat(view2, View.ROTATION, 360f) val set = AnimatorSet() set.playTogether(move, rotate) multiple object, multiple property, sequential → ObjectAnimator +AnimatorSet val move = ObjectAnimator.ofFloat(view1, View.TRANSLATION_Y, 100f) val rotate = ObjectAnimator.ofFloat(view2, View.ROTATION, 360f) val set = AnimatorSet() set.playSequentially(move, rotate)

2021年1月8日金曜日

<fragment> と FragmentContainerView では Fragment のライフサイクルメソッドの呼ばれるタイミングが違う

動作確認は androidx.fragment:fragment-ktx:1.2.5 でしています。今後のバージョンで動作が変わる可能性があります。

fragment-ktx:1.2.5 で <fragment> を使うと FragmentContainerView を使うようにメッセージがでます。


このメッセージに従って FragmentContainerView に変えたとして、大体は問題ないと思いますが、問題が起こる場合もあります。

<fragment> と FragmentContainerView で Fragment のライフサイクルメソッドの呼ばれる順番を確認してみます。 class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val f = supportFragmentManager .findFragmentById(R.id.mainFragment) as MainFragment Log.d("MainActivity", "onCreate : ${f.view}") } } <?xml version="1.0" encoding="utf-8"?> <fragment xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/mainFragment" android:name="yanzm.sample.myapplication.MainFragment" android:layout_width="match_parent" android:layout_height="match_parent" /> class MainFragment : Fragment() { override fun onAttach(context: Context) { super.onAttach(context) Log.d("MainFragment", "onAttach") } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Log.d("MainFragment", "onCreate") } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { Log.d("MainFragment", "onCreateView") return View(inflater.context) } override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) Log.d("MainFragment", "onActivityCreated") } override fun onStart() { super.onStart() Log.d("MainFragment", "onStart") } override fun onResume() { super.onResume() Log.d("MainFragment", "onResume") } } ↑ の <fragment> の場合のコードを実行すると次のようになります。 D/MainFragment: onAttach D/MainFragment: onCreate D/MainFragment: onCreateView D/MainActivity: onCreate : android.view.View{7009652 V.ED..... ......I. 0,0-0,0 #7f0800ca app:id/mainFragment} D/MainFragment: onActivityCreated D/MainFragment: onStart D/MainFragment: onResume Fragment の onAttach(), onCreate(), onCreateView() が呼ばれた後に Activity の onCreate() が呼ばれています。そのため、Activity の onCreate() で Fragment の View にアクセスすると null ではありません。


では FragmentContainerView に変えて見てみましょう。タグ以外は変更なしです。 <?xml version="1.0" encoding="utf-8"?> <androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/mainFragment" android:name="yanzm.sample.myapplication.MainFragment" android:layout_width="match_parent" android:layout_height="match_parent" /> D/MainFragment: onAttach D/MainFragment: onCreate D/MainActivity: onCreate : null D/MainFragment: onCreateView D/MainFragment: onActivityCreated D/MainFragment: onStart D/MainFragment: onResume なんと Fragment の onCreateView() の呼ばれるタイミングが変わっています。 Activity の onCreate() の後に呼ばれるように変わってしまいました。そのため Activity の onCreate() で Fragment の View にアクセスすると null になっています。

Activity の onCreate() の時点で Fragment の onCreateView() がすでに呼ばれている前提の処理だと、FragmentContainerView に変更したときに意図しない動きになってしまうので注意が必要です。