2021年12月16日木曜日

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

今まで
  1. val entryPoint = EntryPoints.get(activity, ActivityCreatorEntryPoint::class.java)  
EntryPointAccessors を使うと
  1. 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 を使うようにします。
  1. class Player {  
  2.   @field:Json(name = "lucky number") val luckyNumber: Int  
  3.   
  4.   ...  
  5. }  
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 される
  1. class MainActivity : ComponentActivity() {  
  2.   
  3.     override fun onCreate(savedInstanceState: Bundle?) {  
  4.         super.onCreate(savedInstanceState)  
  5.         setContent {  
  6.             ...  
  7.         }  
  8.   
  9.         if (savedInstanceState == null) {  
  10.             handleIntent(intent)  
  11.         }  
  12.     }  
  13.   
  14.     override fun onNewIntent(intent: Intent?) {  
  15.         super.onNewIntent(intent)  
  16.         intent?.let { handleIntent(it) }  
  17.     }  
  18.     
  19.     ...  
  20. }  

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

  1. class MainActivity : ComponentActivity() {  
  2.   
  3.     ...  
  4.   
  5.     private fun handleIntent(intent: Intent) {  
  6.         if (intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0) {  
  7.             return  
  8.         }  
  9.   
  10.         // intent の内容に応じて NavHostController.navigate() する  
  11.     }    
  12. }  
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() を使います。

  1. @Preview  
  2. @Composable  
  3. fun SampleScreen() {  
  4.     val density = LocalDensity.current  
  5.     val layoutDirection = LocalLayoutDirection.current  
  6.   
  7.     val bitmap = remember { mutableStateOf<Bitmap?>(null) }  
  8.   
  9.     Column {  
  10.         Button(  
  11.             onClick = {  
  12.                 bitmap.value = createBitmap(density, layoutDirection)  
  13.             },  
  14.             modifier = Modifier.padding(16.dp)  
  15.         ) {  
  16.             Text(text = "click")  
  17.         }  
  18.   
  19.         AndroidView(  
  20.             factory = { context ->  
  21.                 ImageView(context)  
  22.             },  
  23.             update = { imageView ->  
  24.                 imageView.setImageBitmap(bitmap.value)  
  25.             },  
  26.             modifier = Modifier.padding(16.dp)  
  27.         )  
  28.   
  29.         androidx.compose.foundation.Canvas(  
  30.             modifier = Modifier.padding(16.dp)  
  31.                 .size(with(LocalDensity.current) { 512.toDp() })  
  32.         ) {  
  33.             val bmp = bitmap.value  
  34.             if (bmp != null) {  
  35.                 drawImage(bmp.asImageBitmap())  
  36.   
  37.                 drawCircle(Color.White, radius = size.minDimension / 4f)  
  38.             }  
  39.         }  
  40.     }  
  41. }  
  42.   
  43. private fun createBitmap(  
  44.     density: Density,  
  45.     layoutDirection: LayoutDirection  
  46. ): Bitmap {  
  47.     val targetSize = 512  
  48.   
  49.     val imageBitmap = ImageBitmap(targetSize, targetSize)  
  50.     val size = Size(targetSize.toFloat(), targetSize.toFloat())  
  51.   
  52.     CanvasDrawScope().draw(density, layoutDirection, Canvas(imageBitmap), size) {  
  53.         drawCircle(Color.Red)  
  54.     }  
  55.   
  56.     return imageBitmap.asAndroidBitmap()  
  57. }  

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 あり/なしの比較をしたいので、共通する部分のコードを先に出しておきます。
  1. class Screen1ViewModel : ViewModel() {  
  2.   
  3.     val values: List<Int> = (0 until 20).map { Random.nextInt() }  
  4.   
  5.     init {  
  6.         println("Screen1ViewModel : created : $this")  
  7.     }  
  8.   
  9.     override fun onCleared() {  
  10.         println("Screen1ViewModel : cleared : $this")  
  11.     }  
  12. }  
  13.   
  14. class Screen2ViewModel : ViewModel() {  
  15.   
  16.     val value = Random.nextFloat()  
  17.   
  18.     init {  
  19.         println("Screen2ViewModel : created : $this")  
  20.     }  
  21.   
  22.     override fun onCleared() {  
  23.         println("Screen2ViewModel : cleared : $this")  
  24.     }  
  25. }  
  26.   
  27.   
  28. @Composable  
  29. fun Screen1(  
  30.     viewModel: Screen1ViewModel,  
  31.     onClickItem: (Int) -> Unit,  
  32. ) {  
  33.     DisposableEffect(Unit) {  
  34.         println("Screen1 : composed : viewModel = $viewModel")  
  35.         onDispose {  
  36.             println("Screen1 : disposed")  
  37.         }  
  38.     }  
  39.     LazyColumn(modifier = Modifier.fillMaxSize()) {  
  40.         items(viewModel.values) { value ->  
  41.             Text(  
  42.                 text = value.toString(),  
  43.                 modifier = Modifier  
  44.                     .fillMaxWidth()  
  45.                     .clickable {  
  46.                         onClickItem(value)  
  47.                     }  
  48.                     .padding(16.dp)  
  49.             )  
  50.         }  
  51.     }  
  52. }  
  53.   
  54. @Composable  
  55. fun Screen2(  
  56.     viewModel: Screen2ViewModel,  
  57.     value1: Int  
  58. ) {  
  59.     DisposableEffect(Unit) {  
  60.         println("Screen2 : composed : viewModel = $viewModel")  
  61.         onDispose {  
  62.             println("Screen2 : disposed")  
  63.         }  
  64.     }  
  65.     Text(  
  66.         text = "value1 = $value1, value2 = ${viewModel.value}",  
  67.         modifier = Modifier  
  68.             .fillMaxWidth()  
  69.             .padding(16.dp)  
  70.     )  
  71. }  
  72.   
  73.   
  74. class MainActivity : ComponentActivity() {  
  75.     override fun onCreate(savedInstanceState: Bundle?) {  
  76.         super.onCreate(savedInstanceState)  
  77.         setContent {  
  78.             MaterialTheme {  
  79.                 Surface(color = MaterialTheme.colors.background) {  
  80.                     MyApp()  
  81.                 }  
  82.             }  
  83.         }  
  84.     }  
  85. }  

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

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

viewModel() は androidx.lifecycle:lifecycle-viewmodel-compose に定義されている拡張関数で、次のようになっています。
  1. @Composable  
  2. public inline fun <reified VM : ViewModel> viewModel(  
  3.     viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {  
  4.         "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"  
  5.     },  
  6.     key: String? = null,  
  7.     factory: ViewModelProvider.Factory? = null  
  8. ): VM = viewModel(VM::class.java, viewModelStoreOwner, key, factory)  
  9.   
  10. @Composable  
  11. public fun <VM : ViewModel> viewModel(  
  12.     modelClass: Class<VM>,  
  13.     viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {  
  14.         "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"  
  15.     },  
  16.     key: String? = null,  
  17.     factory: ViewModelProvider.Factory? = null  
  18. ): VM = viewModelStoreOwner.get(modelClass, key, factory)  
  19.   
  20. private fun <VM : ViewModel> ViewModelStoreOwner.get(  
  21.     javaClass: Class<VM>,  
  22.     key: String? = null,  
  23.     factory: ViewModelProvider.Factory? = null  
  24. ): VM {  
  25.     val provider = if (factory != null) {  
  26.         ViewModelProvider(this, factory)  
  27.     } else {  
  28.         ViewModelProvider(this)  
  29.     }  
  30.     return if (key != null) {  
  31.         provider.get(key, javaClass)  
  32.     } else {  
  33.         provider.get(javaClass)  
  34.     }  
  35. }  
LocalViewModelStoreOwner.current から ViewModelStoreOwner を取得し、ViewModelProvider() にそれを渡して ViewModel のインスタンスを取得しています。

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

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

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

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

navigation-compose を使った場合どうなるのか確認してみましょう。
  1. @Composable  
  2. fun MyApp() {  
  3.     val navController = rememberNavController()  
  4.   
  5.     NavHost(navController = navController, startDestination = "Screen1") {  
  6.         composable(  
  7.             route = "Screen1"  
  8.         ) {  
  9.             println("Screen1 : ViewModelStoreOwner = ${LocalViewModelStoreOwner.current}")  
  10.             Screen1(viewModel()) {  
  11.                 navController.navigate("Screen2/$it")  
  12.             }  
  13.         }  
  14.         composable(  
  15.             route = "Screen2/{value}",  
  16.             arguments = listOf(navArgument("value") { type = NavType.IntType })  
  17.         ) {  
  18.             val value1 = requireNotNull(it.arguments).getInt("value")  
  19.             println("Screen2 : ViewModelStoreOwner = ${LocalViewModelStoreOwner.current}")  
  20.             Screen2(viewModel(), value1)  
  21.         }  
  22.     }  
  23. }  
  1. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  2. Screen1ViewModel : created : com.sample.myapplication.Screen1ViewModel@f79f3f3  
  3. Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3  
  4. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  5.   
  6. -- Screen1 の LazyColumn のアイテムをタップ  
  7.   
  8. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  9. Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@861e3e2a  
  10. Screen2ViewModel : created : com.sample.myapplication.Screen2ViewModel@7cf0642  
  11. Screen2 : composed : viewModel = com.sample.myapplication.Screen2ViewModel@7cf0642  
  12. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  13. Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@861e3e2a  
  14. Screen1 : disposed  
  15. Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@861e3e2a  
  16.   
  17. -- back キータップ   
  18.   
  19. Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@861e3e2a  
  20. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  21. Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3  
  22. Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@861e3e2a  
  23. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  24. Screen2ViewModel : cleared : com.sample.myapplication.Screen2ViewModel@7cf0642  
  25. Screen2 : disposed  
  26. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  27.   
  28. -- Screen1 の LazyColumn のアイテムをタップ  
  29.   
  30. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  31. Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@27c16dd1  
  32. Screen2ViewModel : created : com.sample.myapplication.Screen2ViewModel@96e0d3b  
  33. Screen2 : composed : viewModel = com.sample.myapplication.Screen2ViewModel@96e0d3b  
  34. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  35. Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@27c16dd1  
  36. Screen1 : disposed  
  37. Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@27c16dd1  
  38.   
  39. -- back キータップ   
  40.   
  41. Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@27c16dd1  
  42. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  43. Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3  
  44. Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@27c16dd1  
  45. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  46. Screen2ViewModel : cleared : com.sample.myapplication.Screen2ViewModel@96e0d3b  
  47. Screen2 : disposed  
  48. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  49.   
  50. -- back キータップ(アプリ終了)  
  51.   
  52. Screen1 : disposed  
  53. Screen1ViewModel : cleared : com.sample.myapplication.Screen1ViewModel@f79f3f3  
LocalViewModelStoreOwner には MainActivity ではなく NavBackStackEntry が入っていることがわかりました。
ViewModelStoreOwner の部分を省くと
  1. Screen1ViewModel : created : com.sample.myapplication.Screen1ViewModel@f79f3f3  
  2. Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3  
  3.   
  4. -- Screen1 の LazyColumn のアイテムをタップ  
  5.   
  6. Screen2ViewModel : created : com.sample.myapplication.Screen2ViewModel@7cf0642  
  7. Screen2 : composed : viewModel = com.sample.myapplication.Screen2ViewModel@7cf0642  
  8. Screen1 : disposed  
  9.   
  10. -- back キータップ = popBackstack  
  11.   
  12. Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3  
  13. Screen2ViewModel : cleared : com.sample.myapplication.Screen2ViewModel@7cf0642  
  14. Screen2 : disposed  
  15.   
  16. -- Screen1 の LazyColumn のアイテムをタップ  
  17.   
  18. Screen2ViewModel : created : com.sample.myapplication.Screen2ViewModel@96e0d3b  
  19. Screen2 : composed : viewModel = com.sample.myapplication.Screen2ViewModel@96e0d3b  
  20. Screen1 : disposed  
  21.   
  22. -- back キータップ = popBackstack  
  23.   
  24. Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3  
  25. Screen2ViewModel : cleared : com.sample.myapplication.Screen2ViewModel@96e0d3b  
  26. Screen2 : disposed  
  27.   
  28. -- back キータップ(アプリ終了)  
  29.   
  30. Screen1 : disposed  
  31. Screen1ViewModel : cleared : com.sample.myapplication.Screen1ViewModel@f79f3f3  
Screen1 → Screen2 → Screen1 のとき(Screen2 が backstack からいなくなった)に Screen2ViewModel が破棄(onCleared)され、再度 Screen2 に遷移したときに別の Screen2ViewModel インスタンスが作られていることがわかります。

LocalViewModelStoreOwner へのセット

navigation-compose では NavHost composable で LocalViewModelStoreOwner に NavBackStackEntry をセットしています。
  1. @Composable  
  2. public fun NavBackStackEntry.LocalOwnersProvider(  
  3.     saveableStateHolder: SaveableStateHolder,  
  4.     content: @Composable () -> Unit  
  5. ) {  
  6.     CompositionLocalProvider(  
  7.         LocalViewModelStoreOwner provides this,  
  8.         LocalLifecycleOwner provides this,  
  9.         LocalSavedStateRegistryOwner provides this  
  10.     ) {  
  11.         saveableStateHolder.SaveableStateProvider(content)  
  12.     }  
  13. }  
  14.     
  15. @Composable  
  16. public fun NavHost(  
  17.     navController: NavHostController,  
  18.     graph: NavGraph,  
  19.     modifier: Modifier = Modifier  
  20. ) {  
  21.     ...  
  22.     if (backStackEntry != null) {  
  23.         ...  
  24.         Crossfade(backStackEntry.id, modifier) {  
  25.             val lastEntry = transitionsInProgress.lastOrNull { entry ->  
  26.                 it == entry.id  
  27.             } ?: backStack.lastOrNull { entry ->  
  28.                 it == entry.id  
  29.             }  
  30.   
  31.             lastEntry?.LocalOwnersProvider(saveableStateHolder) {  
  32.                 (lastEntry.destination as ComposeNavigator.Destination).content(lastEntry)  
  33.             }  
  34.             ...  
  35.         }  
  36.     }  
  37.   
  38.     ...  
  39. }  
backStackEntry には backstack の一番最後のものが入ります。

NavBackStackEntry の ViewModelStoreOwner 実装

では NavBackStackEntry が ViewModelStoreOwner をどのように実装しているのか見てみましょう。
  1. public class NavBackStackEntry private constructor(  
  2.     ...  
  3.     private val viewModelStoreProvider: NavViewModelStoreProvider? = null,  
  4.     ...  
  5. ) : LifecycleOwner,  
  6.     ViewModelStoreOwner,  
  7.     HasDefaultViewModelProviderFactory,  
  8.     SavedStateRegistryOwner {  
  9.   
  10.     ...  
  11.     
  12.     public override fun getViewModelStore(): ViewModelStore {  
  13.         ...  
  14.         checkNotNull(viewModelStoreProvider) {  
  15.              ...  
  16.         }  
  17.         return viewModelStoreProvider.getViewModelStore(id)  
  18.     }    
  19. }  
NavBackStackEntry の ViewModelStoreOwner 実装では NavViewModelStoreProvider から ViewModelStore を取得しています。

NavViewModelStoreProvider は interface で、backStackEntryId: String から ViewModelStore を返すメソッドが定義されています。
  1. @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)  
  2. public interface NavViewModelStoreProvider {  
  3.     public fun getViewModelStore(backStackEntryId: String): ViewModelStore  
  4. }  
NavViewModelStoreProvider を実装しているのは NavControllerViewModel です。
  1. internal class NavControllerViewModel : ViewModel(), NavViewModelStoreProvider {  
  2.     private val viewModelStores = mutableMapOf<String, ViewModelStore>()  
  3.   
  4.     fun clear(backStackEntryId: String) {  
  5.         // Clear and remove the NavGraph's ViewModelStore  
  6.         val viewModelStore = viewModelStores.remove(backStackEntryId)  
  7.         viewModelStore?.clear()  
  8.     }  
  9.   
  10.     override fun onCleared() {  
  11.         for (store in viewModelStores.values) {  
  12.             store.clear()  
  13.         }  
  14.         viewModelStores.clear()  
  15.     }  
  16.   
  17.     override fun getViewModelStore(backStackEntryId: String): ViewModelStore {  
  18.         var viewModelStore = viewModelStores[backStackEntryId]  
  19.         if (viewModelStore == null) {  
  20.             viewModelStore = ViewModelStore()  
  21.             viewModelStores[backStackEntryId] = viewModelStore  
  22.         }  
  23.         return viewModelStore  
  24.     }  
  25.   
  26.     ...  
  27.   
  28.     companion object {  
  29.         private val FACTORY: ViewModelProvider.Factory = object : ViewModelProvider.Factory {  
  30.             @Suppress("UNCHECKED_CAST")  
  31.             override fun <T : ViewModel> create(modelClass: Class<T>): T {  
  32.                 return NavControllerViewModel() as T  
  33.             }  
  34.         }  
  35.   
  36.         @JvmStatic  
  37.         fun getInstance(viewModelStore: ViewModelStore): NavControllerViewModel {  
  38.             val viewModelProvider = ViewModelProvider(viewModelStore, FACTORY)  
  39.             return viewModelProvider.get()  
  40.         }  
  41.     }  
  42. }  
NavControllerViewModel は backStackEntryId: String と ViewModelStore の Map を持っています。

NavControllerViewModel は NavHost composable が呼び出される時点での LocalViewModelStoreOwner にセットされている ViewModelStoreOwner を使って生成され、NavController にセットされます。
  1. @Composable  
  2. public fun NavHost(  
  3.     navController: NavHostController,  
  4.     graph: NavGraph,  
  5.     modifier: Modifier = Modifier  
  6. ) {  
  7.     val lifecycleOwner = LocalLifecycleOwner.current  
  8.     val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {  
  9.         "NavHost requires a ViewModelStoreOwner to be provided via LocalViewModelStoreOwner"  
  10.     }  
  11.   
  12.     ...  
  13.   
  14.     navController.setViewModelStore(viewModelStoreOwner.viewModelStore)  
  15.   
  16.     ...  
  17. }  
  18.   
  19. public open class NavHostController(context: Context) : NavController(context) {  
  20.     ...  
  21.     public final override fun setViewModelStore(viewModelStore: ViewModelStore) {  
  22.         super.setViewModelStore(viewModelStore)  
  23.     }  
  24. }  
  25.   
  26.     
  27. public open class NavController(  
  28.     ...  
  29. ) {  
  30.     ...  
  31.   
  32.     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)  
  33.     public open fun setViewModelStore(viewModelStore: ViewModelStore) {  
  34.         if (viewModel == NavControllerViewModel.getInstance(viewModelStore)) {  
  35.             return  
  36.         }  
  37.         check(backQueue.isEmpty()) { "ViewModelStore should be set before setGraph call" }  
  38.         viewModel = NavControllerViewModel.getInstance(viewModelStore)  
  39.     }  
  40.     ...  
  41. }  
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
  1. @HiltAndroidApp  
  2. class MyApplication : Application()  
  3.   
  4. @AndroidEntryPoint  
  5. class MainActivity : ComponentActivity() {  
  6.     override fun onCreate(savedInstanceState: Bundle?) {  
  7.         super.onCreate(savedInstanceState)  
  8.         setContent {  
  9.             MaterialTheme {  
  10.                 Surface(color = MaterialTheme.colors.background) {  
  11.                     MyApp()  
  12.                 }  
  13.             }  
  14.         }  
  15.     }  
  16. }  
  17.   
  18. @Composable  
  19. fun MyApp() {  
  20.     val navController = rememberNavController()  
  21.   
  22.     NavHost(navController, startDestination = "Screen1") {  
  23.   
  24.         composable("Screen1") {  
  25.             Screen1(hiltViewModel()) {  
  26.                 navController.navigate("Screen2/$it")  
  27.             }  
  28.         }  
  29.   
  30.         composable(  
  31.             route = "Screen2/{value}",  
  32.             arguments = listOf(  
  33.                 navArgument("value") { type = NavType.IntType },  
  34.             )  
  35.         ) {  
  36.             val arguments = requireNotNull(it.arguments)  
  37.             val value = arguments.getInt("value")  
  38.   
  39.             Screen2(value)  
  40.         }  
  41.     }  
  42. }  
  43.   
  44. @Composable  
  45. fun Screen1(  
  46.     viewModel: Screen1ViewModel,  
  47.     onClickItem: (Int) -> Unit,  
  48. ) {  
  49.     LazyColumn(modifier = Modifier.fillMaxSize()) {  
  50.         items(viewModel.values) { value ->  
  51.             Text(  
  52.                 text = value.toString(),  
  53.                 modifier = Modifier  
  54.                     .fillMaxWidth()  
  55.                     .clickable {  
  56.                         onClickItem(value)  
  57.                     }  
  58.                     .padding(16.dp)  
  59.             )  
  60.         }  
  61.     }  
  62. }  
  63.   
  64. @Composable  
  65. fun Screen2(value: Int) {  
  66.     Text(  
  67.         text = "value = $value",  
  68.         modifier = Modifier  
  69.             .fillMaxWidth()  
  70.             .padding(16.dp)  
  71.     )  
  72. }  
  73.   
  74. @HiltViewModel  
  75. class Screen1ViewModel @Inject constructor(  
  76.     valueProvider: ValueProvider  
  77. ) : ViewModel() {  
  78.   
  79.     val values: List<Int> = valueProvider.getValues()  
  80.   
  81. }  
  82.   
  83. interface ValueProvider {  
  84.   
  85.     fun getValues(): List<Int>  
  86. }  
  87.   
  88. class DefaultValueProvider @Inject constructor() : ValueProvider {  
  89.   
  90.     override fun getValues(): List<Int> = (0 until 20).map { Random.nextInt() }  
  91. }  
  92.   
  93. @Module  
  94. @InstallIn(SingletonComponent::class)  
  95. interface ValueProviderModule {  
  96.   
  97.     @Binds  
  98.     @Singleton  
  99.     fun bindValueProvider(provider: DefaultValueProvider): ValueProvider  
  100. }  


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
  1. dependencies {  
  2.     ...  
  3.   
  4.     androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"  
  5.     debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"  
  6.   
  7.     androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"  
  8.     kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version"  
  9. }  

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
  1. class CustomTestRunner : AndroidJUnitRunner() {  
  2.   
  3.     override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {  
  4.         return super.newApplication(cl, HiltTestApplication::class.java.name, context)  
  5.     }  
  6. }  
build.gradle (app)
  1. android {  
  2.     ...  
  3.   
  4.     defaultConfig {  
  5.         ...  
  6.   
  7.         testInstrumentationRunner "com.sample.myapplication.CustomTestRunner"  
  8.     }  
  9.     
  10.     ...  
  11. }  
この設定をしないと

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
  1. @AndroidEntryPoint  
  2. class HiltTestActivity : ComponentActivity()  
app/src/debug/AndroidManifest.xml
  1. <?xml version="1.0" encoding="utf-8"?>  
  2. <manifest xmlns:android="http://schemas.android.com/apk/res/android"  
  3.     package="com.sample.myapplication">  
  4.   
  5.     <application>  
  6.         <activity  
  7.             android:name=".HiltTestActivity"  
  8.             android:exported="false" />  
  9.     </application>  
  10.   
  11. </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 に書いてある。
  1. @UninstallModules(ValueProviderModule::class)  
  2. @HiltAndroidTest  
  3. class MyAppTest {  
  4.   
  5.     @get:Rule(order = 0)  
  6.     val hiltRule = HiltAndroidRule(this)  
  7.   
  8.     @get:Rule(order = 1)  
  9.     val composeTestRule = createAndroidComposeRule<HiltTestActivity>()  
  10.   
  11.     @BindValue  
  12.     val repository: ValueProvider = object : ValueProvider {  
  13.         override fun getValues(): List<Int> {  
  14.             return listOf(100)  
  15.         }  
  16.     }  
  17.   
  18.     @Test  
  19.     fun test() {  
  20.         composeTestRule.setContent {  
  21.             MaterialTheme {  
  22.                 Surface(color = MaterialTheme.colors.background) {  
  23.                     MyApp()  
  24.                 }  
  25.             }  
  26.         }  
  27.   
  28.         // Screen1 に 100 が表示されている  
  29.         composeTestRule.onNodeWithText("100").assertIsDisplayed()  
  30.   
  31.         // Screen1 の 100 が表示されている Node をクリック  
  32.         composeTestRule.onNodeWithText("100").performClick()  
  33.   
  34.         // Screen2 に value = 100 が表示されている  
  35.         composeTestRule.onNodeWithText("value = 100").assertIsDisplayed()  
  36.     }  
  37. }  




2021年8月23日月曜日

Jetpack Compose + ViewModel + Navigation + Hilt + AssistedInject

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


ViewModelExt.kt
  1. @Composable  
  2. inline fun <reified VM : ViewModel> assistedViewModel(  
  3.     viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {  
  4.         "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"  
  5.     },  
  6.     crossinline viewModelProducer: (SavedStateHandle) -> VM  
  7. ): VM {  
  8.     val factory = if (viewModelStoreOwner is NavBackStackEntry) {  
  9.         object : AbstractSavedStateViewModelFactory(viewModelStoreOwner, viewModelStoreOwner.arguments) {  
  10.             @Suppress("UNCHECKED_CAST")  
  11.             override fun <T : ViewModel?> create(key: String, modelClass: Class<T>, handle: SavedStateHandle): T {  
  12.                 return viewModelProducer(handle) as T  
  13.             }  
  14.         }  
  15.     } else {  
  16.         // Use the default factory provided by the ViewModelStoreOwner  
  17.         // and assume it is an @AndroidEntryPoint annotated fragment or activity  
  18.         null  
  19.     }  
  20.     return viewModel(viewModelStoreOwner, factory = factory)  
  21. }  
  22.   
  23. fun Context.extractActivity(): Activity {  
  24.     var ctx = this  
  25.     while (ctx is ContextWrapper) {  
  26.         if (ctx is Activity) {  
  27.             return ctx  
  28.         }  
  29.         ctx = ctx.baseContext  
  30.     }  
  31.     throw IllegalStateException(  
  32.         "Expected an activity context for creating a HiltViewModelFactory for a " +  
  33.                 "NavBackStackEntry but instead found: $ctx"  
  34.     )  
  35. }  
MainActivity.kt
  1. @HiltAndroidApp  
  2. class MyApplication : Application()  
  3.   
  4. @AndroidEntryPoint  
  5. class MainActivity : ComponentActivity() {  
  6.     override fun onCreate(savedInstanceState: Bundle?) {  
  7.         super.onCreate(savedInstanceState)  
  8.         setContent {  
  9.             MaterialTheme {  
  10.                 Surface(color = MaterialTheme.colors.background) {  
  11.                     MyApp()  
  12.                 }  
  13.             }  
  14.         }  
  15.     }  
  16. }  
  17.   
  18. @Composable  
  19. fun MyApp() {  
  20.     val navController = rememberNavController()  
  21.   
  22.     NavHost(navController, startDestination = "Screen1") {  
  23.   
  24.         composable("Screen1") {  
  25.             LazyColumn(modifier = Modifier.fillMaxSize()) {  
  26.                 items(20) {  
  27.                     Text(  
  28.                         text = "Item : $it",  
  29.                         modifier = Modifier  
  30.                             .fillMaxWidth()  
  31.                             .clickable {  
  32.                                 navController.navigate("Screen2/$it")  
  33.                             }  
  34.                             .padding(16.dp)  
  35.                     )  
  36.                 }  
  37.             }  
  38.         }  
  39.   
  40.         composable(  
  41.             route = "Screen2/{id}",  
  42.             arguments = listOf(  
  43.                 navArgument("id") { type = NavType.IntType },  
  44.             )  
  45.         ) {  
  46.             val arguments = requireNotNull(it.arguments)  
  47.             val id = arguments.getInt("id")  
  48.             val viewModel = assistedViewModel { savedStateHandle ->  
  49.                 Screen2ViewModel.provideFactory(LocalContext.current)  
  50.                     .create(savedStateHandle, id)  
  51.             }  
  52.   
  53.             Screen2(viewModel)  
  54.         }  
  55.     }  
  56. }  
  57.   
  58. @Composable  
  59. fun Screen2(viewModel: Screen2ViewModel) {  
  60.     Text(  
  61.         text = viewModel.greet(),  
  62.         modifier = Modifier.padding(24.dp)  
  63.     )  
  64. }  
  65.   
  66. class Screen2ViewModel @AssistedInject constructor(  
  67.     private val nameProvider: NameProvider,  
  68.     @Assisted private val savedStateHandle: SavedStateHandle,  
  69.     @Assisted private val id: Int  
  70. ) : ViewModel() {  
  71.   
  72.     @AssistedFactory  
  73.     interface Factory {  
  74.         fun create(savedStateHandle: SavedStateHandle, id: Int): Screen2ViewModel  
  75.     }  
  76.   
  77.     @EntryPoint  
  78.     @InstallIn(ActivityComponent::class)  
  79.     interface ActivityCreatorEntryPoint {  
  80.         fun getScreen2ViewModelFactory(): Factory  
  81.     }  
  82.   
  83.     companion object {  
  84.         fun provideFactory(context: Context): Factory {  
  85.             val activity = context.extractActivity()  
  86.             return EntryPoints.get(activity, ActivityCreatorEntryPoint::class.java)  
  87.                 .getScreen2ViewModelFactory()  
  88.         }  
  89.     }  
  90.   
  91.     fun greet(): String {  
  92.         return "Hello ${nameProvider.name()} : id = $id"  
  93.     }  
  94. }  
  95.   
  96. @Singleton  
  97. class NameProvider @Inject constructor() {  
  98.   
  99.     fun name(): String {  
  100.         return "Android"  
  101.     }  
  102. }  
Issue (https://github.com/google/dagger/issues/2287) は 1月からあるけど、進んでなさそう。


2021年7月26日月曜日

Jetpack Compose : 点線を描画する

点線を描画するには PathEffect.dashPathEffect() を使う
  1. Canvas {  
  2.     drawRoundRect(  
  3.         color = color,  
  4.         cornerRadius = radius,  
  5.         style = Stroke(  
  6.             width = strokeWidth,  
  7.             pathEffect = PathEffect.dashPathEffect(  
  8.                 intervals = floatArrayOf(onInterval, offInterval),  
  9.                 phase = onInterval + offInterval,  
  10.             )  
  11.         )  
  12.     )  
  13. }  


2021年7月1日木曜日

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

Modifier.weight() には float 値の他に fill するかどうかの boolean パラメータを指定できます。
  1. @Stable  
  2. fun Modifier.weight(  
  3.     weight: Float,  
  4.     fill: Boolean = true  
  5. ): Modifier    
fill に true を指定すると、割り当てられた領域を占めるように配置されます。
fill に false を指定すると、割り当てられた領域より小さくなることができます。使わなかった領域は他のところに割り当てられません。
  1. @Preview  
  2. @Composable  
  3. fun Sample() {  
  4.     Column(modifier = Modifier.height(300.dp)) {  
  5.         Column(  
  6.             modifier = Modifier.weight(1f, true)  
  7.         ) {  
  8.             Text("タイトル")  
  9.             Text("メッセージ")  
  10.         }  
  11.         Button(  
  12.             modifier = Modifier.weight(1f),  
  13.             onClick = { /*TODO*/ },  
  14.         ) {  
  15.             Text("Button")  
  16.         }  
  17.     }  
  18. }  
  19.   
  20. @Preview  
  21. @Composable  
  22. fun Sample2() {  
  23.     Column(modifier = Modifier.height(300.dp)) {  
  24.         Column(  
  25.             modifier = Modifier.weight(1f, false)  
  26.         ) {  
  27.             Text("タイトル")  
  28.             Text("メッセージ")  
  29.         }  
  30.         Button(  
  31.             modifier = Modifier.weight(1f),  
  32.             onClick = { /*TODO*/ },  
  33.         ) {  
  34.             Text("Button")  
  35.         }  
  36.     }  
  37. }  
  38.   
  39. @Preview  
  40. @Composable  
  41. fun Sample3() {  
  42.     Column(modifier = Modifier.height(300.dp)) {  
  43.         Column(  
  44.             modifier = Modifier  
  45.         ) {  
  46.             Text("タイトル")  
  47.             Text("メッセージ")  
  48.         }  
  49.         Button(  
  50.             modifier = Modifier.weight(1f),  
  51.             onClick = { /*TODO*/ },  
  52.         ) {  
  53.             Text("Button")  
  54.         }  
  55.     }  
  56. }  
Sample2 では fill に false を指定しています。これにより "タイトル" と "メッセージ" を含む Column は 150dp より小さくなります。Sample3 と違い、Button の大きさを計算するときに考慮されるため Button の大きさは 150dp になります。

2021年6月30日水曜日

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

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

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

* accompanist-insets がちゃんと動くために必要
  1. class RelocationRequesterSampleActivity : ComponentActivity() {  
  2.   
  3.     override fun onCreate(savedInstanceState: Bundle?) {  
  4.         super.onCreate(savedInstanceState)  
  5.   
  6.         WindowCompat.setDecorFitsSystemWindows(window, false)  
  7.   
  8.         setContent {  
  9.             MaterialTheme {  
  10.                 ProvideWindowInsets {  
  11.                     RelocationRequesterSample()  
  12.                 }  
  13.             }  
  14.         }  
  15.     }  
  16. }  
4. RelocationRequester を使う
  1. @OptIn(ExperimentalComposeUiApi::class)  
  2. @Composable  
  3. fun RelocationRequesterSample() {  
  4.     Column(  
  5.         modifier = Modifier  
  6.             .fillMaxSize()  
  7.             .statusBarsPadding()  
  8.             .navigationBarsWithImePadding()  
  9.             .verticalScroll(rememberScrollState())  
  10.             .padding(24.dp)  
  11.     ) {  
  12.         Spacer(  
  13.             modifier = Modifier  
  14.                 .fillMaxSize()  
  15.                 .height(600.dp)  
  16.                 .background(color = Color.LightGray)  
  17.         )  
  18.   
  19.         Spacer(modifier = Modifier.height(24.dp))  
  20.   
  21.         val relocationRequester = remember { RelocationRequester() }  
  22.         val interactionSource = remember { MutableInteractionSource() }  
  23.         val isFocused by interactionSource.collectIsFocusedAsState()  
  24.   
  25.         val ime = LocalWindowInsets.current.ime  
  26.         if (ime.isVisible && !ime.animationInProgress && isFocused) {  
  27.             LaunchedEffect(Unit) {  
  28.                 relocationRequester.bringIntoView()  
  29.             }  
  30.         }  
  31.   
  32.         var value by remember { mutableStateOf("") }  
  33.   
  34.         OutlinedTextField(  
  35.             value = value,  
  36.             onValueChange = { value = it },  
  37.             interactionSource = interactionSource,  
  38.             modifier = Modifier.relocationRequester(relocationRequester)  
  39.         )  
  40.     }  
  41. }  
ちなみに、 relocationRequester.bringIntoView() 部分をコメントアウトするとこうなる



参考


2021年6月28日月曜日

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

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


2021年6月21日月曜日

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

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

rememberLauncherForActivityResult() を使います。
  1. enum class PermissionState {  
  2.     Checking,  
  3.     Granted,  
  4.     Denied,  
  5. }  
  6.   
  7. @Composable  
  8. private fun NeedPermissionScreen() {  
  9.     var state by remember { mutableStateOf(PermissionState.Checking) }  
  10.   
  11.     val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {  
  12.         state = if (it) PermissionState.Granted else PermissionState.Denied  
  13.     }  
  14.   
  15.     val permission = Manifest.permission.CAMERA  
  16.   
  17.     val context = LocalContext.current  
  18.     val lifecycleObserver = remember {  
  19.         LifecycleEventObserver { _, event ->  
  20.             if (event == Lifecycle.Event.ON_START) {  
  21.                 val result = context.checkSelfPermission(permission)  
  22.                 if (result != PackageManager.PERMISSION_GRANTED) {  
  23.                     state = PermissionState.Checking  
  24.                     launcher.launch(permission)  
  25.                 } else {  
  26.                     state = PermissionState.Granted  
  27.                 }  
  28.             }  
  29.         }  
  30.     }  
  31.     val lifecycle = LocalLifecycleOwner.current.lifecycle  
  32.     DisposableEffect(lifecycle, lifecycleObserver) {  
  33.         lifecycle.addObserver(lifecycleObserver)  
  34.         onDispose {  
  35.             lifecycle.removeObserver(lifecycleObserver)  
  36.         }  
  37.     }  
  38.   
  39.     when (state) {  
  40.         PermissionState.Checking -> {  
  41.         }  
  42.         PermissionState.Granted -> {  
  43.             // TODO パーミッションが必要な機能を使う画面  
  44.         }  
  45.         PermissionState.Denied -> {  
  46.             // TODO 拒否された時の画面  
  47.         }  
  48.     }  
  49. }  


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

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

2021年6月10日木曜日

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

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

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

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




2021年6月1日火曜日

Kotlin の sealed interface が必要になる例

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


ViewModel で持ってる LiveData を Activity で observe してるとする。
  1. class MainActivity : AppCompatActivity() {  
  2.   
  3.     private val viewModel: MainViewModel by viewModels()  
  4.   
  5.     override fun onCreate(savedInstanceState: Bundle?) {  
  6.         super.onCreate(savedInstanceState)  
  7.         setContentView(R.layout.activity_main)  
  8.   
  9.         viewModel.state.observe(this) {  
  10.             when (it) {  
  11.                 is State.Data -> {  
  12.                     println(it.value)  
  13.                 }  
  14.                 State.Loading -> {  
  15.   
  16.                 }  
  17.             }  
  18.         }  
  19.     }  
  20. }  
  1. class MainViewModel : ViewModel() {  
  2.   
  3.     private val _state = MutableLiveData<State>()  
  4.     val state: LiveData<State>  
  5.         get() = _state  
  6.   
  7.     fun doSomething() {  
  8.         val state = _state.value  
  9.         if (state is State.Data) {  
  10.             state.mutableValue = Random.nextInt()  
  11.         }  
  12.     }  
  13. }  
  14.   
  15. sealed class State {  
  16.     object Loading : State()  
  17.   
  18.     data class Data(val id: String) : State() {  
  19.         // 変更できるのは MainViewModel からだけにしたいが、  
  20.         // private にすると MainViewModel からも見えなくなる  
  21.         var mutableValue: Int = -1  
  22.   
  23.         val value: Int  
  24.             get() = mutableValue  
  25.     }  
  26. }  
↑ State.Data が持つ mutableValue は MainViewModel からのみ変更できるようにしたい。

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


ということで sealed interface を使います。
  1. class MainViewModel : ViewModel() {  
  2.   
  3.     private val _state = MutableLiveData<State>()  
  4.     val state: LiveData<State>  
  5.         get() = _state  
  6.   
  7.     fun doSomething() {  
  8.         val state = _state.value  
  9.         if (state is DataImpl) {  
  10.             state.mutableValue = Random.nextInt()  
  11.         }  
  12.     }  
  13. }  
  14.   
  15. sealed interface State  
  16.   
  17. object Loading : State  
  18.   
  19. sealed interface Data : State {  
  20.     val value: Int  
  21. }  
  22.   
  23. private class DataImpl(val id: Int) : Data {  
  24.     // private class なので変更できるのは同じファイルからだけ  
  25.     var mutableValue: Int = id  
  26.   
  27.     override val value: Int  
  28.         get() = mutableValue  
  29. }  


2021年5月28日金曜日

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

通知が有効になっているかどうか調べる処理 (NotificationManagerCompat.from(context).areNotificationsEnabled()) を毎 onResume で行い場合はこんな感じになる。
  1. @Composable  
  2. fun SampleScreen(  
  3.     notificationEnabled: Boolean,  
  4.     onCheckNotification: () -> Unit  
  5. ) {  
  6.     val lifecycleObserver = remember(onCheckNotification) {  
  7.         LifecycleEventObserver { _, event ->  
  8.             if (event == Lifecycle.Event.ON_RESUME) {  
  9.                 onCheckNotification()  
  10.             }  
  11.         }  
  12.     }  
  13.     val lifecycle = LocalLifecycleOwner.current.lifecycle  
  14.     DisposableEffect(lifecycle) {  
  15.         lifecycle.addObserver(lifecycleObserver)  
  16.         onDispose {  
  17.             lifecycle.removeObserver(lifecycleObserver)  
  18.         }  
  19.     }  
  20.   
  21.     if (!notificationEnabled) {  
  22.         Text("通知設定がオフです")  
  23.     }  
  24. }  
state hoisting だとこんな感じだけど、引数 ViewModel にするならこんな感じ。
  1. @Composable  
  2. fun SampleScreen(  
  3.     viewModel: SampleViewModel = viewModel(),  
  4. ) {  
  5.     val lifecycleObserver = remember(viewModel) {  
  6.         LifecycleEventObserver { _, event ->  
  7.             if (event == Lifecycle.Event.ON_RESUME) {  
  8.                 viewModel.updateNotificationEnabledState()  
  9.             }  
  10.         }  
  11.     }  
  12.     val lifecycle = LocalLifecycleOwner.current.lifecycle  
  13.     DisposableEffect(lifecycle) {  
  14.         lifecycle.addObserver(lifecycleObserver)  
  15.         onDispose {  
  16.             lifecycle.removeObserver(lifecycleObserver)  
  17.         }  
  18.     }  
  19.   
  20.     if (!viewModel.notificationEnabled.value) {  
  21.         Text("通知設定がオフです")  
  22.     }  
  23. }  



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

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

Modifier.alpha() を使う

  1. val alpha = if (enabled) LocalContentAlpha.current else ContentAlpha.disabled  
  2.   
  3. Column(  
  4.     modifier = Modifier.padding(16.dp).alpha(alpha)  
  5. ) {  
  6.     Image(Icons.Default.Android, contentDescription = null)  
  7.     Icon(Icons.Default.Home, contentDescription = null)  
  8.     Text("Android")  
  9. }  

LocalContentAlpha を指定する

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

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

  1. val alpha = if (enabled) LocalContentAlpha.current else ContentAlpha.disabled  
  2.   
  3. Column(  
  4.     modifier = Modifier.padding(16.dp)  
  5. ) {  
  6.     Image(  
  7.         Icons.Default.Android,   
  8.         contentDescription = null,  
  9.         alpha = alpha  
  10.     )  
  11.     Icon(  
  12.         Icons.Default.Home,   
  13.         contentDescription = null,  
  14.         tint = MaterialTheme.colors.primary.copy(alpha = alpha)  
  15.     )  
  16.     Text(  
  17.         "Android",  
  18.         color = MaterialTheme.colors.primary.copy(alpha = alpha)  
  19.     )  
  20. }  



2021年5月27日木曜日

Jetpack Compose で AutoSizeableTextView 的なのを作る

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

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





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

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



2021年5月26日水曜日

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

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



2021年5月14日金曜日

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

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


inline class のときのコード
  1. @Serializable  
  2. inline class ItemId(val value: String)  
  3.   
  4. @Serializable  
  5. data class Item(val id: ItemId, val name: String)  
  6.   
  7. fun main() {  
  8.     val item = Item(ItemId("1"), "Android")  
  9.     val json = Json.encodeToString(item)  
  10.     println(json)  
  11.     println(Json.decodeFromString<Item>(json))  
  12. }  
value class のときのコード
  1. @Serializable  
  2. @JvmInline  
  3. value class ItemId(val value: String)  
  4.   
  5. @Serializable  
  6. data class Item(val id: ItemId, val name: String)  
  7.   
  8. fun main() {  
  9.     val item = Item(ItemId("1"), "Android")  
  10.     val json = Json.encodeToString(item)  
  11.     println(json)  
  12.     println(Json.decodeFromString<Item>(json))  
  13. }  

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() とかも呼び出せます。
  1. @Composable  
  2. fun CircleProgress(  
  3.     progress: Int,  
  4.     modifier: Modifier,  
  5.     colorProgress: Color = MaterialTheme.colors.primary,  
  6.     colorBackground: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.12f)  
  7.         .compositeOver(MaterialTheme.colors.surface),  
  8.     strokeWidth: Dp = 8.dp,  
  9. ) {  
  10.     Canvas(modifier = modifier) {  
  11.         val stroke = Stroke(width = strokeWidth.toPx(), cap = StrokeCap.Round)  
  12.   
  13.         val diameter = min(size.width, size.height) - stroke.width  
  14.         val topLeft = Offset((size.width - diameter) / 2, (size.height - diameter) / 2)  
  15.         val circleSize = Size(diameter, diameter)  
  16.   
  17.         drawArc(  
  18.             color = colorBackground,  
  19.             startAngle = -90f,  
  20.             sweepAngle = 360f,  
  21.             useCenter = false,  
  22.             style = stroke,  
  23.             topLeft = topLeft,  
  24.             size = circleSize,  
  25.         )  
  26.   
  27.         drawArc(  
  28.             color = colorProgress,  
  29.             startAngle = -90f,  
  30.             sweepAngle = 360f / 100 * progress,  
  31.             useCenter = false,  
  32.             style = stroke,  
  33.             topLeft = topLeft,  
  34.             size = circleSize,  
  35.         )  
  36.     }  
  37. }  
  38.   
  39. @Preview  
  40. @Composable  
  41. fun CircleProgressPreview() {  
  42.     var progress by remember { mutableStateOf(0) }  
  43.     val animateProgress by animateIntAsState(targetValue = progress, animationSpec = tween())  
  44.   
  45.     Column(  
  46.         horizontalAlignment = Alignment.CenterHorizontally,  
  47.         modifier = Modifier.padding(16.dp)  
  48.     ) {  
  49.         Button(onClick = {  
  50.             progress = Random.nextInt(0101)  
  51.         }) {  
  52.             Text("Change Progress")  
  53.         }  
  54.   
  55.         Spacer(modifier = Modifier.height(16.dp))  
  56.   
  57.         CircleProgress(  
  58.             progress = animateProgress,  
  59.             modifier = Modifier.size(120.dp)  
  60.         )  
  61.     }  
  62. }  
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。
  1. @Composable  
  2. fun IconToggleButton(  
  3.     imageVector: ImageVector,  
  4.     contentDescription: String?,  
  5.     checked: Boolean,  
  6.     onCheckedChange: (Boolean) -> Unit,  
  7.     enabled: Boolean = true  
  8. ) {  
  9.     CompositionLocalProvider(  
  10.         LocalContentColor provides contentColor(enabled = enabled, checked = checked),  
  11.     ) {  
  12.         Box(  
  13.             contentAlignment = Alignment.Center,  
  14.             modifier = Modifier  
  15.                 .toggleable(  
  16.                     value = checked,  
  17.                     onValueChange = onCheckedChange,  
  18.                     role = Role.RadioButton,  
  19.                 )  
  20.                 .size(48.dp)  
  21.         ) {  
  22.             Icon(  
  23.                 imageVector = imageVector,  
  24.                 contentDescription = contentDescription,  
  25.                 modifier = Modifier.size(24.dp)  
  26.             )  
  27.         }  
  28.     }  
  29. }  


枠線などの描画は Modifier.drawWithContent() でやったが、これが面倒だった〜(特にRTL対応)。
  1. private fun Modifier.drawToggleButtonFrame(  
  2.     ...  
  3. ): Modifier = this.drawWithContent {  
  4.   
  5.     ...  
  6.   
  7.     // draw checked border  
  8.     drawPath(  
  9.         path = ...,  
  10.         color = checkedBorderColor,  
  11.         style = Stroke(strokeWidth),  
  12.     )  
  13.   
  14.     drawContent()  
  15. }  


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

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



Modifier.triStateToggleable() を使って独自の3状態ボタンも作ることができます。
  1. var state by remember { mutableStateOf(ToggleableState.On) }  
  2.   
  3. Box(  
  4.     contentAlignment = Alignment.Center,  
  5.     modifier = Modifier.triStateToggleable(  
  6.         state = state,  
  7.         onClick = {  
  8.             state = when (state) {  
  9.                 ToggleableState.On -> ToggleableState.Off  
  10.                 ToggleableState.Off -> ToggleableState.Indeterminate  
  11.                 ToggleableState.Indeterminate -> ToggleableState.On  
  12.             }  
  13.         },  
  14.         role = Role.Checkbox,  
  15.         interactionSource = remember { MutableInteractionSource() },  
  16.         indication = rememberRipple(  
  17.             bounded = false,  
  18.             radius = 24.dp  
  19.         )  
  20.     )  
  21. ) {  
  22.     Icon(  
  23.         imageVector = when (state) {  
  24.             ToggleableState.On -> Icons.Default.Favorite  
  25.             ToggleableState.Off -> Icons.Default.FavoriteBorder  
  26.             ToggleableState.Indeterminate -> Icons.Default.FavoriteBorder  
  27.         },  
  28.         contentDescription = when (state) {  
  29.             ToggleableState.On -> "favorite on"  
  30.             ToggleableState.Off -> "favorite on"  
  31.             ToggleableState.Indeterminate -> "favorite indeterminate"  
  32.         },  
  33.         tint = when (state) {  
  34.             ToggleableState.On -> MaterialTheme.colors.primary  
  35.             ToggleableState.Off -> MaterialTheme.colors.primary  
  36.             ToggleableState.Indeterminate -> MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled)  
  37.         }  
  38.     )  
  39. }  



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

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



2021年5月2日日曜日

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

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

よくない例
  1. @Composable  
  2. fun DogList(list: List<Dog>) {  
  3.     LazyColumn {  
  4.         items(list.size + 1) {  
  5.             if (it == 0) {  
  6.                 Header()  
  7.             } else {  
  8.                 DogListItem(list[it - 1])  
  9.             }  
  10.         }  
  11.     }  
  12. }  


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

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




2021年5月1日土曜日

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

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

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

  1. @Composable  
  2. fun Capsule() {  
  3.     Text(  
  4.         text = "Android",  
  5.         modifier = Modifier  
  6.             .padding(16.dp)  
  7.             .background(  
  8.                 color = Color.LightGray,  
  9.                 shape = RoundedCornerShape(50)  
  10.             )  
  11.             .padding(vertical = 8.dp, horizontal = 16.dp)  
  12.     )  
  13. }  

2021年4月28日水曜日

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

TextAlign.Center を指定した TextStyle を渡します。
  1. BasicTextField(  
  2.     ...,  
  3.     textStyle = TextStyle.Default.copy(textAlign = TextAlign.Center),  
  4.     ...  
  5. )  
  6.     
  7. BasicTextField(  
  8.     ...,  
  9.     textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center),  
  10.     ...  
  11. )  
  12.   
  13. BasicTextField(  
  14.     ...,  
  15.     textStyle = MaterialTheme.typography.body1.copy(textAlign = TextAlign.Center),  
  16.     ...  
  17. )  
MDC の TextField と OutlinedTextField は innerTextField の位置が動かせないので、幅が innerTextField より大きい場合は左に寄った innerTextField の中で中央揃えになってしまいます。そのうち設定できるようになるのかもしれません。
  1. @Composable  
  2. fun CenteringTextField() {  
  3.     Column(  
  4.         modifier = Modifier  
  5.             .fillMaxSize()  
  6.             .padding(16.dp)  
  7.     ) {  
  8.         var text1 by remember { mutableStateOf("") }  
  9.         TextField(  
  10.             value = text1,  
  11.             onValueChange = { text1 = it },  
  12.             textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center),  
  13.         )  
  14.   
  15.         Spacer(modifier = Modifier.height(32.dp))  
  16.   
  17.         var text2 by remember { mutableStateOf("") }  
  18.         OutlinedTextField(  
  19.             value = text2,  
  20.             onValueChange = { text2 = it },  
  21.             textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center)  
  22.         )  
  23.   
  24.         Spacer(modifier = Modifier.height(32.dp))  
  25.   
  26.         var text3 by remember { mutableStateOf("") }  
  27.         BasicTextField(  
  28.             value = text3,  
  29.             onValueChange = { text3 = it },  
  30.             textStyle = TextStyle.Default.copy(textAlign = TextAlign.Center),  
  31.             decorationBox = {  
  32.                 Box(  
  33.                     contentAlignment = Alignment.Center,  
  34.                     modifier = Modifier  
  35.                         .background(Color.LightGray)  
  36.                         .padding(16.dp)  
  37.                 ) {  
  38.                     it()  
  39.                 }  
  40.             }  
  41.         )  
  42.   
  43.         Spacer(modifier = Modifier.height(32.dp))  
  44.   
  45.         var text4 by remember { mutableStateOf("") }  
  46.         BasicTextField(  
  47.             value = text4,  
  48.             onValueChange = { text4 = it },  
  49.             textStyle = TextStyle.Default.copy(textAlign = TextAlign.Center),  
  50.             decorationBox = {  
  51.                 Box(  
  52.                     contentAlignment = Alignment.Center,  
  53.                     modifier = Modifier  
  54.                         .background(Color.LightGray)  
  55.                         .padding(16.dp)  
  56.                 ) {  
  57.                     it()  
  58.                 }  
  59.             },  
  60.             modifier = Modifier.fillMaxWidth()  
  61.         )  
  62.     }  
  63. }  

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 の指定にそって配置される。
  1. Box(  
  2.     Modifier  
  3.         .size(50.dp)  
  4.         .background(Color.LightGray)  
  5. ) {  
  6.     GrayText(  
  7.         "Hello Android",  
  8.         modifier = Modifier  
  9.             .width(100.dp)  
  10.     )  
  11. }  
  12.   
  13. Spacer(Modifier.height(24.dp))  
  14.   
  15. Box(  
  16.     Modifier  
  17.         .size(50.dp)  
  18.         .background(Color.LightGray)  
  19. ) {  
  20.     GrayText(  
  21.         "Hello Android",  
  22.         modifier = Modifier  
  23.             .requiredWidth(100.dp)  
  24.     )  
  25. }  
  26.   
  27. Spacer(Modifier.height(24.dp))  
  28.   
  29. Box(  
  30.     Modifier  
  31.         .size(50.dp)  
  32.         .background(Color.LightGray)  
  33. ) {  
  34.     GrayText(  
  35.         "Hello Android",  
  36.         modifier = Modifier  
  37.             .wrapContentWidth()  
  38.     )  
  39. }  
  40.   
  41. Spacer(Modifier.height(24.dp))  
  42.   
  43. Box(  
  44.     Modifier  
  45.         .size(50.dp)  
  46.         .background(Color.LightGray)  
  47. ) {  
  48.     GrayText(  
  49.         "Hello Android",  
  50.         modifier = Modifier  
  51.             .wrapContentWidth(align = Alignment.Start, unbounded = true)  
  52.     )  
  53. }  
  54.   
  55. Spacer(Modifier.height(24.dp))  
  56.   
  57. Box(  
  58.     Modifier  
  59.         .size(50.dp)  
  60.         .background(Color.LightGray)  
  61. ) {  
  62.     GrayText(  
  63.         "Hello Android",  
  64.         modifier = Modifier  
  65.             .wrapContentWidth(align = Alignment.CenterHorizontally, unbounded = true)  
  66.     )  
  67. }  
  68.   
  69. Spacer(Modifier.height(24.dp))  
  70.   
  71. Box(  
  72.     Modifier  
  73.         .size(50.dp)  
  74.         .background(Color.LightGray)  
  75. ) {  
  76.     GrayText(  
  77.         "Hello Android",  
  78.         modifier = Modifier  
  79.             .wrapContentWidth(align = Alignment.End, unbounded = true)  
  80.     )  
  81. }  




2021年4月15日木曜日

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

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

バケツリレー問題

例えば深いところの Composable で Context が必要だとします。
  1. class MainActivity : AppCompatActivity() {  
  2.   
  3.     override fun onCreate(savedInstanceState: Bundle?) {  
  4.         super.onCreate(savedInstanceState)  
  5.   
  6.         val context: Context = this  
  7.   
  8.         setContent {  
  9.             MyAppTheme {  
  10.                 MyApp(context)  
  11.             }  
  12.         }  
  13.     }  
  14. }  
  15.   
  16. @Composable  
  17. fun MyApp(context:Context) {  
  18.     TopScreen(context)  
  19. }  
  20.   
  21. @Composable  
  22. fun TopScreen(context:Context) {  
  23.     MyList(context)  
  24. }  
  25.   
  26. @Composable  
  27. fun MyList(context:Context) {  
  28.     // use context  
  29. }  
この場合 Context を必要としているのは MyList ですが、MyList に Context を渡すために

MyApp -> TopScreen -> MyList

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

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

Composition Local

Composition Local を使うと、深いところの Composable にデータを渡すことができます。
上のコードを Composition Local を使って書き換えるとこのようになります。
後ほどそれぞれを詳しく説明します。
  1. private val MyLocalContext = staticCompositionLocalOf<Context> {   
  2.     error("No current Context")   
  3. }  
  4.   
  5. class MainActivity : AppCompatActivity() {  
  6.   
  7.     override fun onCreate(savedInstanceState: Bundle?) {  
  8.         super.onCreate(savedInstanceState)  
  9.   
  10.         val context: Context = this  
  11.   
  12.         setContent {  
  13.             MyAppTheme {  
  14.                 CompositionLocalProvider(MyLocalContext provides context) {  
  15.                     MyApp()  
  16.                 }  
  17.             }  
  18.         }  
  19.     }  
  20. }  
  21.   
  22. @Composable  
  23. fun MyApp() {  
  24.     TopScreen()  
  25. }  
  26.   
  27. @Composable  
  28. fun TopScreen() {  
  29.     MyList()  
  30. }  
  31.   
  32. @Composable  
  33. fun MyList() {  
  34.     val context:Context = MyLocalContext.current  
  35.     // use context  
  36. }  
実は Context を受け渡す Composition Local は Jetpack Compose の方で用意されています。それが LocalContext です。
LocalContext を使って書き換えるとこのようになります。
Context が欲しい Composable で LocalContext.current から Context のインスタンスが取得できます。
  1. class MainActivity : AppCompatActivity() {  
  2.   
  3.     override fun onCreate(savedInstanceState: Bundle?) {  
  4.         super.onCreate(savedInstanceState)  
  5.   
  6.         setContent {  
  7.             MyAppTheme {  
  8.                 MyApp()  
  9.             }  
  10.         }  
  11.     }  
  12. }  
  13.   
  14. @Composable  
  15. fun MyApp() {  
  16.     TopScreen()  
  17. }  
  18.   
  19. @Composable  
  20. fun TopScreen() {  
  21.     MyList()  
  22. }  
  23.   
  24. @Composable  
  25. fun MyList() {  
  26.     val context:Context = LocalContext.current  
  27.     // use context  
  28. }  


用意されている Composition Local

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


独自の Composition Local を作る

まず、受け渡したい値のホルダーになる ProvidableCompositionLocal を作ります。作るには staticCompositionLocalOf() か compositionLocalOf() を使います。
  1. private val MyLocalColor: ProvidableCompositionLocal<Color> = compositionLocalOf<Color> {  
  2.     error("No current color")  
  3. }  
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<*> なので、複数渡すこともできます。
  1. CompositionLocalProvider(MyLocalColor provides Color.Black) {  
  2.     MyApp()  
  3. }  
こうすると、CompositionLocalProvider の content lambda 内の Composable では ProvidableCompositionLocal.current で指定された値を取得することができます。
  1. @Composable  
  2. fun MyApp() {  
  3.     TopScreen()  
  4. }  
  5.   
  6. @Composable  
  7. fun TopScreen() {  
  8.     MyList()  
  9. }  
  10.   
  11. @Composable  
  12. fun MyList() {  
  13.     val color:Color = MyLocalColor.current // Color.Black  
  14. }  


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


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

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

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


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


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

これにより Surface の color を指定することで、内部に置かれた LocalContentColor を使っている Text などの色が自動で変わるようになっています。
  1. @Composable  
  2. fun Surface(  
  3.     ...  
  4.     color: Color = MaterialTheme.colors.surface,  
  5.     contentColor: Color = contentColorFor(color),  
  6.     ...  
  7.     content: @Composable () -> Unit  
  8. ) {  
  9.     ...  
  10.     CompositionLocalProvider(  
  11.         LocalContentColor provides contentColor,  
  12.         LocalAbsoluteElevation provides absoluteElevation  
  13.     ) {  
  14.         Box(  
  15.             ...  
  16.         ) {  
  17.             content()  
  18.         }  
  19.     }  
  20. }  



2021年4月2日金曜日

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

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

上が Icon + clickable で 下が IconButton です。
  1. Column(modifier = Modifier.padding(16.dp)) {  
  2.     Icon(  
  3.         imageVector = Icons.Filled.Favorite,  
  4.         contentDescription = "favorite",  
  5.         modifier = Modifier  
  6.             .clickable { }  
  7.             .padding(12.dp)  
  8.             .size(24.dp)  
  9.     )  
  10.   
  11.     Spacer(modifier = Modifier.height(48.dp))  
  12.   
  13.     IconButton(onClick = { /*TODO*/ }) {  
  14.         Icon(  
  15.             imageVector = Icons.Filled.Favorite,  
  16.             contentDescription = "favorite",  
  17.             modifier = Modifier  
  18.                 .size(24.dp)  
  19.         )  
  20.     }  
  21. }  
IconButton は ripple がいい感じです。まるいし。



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

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

例えば #232323 のグレーに透明度15%の白を重ねたときの色(不透明のグレー)を作りたい場合は、このようになる。
  1. val gray = Color(0xFF232323)  
  2. val white150 = Color.White.copy(alpha = 0.15f)  
  3.   
  4. val compositeColor = white150.compositeOver(gray)  
Card の背景は不透明じゃないと影が変になる。デフォルトでは Card の backgroundColor は MaterialTheme の surface なので、MaterialTheme の surface に半透明の色を指定しているときは、こんな感じで compositeOver で合成した色を backgroundColor に指定するとよい。
  1. Card(  
  2.     backgroundColor = MaterialTheme.colors.surface.compositeOver(MaterialTheme.colors.background),  
  3. ) {  
  4. }  

以下のコードでは、MaterialTheme の surface に透明度15%の白を指定しているので、左では影が変になっている。右は compositeOver を使って作った不透明の色(ベースの色が不透明のグレーだから不透明になる)を backgroundColor に指定しているので、意図通りの表示になっている。
  1. MaterialTheme(  
  2.     colors = lightColors(  
  3.         background = Color(0xFF232323),  
  4.         surface = Color.White.copy(alpha = 0.15f)  
  5.     )  
  6. ) {  
  7.     Surface(color = MaterialTheme.colors.background) {  
  8.         Row(  
  9.             modifier = Modifier  
  10.                 .padding(top = 8.dp)  
  11.                 .fillMaxSize()  
  12.         ) {  
  13.             Card(  
  14.                 modifier = Modifier  
  15.                     .padding(start = 8.dp, bottom = 8.dp)  
  16.                     .size(136.dp)  
  17.             ) {  
  18.                // 左のカード  
  19.             }  
  20.   
  21.             Card(  
  22.                 backgroundColor = MaterialTheme.colors.surface.compositeOver(MaterialTheme.colors.background),  
  23.                 modifier = Modifier  
  24.                     .padding(start = 8.dp, bottom = 8.dp)  
  25.                     .size(136.dp)  
  26.             ) {  
  27.                // 右のカード  
  28.             }  
  29.         }  
  30.     }  
  31. }  



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

Card の bottom に padding を入れていないとこのように影が切れてしまう。
  1. Row(  
  2.     modifier = Modifier  
  3.         .horizontalScroll(rememberScrollState())  
  4.         .padding(top = 16.dp)  
  5. ) {  
  6.     repeat(10) {  
  7.         Card(  
  8.             modifier = Modifier  
  9.                 .padding(  
  10.                     start = 8.dp,  
  11.                     // bottom padding がない  
  12.                 )  
  13.                 .size(136.dp)  
  14.         ) {  
  15.   
  16.         }  
  17.     }  
  18. }  


このように bottom に padding を入れると切れない。
(top はこのコードのように padding を入れていなくても影が出る)
  1. Row(  
  2.     modifier = Modifier  
  3.         .horizontalScroll(rememberScrollState())  
  4.         .padding(top = 16.dp)  
  5. ) {  
  6.     repeat(10) {  
  7.         Card(  
  8.             modifier = Modifier  
  9.                 .padding(  
  10.                     start = 8.dp,  
  11.                     bottom = 8.dp, // 追加  
  12.                 )  
  13.                 .size(136.dp)  
  14.         ) {  
  15.   
  16.         }  
  17.     }  
  18. }  



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

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

2021年3月29日月曜日

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

  1. class MainActivity : AppCompatActivity() {  
  2.   
  3.     override fun onCreate(savedInstanceState: Bundle?) {  
  4.         super.onCreate(savedInstanceState)  
  5.         setContentView(R.layout.activity_main)  
  6.   
  7.         val editText = findViewById<EditText>(R.id.editText)  
  8.         editText.filters = editText.filters + MyInputFilter()  
  9.     }  
  10. }  
  11.   
  12. class MyInputFilter : InputFilter {  
  13.   
  14.     override fun filter(  
  15.         source: CharSequence,  
  16.         start: Int,  
  17.         end: Int,  
  18.         dest: Spanned,  
  19.         dstart: Int,  
  20.         dend: Int  
  21.     ): CharSequence? {  
  22.   
  23.         var i: Int = start  
  24.         while (i < end) {  
  25.             if (source[i] == '\n') {  
  26.                 break  
  27.             }  
  28.             i++  
  29.         }  
  30.   
  31.         if (i == end) {  
  32.             return null  
  33.         }  
  34.   
  35.         val filtered = SpannableStringBuilder(source, start, end)  
  36.         val start2 = i - start  
  37.         val end2 = end - start  
  38.   
  39.         for (j in end2 - 1 downTo start2) {  
  40.             if (source[j] == '\n') {  
  41.                 filtered.delete(j, j + 1)  
  42.             }  
  43.         }  
  44.   
  45.         return filtered  
  46.     }  
  47. }  

2021年3月23日火曜日

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

Kotlin Serialization

  1. plugins {  
  2.     ...  
  3.     id "org.jetbrains.kotlin.plugin.serialization" version "1.4.31"  
  4. }  
  5.   
  6. dependencies {  
  7.     ...  
  8.     implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.1.0"  
  9. }  
  1. @Serializable  
  2. data class Dog(val name: String, val age: Int, val sex: Sex, val kind: Kind)  
  3.   
  4. enum class Sex {  
  5.     MALE,  
  6.     FEMALE  
  7. }  
  8.   
  9. @Serializable  
  10. sealed class Kind {  
  11.   
  12.     @Serializable  
  13.     object Hybrid : Kind()  
  14.   
  15.     @Serializable  
  16.     data class PureBlood(val name: String) : Kind()  
  17. }  
  1. class DogTest {  
  2.   
  3.     @Test  
  4.     fun list() {  
  5.         val dogs = listOf(  
  6.             Dog("White"10, Sex.MALE, Kind.Hybrid),  
  7.             Dog("Black"20, Sex.FEMALE, Kind.PureBlood("Husky"))  
  8.         )  
  9.   
  10.         val json = Json.encodeToString(dogs)  
  11.         println(json)  
  12.         // [{"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"}}]  
  13.   
  14.   
  15.         val decoded = Json.decodeFromString<List<Dog>>(json)  
  16.   
  17.         assertThat(decoded).isEqualTo(dogs)  
  18.     }  
  19. }