2022年12月7日水曜日

ラベルと値が縦に並んでいて、値部分の start が揃っている Composable を作る

ラベルと値が縦に並んでいる表があって、その値の start の位置を合わせたい。
ConstraintLayout で Barrier を使うか、自分で Layout を作ることになる。


Composableの順番で位置を指定する場合

content 内の Composable が「ラベル、値、区切り線、ラベル、値、区切り線、...」のようになっている前提の場合、このようなコードで実現できる。 @Composable fun LabelValueTable( modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { Layout( content = content, modifier = modifier ) { measurables, constraints -> val layoutWidth = constraints.maxWidth val labelMeasurables = mutableListOf<Measurable>() val valueMeasurables = mutableListOf<Measurable>() val dividerMeasurables = mutableListOf<Measurable>() measurables.forEachIndexed { index, measurable -> when (index % 3) { 0 -> labelMeasurables.add(measurable) 1 -> valueMeasurables.add(measurable) 2 -> dividerMeasurables.add(measurable) } } val constraintsForLabel = constraints.copy(minWidth = 0, minHeight = 0) val labelPlaceables = labelMeasurables.map { it.measure(constraintsForLabel) } val widthOfLabel = labelPlaceables.maxOf { it.width } val constraintsForValue = constraintsForLabel.copy(maxWidth = layoutWidth - widthOfLabel) val valuePlaceables = valueMeasurables.map { it.measure(constraintsForValue) } val dividerPlaceables = dividerMeasurables.map { it.measure(constraintsForLabel) } val heights = labelPlaceables.mapIndexed { index, labelPlaceable -> val valuePlaceable = valuePlaceables.getOrNull(index) max(labelPlaceable.height, valuePlaceable?.height ?: 0) } layout( width = constraints.maxWidth, height = max( heights.sum() + dividerPlaceables.sumOf { it.height }, constraints.minHeight ), ) { var top = 0 labelPlaceables.forEachIndexed { index, labelPlaceable -> val rowHeight = heights[index] labelPlaceable.placeRelative( x = 0, y = top + (rowHeight - labelPlaceable.height) / 2 ) val valuePlaceable = valuePlaceables.getOrNull(index) valuePlaceable?.placeRelative( x = widthOfLabel, y = top + (rowHeight - valuePlaceable.height) / 2 ) val dividerPlaceable = dividerPlaceables.getOrNull(index) dividerPlaceable?.placeRelative( x = 0, y = top + rowHeight ) top += rowHeight + (dividerPlaceable?.height ?: 0) } } } } まずラベル部分を measure して、全てのラベルから最大の width(= widthOfLabel) を計算する。

constraints.maxWidth から widthOfLabel を引いた値を maxWidth とした Constrains で値部分を measure する。

あとは、ラベル、値、区切り線を配置する。

値や区切り線に何も表示しないところは Spacer() をおけばいい。 LabelValueTable( modifier = modifier.fillMaxWidth() ) { Text( text = "名前", modifier = Modifier.padding(16.dp) ) Text( text = "山田 太郎", modifier = Modifier.padding(16.dp) ) Divider() Text( text = "bio", modifier = Modifier.padding(16.dp) ) Text( text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam", modifier = Modifier.padding(16.dp) ) Divider() Text( text = "label", modifier = Modifier.padding(16.dp) ) Column( modifier = Modifier.padding(16.dp) ) { Text( text = "headline", style = MaterialTheme.typography.bodyLarge, ) Text( text = "subtitle", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Divider( modifier = Modifier.padding(bottom = 24.dp) ) Text( text = "生年月日", modifier = Modifier.padding(16.dp) ) Row( modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = "1990-01-01", modifier = Modifier.weight(1f) ) IconButton(onClick = { /*TODO*/ }) { Icon(imageVector = Icons.Default.Edit, contentDescription = "edit") } } Divider() Text( text = "性別", modifier = Modifier.padding(16.dp) ) Text( text = "男性", modifier = Modifier.padding(16.dp) ) }

ParentDataModifier で位置を指定する場合

ParentDataModifier を使って Cell の位置を指定する場合、このようなコードで実現できる。 @Composable fun LabelValueTable( modifier: Modifier = Modifier, content: @Composable LabelValueTableScope.() -> Unit, ) { Layout( content = { LabelValueTableScopeInstance.content() }, modifier = modifier ) { measurables, constraints -> val layoutWidth = constraints.maxWidth val labelMeasurables = mutableListOf<Measurable>() val valueMeasurables = mutableListOf<Measurable>() val dividerMeasurables = mutableListOf<Measurable>() measurables.forEach { measurable -> when (measurable.parentData) { is LabelIndex -> labelMeasurables.add(measurable) is ValueIndex -> valueMeasurables.add(measurable) is DividerIndex -> dividerMeasurables.add(measurable) } } val map = mutableMapOf<Int, Triple<Placeable?, Placeable?, Placeable?>>() val constraintsForLabel = constraints.copy(minWidth = 0, minHeight = 0) val labelPlaceables = labelMeasurables.map { it.measure(constraintsForLabel) } val widthOfLabel = labelPlaceables.maxOf { it.width } labelMeasurables.forEachIndexed { index, measurable -> val columnIndex = (measurable.parentData as LabelIndex).columnIndex map[columnIndex] = Triple(labelPlaceables[index], null, null) } val constraintsForValue = constraintsForLabel.copy(maxWidth = layoutWidth - widthOfLabel) val valuePlaceables = valueMeasurables.map { it.measure(constraintsForValue) } valueMeasurables.forEachIndexed { index, measurable -> val columnIndex = (measurable.parentData as ValueIndex).columnIndex map[columnIndex] = map.getOrDefault(columnIndex, Triple(null, null, null)) .copy(second = valuePlaceables[index]) } val dividerPlaceables = dividerMeasurables.map { it.measure(constraintsForLabel) } dividerMeasurables.forEachIndexed { index, measurable -> val columnIndex = (measurable.parentData as DividerIndex).columnIndex map[columnIndex] = map.getOrDefault(columnIndex, Triple(null, null, null)) .copy(third = dividerPlaceables[index]) } val list = map.toList() .sortedBy { it.first } .map { it.second } val heights = list.map { max(it.first?.height ?: 0, it.second?.height ?: 0) } layout( width = constraints.maxWidth, height = max( heights.sum() + dividerPlaceables.sumOf { it.height }, constraints.minHeight ), ) { var top = 0 list.forEachIndexed { index, triple -> val (labelPlaceable, valuePlaceable, dividerPlaceable) = triple val rowHeight = heights[index] labelPlaceable?.placeRelative( x = 0, y = top + (rowHeight - labelPlaceable.height) / 2 ) valuePlaceable?.placeRelative( x = widthOfLabel, y = top + (rowHeight - valuePlaceable.height) / 2 ) dividerPlaceable?.placeRelative( x = 0, y = top + rowHeight ) top += rowHeight + (dividerPlaceable?.height ?: 0) } } } } @Immutable interface LabelValueTableScope { @Stable fun Modifier.label(columnIndex: Int): Modifier @Stable fun Modifier.value(columnIndex: Int): Modifier @Stable fun Modifier.divider(columnIndex: Int): Modifier } internal object LabelValueTableScopeInstance : LabelValueTableScope { @Stable override fun Modifier.label(columnIndex: Int): Modifier { return this.then(LabelIndex(columnIndex)) } @Stable override fun Modifier.value(columnIndex: Int): Modifier { return this.then(ValueIndex(columnIndex)) } @Stable override fun Modifier.divider(columnIndex: Int): Modifier { return this.then(DividerIndex(columnIndex)) } } @Immutable private data class LabelIndex(val columnIndex: Int) : ParentDataModifier { override fun Density.modifyParentData(parentData: Any?): Any? { return this@LabelIndex } } @Immutable private data class ValueIndex(val columnIndex: Int) : ParentDataModifier { override fun Density.modifyParentData(parentData: Any?): Any? { return this@ValueIndex } } @Immutable private data class DividerIndex(val columnIndex: Int) : ParentDataModifier { override fun Density.modifyParentData(parentData: Any?): Any? { return this@DividerIndex } } measurable.parentData からラベル、値、区切り線のどれで、column index が何かを取得する。

measure と配置部分でやっていることは同じ。

使う側では Modifier.label(), Modifier.value(), Modifier.divider() を使って位置を指定する。 LabelValueTable( modifier = modifier.fillMaxWidth() ) { Text( text = "名前", modifier = Modifier .padding(16.dp) .label(0) ) Text( text = "山田 太郎", modifier = Modifier .padding(16.dp) .value(0) ) Divider( modifier = Modifier.divider(0) ) Text( text = "bio", modifier = Modifier .padding(16.dp) .label(1) ) Text( text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam", modifier = Modifier .padding(16.dp) .value(1) ) Divider( modifier = Modifier.divider(1) ) Text( text = "label", modifier = Modifier .padding(16.dp) .label(2) ) Column( modifier = Modifier .padding(16.dp) .value(2) ) { Text( text = "headline", style = MaterialTheme.typography.bodyLarge, ) Text( text = "subtitle", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Divider( modifier = Modifier .padding(bottom = 24.dp) .divider(2) ) Text( text = "生年月日", modifier = Modifier .padding(16.dp) .label(3) ) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .padding(16.dp) .value(3), ) { Text( text = "1990-01-01", modifier = Modifier.weight(1f) ) IconButton(onClick = { /*TODO*/ }) { Icon(imageVector = Icons.Default.Edit, contentDescription = "edit") } } Divider( modifier = Modifier.divider(3) ) Text( text = "性別", modifier = Modifier .padding(16.dp) .label(4) ) Text( text = "男性", modifier = Modifier .padding(16.dp) .value(4) ) }

2022年12月6日火曜日

ParentDataModifier

ParentDataModifier は、親の Layout にデータを提供するための Modifier です。
ParentDataModifier の Density.modifyParentData() で返したデータが IntrinsicMeasurable.parentData に格納されます。

Measureable が IntrinsicMeasurable を実装しているので、MeasurePolicy の MeasureScope.measure() でこのデータを利用できます。 Layout( content = content, modifier = modifier ) { measurables, constraints -> measurables.forEach { val parentData = measurable.parentData ... } ... } ParentDataModifier interface を実装している Modifier として
  • LayoutId
  • BoxChildData
  • LayoutWeightImpl
  • HorizontalAlignModifier
  • VerticalAlignModifier
  • SiblingsAlignedModifier
などがあります。

この中で LayoutId は自分の Layout でも使うことができます。 Layout( content = { Icon( imageVector = Icons.Default.Home, contentDescription = "home", modifier = Modifier.layoutId("icon") ) Text( text = "home", modifier = Modifier.layoutId("text") ) }, modifier = modifier ) { measurables, constraints -> val iconMeasurable = measurables.first { it.layoutId == "icon" } val textMeasurable = measurables.first { it.layoutId == "text" } ... } Modifier.layoutId() は LayoutId を適用した Modifier を返すメソッドです。 @Stable fun Modifier.layoutId(layoutId: Any) = this.then( LayoutId( layoutId = layoutId, inspectorInfo = debugInspectorInfo { name = "layoutId" value = layoutId } ) ) LayoutId は ParentDataModifier と LayoutIdParentData を実装した Modifier で、Density.modifyParentData() では LayoutId 自身を返します。 @Immutable private class LayoutId( override val layoutId: Any, inspectorInfo: InspectorInfo.() -> Unit ) : ParentDataModifier, LayoutIdParentData, InspectorValueInfo(inspectorInfo) { override fun Density.modifyParentData(parentData: Any?): Any? { return this@LayoutId } ... } interface LayoutIdParentData { val layoutId: Any } Measurable.layoutId は parentData から Modifier.layoutId() で渡した layoutId を取得する便利メソッドです。 val Measurable.layoutId: Any? get() = (parentData as? LayoutIdParentData)?.layoutId

ParentDataModifier を実装した独自 Modifier を用意することができます。 @Immutable interface MyLayoutScope { @Stable fun Modifier.myLayoutData(index: Int): Modifier } internal object MyLayoutScopeInstance : MyLayoutScope { @Stable override fun Modifier.myLayoutData(index: Int): Modifier { return this.then(MyLayoutData(index = index)) } } @Immutable private data class MyLayoutData(val index: Int) : ParentDataModifier { override fun Density.modifyParentData(parentData: Any?): Any? { return this@MyLayoutData } } val Measurable.index: Int? get() = (parentData as? MyLayoutData)?.index



2022年11月25日金曜日

BottomNavigation / NavigationBar が消える時にレイアウトが下にずれるのを防ぎたい

このように Compose Navigation で BottomNavigation / NavigationBar を表示しない画面に遷移したときに、現在のレイアウトが下にずれてしまうことがあります。
これを防ぐ方法を紹介します。

1. NavHost に innerPadding を使わない

Scaffold( ... ) { innerPadding -> NavHost( modifier = Modifier.padding(innerPadding), // これはダメ ... ) { ... NavHost の中の Composable で innerPadding を使うようにします。 Scaffold( ... ) { innerPadding -> val modifier = Modifier.padding(innerPadding) NavHost( ... ) { composable(Destination.Home.route) { HomeScreen( onClick = { navController.navigate("profile") }, modifier = modifier, // こうする ) } ... @Composable private fun HomeScreen( onClick: () -> Unit, modifier: Modifier = Modifier, ) { Box(modifier = modifier.fillMaxSize()) { ... } } このとき currentBackStack が null でも BottomNavigation / NavigationBar が表示されるようにしないと bottom padding が 0 になる問題があるので注意してください。 val navBackStackEntry by navController.currentBackStackEntryAsState() // ここで ?: Destination.Home.route をつけないと HomeScreen() に渡される Modifier で bottom padding が 0 になってしまう val currentRoute = navBackStackEntry?.destination?.route ?: Destination.Home.route val routes = remember { Destination.values().map { it.route } } val showNavigationBar = currentRoute in routes if (showNavigationBar) { BottomNavigation { ...

2. BottomNavigation / NavigationBar が表示される画面を nested graph にする

これを NavHost( navController = navController, startDestination = Destination.Home.route ) { composable(Destination.Home.route) { HomeScreen( onClick = { navController.navigate("profile") }, modifier = modifier, ) } composable(Destination.Settings.route) { SettingsScreen( onClick = { navController.navigate("profile") }, modifier = modifier, ) } composable("profile") { ProfileScreen( modifier = modifier, ) } } こうする NavHost( navController = navController, startDestination = "main" ) { // BottomNavigation / NavigationBar が表示される画面を nested graph にする navigation( route = "main", startDestination = Destination.Home.route ) { composable(Destination.Home.route) { HomeScreen( onClick = { navController.navigate("profile") }, modifier = modifier, ) } composable(Destination.Settings.route) { SettingsScreen( onClick = { navController.navigate("profile") }, modifier = modifier, ) } } composable("profile") { ProfileScreen( modifier = modifier, ) } } これで、下にずれなくなります。
最後に全体のコードを置いておきます。 enum class Destination(val title: String, val route: String, val imageVector: ImageVector) { Home("Home", "home", Icons.Filled.Home), Settings("Settings", "settings", Icons.Filled.Settings) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen() { val navController = rememberNavController() Scaffold( topBar = { TopAppBar( title = { Text("Main") } ) }, bottomBar = { val navBackStackEntry by navController.currentBackStackEntryAsState() val routes = remember { Destination.values().map { it.route } } val currentRoute = navBackStackEntry?.destination?.route ?: Destination.Home.route val showNavigationBar = currentRoute in routes if (showNavigationBar) { NavigationBar { Destination.values().forEach { destination -> NavigationBarItem( icon = { Icon(destination.imageVector, contentDescription = null) }, label = { Text(destination.title) }, selected = currentRoute == destination.route, onClick = { navController.navigate(destination.route) { popUpTo(navController.graph.findStartDestination().id) { saveState = true } launchSingleTop = true restoreState = true } } ) } } } } ) { innerPadding -> val modifier = Modifier.padding(innerPadding) NavHost( navController = navController, startDestination = "main" ) { navigation( route = "main", startDestination = Destination.Home.route ) { composable(Destination.Home.route) { HomeScreen( onClick = { navController.navigate("profile") }, modifier = modifier, ) } composable(Destination.Settings.route) { SettingsScreen( onClick = { navController.navigate("profile") }, modifier = modifier, ) } } composable("profile") { ProfileScreen( modifier = modifier, ) } } } } @Composable private fun HomeScreen( onClick: () -> Unit, modifier: Modifier = Modifier, ) { Box(modifier = modifier.fillMaxSize()) { Text("home") Button(onClick = onClick, modifier = Modifier.align(Alignment.BottomCenter)) { Text("button") } } } @Composable private fun SettingsScreen( onClick: () -> Unit, modifier: Modifier = Modifier, ) { Box(modifier = modifier.fillMaxSize()) { Text("settings") Button(onClick = onClick, modifier = Modifier.align(Alignment.BottomCenter)) { Text("button") } } } @Composable private fun ProfileScreen( modifier: Modifier = Modifier, ) { Box(modifier = modifier.fillMaxSize()) { Text("profile") } }


2022年11月15日火曜日

Material 3 で TopAppBar の shadow がなくなったけど、コンテンツとの境界はどう表現する?

Material 2 では TopAppBar に shadow がついていて、スクロールアウトするコンテンツとの境界として機能していました。


Material 3 では shadow はつきません。そのため、Material 2 のコードをそのまま Material 3 に移行すると、コンテンツの境界表現がなくなってしまいます。
Material 3 では shadow の代わりに色のオーバーレイでコンテンツと分離します。
そのために TopAppBarScrollBehavior を使います。 val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( topBar = { TopAppBar( title = { ... }, scrollBehavior = scrollBehavior ) }, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) ) { LazyColumn( contentPadding = it ) { ... } }
TopAppBarScrollBehavior として
  • PinnedScrollBehavior
  • EnterAlwaysScrollBehavior
  • ExitUntilCollapsedScrollBehavior
が用意されています。

TopAppBarDefaults.pinnedScrollBehavior()

TopAppBarDefaults.enterAlwaysScrollBehavior()

ExitUntilCollapsedScrollBehavior




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 に変換することができます。