android:adjustViewBounds=”true”をImageコンポーズで実装する

https://stackoverflow.com/a/60651336

import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource

@Composable
fun Modifier.adjustViewBounds(@DrawableRes id: Int): Modifier {
    val painter = painterResource(id)
    return this then Modifier.aspectRatio(painter.intrinsicSize.width / painter.intrinsicSize.height)
}

ImageコンポーズのModifierに設定すればOK。

Image(
    modifier = Modifier.adjustViewBounds(R.drawable.xxxx),
    painter = painterResource(R.drawable.xxxx),
    contentDescription = "description"
)

拡張関数便利


Composeをクリックしたときの波紋効果(リップルエフェクト)を消す

import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier

@Composable
fun Modifier.noRippleClickable(onClick: () -> Unit): Modifier =
    this then Modifier.clickable(
        interactionSource = remember { MutableInteractionSource() },
        indication = null,
        onClick = onClick
    )

使う時はModifierに設定すれば良いだけです。


[Android] ViewModelの書き方

Androidアプリを作る上でViewModelをどれだけうまく書くかがアプリを長く運営していく上で大事になってきます。

昔書いた記事はこちら

ViewModelがAndroidアプリの生命線であり、設計良く保ち、テストコードを書くことでバグを少なく、変更にも対応しやすく変更失敗率も減少し、生産性の向上に繋がります。逆にViewModelが良くないとテストコードも書けず、変更にも対応しにくく変更失敗率が増加し、生産性は下降します。

ViewModelを実装する上でどのように書いたら良いか、海外の記事でうまくまとめられているので1個ずつルールに従っていくと良いViewModelが書けます。2024年6月時点でpart3まであります。

https://proandroiddev.com/mastering-android-viewmodels-essential-dos-and-donts-part-1-%EF%B8%8F-bdf05287bca9

https://proandroiddev.com/mastering-android-viewmodels-essential-dos-and-donts-part-2-%EF%B8%8F-2b49281f0029

https://proandroiddev.com/mastering-android-viewmodels-essential-dos-and-donts-part-3-%EF%B8%8F3%EF%B8%8F%E2%83%A3-1833ce3ddd2b

技術書典16ではmhidakaさんがViewModelの書き方の本を出しているのでこちらもおすすめです。

https://techbooster.booth.pm/items/5748877

内容が少し古いですが釘宮さんの下記の書籍も設計の全体像を把握しやすいのでおすすめです。

https://peaks.cc/books/architecture_with_team


[JetpackCompose] iOSのようにリストの項目をスワイプできるようにする

iOSではリストの一部をスワイプして削除、マーキングする等アクションを発生させることができますが、Androidも同じ機能を実装する場合は自作するかGoogle非公式ライブラリを使うことが多かったと思います。マテリアルデザイン3でもリストのガイドラインでもスワイプについての言及があります。

https://m3.material.io/components/lists/guidelines#79546d3e-e897-4cfe-b842-664f6ad8750b

まだベータ版ですが、JetpackComposeでSwipeToDismissBoxが追加されたのでこれでiOSに近いUIを提供できます。

https://developer.android.com/reference/kotlin/androidx/compose/material3/package-summary#SwipeToDismissBox(androidx.compose.material3.SwipeToDismissBoxState,kotlin.Function1,androidx.compose.ui.Modifier,kotlin.Boolean,kotlin.Boolean,kotlin.Boolean,kotlin.Function1)

import android.widget.Toast
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Tex
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalContext

@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun SwipeToDismissBoxScreen() {
    val context = LocalContext.current
    val firstDismissState = rememberSwipeToDismissBoxState()
    val firstTargetColor by animateColorAsState(getColor(firstDismissState.targetValue), label = "firstTargetColor")
    val secondDismissState = rememberSwipeToDismissBoxState()
    MaterialTheme {
        Column {
            if (firstDismissState.currentValue != SwipeToDismissBoxValue.Settled) {
                LaunchedEffect(key1 = "first") {
                    Toast.makeText(context, firstDismissState.currentValue.toString(), Toast.LENGTH_SHORT).show()
                    firstDismissState.reset()
                }
            }

            SwipeToDismissBox(
                state = firstDismissState,
                backgroundContent = {
                    Box(Modifier.fillMaxSize().background(firstTargetColor))
                }
            ) {
                OutlinedCard(shape = RectangleShape) {
                    ListItem(
                        headlineContent = { Text("FirstItem") },
                        supportingContent = { Text("Swipe me left or right!") }
                    )
                }
            }

            if (secondDismissState.currentValue == SwipeToDismissBoxValue.Settled) {
                SwipeToDismissBox(
                    state = secondDismissState,
                    backgroundContent = {
                        val color by animateColorAsState(getColor(secondDismissState.targetValue), label = "second")
                        Box(Modifier.fillMaxSize().background(color))
                    }
                ) {
                    OutlinedCard(shape = RectangleShape) {
                        ListItem(
                            headlineContent = { Text("SecondItem") },
                            supportingContent = { Text("Swipe me left or right!") }
                        )
                    }
                }
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
fun getColor(value: SwipeToDismissBoxValue): Color =
    when (value) {
        SwipeToDismissBoxValue.Settled -> Color.LightGray
        SwipeToDismissBoxValue.StartToEnd -> Color.Green
        SwipeToDismissBoxValue.EndToStart -> Color.Red
    }

2024年5月時点ではまだ正式版ではないので@OptIn(ExperimentalMaterial3Api::class)が必要です。今回の実装ではスワイプして項目を維持する場合と消えてなくなる場合のコードになります。アニメーション使ってキレイに消したり、背景にアイコンを表示したり、LazyColumnで実装してもできると思います。

"androidx.compose.material3:material3のライブラリは2023年あたりだと使えないので最新のバージョンを使いましょう。

JetpackComposeでカスタムダイアログを実装する

AndroidViewのときはダイアログのライブラリとか作ってくる方がいて使っていたのですが、JetpackComposeだとあんまり無くて自作することになることが多いです。

タイトル、テキスト、キャンセルボタン、OKボタンのみのダイアログを作る場合はAlertDialogを使いましょう。

https://developer.android.com/develop/ui/compose/components/dialog?hl=ja#alert

っで本題ですが、ダイアログの中身を変更する場合はDialogを使います。

@Composable
internal fun CustomDialog(
    onDismissRequest: () -> Unit,
    onConfirmation: () -> Unit
) {
    val narrowPadding = 16.dp
    val widePadding = 24.dp
    Dialog(
        onDismissRequest = { onDismissRequest.invoke() }) { // onDismissRequest()でもOK
        Card(
            shape = RoundedCornerShape(28.dp)
        ) {
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(horizontal = narrowPadding, vertical = widePadding),
                verticalArrangement = Arrangement.Center,
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                /* ダイアログに表示したいComposeをここに書く */

                // 下部のOK、Cancelボタン
                Row(
                    modifier = Modifier
                        .align(Alignment.End)
                        .padding(top = widePadding)
                ) {
                    TextButton(
                        onClick = { onDismissRequest.invoke() }
                    ) {
                        Text(text = stringResource(id = android.R.string.cancel))
                    }
                    TextButton(
                        onClick = { onConfirmation.invoke((sliderValue * 10).toInt()) }
                    ) {
                        Text(text = stringResource(id = android.R.string.ok))
                    }
                }
            }
        }
    }
}

角丸やパディングはマテリアルデザインのルールがあるようなので、それに従うように実装すると見た目がきれいになります。

https://m3.material.io/components/dialogs/specs#9a8c226b-19fa-4d6b-894e-e7d5ca9203e8


material design 3 composeでテキスト付きボタンを表示する

Composeで実装しているとボタンでも複数ありますし、ボタン内にアイコンを表示する場合もあるかと思います。material design 3でテキスト付きのボタンは下記の種類があります。

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        androidx.compose.material3.Button(onClick = { }) { Text("androidx.compose.material3.Button") }
        androidx.compose.material3.ElevatedButton(onClick = { }) { Text("androidx.compose.material3.ElevatedButton") }
        androidx.compose.material3.FilledTonalButton(onClick = { }) { Text("androidx.compose.material3.FilledTonalButton") }
        androidx.compose.material3.OutlinedButton(onClick = { }) { Text("androidx.compose.material3.OutlinedButton")}
        androidx.compose.material3.TextButton(onClick = { }) {Text("androidx.compose.material3.TextButton") }
    }

IconButtonも結構見た目が違います

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        androidx.compose.material3.IconButton(onClick = { }) {
            Icon(painter = painterResource(id = R.drawable.ic_stop), "")
        }
        androidx.compose.material3.IconToggleButton(checked = true, onCheckedChange = { }) {
            Icon(painter = painterResource(id = R.drawable.ic_stop), "")
        }
        androidx.compose.material3.FilledIconButton(onClick = { }) {
            Icon(painter = painterResource(id = R.drawable.ic_stop), "")
        }
        androidx.compose.material3.FilledIconToggleButton(checked = true, onCheckedChange = { }) {
            Icon(painter = painterResource(id = R.drawable.ic_stop), "")
        }
        androidx.compose.material3.FilledTonalIconButton(onClick = { }) {
            Icon(painter = painterResource(id = R.drawable.ic_stop), "")
        }
        androidx.compose.material3.FilledTonalIconToggleButton(checked = true, onCheckedChange = { }) {
            Icon(painter = painterResource(id = R.drawable.ic_stop), "")
        }
        androidx.compose.material3.OutlinedIconButton(onClick = { }) {
            Icon(painter = painterResource(id = R.drawable.ic_stop), "")
        }
        androidx.compose.material3.OutlinedIconToggleButton(checked = true, onCheckedChange = { }) {
            Icon(painter = painterResource(id = R.drawable.ic_stop), "")
        }
        }

特定のComposeに固執してデザインを変えるより期待するデザインに近いComposeを使う方がコードも少なくできるのでどんなComposeがあるか確認してから実装した方が良さそうです。


KotlinScriptingを動かしてみる

kotlinでスクリプトファイルを作成して実行できるようにします。

https://kotlinlang.org/docs/custom-script-deps-tutorial.html

macではbrew install kotlinでkotlinをインストールします。

同一ディレクトリ内にスクリプトファイルを書きます。

exclude.kts

fun helloExclude() {
    println("hello exclude")
}

hello.main.kts

@file:Import("./exclude.kts")

println("hello main")
helloExclude()

実行したいファイルの末尾は「.main.kts」にします。ファイル名をこのようにすることで@file:Import()等が動くようになります。

実行するには下記のコマンドを実行します。

$ kotlinc -script hello.main.kts
hello main
hello exclude

AndroidStudioとVisualStudioCodeで試してみたのですが、main.kts側のコード自動補完が効かないのが辛いです。なんかやり方が間違っているのかなぁ。


[JetpackCompose] 画像を表示してクリックリスナーを設定し、波紋効果も効くようにする

JetpackComposeって実装方法色々あってどの方法が良いのか調べるのがちょっと面倒だったりする。結論から言うとIconButtonを使うとできます。ImageやIconでも多分やりかたあると思うけど、波紋効果がデフォルトで効かなかったりクリックリスナーが無かったりするので、IconButtonが一番簡単。

https://m3.material.io/components/icon-buttons/overview

@Composable
fun MyIconButton() {
    IconButton(onClick = {}) {
        Icon(
            painter = painterResource(R.drawable.icon),
            contentDescription = "icon"
        )
    }
}


[Android] ProductFlavorのデフォルトを変更する

AndroidStudioのBuildVariantでビルド方法を変更できます。コードをgit cloneしたばかりとかAndroidStudioを再起動したときにBuildVariantがリセットしたりします。そのときに間違ったBuildVariantでビルドしないように普段開発で使うビルド方法をデフォルトにできます。

build.gradle.ktsを下記のように変更し、sync project with gradle filesを実行します。

android {
  buildTypes {
    getByName("release") {
      isDefault = true
    }
  }
}

buildTypesがreleaseの方をデフォルトに変更できました。productFlavorsでも同じことが出来ます。大きいプロジェクトになるとビルド時間が5分を超えるところもあり、意図しないBuildVariantでビルドしてしまうと中止したとしても時間を無駄にしてしまうので、このような細かいところもちゃんとデフォルト設定しておきたいです。


Retrofit2にBOMが追加されました

https://github.com/square/retrofit/releases/tag/2.10.0

https://mvnrepository.com/artifact/com.squareup.retrofit2/retrofit-bom/2.10.0

retrofit:2.10.0からBOM機能が追加されました。

retrofit2の関連ライブラリがいくつかありますが、都度バージョンを確認するのも面倒ですし、バージョン間の依存関係も複雑になりがちなのでBOMを使うことがおすすめされています。

利用例

build.gradle.kts

dependencies {
    implementation(platform(“com.squareup.retrofit2:retrofit-bom:2.10.0”))
    implementation(“com.squareup.retrofit2:converter-jackson”)
}

BOMは他にもcomposeやfirebaseにもあるので積極的に使っていきましょう。