2022年12月7日水曜日

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

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


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

content 内の Composable が「ラベル、値、区切り線、ラベル、値、区切り線、...」のようになっている前提の場合、このようなコードで実現できる。
  1. @Composable  
  2. fun LabelValueTable(  
  3.     modifier: Modifier = Modifier,  
  4.     content: @Composable () -> Unit,  
  5. ) {  
  6.     Layout(  
  7.         content = content,  
  8.         modifier = modifier  
  9.     ) { measurables, constraints ->  
  10.         val layoutWidth = constraints.maxWidth  
  11.   
  12.         val labelMeasurables = mutableListOf<Measurable>()  
  13.         val valueMeasurables = mutableListOf<Measurable>()  
  14.         val dividerMeasurables = mutableListOf<Measurable>()  
  15.   
  16.         measurables.forEachIndexed { index, measurable ->  
  17.             when (index % 3) {  
  18.                 0 -> labelMeasurables.add(measurable)  
  19.                 1 -> valueMeasurables.add(measurable)  
  20.                 2 -> dividerMeasurables.add(measurable)  
  21.             }  
  22.         }  
  23.   
  24.         val constraintsForLabel = constraints.copy(minWidth = 0, minHeight = 0)  
  25.         val labelPlaceables = labelMeasurables.map { it.measure(constraintsForLabel) }  
  26.         val widthOfLabel = labelPlaceables.maxOf { it.width }  
  27.   
  28.         val constraintsForValue = constraintsForLabel.copy(maxWidth = layoutWidth - widthOfLabel)  
  29.         val valuePlaceables = valueMeasurables.map { it.measure(constraintsForValue) }  
  30.   
  31.         val dividerPlaceables = dividerMeasurables.map { it.measure(constraintsForLabel) }  
  32.   
  33.         val heights = labelPlaceables.mapIndexed { index, labelPlaceable ->  
  34.             val valuePlaceable = valuePlaceables.getOrNull(index)  
  35.             max(labelPlaceable.height, valuePlaceable?.height ?: 0)  
  36.         }  
  37.   
  38.         layout(  
  39.             width = constraints.maxWidth,  
  40.             height = max(  
  41.                 heights.sum() + dividerPlaceables.sumOf { it.height },  
  42.                 constraints.minHeight  
  43.             ),  
  44.         ) {  
  45.             var top = 0  
  46.   
  47.             labelPlaceables.forEachIndexed { index, labelPlaceable ->  
  48.                 val rowHeight = heights[index]  
  49.   
  50.                 labelPlaceable.placeRelative(  
  51.                     x = 0,  
  52.                     y = top + (rowHeight - labelPlaceable.height) / 2  
  53.                 )  
  54.   
  55.                 val valuePlaceable = valuePlaceables.getOrNull(index)  
  56.                 valuePlaceable?.placeRelative(  
  57.                     x = widthOfLabel,  
  58.                     y = top + (rowHeight - valuePlaceable.height) / 2  
  59.                 )  
  60.   
  61.                 val dividerPlaceable = dividerPlaceables.getOrNull(index)  
  62.                 dividerPlaceable?.placeRelative(  
  63.                     x = 0,  
  64.                     y = top + rowHeight  
  65.                 )  
  66.   
  67.                 top += rowHeight + (dividerPlaceable?.height ?: 0)  
  68.             }  
  69.         }  
  70.     }  
  71. }  
まずラベル部分を measure して、全てのラベルから最大の width(= widthOfLabel) を計算する。

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

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

値や区切り線に何も表示しないところは Spacer() をおけばいい。
  1. LabelValueTable(  
  2.     modifier = modifier.fillMaxWidth()  
  3. ) {  
  4.     Text(  
  5.         text = "名前",  
  6.         modifier = Modifier.padding(16.dp)  
  7.     )  
  8.     Text(  
  9.         text = "山田 太郎",  
  10.         modifier = Modifier.padding(16.dp)  
  11.     )  
  12.     Divider()  
  13.   
  14.     Text(  
  15.         text = "bio",  
  16.         modifier = Modifier.padding(16.dp)  
  17.     )  
  18.     Text(  
  19.         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",  
  20.         modifier = Modifier.padding(16.dp)  
  21.     )  
  22.     Divider()  
  23.   
  24.     Text(  
  25.         text = "label",  
  26.         modifier = Modifier.padding(16.dp)  
  27.     )  
  28.     Column(  
  29.         modifier = Modifier.padding(16.dp)  
  30.     ) {  
  31.         Text(  
  32.             text = "headline",  
  33.             style = MaterialTheme.typography.bodyLarge,  
  34.         )  
  35.         Text(  
  36.             text = "subtitle",  
  37.             style = MaterialTheme.typography.bodySmall,  
  38.             color = MaterialTheme.colorScheme.onSurfaceVariant,  
  39.         )  
  40.     }  
  41.     Divider(  
  42.         modifier = Modifier.padding(bottom = 24.dp)  
  43.     )  
  44.   
  45.     Text(  
  46.         text = "生年月日",  
  47.         modifier = Modifier.padding(16.dp)  
  48.     )  
  49.     Row(  
  50.         modifier = Modifier.padding(16.dp),  
  51.         verticalAlignment = Alignment.CenterVertically,  
  52.     ) {  
  53.         Text(  
  54.             text = "1990-01-01",  
  55.             modifier = Modifier.weight(1f)  
  56.         )  
  57.         IconButton(onClick = { /*TODO*/ }) {  
  58.             Icon(imageVector = Icons.Default.Edit, contentDescription = "edit")  
  59.         }  
  60.     }  
  61.     Divider()  
  62.   
  63.     Text(  
  64.         text = "性別",  
  65.         modifier = Modifier.padding(16.dp)  
  66.     )  
  67.     Text(  
  68.         text = "男性",  
  69.         modifier = Modifier.padding(16.dp)  
  70.     )  
  71. }  


ParentDataModifier で位置を指定する場合

ParentDataModifier を使って Cell の位置を指定する場合、このようなコードで実現できる。
  1. @Composable  
  2. fun LabelValueTable(  
  3.     modifier: Modifier = Modifier,  
  4.     content: @Composable LabelValueTableScope.() -> Unit,  
  5. ) {  
  6.     Layout(  
  7.         content = { LabelValueTableScopeInstance.content() },  
  8.         modifier = modifier  
  9.     ) { measurables, constraints ->  
  10.         val layoutWidth = constraints.maxWidth  
  11.   
  12.         val labelMeasurables = mutableListOf<Measurable>()  
  13.         val valueMeasurables = mutableListOf<Measurable>()  
  14.         val dividerMeasurables = mutableListOf<Measurable>()  
  15.   
  16.         measurables.forEach { measurable ->  
  17.             when (measurable.parentData) {  
  18.                 is LabelIndex -> labelMeasurables.add(measurable)  
  19.                 is ValueIndex -> valueMeasurables.add(measurable)  
  20.                 is DividerIndex -> dividerMeasurables.add(measurable)  
  21.             }  
  22.         }  
  23.   
  24.         val map = mutableMapOf<Int, Triple<Placeable?, Placeable?, Placeable?>>()  
  25.   
  26.         val constraintsForLabel = constraints.copy(minWidth = 0, minHeight = 0)  
  27.         val labelPlaceables = labelMeasurables.map { it.measure(constraintsForLabel) }  
  28.         val widthOfLabel = labelPlaceables.maxOf { it.width }  
  29.         labelMeasurables.forEachIndexed { index, measurable ->  
  30.             val columnIndex = (measurable.parentData as LabelIndex).columnIndex  
  31.             map[columnIndex] = Triple(labelPlaceables[index], nullnull)  
  32.         }  
  33.   
  34.         val constraintsForValue = constraintsForLabel.copy(maxWidth = layoutWidth - widthOfLabel)  
  35.         val valuePlaceables = valueMeasurables.map { it.measure(constraintsForValue) }  
  36.         valueMeasurables.forEachIndexed { index, measurable ->  
  37.             val columnIndex = (measurable.parentData as ValueIndex).columnIndex  
  38.             map[columnIndex] = map.getOrDefault(columnIndex, Triple(nullnullnull))  
  39.                 .copy(second = valuePlaceables[index])  
  40.         }  
  41.   
  42.         val dividerPlaceables = dividerMeasurables.map { it.measure(constraintsForLabel) }  
  43.         dividerMeasurables.forEachIndexed { index, measurable ->  
  44.             val columnIndex = (measurable.parentData as DividerIndex).columnIndex  
  45.             map[columnIndex] = map.getOrDefault(columnIndex, Triple(nullnullnull))  
  46.                 .copy(third = dividerPlaceables[index])  
  47.         }  
  48.   
  49.         val list = map.toList()  
  50.             .sortedBy { it.first }  
  51.             .map { it.second }  
  52.   
  53.         val heights = list.map {  
  54.             max(it.first?.height ?: 0, it.second?.height ?: 0)  
  55.         }  
  56.   
  57.         layout(  
  58.             width = constraints.maxWidth,  
  59.             height = max(  
  60.                 heights.sum() + dividerPlaceables.sumOf { it.height },  
  61.                 constraints.minHeight  
  62.             ),  
  63.         ) {  
  64.             var top = 0  
  65.   
  66.             list.forEachIndexed { index, triple ->  
  67.                 val (labelPlaceable, valuePlaceable, dividerPlaceable) = triple  
  68.                 val rowHeight = heights[index]  
  69.   
  70.                 labelPlaceable?.placeRelative(  
  71.                     x = 0,  
  72.                     y = top + (rowHeight - labelPlaceable.height) / 2  
  73.                 )  
  74.   
  75.                 valuePlaceable?.placeRelative(  
  76.                     x = widthOfLabel,  
  77.                     y = top + (rowHeight - valuePlaceable.height) / 2  
  78.                 )  
  79.   
  80.                 dividerPlaceable?.placeRelative(  
  81.                     x = 0,  
  82.                     y = top + rowHeight  
  83.                 )  
  84.   
  85.                 top += rowHeight + (dividerPlaceable?.height ?: 0)  
  86.             }  
  87.         }  
  88.     }  
  89. }  
  90.   
  91.   
  92. @Immutable  
  93. interface LabelValueTableScope {  
  94.     @Stable  
  95.     fun Modifier.label(columnIndex: Int): Modifier  
  96.   
  97.     @Stable  
  98.     fun Modifier.value(columnIndex: Int): Modifier  
  99.   
  100.     @Stable  
  101.     fun Modifier.divider(columnIndex: Int): Modifier  
  102. }  
  103.   
  104. internal object LabelValueTableScopeInstance : LabelValueTableScope {  
  105.   
  106.     @Stable  
  107.     override fun Modifier.label(columnIndex: Int): Modifier {  
  108.         return this.then(LabelIndex(columnIndex))  
  109.     }  
  110.   
  111.     @Stable  
  112.     override fun Modifier.value(columnIndex: Int): Modifier {  
  113.         return this.then(ValueIndex(columnIndex))  
  114.     }  
  115.   
  116.     @Stable  
  117.     override fun Modifier.divider(columnIndex: Int): Modifier {  
  118.         return this.then(DividerIndex(columnIndex))  
  119.     }  
  120. }  
  121.   
  122. @Immutable  
  123. private data class LabelIndex(val columnIndex: Int) : ParentDataModifier {  
  124.     override fun Density.modifyParentData(parentData: Any?): Any? {  
  125.         return this@LabelIndex  
  126.     }  
  127. }  
  128.   
  129. @Immutable  
  130. private data class ValueIndex(val columnIndex: Int) : ParentDataModifier {  
  131.     override fun Density.modifyParentData(parentData: Any?): Any? {  
  132.         return this@ValueIndex  
  133.     }  
  134. }  
  135.   
  136. @Immutable  
  137. private data class DividerIndex(val columnIndex: Int) : ParentDataModifier {  
  138.     override fun Density.modifyParentData(parentData: Any?): Any? {  
  139.         return this@DividerIndex  
  140.     }  
  141. }  
measurable.parentData からラベル、値、区切り線のどれで、column index が何かを取得する。

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

使う側では Modifier.label(), Modifier.value(), Modifier.divider() を使って位置を指定する。
  1. LabelValueTable(  
  2.     modifier = modifier.fillMaxWidth()  
  3. ) {  
  4.     Text(  
  5.         text = "名前",  
  6.         modifier = Modifier  
  7.             .padding(16.dp)  
  8.             .label(0)  
  9.     )  
  10.     Text(  
  11.         text = "山田 太郎",  
  12.         modifier = Modifier  
  13.             .padding(16.dp)  
  14.             .value(0)  
  15.     )  
  16.     Divider(  
  17.         modifier = Modifier.divider(0)  
  18.     )  
  19.   
  20.     Text(  
  21.         text = "bio",  
  22.         modifier = Modifier  
  23.             .padding(16.dp)  
  24.             .label(1)  
  25.     )  
  26.     Text(  
  27.         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",  
  28.         modifier = Modifier  
  29.             .padding(16.dp)  
  30.             .value(1)  
  31.     )  
  32.     Divider(  
  33.         modifier = Modifier.divider(1)  
  34.     )  
  35.   
  36.     Text(  
  37.         text = "label",  
  38.         modifier = Modifier  
  39.             .padding(16.dp)  
  40.             .label(2)  
  41.     )  
  42.     Column(  
  43.         modifier = Modifier  
  44.             .padding(16.dp)  
  45.             .value(2)  
  46.     ) {  
  47.         Text(  
  48.             text = "headline",  
  49.             style = MaterialTheme.typography.bodyLarge,  
  50.         )  
  51.         Text(  
  52.             text = "subtitle",  
  53.             style = MaterialTheme.typography.bodySmall,  
  54.             color = MaterialTheme.colorScheme.onSurfaceVariant,  
  55.         )  
  56.     }  
  57.     Divider(  
  58.         modifier = Modifier  
  59.             .padding(bottom = 24.dp)  
  60.             .divider(2)  
  61.     )  
  62.   
  63.     Text(  
  64.         text = "生年月日",  
  65.         modifier = Modifier  
  66.             .padding(16.dp)  
  67.             .label(3)  
  68.     )  
  69.     Row(  
  70.         verticalAlignment = Alignment.CenterVertically,  
  71.         modifier = Modifier  
  72.             .padding(16.dp)  
  73.             .value(3),  
  74.     ) {  
  75.         Text(  
  76.             text = "1990-01-01",  
  77.             modifier = Modifier.weight(1f)  
  78.         )  
  79.         IconButton(onClick = { /*TODO*/ }) {  
  80.             Icon(imageVector = Icons.Default.Edit, contentDescription = "edit")  
  81.         }  
  82.     }  
  83.     Divider(  
  84.         modifier = Modifier.divider(3)  
  85.     )  
  86.   
  87.     Text(  
  88.         text = "性別",  
  89.         modifier = Modifier  
  90.             .padding(16.dp)  
  91.             .label(4)  
  92.     )  
  93.     Text(  
  94.         text = "男性",  
  95.         modifier = Modifier  
  96.             .padding(16.dp)  
  97.             .value(4)  
  98.     )  
  99. }  

2022年12月6日火曜日

ParentDataModifier

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

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

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


ParentDataModifier を実装した独自 Modifier を用意することができます。
  1. @Immutable  
  2. interface MyLayoutScope {  
  3.     @Stable  
  4.     fun Modifier.myLayoutData(index: Int): Modifier  
  5. }  
  6.   
  7. internal object MyLayoutScopeInstance : MyLayoutScope {  
  8.   
  9.     @Stable  
  10.     override fun Modifier.myLayoutData(index: Int): Modifier {  
  11.         return this.then(MyLayoutData(index = index))  
  12.     }  
  13. }  
  14.   
  15. @Immutable  
  16. private data class MyLayoutData(val index: Int) : ParentDataModifier {  
  17.     override fun Density.modifyParentData(parentData: Any?): Any? {  
  18.         return this@MyLayoutData  
  19.     }  
  20. }  
  21.     
  22. val Measurable.index: Int?  
  23.     get() = (parentData as? MyLayoutData)?.index  




2022年11月25日金曜日

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

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

1. NavHost に innerPadding を使わない

  1. Scaffold(  
  2.     ...  
  3. ) { innerPadding ->  
  4.     NavHost(  
  5.         modifier = Modifier.padding(innerPadding), // これはダメ  
  6.         ...  
  7.     ) {  
  8. .  
NavHost の中の Composable で innerPadding を使うようにします。
  1.     Scaffold(  
  2.         ...  
  3.     ) { innerPadding ->  
  4.         val modifier = Modifier.padding(innerPadding)  
  5.   
  6.         NavHost(  
  7.             ...  
  8.         ) {  
  9.                 composable(Destination.Home.route) {  
  10.                     HomeScreen(  
  11.                         onClick = {  
  12.                             navController.navigate("profile")  
  13.                         },  
  14.                         modifier = modifier, // こうする  
  15.                     )  
  16.                 }  
  17.   ...  
  18.     
  19. @Composable  
  20. private fun HomeScreen(  
  21.     onClick: () -> Unit,  
  22.     modifier: Modifier = Modifier,  
  23. ) {  
  24.     Box(modifier = modifier.fillMaxSize()) {  
  25.         ...  
  26.     }  
  27. }  
このとき currentBackStack が null でも BottomNavigation / NavigationBar が表示されるようにしないと bottom padding が 0 になる問題があるので注意してください。
  1. val navBackStackEntry by navController.currentBackStackEntryAsState()  
  2.   
  3. // ここで ?: Destination.Home.route をつけないと HomeScreen() に渡される Modifier で bottom padding が 0 になってしまう  
  4. val currentRoute = navBackStackEntry?.destination?.route ?: Destination.Home.route  
  5.   
  6. val routes = remember { Destination.values().map { it.route } }  
  7. val showNavigationBar = currentRoute in routes  
  8. if (showNavigationBar) {  
  9.     BottomNavigation {  

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

これを
  1. NavHost(  
  2.     navController = navController,  
  3.     startDestination = Destination.Home.route  
  4. ) {  
  5.     composable(Destination.Home.route) {  
  6.         HomeScreen(  
  7.             onClick = {  
  8.                 navController.navigate("profile")  
  9.             },  
  10.             modifier = modifier,  
  11.         )  
  12.     }  
  13.   
  14.     composable(Destination.Settings.route) {  
  15.         SettingsScreen(  
  16.             onClick = {  
  17.                 navController.navigate("profile")  
  18.             },  
  19.             modifier = modifier,  
  20.         )  
  21.     }  
  22.   
  23.     composable("profile") {  
  24.         ProfileScreen(  
  25.             modifier = modifier,  
  26.         )  
  27.     }  
  28. }  
こうする
  1. NavHost(  
  2.     navController = navController,  
  3.     startDestination = "main"  
  4. ) {  
  5.     // BottomNavigation / NavigationBar が表示される画面を nested graph にする  
  6.     navigation(  
  7.         route = "main",  
  8.         startDestination = Destination.Home.route  
  9.     ) {  
  10.         composable(Destination.Home.route) {  
  11.             HomeScreen(  
  12.                 onClick = {  
  13.                     navController.navigate("profile")  
  14.                 },  
  15.                 modifier = modifier,  
  16.             )  
  17.         }  
  18.   
  19.         composable(Destination.Settings.route) {  
  20.             SettingsScreen(  
  21.                 onClick = {  
  22.                     navController.navigate("profile")  
  23.                 },  
  24.                 modifier = modifier,  
  25.             )  
  26.         }  
  27.     }  
  28.   
  29.     composable("profile") {  
  30.         ProfileScreen(  
  31.             modifier = modifier,  
  32.         )  
  33.     }  
  34. }  
これで、下にずれなくなります。
最後に全体のコードを置いておきます。
  1. enum class Destination(val title: String, val route: String, val imageVector: ImageVector) {  
  2.     Home("Home""home", Icons.Filled.Home),  
  3.     Settings("Settings""settings", Icons.Filled.Settings)  
  4. }  
  5.   
  6. @OptIn(ExperimentalMaterial3Api::class)  
  7. @Composable  
  8. fun MainScreen() {  
  9.     val navController = rememberNavController()  
  10.   
  11.     Scaffold(  
  12.         topBar = {  
  13.             TopAppBar(  
  14.                 title = {  
  15.                     Text("Main")  
  16.                 }  
  17.             )  
  18.         },  
  19.         bottomBar = {  
  20.             val navBackStackEntry by navController.currentBackStackEntryAsState()  
  21.             val routes = remember { Destination.values().map { it.route } }  
  22.             val currentRoute = navBackStackEntry?.destination?.route ?: Destination.Home.route  
  23.   
  24.             val showNavigationBar = currentRoute in routes  
  25.             if (showNavigationBar) {  
  26.                 NavigationBar {  
  27.                     Destination.values().forEach { destination ->  
  28.                         NavigationBarItem(  
  29.                             icon = { Icon(destination.imageVector, contentDescription = null) },  
  30.                             label = { Text(destination.title) },  
  31.                             selected = currentRoute == destination.route,  
  32.                             onClick = {  
  33.                                 navController.navigate(destination.route) {  
  34.                                     popUpTo(navController.graph.findStartDestination().id) {  
  35.                                         saveState = true  
  36.                                     }  
  37.                                     launchSingleTop = true  
  38.                                     restoreState = true  
  39.                                 }  
  40.                             }  
  41.                         )  
  42.                     }  
  43.                 }  
  44.             }  
  45.         }  
  46.     ) { innerPadding ->  
  47.         val modifier = Modifier.padding(innerPadding)  
  48.   
  49.         NavHost(  
  50.             navController = navController,  
  51.             startDestination = "main"  
  52.         ) {  
  53.             navigation(  
  54.                 route = "main",  
  55.                 startDestination = Destination.Home.route  
  56.             ) {  
  57.                 composable(Destination.Home.route) {  
  58.                     HomeScreen(  
  59.                         onClick = {  
  60.                             navController.navigate("profile")  
  61.                         },  
  62.                         modifier = modifier,  
  63.                     )  
  64.                 }  
  65.   
  66.                 composable(Destination.Settings.route) {  
  67.                     SettingsScreen(  
  68.                         onClick = {  
  69.                             navController.navigate("profile")  
  70.                         },  
  71.                         modifier = modifier,  
  72.                     )  
  73.                 }  
  74.             }  
  75.   
  76.             composable("profile") {  
  77.                 ProfileScreen(  
  78.                     modifier = modifier,  
  79.                 )  
  80.             }  
  81.         }  
  82.     }  
  83. }  
  84.   
  85. @Composable  
  86. private fun HomeScreen(  
  87.     onClick: () -> Unit,  
  88.     modifier: Modifier = Modifier,  
  89. ) {  
  90.     Box(modifier = modifier.fillMaxSize()) {  
  91.         Text("home")  
  92.         Button(onClick = onClick, modifier = Modifier.align(Alignment.BottomCenter)) {  
  93.             Text("button")  
  94.         }  
  95.     }  
  96. }  
  97.   
  98. @Composable  
  99. private fun SettingsScreen(  
  100.     onClick: () -> Unit,  
  101.     modifier: Modifier = Modifier,  
  102. ) {  
  103.     Box(modifier = modifier.fillMaxSize()) {  
  104.         Text("settings")  
  105.         Button(onClick = onClick, modifier = Modifier.align(Alignment.BottomCenter)) {  
  106.             Text("button")  
  107.         }  
  108.     }  
  109. }  
  110.   
  111. @Composable  
  112. private fun ProfileScreen(  
  113.     modifier: Modifier = Modifier,  
  114. ) {  
  115.     Box(modifier = modifier.fillMaxSize()) {  
  116.         Text("profile")  
  117.     }  
  118. }  



2022年11月15日火曜日

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

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


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

TopAppBarDefaults.pinnedScrollBehavior()

TopAppBarDefaults.enterAlwaysScrollBehavior()

ExitUntilCollapsedScrollBehavior




2022年9月25日日曜日

AndroidViewBinding

  1. implementation "androidx.compose.ui:ui-viewbinding:$compose_version"  
  1. AndroidViewBinding(  
  2.     factory = ListItemBinding::inflate,  
  3.     update = {  
  4.         ...  
  5.     },  
  6.     modifier = ...,  
  7. )  
  1. AndroidViewBinding(  
  2.     factory = { inflater, parent, attachToParent ->  
  3.         ListItemBinding.inflate(inflater, parent, attachToParent).apply {  
  4.             ...  
  5.         }  
  6.     },  
  7.     update = {  
  8.         ...  
  9.     },  
  10.     modifier = ...,  
  11. )  

2022年9月17日土曜日

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

derivedStateOf を使っていない、よくないコード
  1. val state = rememberLazyListState()  
  2.   
  3. // TODO derivedStateOf を使う  
  4. val showScrollToTopButton= state.firstVisibleItemIndex > 0  
  5.   
  6. LazyColumn(  
  7.     state = state,  
  8.     modifier = Modifier.fillMaxSize()  
  9. ) {  
  10.     ...  
  11. }  
  12.   
  13. if (showScrollToTopButton) {  
  14.     Button(  
  15.         onClick = {  
  16.             ...  
  17.         },  
  18.         ...  
  19.     ) {  
  20.         Text("scroll to top")  
  21.     }  
  22. }  
LayoutInspector で Button が表示されたあともスクロールのたびに recompose が走っているので Button の skip count が増えていっています。
  1. val showScrollToTopButton by remember {  
  2.     derivedStateOf { state.firstVisibleItemIndex > 0 }  
  3. }  
に変えると、スクロールのたびに recompose が走っていたのがなくなり、Button の skip count が増えなくなりました。



2022年8月20日土曜日

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

Accompanist : Navigation Material
  1. @OptIn(ExperimentalMaterialNavigationApi::class)  
  2. @Composable  
  3. fun MyApp() {  
  4.     val bottomSheetNavigator = rememberBottomSheetNavigator()  
  5.     val navController = rememberNavController(bottomSheetNavigator)  
  6.   
  7.     ModalBottomSheetLayout(bottomSheetNavigator) {  
  8.         MyNavHost(navController)  
  9.     }  
  10. }  
  11.   
  12. @OptIn(ExperimentalMaterialNavigationApi::class)  
  13. @Composable  
  14. fun MyNavHost(navController: NavHostController) {  
  15.     NavHost(  
  16.         navController = navController,  
  17.         startDestination = "home"  
  18.     ) {  
  19.         composable(route = "home") {  
  20.             Button(onClick = {  
  21.                 navController.navigate("sheet")  
  22.             }) {  
  23.                 Text("show bottom sheet")  
  24.             }  
  25.         }  
  26.   
  27.         bottomSheet(route = "sheet") {  
  28.             Box(  
  29.                 contentAlignment = Alignment.Center,  
  30.                 modifier = Modifier.fillMaxSize()  
  31.             ) {  
  32.                 Spacer(  
  33.                     modifier = Modifier  
  34.                         .size(100.dp)  
  35.                         .background(Color.Blue)  
  36.                 )  
  37.             }  
  38.         }  
  39.     }  
  40. }  
この場合、Blue の四角は Bottom Sheet を完全に展開したときの中心に配置されます。
rememberNavController に指定した BottomSheetNavigator は NavHostController の navigatorProvider から取得できます。

BottomSheetNavigator の navigatorSheetState から BottomSheet の offset が取れるので、それを利用すると Blue の四角を BottomSheet の表示されている領域の中心に配置することができます。
  1. @OptIn(ExperimentalMaterialNavigationApi::class)  
  2. @Composable  
  3. fun MyNavHost(navController: NavHostController) {  
  4.     NavHost(  
  5.         navController = navController,  
  6.         startDestination = "home"  
  7.     ) {  
  8.         composable(route = "home") {  
  9.             Button(onClick = {  
  10.                 navController.navigate("sheet")  
  11.             }) {  
  12.                 Text("show bottom sheet")  
  13.             }  
  14.         }  
  15.   
  16.         bottomSheet(route = "sheet") {  
  17.   
  18.             val bottomSheetNavigator = navController.navigatorProvider[BottomSheetNavigator::class]  
  19.             val offset = bottomSheetNavigator.navigatorSheetState.offset.value  
  20.   
  21.             Column {  
  22.                 Box(  
  23.                     contentAlignment = Alignment.Center,  
  24.                     modifier = Modifier  
  25.                         .fillMaxWidth()  
  26.                         .weight(1f)  
  27.                 ) {  
  28.                     Spacer(  
  29.                         modifier = Modifier  
  30.                             .size(100.dp)  
  31.                             .background(Color.Blue)  
  32.                     )  
  33.                 }  
  34.                 with(LocalDensity.current) {  
  35.                     Spacer(modifier = Modifier.height(offset.toDp()))  
  36.                 }  
  37.             }  
  38.         }  
  39.     }  
  40. }  



2022年8月8日月曜日

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

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

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

  1. @Test  
  2. fun test() = runTest {  
  3.   
  4.     val list = mutableListOf<SomeValue>()  
  5.   
  6.     val job = launch(UnconfinedTestDispatcher()) {  
  7.         repository.someValueFlow().collect {  
  8.             list.add(it)  
  9.         }  
  10.     }  
  11.   
  12.     ...  
  13.   
  14.     assertEquals(expectedSomeValueList, list)  
  15.   
  16.     job.cancelAndJoin()  
  17. }  
  1. @Test  
  2. fun test() = runTest {  
  3.   
  4.     val list = mutableListOf<SomeValue>()  
  5.   
  6.     backgroundScope.launch(UnconfinedTestDispatcher()) {  
  7.         repository.someValueFlow().collect {  
  8.             list.add(it)  
  9.         }  
  10.     }  
  11.   
  12.     ...  
  13.   
  14.     assertEquals(expectedSomeValueList, list)  
  15. }  

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 といわれたら

  1. NavHost(...) {  
  2.     composable(...) {  
  3.         val parentEntry = remember { navController.getBackStackEntry(route) }  
  4.         ...  
  5.     }  
  6. }  
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 として渡します。
  1. NavHost(...) {  
  2.     composable(...) { entry ->  
  3.         val parentEntry = remember(entry) { navController.getBackStackEntry(route) }  
  4.         ...  
  5.     }  
  6. }  


2022年5月6日金曜日

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

次のような Compose のテストを実行したときに
  1. class ComposeTest {  
  2.   
  3.     @get:Rule  
  4.     val composeTestRule = createComposeRule()  
  5.   
  6.     @Test  
  7.     fun myTest() {  
  8.         composeTestRule.setContent {  
  9.             Text("Hello")  
  10.         }  
  11.   
  12.         composeTestRule.onNodeWithText("Hello").assertIsDisplayed()  
  13.     }  
  14. }  

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 します。
  1. androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0"  
  2. androidTestImplementation("androidx.test.espresso:espresso-contrib:3.4.0") {  
  3.     exclude group: "org.checkerframework", module: "checker"  
  4. }  


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

2022年4月30日土曜日

LazyColumn/LazyRow で content types を使う

  1. LazyColumn(  
  2.     modifier = Modifier.fillMaxSize(),  
  3.     state = rememberLazyListState()  
  4. ) {  
  5.     item(  
  6.         contentType = "header"  
  7.     ) {  
  8.         Text(  
  9.             text = "Header",  
  10.             modifier = Modifier  
  11.                 .padding(16.dp)  
  12.                 .fillParentMaxWidth()  
  13.         )  
  14.     }  
  15.     items(  
  16.         count = 100,  
  17.         contentType = { "item" }  
  18.     ) {  
  19.         Text(  
  20.             text = "Item : $it",  
  21.             modifier = Modifier  
  22.                 .padding(16.dp)  
  23.                 .fillParentMaxWidth()  
  24.         )  
  25.     }  
  26. }  

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 で頑張らなくて良くなるので便利です。
  1. @Composable  
  2. fun MyAppTheme(  
  3.     darkTheme: Boolean = isSystemInDarkTheme(),  
  4.     content: @Composable () -> Unit  
  5. ) {  
  6.     val view = LocalView.current  
  7.     val context = LocalContext.current  
  8.     SideEffect {  
  9.         val controller = WindowCompat.getInsetsController(context.findActivity().window, view)  
  10.         controller?.isAppearanceLightStatusBars = !darkTheme  
  11.         controller?.isAppearanceLightNavigationBars = !darkTheme  
  12.     }  
  13.   
  14.     MaterialTheme(  
  15.         colors = if (!darkTheme) LightColorPalette else DarkColorPalette,,  
  16.         typography = Typography,  
  17.         shapes = Shapes,  
  18.         content = content  
  19.     )  
  20. }  
  21.   
  22. private tailrec fun Context.findActivity(): Activity =  
  23.     when (this) {  
  24.         is Activity -> this  
  25.         is ContextWrapper -> this.baseContext.findActivity()  
  26.         else -> throw IllegalArgumentException("Could not find activity!")  
  27.     }  

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 に指定した値が初期値になります。
  1. @Composable  
  2. fun <T> StateFlow<T>.collectAsState(  
  3.     context: CoroutineContext = EmptyCoroutineContext  
  4. ): State<T> = collectAsState(value, context)  
  5.   
  6. @Composable  
  7. fun <T : R, R> Flow<T>.collectAsState(  
  8.     initial: R,  
  9.     context: CoroutineContext = EmptyCoroutineContext  
  10. ): State<R> = produceState(initial, this, context) {  
  11.     if (context == EmptyCoroutineContext) {  
  12.         collect { value = it }  
  13.     } else withContext(context) {  
  14.         collect { value = it }  
  15.     }  
  16. }  


例えばサーバーからデータをとってきて表示する画面があり、画面の状態を表す UiState が次のようになっているとします。
  1. sealed interface UiState {  
  2.     object Initial : UiState  
  3.     object Loading : UiState  
  4.     data class Error(val e: Exception) : UiState  
  5.     data class Success(val profile: Profile) : UiState  
  6. }  

(* 私は基本的には ViewModel からは StateFlow ではなく State を公開するようにしています。)


Flow.collectAsState() の場合
  1. class ProfileViewModel : ViewModel() {  
  2.   
  3.     val uiState: Flow<UiState> = ...  
  4.   
  5.     ...  
  6. }  
  1. @Composable  
  2. fun ProfileScreen(  
  3.     viewModel: ProfileViewModel  
  4. ) {  
  5.     ProfileContent(  
  6.         uiState = viewModel.uiState.collectAsState(initial = UiState.Initial).value  
  7.     )  
  8. }  
  9.   
  10. @Composable  
  11. private fun ProfileContent(  
  12.     uiState: UiState  
  13. ) {  
  14.     when (uiState) {  
  15.         UiState.Initial,  
  16.         UiState.Loading -> {  
  17.             ...  
  18.         }  
  19.         is UiState.Error -> {  
  20.             ...  
  21.         }  
  22.         is UiState.Success -> {  
  23.             ...  
  24.         }  
  25.     }  
  26. }  
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() の場合
  1. class ProfileViewModel : ViewModel() {  
  2.   
  3.     val uiState: StateFlow<UiState> = ...  
  4.   
  5.     ...  
  6. }  
  1. @Composable  
  2. fun ProfileScreen(  
  3.     viewModel: ProfileViewModel  
  4. ) {  
  5.     ProfileContent(  
  6.         uiState = viewModel.uiState.collectAsState().value  
  7.     )  
  8. }  
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 に変換することができます。