2021年12月16日木曜日

Dagger Hilt 2.40.2 で EntryPoints.get() の便利 overloads である EntryPointAccessors が追加された

今まで val entryPoint = EntryPoints.get(activity, ActivityCreatorEntryPoint::class.java) EntryPointAccessors を使うと val entryPoint = EntryPointAccessors.fromActivity<ActivityCreatorEntryPoint>(activity) fromApplication(), fromActivity(), fromFragment(), fromView() が用意されている。


2021年12月9日木曜日

moshi を Kotlin で使うときは @Json ではなく @field:Json を使う

moshi では、property の名前を json の field 名と別にしたい場合 @Json アノテーションを使いますが、@Json ではなく @field:Json を使うようにします。 class Player { @field:Json(name = "lucky number") val luckyNumber: Int ... } R8 / ProGuard をかけない場合は @Json でも動くのですが、R8 / ProGuard をかける場合は(ライブラリに含まれる keep 設定では) @field:Json にしないとこの指定が効かず、上記のコードだと実行時に luckyNumber にアクセスしたときに NullPointerException になります。


2021年10月28日木曜日

遷移先情報(deep link とか)を持つ Intent の処理タイミング

以下では、SingleActivity で画面遷移は Navigation Compose を使っている前提である。


ポイント1 : Activity 再生成時には処理しないように onCreate() では savedInstanceState をチェックする

Navigation Compose の backstack 情報は再生成に restore される class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { ... } if (savedInstanceState == null) { handleIntent(intent) } } override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) intent?.let { handleIntent(it) } } ... }

ポイント2 : Recent Apps から起動されたときは処理しないように FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY をチェックする

class MainActivity : ComponentActivity() { ... private fun handleIntent(intent: Intent) { if (intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0) { return } // intent の内容に応じて NavHostController.navigate() する } } Task が生きているときに Recent Apps からアプリを開いても FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY はつかない

Root の Activity が finish されたり、プロセスが kill されているときに Recent Apps からアプリを開くと FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY がつく



2021年9月5日日曜日

Jeptack Compose : DrawScope に描画したものを Bitmap にする。Bitmap を DrawScope に描画する。

ImageBitmap から Bitmap への変換には ImageBitmap.asAndroidBitmap() を使います。
Bitmap から ImageBitmap への変換には Bitmap.asImageBitmap() を使います。

@Preview @Composable fun SampleScreen() { val density = LocalDensity.current val layoutDirection = LocalLayoutDirection.current val bitmap = remember { mutableStateOf<Bitmap?>(null) } Column { Button( onClick = { bitmap.value = createBitmap(density, layoutDirection) }, modifier = Modifier.padding(16.dp) ) { Text(text = "click") } AndroidView( factory = { context -> ImageView(context) }, update = { imageView -> imageView.setImageBitmap(bitmap.value) }, modifier = Modifier.padding(16.dp) ) androidx.compose.foundation.Canvas( modifier = Modifier.padding(16.dp) .size(with(LocalDensity.current) { 512.toDp() }) ) { val bmp = bitmap.value if (bmp != null) { drawImage(bmp.asImageBitmap()) drawCircle(Color.White, radius = size.minDimension / 4f) } } } } private fun createBitmap( density: Density, layoutDirection: LayoutDirection ): Bitmap { val targetSize = 512 val imageBitmap = ImageBitmap(targetSize, targetSize) val size = Size(targetSize.toFloat(), targetSize.toFloat()) CanvasDrawScope().draw(density, layoutDirection, Canvas(imageBitmap), size) { drawCircle(Color.Red) } return imageBitmap.asAndroidBitmap() }

2021年8月29日日曜日

navigation-compose は ViewModel のライフサイクルをどう管理しているのか

twitter で言及したこれの解説です。

使用ライブラリ

以下の解説は

compose_version : 1.0.1
androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha07
androidx.navigation:navigation-compose:2.4.0-alpha07
のコードをもとにしています。

内部の実装は今後変わる可能性があります。

共通コード

navigation-compose あり/なしの比較をしたいので、共通する部分のコードを先に出しておきます。 class Screen1ViewModel : ViewModel() { val values: List<Int> = (0 until 20).map { Random.nextInt() } init { println("Screen1ViewModel : created : $this") } override fun onCleared() { println("Screen1ViewModel : cleared : $this") } } class Screen2ViewModel : ViewModel() { val value = Random.nextFloat() init { println("Screen2ViewModel : created : $this") } override fun onCleared() { println("Screen2ViewModel : cleared : $this") } } @Composable fun Screen1( viewModel: Screen1ViewModel, onClickItem: (Int) -> Unit, ) { DisposableEffect(Unit) { println("Screen1 : composed : viewModel = $viewModel") onDispose { println("Screen1 : disposed") } } LazyColumn(modifier = Modifier.fillMaxSize()) { items(viewModel.values) { value -> Text( text = value.toString(), modifier = Modifier .fillMaxWidth() .clickable { onClickItem(value) } .padding(16.dp) ) } } } @Composable fun Screen2( viewModel: Screen2ViewModel, value1: Int ) { DisposableEffect(Unit) { println("Screen2 : composed : viewModel = $viewModel") onDispose { println("Screen2 : disposed") } } Text( text = "value1 = $value1, value2 = ${viewModel.value}", modifier = Modifier .fillMaxWidth() .padding(16.dp) ) } class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MaterialTheme { Surface(color = MaterialTheme.colors.background) { MyApp() } } } } }

navigation-compose を使わないときの ViewModel のライフサイクル

まず navigation-compose を使わないときの動作を確認してみましょう。 @Composable fun MyApp() { val screenState = remember { mutableStateOf<Screen>(Screen.Screen1) } when (val screen = screenState.value) { Screen.Screen1 -> { Screen1(viewModel()) { screenState.value = Screen.Screen2(it) } } is Screen.Screen2 -> { BackHandler { screenState.value = Screen.Screen1 } Screen2(viewModel(), screen.value) } } } Screen1ViewModel : created : com.sample.myapplication.Screen1ViewModel@7826ac6 Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@7826ac6 -- Screen1 の LazyColumn のアイテムをタップ Screen2ViewModel : created : com.sample.myapplication.Screen2ViewModel@4d64aba Screen1 : disposed Screen2 : composed : viewModel = com.sample.myapplication.Screen2ViewModel@4d64aba -- back キータップ Screen2 : disposed Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@7826ac6 -- Screen1 の LazyColumn のアイテムをタップ Screen1 : disposed Screen2 : composed : viewModel = com.sample.myapplication.Screen2ViewModel@4d64aba -- back キータップ Screen2 : disposed Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@7826ac6 -- back キータップ(アプリ終了) Screen1 : disposed Screen1ViewModel : cleared : com.sample.myapplication.Screen1ViewModel@7826ac6 Screen2ViewModel : cleared : com.sample.myapplication.Screen2ViewModel@4d64aba Screen1ViewModel、Screen2ViewModel いずれも1回しか生成されておらず、画面上の表示は Screen1 → Screen2 → Screen1 → Screen2 → Screen1 と遷移していますが、viewModel() では 同じ ViewModel インスタンスが返ってきていることがわかります。

viewModel() は androidx.lifecycle:lifecycle-viewmodel-compose に定義されている拡張関数で、次のようになっています。 @Composable public inline fun <reified VM : ViewModel> viewModel( viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" }, key: String? = null, factory: ViewModelProvider.Factory? = null ): VM = viewModel(VM::class.java, viewModelStoreOwner, key, factory) @Composable public fun <VM : ViewModel> viewModel( modelClass: Class<VM>, viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" }, key: String? = null, factory: ViewModelProvider.Factory? = null ): VM = viewModelStoreOwner.get(modelClass, key, factory) private fun <VM : ViewModel> ViewModelStoreOwner.get( javaClass: Class<VM>, key: String? = null, factory: ViewModelProvider.Factory? = null ): VM { val provider = if (factory != null) { ViewModelProvider(this, factory) } else { ViewModelProvider(this) } return if (key != null) { provider.get(key, javaClass) } else { provider.get(javaClass) } } LocalViewModelStoreOwner.current から ViewModelStoreOwner を取得し、ViewModelProvider() にそれを渡して ViewModel のインスタンスを取得しています。

ViewModelProvider() に ViewModelStoreOwner を渡して ViewModel のインスタンスを取得するのは、今まで Activity や Fragment でやっていたのと同じです。 Activity や Fragment は ViewModelStoreOwner を実装しています。

では Screen1 や Screen2 で viewModel() を呼ぶとき LocalViewModelStoreOwner には何がセットされているのでしょうか。 Screen.Screen1 -> { println("Screen1 : ViewModelStoreOwner = ${LocalViewModelStoreOwner.current}") Screen1(viewModel()) { screenState.value = Screen.Screen2(it) } } Screen1 : ViewModelStoreOwner = com.sample.myapplication.MainActivity@7826ac6 ... Screen2 : ViewModelStoreOwner = com.sample.myapplication.MainActivity@7826ac6 LocalViewModelStoreOwner には MainActivity が入っていることがわかりました。

つまり Screen1ViewModel、Screen2ViewModel いずれも MainActivity に紐づいており、表示上は Screen1, Screen2 と遷移していますが、常に同じ MainActivity にいるので viewModel() で同じインスタンスが返ってきていたということだったのです。

navigation-compose での ViewModel のライフサイクル

navigation-compose を使った場合どうなるのか確認してみましょう。 @Composable fun MyApp() { val navController = rememberNavController() NavHost(navController = navController, startDestination = "Screen1") { composable( route = "Screen1" ) { println("Screen1 : ViewModelStoreOwner = ${LocalViewModelStoreOwner.current}") Screen1(viewModel()) { navController.navigate("Screen2/$it") } } composable( route = "Screen2/{value}", arguments = listOf(navArgument("value") { type = NavType.IntType }) ) { val value1 = requireNotNull(it.arguments).getInt("value") println("Screen2 : ViewModelStoreOwner = ${LocalViewModelStoreOwner.current}") Screen2(viewModel(), value1) } } } Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb Screen1ViewModel : created : com.sample.myapplication.Screen1ViewModel@f79f3f3 Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3 Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb -- Screen1 の LazyColumn のアイテムをタップ Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@861e3e2a Screen2ViewModel : created : com.sample.myapplication.Screen2ViewModel@7cf0642 Screen2 : composed : viewModel = com.sample.myapplication.Screen2ViewModel@7cf0642 Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@861e3e2a Screen1 : disposed Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@861e3e2a -- back キータップ Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@861e3e2a Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3 Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@861e3e2a Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb Screen2ViewModel : cleared : com.sample.myapplication.Screen2ViewModel@7cf0642 Screen2 : disposed Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb -- Screen1 の LazyColumn のアイテムをタップ Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@27c16dd1 Screen2ViewModel : created : com.sample.myapplication.Screen2ViewModel@96e0d3b Screen2 : composed : viewModel = com.sample.myapplication.Screen2ViewModel@96e0d3b Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@27c16dd1 Screen1 : disposed Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@27c16dd1 -- back キータップ Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@27c16dd1 Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3 Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@27c16dd1 Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb Screen2ViewModel : cleared : com.sample.myapplication.Screen2ViewModel@96e0d3b Screen2 : disposed Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb -- back キータップ(アプリ終了) Screen1 : disposed Screen1ViewModel : cleared : com.sample.myapplication.Screen1ViewModel@f79f3f3 LocalViewModelStoreOwner には MainActivity ではなく NavBackStackEntry が入っていることがわかりました。
ViewModelStoreOwner の部分を省くと Screen1ViewModel : created : com.sample.myapplication.Screen1ViewModel@f79f3f3 Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3 -- Screen1 の LazyColumn のアイテムをタップ Screen2ViewModel : created : com.sample.myapplication.Screen2ViewModel@7cf0642 Screen2 : composed : viewModel = com.sample.myapplication.Screen2ViewModel@7cf0642 Screen1 : disposed -- back キータップ = popBackstack Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3 Screen2ViewModel : cleared : com.sample.myapplication.Screen2ViewModel@7cf0642 Screen2 : disposed -- Screen1 の LazyColumn のアイテムをタップ Screen2ViewModel : created : com.sample.myapplication.Screen2ViewModel@96e0d3b Screen2 : composed : viewModel = com.sample.myapplication.Screen2ViewModel@96e0d3b Screen1 : disposed -- back キータップ = popBackstack Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3 Screen2ViewModel : cleared : com.sample.myapplication.Screen2ViewModel@96e0d3b Screen2 : disposed -- back キータップ(アプリ終了) Screen1 : disposed Screen1ViewModel : cleared : com.sample.myapplication.Screen1ViewModel@f79f3f3 Screen1 → Screen2 → Screen1 のとき(Screen2 が backstack からいなくなった)に Screen2ViewModel が破棄(onCleared)され、再度 Screen2 に遷移したときに別の Screen2ViewModel インスタンスが作られていることがわかります。

LocalViewModelStoreOwner へのセット

navigation-compose では NavHost composable で LocalViewModelStoreOwner に NavBackStackEntry をセットしています。 @Composable public fun NavBackStackEntry.LocalOwnersProvider( saveableStateHolder: SaveableStateHolder, content: @Composable () -> Unit ) { CompositionLocalProvider( LocalViewModelStoreOwner provides this, LocalLifecycleOwner provides this, LocalSavedStateRegistryOwner provides this ) { saveableStateHolder.SaveableStateProvider(content) } } @Composable public fun NavHost( navController: NavHostController, graph: NavGraph, modifier: Modifier = Modifier ) { ... if (backStackEntry != null) { ... Crossfade(backStackEntry.id, modifier) { val lastEntry = transitionsInProgress.lastOrNull { entry -> it == entry.id } ?: backStack.lastOrNull { entry -> it == entry.id } lastEntry?.LocalOwnersProvider(saveableStateHolder) { (lastEntry.destination as ComposeNavigator.Destination).content(lastEntry) } ... } } ... } backStackEntry には backstack の一番最後のものが入ります。

NavBackStackEntry の ViewModelStoreOwner 実装

では NavBackStackEntry が ViewModelStoreOwner をどのように実装しているのか見てみましょう。 public class NavBackStackEntry private constructor( ... private val viewModelStoreProvider: NavViewModelStoreProvider? = null, ... ) : LifecycleOwner, ViewModelStoreOwner, HasDefaultViewModelProviderFactory, SavedStateRegistryOwner { ... public override fun getViewModelStore(): ViewModelStore { ... checkNotNull(viewModelStoreProvider) { ... } return viewModelStoreProvider.getViewModelStore(id) } } NavBackStackEntry の ViewModelStoreOwner 実装では NavViewModelStoreProvider から ViewModelStore を取得しています。

NavViewModelStoreProvider は interface で、backStackEntryId: String から ViewModelStore を返すメソッドが定義されています。 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public interface NavViewModelStoreProvider { public fun getViewModelStore(backStackEntryId: String): ViewModelStore } NavViewModelStoreProvider を実装しているのは NavControllerViewModel です。 internal class NavControllerViewModel : ViewModel(), NavViewModelStoreProvider { private val viewModelStores = mutableMapOf<String, ViewModelStore>() fun clear(backStackEntryId: String) { // Clear and remove the NavGraph's ViewModelStore val viewModelStore = viewModelStores.remove(backStackEntryId) viewModelStore?.clear() } override fun onCleared() { for (store in viewModelStores.values) { store.clear() } viewModelStores.clear() } override fun getViewModelStore(backStackEntryId: String): ViewModelStore { var viewModelStore = viewModelStores[backStackEntryId] if (viewModelStore == null) { viewModelStore = ViewModelStore() viewModelStores[backStackEntryId] = viewModelStore } return viewModelStore } ... companion object { private val FACTORY: ViewModelProvider.Factory = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun <T : ViewModel> create(modelClass: Class<T>): T { return NavControllerViewModel() as T } } @JvmStatic fun getInstance(viewModelStore: ViewModelStore): NavControllerViewModel { val viewModelProvider = ViewModelProvider(viewModelStore, FACTORY) return viewModelProvider.get() } } } NavControllerViewModel は backStackEntryId: String と ViewModelStore の Map を持っています。

NavControllerViewModel は NavHost composable が呼び出される時点での LocalViewModelStoreOwner にセットされている ViewModelStoreOwner を使って生成され、NavController にセットされます。 @Composable public fun NavHost( navController: NavHostController, graph: NavGraph, modifier: Modifier = Modifier ) { val lifecycleOwner = LocalLifecycleOwner.current val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { "NavHost requires a ViewModelStoreOwner to be provided via LocalViewModelStoreOwner" } ... navController.setViewModelStore(viewModelStoreOwner.viewModelStore) ... } public open class NavHostController(context: Context) : NavController(context) { ... public final override fun setViewModelStore(viewModelStore: ViewModelStore) { super.setViewModelStore(viewModelStore) } } public open class NavController( ... ) { ... @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public open fun setViewModelStore(viewModelStore: ViewModelStore) { if (viewModel == NavControllerViewModel.getInstance(viewModelStore)) { return } check(backQueue.isEmpty()) { "ViewModelStore should be set before setGraph call" } viewModel = NavControllerViewModel.getInstance(viewModelStore) } ... } NavController にセットされた NavControllerViewModel は、NavBackStackEntry を生成するときに NavViewModelStoreProvider として渡されます。

つまり NavBackStackEntry を ViewModelStoreOwner として ViewModel を取得する場合、NavControllerViewModel に ViewModelStore が作られ、対応する backStackEntryId との Map で保持されます。

NavController では NavBackStackEntry が backstack から pop されたときに、NavControllerViewModel で保持されている対応する ViewModelStore をクリアしています。


2021年8月24日火曜日

Jetpack Compose + ViewModel + Navigation + Hilt で UI テスト

テスト対象の Compose @HiltAndroidApp class MyApplication : Application() @AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MaterialTheme { Surface(color = MaterialTheme.colors.background) { MyApp() } } } } } @Composable fun MyApp() { val navController = rememberNavController() NavHost(navController, startDestination = "Screen1") { composable("Screen1") { Screen1(hiltViewModel()) { navController.navigate("Screen2/$it") } } composable( route = "Screen2/{value}", arguments = listOf( navArgument("value") { type = NavType.IntType }, ) ) { val arguments = requireNotNull(it.arguments) val value = arguments.getInt("value") Screen2(value) } } } @Composable fun Screen1( viewModel: Screen1ViewModel, onClickItem: (Int) -> Unit, ) { LazyColumn(modifier = Modifier.fillMaxSize()) { items(viewModel.values) { value -> Text( text = value.toString(), modifier = Modifier .fillMaxWidth() .clickable { onClickItem(value) } .padding(16.dp) ) } } } @Composable fun Screen2(value: Int) { Text( text = "value = $value", modifier = Modifier .fillMaxWidth() .padding(16.dp) ) } @HiltViewModel class Screen1ViewModel @Inject constructor( valueProvider: ValueProvider ) : ViewModel() { val values: List<Int> = valueProvider.getValues() } interface ValueProvider { fun getValues(): List<Int> } class DefaultValueProvider @Inject constructor() : ValueProvider { override fun getValues(): List<Int> = (0 until 20).map { Random.nextInt() } } @Module @InstallIn(SingletonComponent::class) interface ValueProviderModule { @Binds @Singleton fun bindValueProvider(provider: DefaultValueProvider): ValueProvider }

MyApp composable では最初に Screen1が表示される。
Screen1 にはランダムな Int 値のリストが表示され、タップすると Screen2 に遷移し、タップした値が表示される。

Screen1 のリストをタップして Screen2 に遷移し、タップしたところの値が表示されていることをテストしたいとする。

Screen1 の引数の Screen1ViewModel は hiltViewModel() で取得しているので、テストでも Hilt が動くようにしたい。
ただし、Screen1ViewModel で使っている ValueProvidier はテスト用のものに差し替えたい。


1. テスト用のライブラリを追加する

参考 (Hilt) : https://developer.android.com/training/dependency-injection/hilt-testing#testing-dependencies
参考 (Compose) : https://developer.android.com/jetpack/compose/testing#setup dependencies { ... androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version" kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version" }
2. テスト時に HiltTestApplication が使われるように CustomTestRunner を用意する

参考 : https://developer.android.com/training/dependency-injection/hilt-testing#instrumented-tests

(継承元のApplicationが必要な場合は https://developer.android.com/training/dependency-injection/hilt-testing#custom-application に方法が書いてある)

app/src/androidTest class CustomTestRunner : AndroidJUnitRunner() { override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { return super.newApplication(cl, HiltTestApplication::class.java.name, context) } } build.gradle (app) android { ... defaultConfig { ... testInstrumentationRunner "com.sample.myapplication.CustomTestRunner" } ... } この設定をしないと

java.lang.IllegalStateException: Hilt test, com.sample.myapplication.MyAppTest, cannot use a @HiltAndroidApp application but found com.sample.myapplication.MyApplication. To fix, configure the test to use HiltTestApplication or a custom Hilt test application generated with @CustomTestApplication.

のようなエラーがでる


3. @AndroidEntryPoint がついたテスト用の Activity を debug に用意する

app/src/debug @AndroidEntryPoint class HiltTestActivity : ComponentActivity() app/src/debug/AndroidManifest.xml <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.sample.myapplication"> <application> <activity android:name=".HiltTestActivity" android:exported="false" /> </application> </manifest>
4. テストを書く

テストクラスに @HiltAndroidTest をつける

HiltAndroidRule が先になるように oder を指定する。
参考 :
https://developer.android.com/training/dependency-injection/hilt-testing#multiple-testrules

createAndroidComposeRule() に 3. で作った HiltTestActivity を指定する。createComposeRule() だと

Given component holder class androidx.activity.ComponentActivity does not implement interface dagger.hilt.internal.GeneratedComponent or interface dagger.hilt.internal.GeneratedComponentManager

のようなエラーがでる

binding の差し替えは https://developer.android.com/training/dependency-injection/hilt-testing#testing-features に書いてある。 @UninstallModules(ValueProviderModule::class) @HiltAndroidTest class MyAppTest { @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule<HiltTestActivity>() @BindValue val repository: ValueProvider = object : ValueProvider { override fun getValues(): List<Int> { return listOf(100) } } @Test fun test() { composeTestRule.setContent { MaterialTheme { Surface(color = MaterialTheme.colors.background) { MyApp() } } } // Screen1 に 100 が表示されている composeTestRule.onNodeWithText("100").assertIsDisplayed() // Screen1 の 100 が表示されている Node をクリック composeTestRule.onNodeWithText("100").performClick() // Screen2 に value = 100 が表示されている composeTestRule.onNodeWithText("value = 100").assertIsDisplayed() } }



2021年8月23日月曜日

Jetpack Compose + ViewModel + Navigation + Hilt + AssistedInject

Jetpack Compose + ViewModel + Navigation + Hilt の ViewModel で AssistedInject を使う方法。


ViewModelExt.kt @Composable inline fun <reified VM : ViewModel> assistedViewModel( viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" }, crossinline viewModelProducer: (SavedStateHandle) -> VM ): VM { val factory = if (viewModelStoreOwner is NavBackStackEntry) { object : AbstractSavedStateViewModelFactory(viewModelStoreOwner, viewModelStoreOwner.arguments) { @Suppress("UNCHECKED_CAST") override fun <T : ViewModel?> create(key: String, modelClass: Class<T>, handle: SavedStateHandle): T { return viewModelProducer(handle) as T } } } else { // Use the default factory provided by the ViewModelStoreOwner // and assume it is an @AndroidEntryPoint annotated fragment or activity null } return viewModel(viewModelStoreOwner, factory = factory) } fun Context.extractActivity(): Activity { var ctx = this while (ctx is ContextWrapper) { if (ctx is Activity) { return ctx } ctx = ctx.baseContext } throw IllegalStateException( "Expected an activity context for creating a HiltViewModelFactory for a " + "NavBackStackEntry but instead found: $ctx" ) } MainActivity.kt @HiltAndroidApp class MyApplication : Application() @AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MaterialTheme { Surface(color = MaterialTheme.colors.background) { MyApp() } } } } } @Composable fun MyApp() { val navController = rememberNavController() NavHost(navController, startDestination = "Screen1") { composable("Screen1") { LazyColumn(modifier = Modifier.fillMaxSize()) { items(20) { Text( text = "Item : $it", modifier = Modifier .fillMaxWidth() .clickable { navController.navigate("Screen2/$it") } .padding(16.dp) ) } } } composable( route = "Screen2/{id}", arguments = listOf( navArgument("id") { type = NavType.IntType }, ) ) { val arguments = requireNotNull(it.arguments) val id = arguments.getInt("id") val viewModel = assistedViewModel { savedStateHandle -> Screen2ViewModel.provideFactory(LocalContext.current) .create(savedStateHandle, id) } Screen2(viewModel) } } } @Composable fun Screen2(viewModel: Screen2ViewModel) { Text( text = viewModel.greet(), modifier = Modifier.padding(24.dp) ) } class Screen2ViewModel @AssistedInject constructor( private val nameProvider: NameProvider, @Assisted private val savedStateHandle: SavedStateHandle, @Assisted private val id: Int ) : ViewModel() { @AssistedFactory interface Factory { fun create(savedStateHandle: SavedStateHandle, id: Int): Screen2ViewModel } @EntryPoint @InstallIn(ActivityComponent::class) interface ActivityCreatorEntryPoint { fun getScreen2ViewModelFactory(): Factory } companion object { fun provideFactory(context: Context): Factory { val activity = context.extractActivity() return EntryPoints.get(activity, ActivityCreatorEntryPoint::class.java) .getScreen2ViewModelFactory() } } fun greet(): String { return "Hello ${nameProvider.name()} : id = $id" } } @Singleton class NameProvider @Inject constructor() { fun name(): String { return "Android" } } Issue (https://github.com/google/dagger/issues/2287) は 1月からあるけど、進んでなさそう。


2021年7月26日月曜日

Jetpack Compose : 点線を描画する

点線を描画するには PathEffect.dashPathEffect() を使う Canvas { drawRoundRect( color = color, cornerRadius = radius, style = Stroke( width = strokeWidth, pathEffect = PathEffect.dashPathEffect( intervals = floatArrayOf(onInterval, offInterval), phase = onInterval + offInterval, ) ) ) }

2021年7月1日木曜日

Jetpack Compose : Modifier.weight() の fill パラメータ

Modifier.weight() には float 値の他に fill するかどうかの boolean パラメータを指定できます。 @Stable fun Modifier.weight( weight: Float, fill: Boolean = true ): Modifier fill に true を指定すると、割り当てられた領域を占めるように配置されます。
fill に false を指定すると、割り当てられた領域より小さくなることができます。使わなかった領域は他のところに割り当てられません。 @Preview @Composable fun Sample() { Column(modifier = Modifier.height(300.dp)) { Column( modifier = Modifier.weight(1f, true) ) { Text("タイトル") Text("メッセージ") } Button( modifier = Modifier.weight(1f), onClick = { /*TODO*/ }, ) { Text("Button") } } } @Preview @Composable fun Sample2() { Column(modifier = Modifier.height(300.dp)) { Column( modifier = Modifier.weight(1f, false) ) { Text("タイトル") Text("メッセージ") } Button( modifier = Modifier.weight(1f), onClick = { /*TODO*/ }, ) { Text("Button") } } } @Preview @Composable fun Sample3() { Column(modifier = Modifier.height(300.dp)) { Column( modifier = Modifier ) { Text("タイトル") Text("メッセージ") } Button( modifier = Modifier.weight(1f), onClick = { /*TODO*/ }, ) { Text("Button") } } } Sample2 では fill に false を指定しています。これにより "タイトル" と "メッセージ" を含む Column は 150dp より小さくなります。Sample3 と違い、Button の大きさを計算するときに考慮されるため Button の大きさは 150dp になります。

2021年6月30日水曜日

Jetpack Compose : キーボードが表示されたときに scrollable な Column 内の TextField が見えるようにスクロールする

1. accompanist-insets を入れる implementation "com.google.accompanist:accompanist-insets:$accompanist_version" 2. Activity に android:windowSoftInputMode="adjustResize" をセットする

* accompanist-insets がちゃんと動くために必要 <activity android:name=".RelocationRequesterSampleActivity" android:windowSoftInputMode="adjustResize"> 3. Activity で WindowCompat.setDecorFitsSystemWindows(window, false) する

* accompanist-insets がちゃんと動くために必要 class RelocationRequesterSampleActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) setContent { MaterialTheme { ProvideWindowInsets { RelocationRequesterSample() } } } } } 4. RelocationRequester を使う @OptIn(ExperimentalComposeUiApi::class) @Composable fun RelocationRequesterSample() { Column( modifier = Modifier .fillMaxSize() .statusBarsPadding() .navigationBarsWithImePadding() .verticalScroll(rememberScrollState()) .padding(24.dp) ) { Spacer( modifier = Modifier .fillMaxSize() .height(600.dp) .background(color = Color.LightGray) ) Spacer(modifier = Modifier.height(24.dp)) val relocationRequester = remember { RelocationRequester() } val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() val ime = LocalWindowInsets.current.ime if (ime.isVisible && !ime.animationInProgress && isFocused) { LaunchedEffect(Unit) { relocationRequester.bringIntoView() } } var value by remember { mutableStateOf("") } OutlinedTextField( value = value, onValueChange = { value = it }, interactionSource = interactionSource, modifier = Modifier.relocationRequester(relocationRequester) ) } }
ちなみに、 relocationRequester.bringIntoView() 部分をコメントアウトするとこうなる



参考


2021年6月28日月曜日

Jetpack Compose: Text の文字サイズを dp で指定する

Text の文字サイズを dp で指定したい(fontScale に依存しないようにしたい)ときはこのようにします。 val density = LocalDensity.current Text( text = "Hello", fontSize = with(density) { 18.dp.toSp() }, )

2021年6月21日月曜日

Jetpack Compose で Runtime Permission をリクエストする

onStart() でパーミッションがあるかどうかチェックして、無い場合はパーミッションをリクエストする処理です。

rememberLauncherForActivityResult() を使います。 enum class PermissionState { Checking, Granted, Denied, } @Composable private fun NeedPermissionScreen() { var state by remember { mutableStateOf(PermissionState.Checking) } val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { state = if (it) PermissionState.Granted else PermissionState.Denied } val permission = Manifest.permission.CAMERA val context = LocalContext.current val lifecycleObserver = remember { LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_START) { val result = context.checkSelfPermission(permission) if (result != PackageManager.PERMISSION_GRANTED) { state = PermissionState.Checking launcher.launch(permission) } else { state = PermissionState.Granted } } } } val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(lifecycle, lifecycleObserver) { lifecycle.addObserver(lifecycleObserver) onDispose { lifecycle.removeObserver(lifecycleObserver) } } when (state) { PermissionState.Checking -> { } PermissionState.Granted -> { // TODO パーミッションが必要な機能を使う画面 } PermissionState.Denied -> { // TODO 拒否された時の画面 } } }

追記:
Accompanist に最近 Permission のやつが追加されました (現状 Permission 関係のすべての機能は experimental)

https://google.github.io/accompanist/permissions/

2021年6月10日木曜日

Compose の Text を長押しで文字選択できるようにしたい

SelectionContainer を使います。 SelectionContainer { Text("This text is selectable") }
選択ハンドルなどの色は MaterialTheme.colors.primary が使われます。

残念ながら SelectionContainer には直接色を指定するパラメータは用意されていません。

ピンポイントで色を指定したいなら MaterialTheme を使って primary を上書きします。 val original = MaterialTheme.colors val textColor = original.primary MaterialTheme(colors = original.copy(primary = original.secondary)) { SelectionContainer { Text("This text is selectable", color = textColor) } }




2021年6月1日火曜日

Kotlin の sealed interface が必要になる例

(本当は Compose のコード例(State/MutableState)にしたかったんだけど、まだ Jetpack Compose は Kotlin 1.5 に対応してないので sealed interface 使えないんだよね...)


ViewModel で持ってる LiveData を Activity で observe してるとする。 class MainActivity : AppCompatActivity() { private val viewModel: MainViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) viewModel.state.observe(this) { when (it) { is State.Data -> { println(it.value) } State.Loading -> { } } } } } class MainViewModel : ViewModel() { private val _state = MutableLiveData<State>() val state: LiveData<State> get() = _state fun doSomething() { val state = _state.value if (state is State.Data) { state.mutableValue = Random.nextInt() } } } sealed class State { object Loading : State() data class Data(val id: String) : State() { // 変更できるのは MainViewModel からだけにしたいが、 // private にすると MainViewModel からも見えなくなる var mutableValue: Int = -1 val value: Int get() = mutableValue } } ↑ State.Data が持つ mutableValue は MainViewModel からのみ変更できるようにしたい。

mutableValue に private は MainViewModel から見えなくなるのでダメ。 private var mutableValue: Int = -1 これもダメ。 private sealed class State { private data class Data(val id: String) : State() { Data を top level にすると private をつけても MainViewModel から見えるけど、MainActivity から見えなくなるのでダメ。 sealed class State object Loading : State() // MainViewModel から mutableValue は見えるが // Data が MainActivity からは見えなくなる private data class Data(val id: String) : State() { var mutableValue: Int = -1 val value: Int get() = mutableValue }

ということで sealed interface を使います。 class MainViewModel : ViewModel() { private val _state = MutableLiveData<State>() val state: LiveData<State> get() = _state fun doSomething() { val state = _state.value if (state is DataImpl) { state.mutableValue = Random.nextInt() } } } sealed interface State object Loading : State sealed interface Data : State { val value: Int } private class DataImpl(val id: Int) : Data { // private class なので変更できるのは同じファイルからだけ var mutableValue: Int = id override val value: Int get() = mutableValue }

2021年5月28日金曜日

Jetpack Compose : Lifecycle.Event で処理をトリガーする

通知が有効になっているかどうか調べる処理 (NotificationManagerCompat.from(context).areNotificationsEnabled()) を毎 onResume で行い場合はこんな感じになる。 @Composable fun SampleScreen( notificationEnabled: Boolean, onCheckNotification: () -> Unit ) { val lifecycleObserver = remember(onCheckNotification) { LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { onCheckNotification() } } } val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(lifecycle) { lifecycle.addObserver(lifecycleObserver) onDispose { lifecycle.removeObserver(lifecycleObserver) } } if (!notificationEnabled) { Text("通知設定がオフです") } } state hoisting だとこんな感じだけど、引数 ViewModel にするならこんな感じ。 @Composable fun SampleScreen( viewModel: SampleViewModel = viewModel(), ) { val lifecycleObserver = remember(viewModel) { LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { viewModel.updateNotificationEnabledState() } } } val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(lifecycle) { lifecycle.addObserver(lifecycleObserver) onDispose { lifecycle.removeObserver(lifecycleObserver) } } if (!viewModel.notificationEnabled.value) { Text("通知設定がオフです") } }


Jetpack Compose : disabled のとき Image や Text の色を薄くしたい

Checkbox とか Switch とか enabled 設定が用意されている Composable では enabled に false を設定すると色が薄くなるよう実装されています。 @Composable fun Checkbox( ... enabled: Boolean = true, ... ) { ... } Text とか Image には enabled 設定は用意されていないので自分でがんばる必要があります。 方法としては
  • Modifier.alpha() を使う
  • LocalContentAlpha を指定する
  • Text の color に指定する色の alpha を変える
  • Image の alpha パラメータを指定する
などがあります。

Modifier.alpha() を使う

val alpha = if (enabled) LocalContentAlpha.current else ContentAlpha.disabled Column( modifier = Modifier.padding(16.dp).alpha(alpha) ) { Image(Icons.Default.Android, contentDescription = null) Icon(Icons.Default.Home, contentDescription = null) Text("Android") }

LocalContentAlpha を指定する

LocalContentAlpha はデフォルトのときの文字色と Icon の tint color に使われていますが、Image では使われていません。 val alpha = if (enabled) LocalContentAlpha.current else ContentAlpha.disabled CompositionLocalProvider(LocalContentAlpha provides alpha) { Column( modifier = Modifier.padding(16.dp) ) { Image(Icons.Default.Android, contentDescription = null) Icon(Icons.Default.Home, contentDescription = null) Text("Android") } }

Text, Icon の color, tint に指定する色の alpha を変える & Image の alpha パラメータを指定する

val alpha = if (enabled) LocalContentAlpha.current else ContentAlpha.disabled Column( modifier = Modifier.padding(16.dp) ) { Image( Icons.Default.Android, contentDescription = null, alpha = alpha ) Icon( Icons.Default.Home, contentDescription = null, tint = MaterialTheme.colors.primary.copy(alpha = alpha) ) Text( "Android", color = MaterialTheme.colors.primary.copy(alpha = alpha) ) }



2021年5月27日木曜日

Jetpack Compose で AutoSizeableTextView 的なのを作る

残念ながらオフィシャルでのサポートは(まだ)ない。

Text の onTextLayout で TextLayoutResult の hasVisualOverflow (didOverflowWidth および didOverflowHeight もある) が true だったら remember {} してる文字サイズを変更するという方法でそれっぽいのは作れるが、State が変わる回数が多い(最終的な textSize と maxTextSize の差が大きい)と文字サイズが変わるアニメーションみたいになってしまうのがつらい。 @Composable fun AutoSizeableText( text: String, maxTextSize: Int = 16, minTextSize: Int = 14, modifier: Modifier ) { var textSize by remember(text) { mutableStateOf(maxTextSize) } Text( text = text, fontSize = textSize.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = modifier, onTextLayout = { if (it.hasVisualOverflow && textSize > minTextSize) { textSize -= 1 } } ) }
↑だと1文字増減したときに maxTextSize からやり直しになるので、現在の文字サイズを覚えておいてそこから +/- するようにしたのが ↓ @Composable fun AutoSizeableText( text: String, maxTextSize: Int = 16, minTextSize: Int = 14, modifier: Modifier ) { var textSize by remember { mutableStateOf(maxTextSize) } val checked = remember(text) { mutableMapOf<Int, Boolean?>() } var overflow by remember { mutableStateOf(TextOverflow.Clip) } Text( text = text, fontSize = textSize.sp, maxLines = 1, overflow = overflow, modifier = modifier, onTextLayout = { if (it.hasVisualOverflow) { checked[textSize] = true if (textSize > minTextSize) { textSize -= 1 } else { overflow = TextOverflow.Ellipsis } } else { checked[textSize] = false if (textSize < maxTextSize) { if (checked[textSize + 1] == null) { textSize += 1 } } } } ) }
それでも State が変わる回数が多い(maxTextSize と minTextSize の差が大きくて、一度に長い文字をペーストするとか)と文字サイズが変わるアニメーションみたいになってしまうのがつらいんですよね〜。





Jetpack Compose で Spannable 的なことをしたいときは AnnotatedString を作る

buildAnnotatedString 関数が用意されている。 Text( text = buildAnnotatedString { append("By clicking blow you agree to our ") withStyle(style = SpanStyle(textDecoration = TextDecoration.Underline)) { append("Terms of Use") } append(" and consent \nto our ") withStyle(style = SpanStyle(textDecoration = TextDecoration.Underline)) { append("Privacy Policy") } append(".") }, ) SpanStyleParagraphStyle を指定する。 class SpanStyle( val color: Color = Color.Unspecified, val fontSize: TextUnit = TextUnit.Unspecified, val fontWeight: FontWeight? = null, val fontStyle: FontStyle? = null, val fontSynthesis: FontSynthesis? = null, val fontFamily: FontFamily? = null, val fontFeatureSettings: String? = null, val letterSpacing: TextUnit = TextUnit.Unspecified, val baselineShift: BaselineShift? = null, val textGeometricTransform: TextGeometricTransform? = null, val localeList: LocaleList? = null, val background: Color = Color.Unspecified, val textDecoration: TextDecoration? = null, val shadow: Shadow? = null ) { ... } class ParagraphStyle constructor( val textAlign: TextAlign? = null, val textDirection: TextDirection? = null, val lineHeight: TextUnit = TextUnit.Unspecified, val textIndent: TextIndent? = null ) { ... }


2021年5月26日水曜日

Jetpack Compose でフォーカスをクリアする

フォーカスをクリアするときは LocalFocusManager Composition Local から取得した FocusManager を使う val focusManager = LocalFocusManager.current Button(onClick = { focusManager.clearFocus() }) { Text("Button") }


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) } }