2022年9月25日日曜日

AndroidViewBinding

implementation "androidx.compose.ui:ui-viewbinding:$compose_version" AndroidViewBinding( factory = ListItemBinding::inflate, update = { ... }, modifier = ..., ) AndroidViewBinding( factory = { inflater, parent, attachToParent -> ListItemBinding.inflate(inflater, parent, attachToParent).apply { ... } }, update = { ... }, modifier = ..., )

2022年9月17日土曜日

derivedStateOf の効果を LayoutInspector の composition count で確認する

derivedStateOf を使っていない、よくないコード val state = rememberLazyListState() // TODO derivedStateOf を使う val showScrollToTopButton= state.firstVisibleItemIndex > 0 LazyColumn( state = state, modifier = Modifier.fillMaxSize() ) { ... } if (showScrollToTopButton) { Button( onClick = { ... }, ... ) { Text("scroll to top") } }
LayoutInspector で Button が表示されたあともスクロールのたびに recompose が走っているので Button の skip count が増えていっています。 val showScrollToTopButton by remember { derivedStateOf { state.firstVisibleItemIndex > 0 } } に変えると、スクロールのたびに recompose が走っていたのがなくなり、Button の skip count が増えなくなりました。



2022年8月20日土曜日

Accompanist の Navigation Material の BottomSheet で表示エリアにおさまるように配置する

Accompanist : Navigation Material @OptIn(ExperimentalMaterialNavigationApi::class) @Composable fun MyApp() { val bottomSheetNavigator = rememberBottomSheetNavigator() val navController = rememberNavController(bottomSheetNavigator) ModalBottomSheetLayout(bottomSheetNavigator) { MyNavHost(navController) } } @OptIn(ExperimentalMaterialNavigationApi::class) @Composable fun MyNavHost(navController: NavHostController) { NavHost( navController = navController, startDestination = "home" ) { composable(route = "home") { Button(onClick = { navController.navigate("sheet") }) { Text("show bottom sheet") } } bottomSheet(route = "sheet") { Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize() ) { Spacer( modifier = Modifier .size(100.dp) .background(Color.Blue) ) } } } } この場合、Blue の四角は Bottom Sheet を完全に展開したときの中心に配置されます。
rememberNavController に指定した BottomSheetNavigator は NavHostController の navigatorProvider から取得できます。

BottomSheetNavigator の navigatorSheetState から BottomSheet の offset が取れるので、それを利用すると Blue の四角を BottomSheet の表示されている領域の中心に配置することができます。 @OptIn(ExperimentalMaterialNavigationApi::class) @Composable fun MyNavHost(navController: NavHostController) { NavHost( navController = navController, startDestination = "home" ) { composable(route = "home") { Button(onClick = { navController.navigate("sheet") }) { Text("show bottom sheet") } } bottomSheet(route = "sheet") { val bottomSheetNavigator = navController.navigatorProvider[BottomSheetNavigator::class] val offset = bottomSheetNavigator.navigatorSheetState.offset.value Column { Box( contentAlignment = Alignment.Center, modifier = Modifier .fillMaxWidth() .weight(1f) ) { Spacer( modifier = Modifier .size(100.dp) .background(Color.Blue) ) } with(LocalDensity.current) { Spacer(modifier = Modifier.height(offset.toDp())) } } } } }



2022年8月8日月曜日

kotlin coroutines 1.6.4 で TestScope.backgroundScope が追加された

https://github.com/Kotlin/kotlinx.coroutines/releases/tag/1.6.4

background で実行してテスト終了時にキャンセルされる coroutines を起動できます。
いままでは明示的に cancelAndJoin() していたのがいらなくなりますね。

@Test fun test() = runTest { val list = mutableListOf<SomeValue>() val job = launch(UnconfinedTestDispatcher()) { repository.someValueFlow().collect { list.add(it) } } ... assertEquals(expectedSomeValueList, list) job.cancelAndJoin() } @Test fun test() = runTest { val list = mutableListOf<SomeValue>() backgroundScope.launch(UnconfinedTestDispatcher()) { repository.someValueFlow().collect { list.add(it) } } ... assertEquals(expectedSomeValueList, list) }

2022年7月27日水曜日

Notification runtime permission の挙動メモ

Android 13 に新規インストール
hasPermission = false, rationale = false
    ↓
  requestPermission : ダイアログ出る
  選択しない
    ↓
hasPermission = false, rationale = false
    ↓
  requestPermission : ダイアログ出る
  拒否
    ↓
hasPermission = false, rationale = true
    ↓
  requestPermission : ダイアログ出る
  選択しない
    ↓
hasPermission = false, rationale = true
    ↓
  requestPermission : ダイアログ出る
  拒否
    ↓
hasPermission = false, rationale = false,
    ↓
  requestPermission : ダイアログ出ない


hasPermission = false, rationale = false
    ↓
  requestPermission : ダイアログ出る
  選択しない
    ↓
  設定画面で ON → OFF
    ↓
hasPermission = false, rationale = true
    ↓
  requestPermission : ダイアログ出る
  選択しない
    ↓
  設定画面で ON → OFF
    ↓
hasPermission = false, rationale = true
    ↓
  requestPermission : ダイアログ出る
  拒否
    ↓
hasPermission = false, rationale = false
    ↓
  requestPermission : ダイアログ出ない
    ↓
  設定画面で ON → OFF
    ↓
hasPermission = false, rationale = false
    ↓
  requestPermission : ダイアログ出ない

2022年7月11日月曜日

Calling getBackStackEntry during composition without using remember with a NavBackStackEntry key といわれたら

NavHost(...) { composable(...) { val parentEntry = remember { navController.getBackStackEntry(route) } ... } } Navigation 2.5.0-rc01 から上記コードは lint error になり、「Calling getBackStackEntry during composition without using remember with a NavBackStackEntry key」といわれます。
この変更については https://issuetracker.google.com/issues/227382831 に書かれています。

修正するには、composable の lambda に渡される BackStackEntry を remember の key として渡します。 NavHost(...) { composable(...) { entry -> val parentEntry = remember(entry) { navController.getBackStackEntry(route) } ... } }

2022年5月6日金曜日

Compose のテストで No compose hierarchies found in the app エラーがでた場合

次のような Compose のテストを実行したときに class ComposeTest { @get:Rule val composeTestRule = createComposeRule() @Test fun myTest() { composeTestRule.setContent { Text("Hello") } composeTestRule.onNodeWithText("Hello").assertIsDisplayed() } }
java.lang.IllegalStateException: No compose views found in the app. Is your Activity resumed?

(1.1 系)


java.lang.IllegalStateException: No compose hierarchies found in the app. Possible reasons include: (1) the Activity that calls setContent did not launch; (2) setContent was not called; (3) setContent was called before the ComposeTestRule ran. If setContent is called by the Activity, make sure the Activity is launched after the ComposeTestRule runs

(1.2 系)

というエラーがでる場合、テストを実行しているエミュレータやデバイスの画面が off になっていないかチェックしましょう。

2022年5月5日木曜日

Espresso test 3.4.0 で Duplicate class org.checkerframework.checker エラーが出る場合

espresso-contrib から org.checkerframework:checker を exclude します。 androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0" androidTestImplementation("androidx.test.espresso:espresso-contrib:3.4.0") { exclude group: "org.checkerframework", module: "checker" }

ref : https://github.com/android/android-test/issues/861

2022年4月30日土曜日

LazyColumn/LazyRow で content types を使う

LazyColumn( modifier = Modifier.fillMaxSize(), state = rememberLazyListState() ) { item( contentType = "header" ) { Text( text = "Header", modifier = Modifier .padding(16.dp) .fillParentMaxWidth() ) } items( count = 100, contentType = { "item" } ) { Text( text = "Item : $it", modifier = Modifier .padding(16.dp) .fillParentMaxWidth() ) } }

2022年4月14日木曜日

WindowInsetsControllerCompat を使って status bar と navigation bar の light mode を切り替える

Material Catalog アプリのコードを読んでいて見つけたんですが、
WindowCompat.getInsetsController() で取得した WindowInsetsControllerCompat の setAppearanceLightStatusBars() と setAppearanceLightNavigationBars() を使うことで、status bar と navigation bar の light mode(light mode だとアイコンがグレーになり、dark だと白になる)をコードから切り替えることができます。

このようにアプリ用の MaterialTheme のところで SideEffect を使って切り替え処理をすると、Theme の xml で頑張らなくて良くなるので便利です。 @Composable fun MyAppTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit ) { val view = LocalView.current val context = LocalContext.current SideEffect { val controller = WindowCompat.getInsetsController(context.findActivity().window, view) controller?.isAppearanceLightStatusBars = !darkTheme controller?.isAppearanceLightNavigationBars = !darkTheme } MaterialTheme( colors = if (!darkTheme) LightColorPalette else DarkColorPalette,, typography = Typography, shapes = Shapes, content = content ) } private tailrec fun Context.findActivity(): Activity = when (this) { is Activity -> this is ContextWrapper -> this.baseContext.findActivity() else -> throw IllegalArgumentException("Could not find activity!") }

2022年3月27日日曜日

CameraX で動画撮影できるようになったので Compose で実装してみた。

公式のサンプル https://github.com/android/camera-samples/tree/main/CameraXVideo を参考に、Compose で実装してみました。

https://github.com/yanzm/CameraXComposeSample



最初は逐次的に Compose に置き換えていたのですが、Recording の状態を処理するあたりで難しくなって、declarative UI の意識で状態を一から考えないと無理だなってなりました。
なので公式のサンプルがやっている処理を理解したうえで一から状態を考えた結果、公式のサンプルとはかなり違うコードになっています。

既存の imperative UI なコードを declarative UI に移行するのは結構難しいなと思いました。



2022年2月23日水曜日

StateFlow.collectAsState() だと現在の値を初期値として使ってくれる

StateFlow.collectAsState() だと StateFlow の value を初期値として使ってくれます。一方 Flow.collectAsState() では initial に指定した値が初期値になります。 @Composable fun <T> StateFlow<T>.collectAsState( context: CoroutineContext = EmptyCoroutineContext ): State<T> = collectAsState(value, context) @Composable fun <T : R, R> Flow<T>.collectAsState( initial: R, context: CoroutineContext = EmptyCoroutineContext ): State<R> = produceState(initial, this, context) { if (context == EmptyCoroutineContext) { collect { value = it } } else withContext(context) { collect { value = it } } }

例えばサーバーからデータをとってきて表示する画面があり、画面の状態を表す UiState が次のようになっているとします。 sealed interface UiState { object Initial : UiState object Loading : UiState data class Error(val e: Exception) : UiState data class Success(val profile: Profile) : UiState }
(* 私は基本的には ViewModel からは StateFlow ではなく State を公開するようにしています。)


Flow.collectAsState() の場合 class ProfileViewModel : ViewModel() { val uiState: Flow<UiState> = ... ... } @Composable fun ProfileScreen( viewModel: ProfileViewModel ) { ProfileContent( uiState = viewModel.uiState.collectAsState(initial = UiState.Initial).value ) } @Composable private fun ProfileContent( uiState: UiState ) { when (uiState) { UiState.Initial, UiState.Loading -> { ... } is UiState.Error -> { ... } is UiState.Success -> { ... } } } Flow.collectAsState() の場合、ProfileContent の (re)compose およびそのときの UiState は次のようになります。

UiState.Initial → (ViewModel でデータ取得開始)→ UiState.Loading → (取得成功)→ UiState.Success

ここで ProfileScreen から Navigation Compose で別の画面に行って戻ってくると、一瞬 UiState.Initial になります。

UiState.Success → (Navigation Compose で別の画面に行って戻ってくる) → UiState.Initial → UiState.Success



StateFlow.collectAsState() の場合 class ProfileViewModel : ViewModel() { val uiState: StateFlow<UiState> = ... ... } @Composable fun ProfileScreen( viewModel: ProfileViewModel ) { ProfileContent( uiState = viewModel.uiState.collectAsState().value ) } StateFlow.collectAsState() の場合、データ取得完了までの UiState の流れは同じです。

UiState.Initial → (ViewModel でデータ取得開始)→ UiState.Loading → (取得成功)→ UiState.Success

一方、ここで ProfileScreen から Navigation Compose で別の画面に行って戻ってきても UiState.Initial にはなりません。

UiState.Success → (Navigation Compose で別の画面に行って戻ってくる) → UiState.Success

そのため、ViewModel から StateFlow を公開して StateFlow の collectAsState() を使ったほうがよいです。
stateIn() を使えば Flow を StateFlow に変換することができます。