Custom top app bar with children drawn outside of parent bounds - Compose - android

I am trying to create a custom top app bar in Compose and Material3 (using TopAppBarLayout as base), that has a row of buttons running along the bottom edge. I want the row to spill past the bottom boundary and appear over content body of the Scaffold. Within the layout inspector, it seems that the row of buttons is placed in the correct spot, however the content body is covering the bottom part of the row:
I've looked at the Scaffold source to see if the placement issue is there, but it looks correct to me (content body is placed first, followed by top bar):
// Placing to control drawing order to match default elevation of each placeable
bodyContentPlaceables.forEach {
it.place(0, 0)
}
topBarPlaceables.forEach {
it.place(0, 0)
}
Swapping those around had no effect in any case. I also tried adding Modifier.wrapContentHeight(unbounded = true) in various spots, but none of them did what I was after.
This code is essentially TopAppBarLayout with the button row added to it:
#Composable
private fun TopAppBarLayout(
modifier: Modifier,
heightPx: Float,
navigationIconContentColor: Color,
titleContentColor: Color,
actionIconContentColor: Color,
title: #Composable () -> Unit,
titleTextStyle: TextStyle,
titleAlpha: Float,
titleVerticalArrangement: Arrangement.Vertical,
titleHorizontalArrangement: Arrangement.Horizontal,
titleBottomPadding: Int,
hideTitleSemantics: Boolean,
navigationIcon: #Composable () -> Unit,
actions: #Composable () -> Unit,
buttons: #Composable () -> Unit
) {
Layout(
{
Box(
Modifier
.layoutId("navigationIcon")
.padding(start = TopAppBarHorizontalPadding)
) {
CompositionLocalProvider(
LocalContentColor provides navigationIconContentColor,
content = navigationIcon
)
}
Box(
Modifier
.layoutId("title")
.padding(horizontal = TopAppBarHorizontalPadding)
.then(if (hideTitleSemantics) Modifier.clearAndSetSemantics { } else Modifier)
) {
ProvideTextStyle(value = titleTextStyle) {
CompositionLocalProvider(
LocalContentColor provides titleContentColor.copy(alpha = titleAlpha),
content = title
)
}
}
Box(
Modifier
.layoutId("actionIcons")
.padding(end = TopAppBarHorizontalPadding)
) {
CompositionLocalProvider(
LocalContentColor provides actionIconContentColor,
content = actions
)
}
Box(
Modifier
.layoutId("buttons")
.wrapContentHeight(unbounded = true)
) {
CompositionLocalProvider(
LocalContentColor provides Color.Red,
content = buttons
)
}
},
modifier = modifier
) { measurables, constraints ->
val navigationIconPlaceable =
measurables.first { it.layoutId == "navigationIcon" }
.measure(constraints.copy(minWidth = 0))
val actionIconsPlaceable =
measurables.first { it.layoutId == "actionIcons" }
.measure(constraints.copy(minWidth = 0))
val maxTitleWidth = if (constraints.maxWidth == Constraints.Infinity) {
constraints.maxWidth
} else {
(constraints.maxWidth - navigationIconPlaceable.width - actionIconsPlaceable.width)
.coerceAtLeast(0)
}
val titlePlaceable =
measurables.first { it.layoutId == "title" }
.measure(constraints.copy(minWidth = 0, maxWidth = maxTitleWidth))
// Locate the title's baseline.
val titleBaseline =
if (titlePlaceable[LastBaseline] != AlignmentLine.Unspecified) {
titlePlaceable[LastBaseline]
} else {
0
}
val buttonRowPlaceable =
measurables.first { it.layoutId == "buttons" }
.measure(constraints.copy(minWidth = 0))
val layoutHeight = heightPx.roundToInt()
layout(constraints.maxWidth, layoutHeight) {
// Navigation icon
navigationIconPlaceable.placeRelative(
x = 0,
y = (layoutHeight - navigationIconPlaceable.height) / 2
)
// Title
titlePlaceable.placeRelative(
x = when (titleHorizontalArrangement) {
Arrangement.Center -> (constraints.maxWidth - titlePlaceable.width) / 2
Arrangement.End ->
constraints.maxWidth - titlePlaceable.width - actionIconsPlaceable.width
// Arrangement.Start.
// An TopAppBarTitleInset will make sure the title is offset in case the
// navigation icon is missing.
else -> max(TopAppBarTitleInset.roundToPx(), navigationIconPlaceable.width)
},
y = when (titleVerticalArrangement) {
Arrangement.Center -> (layoutHeight - titlePlaceable.height) / 2
// Apply bottom padding from the title's baseline only when the Arrangement is
// "Bottom".
Arrangement.Bottom ->
if (titleBottomPadding == 0) layoutHeight - titlePlaceable.height
else layoutHeight - titlePlaceable.height - max(
0,
titleBottomPadding - titlePlaceable.height + titleBaseline
)
// Arrangement.Top
else -> 0
}
)
// Action icons
actionIconsPlaceable.placeRelative(
x = constraints.maxWidth - actionIconsPlaceable.width,
y = (layoutHeight - actionIconsPlaceable.height) / 2
)
// Button row
buttonRowPlaceable.placeRelative(
x = 0,
y = layoutHeight - buttonRowPlaceable.height / 2,
)
}
}
}

Related

How to build grid images that change position and size and display number when image count is above threshold?

This is a share your knowledge, Q&A-style question about using Layout, different Constraints and placing based on size and other Composables' positions to achieve a grid that changes Composable size, position and adds a number if number of Composables are greater than 4.
To layout Composables based on their count Layout is required and need to select Constraints, a detailed answer about Constraints is available in this link, based on number of items and place them accordingly.
For Constraints selection when there are 2 items we need to pick half width and full height to have result in question. When there are 4 items we need to pick half width and half height.
When item count is 3 we need to use 2 constraints, 1 for measuring first 2 items, another one measuring the one that covers whole width
#Composable
private fun ImageDrawLayout(
modifier: Modifier = Modifier,
itemCount: Int,
divider: Dp,
content: #Composable () -> Unit
) {
val spacePx = LocalDensity.current.run { (divider).roundToPx() }
val measurePolicy = remember(itemCount, spacePx) {
MeasurePolicy { measurables, constraints ->
val newConstraints = when (itemCount) {
1 -> constraints
2 -> Constraints.fixed(
width = constraints.maxWidth / 2 - spacePx / 2,
height = constraints.maxHeight
)
else -> Constraints.fixed(
width = constraints.maxWidth / 2 - spacePx / 2,
height = constraints.maxHeight / 2 - spacePx / 2
)
}
val gridMeasurables = if (itemCount < 5) {
measurables
} else {
measurables.take(3) + measurables.first { it.layoutId == "Text" }
}
val placeables: List<Placeable> = if (measurables.size != 3) {
gridMeasurables.map { measurable: Measurable ->
measurable.measure(constraints = newConstraints)
}
} else {
gridMeasurables
.take(2)
.map { measurable: Measurable ->
measurable.measure(constraints = newConstraints)
} +
gridMeasurables
.last()
.measure(
constraints = Constraints.fixed(
constraints.maxWidth,
constraints.maxHeight / 2 - spacePx
)
)
}
layout(constraints.maxWidth, constraints.maxHeight) {
when (itemCount) {
1 -> {
placeables.forEach { placeable: Placeable ->
placeable.placeRelative(0, 0)
}
}
2 -> {
var xPos = 0
placeables.forEach { placeable: Placeable ->
placeable.placeRelative(xPos, 0)
xPos += placeable.width + spacePx
}
}
else -> {
var xPos = 0
var yPos = 0
placeables.forEachIndexed { index: Int, placeable: Placeable ->
placeable.placeRelative(xPos, yPos)
if (index % 2 == 0) {
xPos += placeable.width + spacePx
} else {
xPos = 0
}
if (index % 2 == 1) {
yPos += placeable.height + spacePx
}
}
}
}
}
}
}
Layout(
modifier = modifier,
content = content,
measurePolicy = measurePolicy
)
}
Another thing to note here is we need to find find Composable that contains Text. It's possible to find it from index since it's 4th item but i used Modifier.layoutId() for demonstration. This Modifier helps finding Composables when you don't know in which order they are placed inside a Composaable.
val gridMeasurables = if (size < 5) {
measurables
} else {
measurables.take(3) + measurables.first { it.layoutId == "Text" }
}
And place items based on item count and we add space only after first item on each row.
Usage
#Composable
fun GridImageLayout(
modifier: Modifier = Modifier,
thumbnails: List<Int>,
divider: Dp = 2.dp,
onClick: ((List<Int>) -> Unit)? = null
) {
if (thumbnails.isNotEmpty()) {
ImageDrawLayout(
modifier = modifier
.clickable {
onClick?.invoke(thumbnails)
},
divider = divider,
itemCount = thumbnails.size
) {
thumbnails.forEach {
Image(
modifier = Modifier.layoutId("Icon"),
painter = painterResource(id = it),
contentDescription = "Icon",
contentScale = ContentScale.Crop,
)
}
if (thumbnails.size > 4) {
val carry = thumbnails.size - 3
Box(
modifier = Modifier.layoutId("Text"),
contentAlignment = Alignment.Center
) {
Text(text = "+$carry", fontSize = 20.sp)
}
}
}
}
}
And using GridImageLayout
Column {
val icons5 = listOf(
R.drawable.landscape1,
R.drawable.landscape2,
R.drawable.landscape3,
R.drawable.landscape4,
R.drawable.landscape5,
R.drawable.landscape6,
R.drawable.landscape7,
)
GridImageLayout(
modifier = Modifier
.border(1.dp, Color.Red, RoundedCornerShape(10))
.clip(RoundedCornerShape(10))
.size(150.dp),
thumbnails = icons5
)
}

How to align items in separate rows in Compose?

I want to create a Compose view that looks something like this:
|-------------------------------------------|
| "Dynamic text" Image "Text " |
|-------------------------------------------|
| "Dynamic text 2" Image "Text" |
|-------------------------------------------|
Logical way to do it would be to add two Rows inside a Column. Problematic part is that Images inside those Rows must always align while the first text elements length can change.
|-------------------------------------------|
| "Dynamic longer text" Image "Text " |
|-------------------------------------------|
| "Text" Image "Text" |
|-------------------------------------------|
What is the best option for it? I've thought about using ConstraintLayout but that seems like an overkill for this simple scenario. I have also considered using Columns inside a Row, but that just doesn't feel natural in this case. All ideas welcome!
If aligning Image and Text with static text end of your Composable is okay you can set Modifier.weight(1f) for Text with dynamic text so it will cover the space that is not used by Image and Text with static text.
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
) {
Text("Dynamic text", modifier = Modifier.weight(1f))
Image(
modifier = Modifier.size(50.dp),
painter = painterResource(id = R.drawable.landscape1),
contentDescription = ""
)
Text("text")
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
) {
Text("Dynamic longer text", modifier = Modifier.weight(1f))
Image(
modifier = Modifier.size(50.dp),
painter = painterResource(id = R.drawable.landscape1),
contentDescription = ""
)
Text("text")
}
}
If you want a layout like in first table you should use a Layout. If you don't have any experience with Layout it might be complicated.
Steps to accomplish what's needed
1- First measure your every child Composable with Constraints and get placeable
val placeables: List<Placeable> = measurables.map { measurable ->
measurable.measure(constraints)
}
2- Get maximum width of dynamic texts, this will be our threshold for each row's second element x position, i added a padding but you can add padding to Text if you want to
// Get the maximum with of first Text on each Row
val maxDynamicWidth = placeables.filterIndexed { index, _ ->
index % 3 == 0
}.maxOf { it.width } + padding
3- Get height of each row. We will use row height for placing every 3 Composables
val rowHeights = mutableListOf<Int>()
var maxHeight = 0
// Get height for each row
placeables.forEachIndexed { index, placeable ->
maxHeight = if (index % 3 == 2) {
rowHeights.add(maxHeight)
0
} else {
placeable.height.coerceAtLeast(maxHeight)
}
}
4- get total height of our Layout, you can use constraints.maxHeight if you want to and then layout every 3 Composables on each row
val totalHeight = rowHeights.sum()
var y = 0
var x = 0
layout(constraints.maxWidth, totalHeight) {
placeables.forEachIndexed { index, placeable ->
if (index % 3 == 0) {
if (index > 0) y += rowHeights[index / 3]
placeable.placeRelative(0, y)
x = maxDynamicWidth
} else {
placeable.placeRelative(x, y)
x += placeable.width
}
}
}
Full implementation
#Composable
private fun MyLayout(
modifier: Modifier = Modifier,
paddingAfterDynamicText: Dp = 0.dp,
content: #Composable () -> Unit
) {
val padding = with(LocalDensity.current) {
paddingAfterDynamicText.roundToPx()
}
Layout(
modifier = modifier,
content = content
) { measurables: List<Measurable>, constraints: Constraints ->
require(measurables.size % 3 == 0)
val placeables: List<Placeable> = measurables.map { measurable ->
measurable.measure(constraints)
}
// Get the maximum with of first Text on each Row
val maxDynamicWidth = placeables.filterIndexed { index, _ ->
index % 3 == 0
}.maxOf { it.width } + padding
val rowHeights = mutableListOf<Int>()
var maxHeight = 0
// Get height for each row
placeables.forEachIndexed { index, placeable ->
maxHeight = if (index % 3 == 2) {
rowHeights.add(maxHeight)
0
} else {
placeable.height.coerceAtLeast(maxHeight)
}
}
val totalHeight = rowHeights.sum()
var y = 0
var x = 0
// i put Composable on each row to top of the Row
// (y-placeable.height)/2 places them to center of row
layout(constraints.maxWidth, totalHeight) {
placeables.forEachIndexed { index, placeable ->
if (index % 3 == 0) {
if (index > 0) y += rowHeights[index / 3]
placeable.placeRelative(0, y)
x = maxDynamicWidth
} else {
placeable.placeRelative(x, y)
x += placeable.width
}
}
}
}
}
You can use it as
MyLayout(
modifier = Modifier.border(3.dp, Color.Red),
paddingAfterDynamicText = 15.dp
) {
Text("Dynamic text")
Image(
modifier = Modifier.size(50.dp),
painter = painterResource(id = R.drawable.landscape1),
contentDescription = ""
)
Text("text")
Text("Dynamic longer text")
Image(
modifier = Modifier.size(50.dp),
painter = painterResource(id = R.drawable.landscape1),
contentDescription = ""
)
Text("text")
}
Result

How to continuously animate text size based on lazy column / lazy llist scroll in Android - Jetpack Compose?

I want to make a smooth transition for scaling text inside the lazy column. Currently, I am using the graphics layer to animate the text scale based on the first visible item index from the list state. But it does not provide smooth and continuous animation. I want to make it as an Animated Flat list in React native. Here is an example of what I want to achieve.
Here is my code for scaling text based on the selected items.
val animateSizeText by animateFloatAsState(
targetValue = if (item == selectedItem) {
1f
}
else if (item == selectedItem- 1 || item == selectedItem+ 1) {
0.9f
}
else if (item == selectedItem- 2 || item == selectedItem+ 2) {
0.7f
}
else {
0.5f
},
animationSpec = tween(100, easing = LinearOutSlowInEasing)
)
Modifier for scaling text:
modifier = Modifier
.graphicsLayer {
scaleY = animateSizeText
scaleX = animateSizeText
}
Comparing to related question, you need to enable non default opacity value for other items using firstOrNull block and control how it depends on scroll position with a multiplier. It's pretty simple math, change this formula according to the scale effect you need.
val items = remember {
('A'..'Z').map { it.toString() }
}
val listState = rememberLazyListState()
val horizontalContentPadding = 16.dp
val boxSize = 50.dp
BoxWithConstraints {
val halfRowWidth = constraints.maxWidth / 2
LazyRow(
state = listState,
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = horizontalContentPadding, vertical = 8.dp),
modifier = Modifier
.fillMaxWidth()
) {
itemsIndexed(items) { i, item ->
val opacity by remember {
derivedStateOf {
val currentItemInfo = listState.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == i }
?: return#derivedStateOf 0.5f
val itemHalfSize = currentItemInfo.size / 2
(1f - minOf(1f, abs(currentItemInfo.offset + itemHalfSize - halfRowWidth).toFloat() / halfRowWidth) * 0.5f)
}
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.scale(opacity)
.alpha(opacity)
.size(boxSize)
.background(Color.Blue)
) {
Text(item, color = Color.White)
}
}
}
}

Why Custom Layout's modifer's height and width is applied to it's child? How to avoid it?

I've created a custom layout in jetpack compose to align the items in
a circular fashion. I have noticed something strange, or I don't know if I missed something from the android documentation. So, whenever I apply width and height or any of the either to modifier of the Layout. Its child items get that width or height. How should I avoid it, use it just apply to the parent but not child?
Here is the output.
Here is the code:
#Composable
fun CircularRevealMenu(
modifier: Modifier,
contentPadding: Dp = 16.dp,
circleRadius: () -> Float,
content: #Composable () -> Unit
) {
val configuration = LocalConfiguration.current
Layout(content = content, modifier = modifier) { children, constraints ->
val screenWidth = configuration.screenWidthDp.dp.toPx() * circleRadius()
val placeables = children.map { it.measure(constraints) }
val maxItemHeight = placeables.maxOf {
it.height
}
val maxItemWidth = placeables.maxOf {
it.width
}
val gap = 90 / placeables.size
val radiusOffset = (max(maxItemHeight, maxItemWidth) / 2) + contentPadding.toPx()
val radius = screenWidth - radiusOffset
val offset = 180 - gap / 2f
layout(screenWidth.toInt(), screenWidth.toInt()) {
for (i in placeables.indices) {
val radians = Math.toRadians((offset - (gap * i)).toDouble())
placeables[i].placeRelative(
x = (cos(radians) * radius + screenWidth).toInt() - placeables[i].width / 2,
y = (sin(radians) * radius + 0).toInt() - placeables[i].height / 2
)
}
}
}
}
enum class CircularMenuStates { Collapsed, Expanded }
#Preview(showBackground = true)
#Composable
fun PreviewCircularMenu() {
CircularRevealMenu(modifier = Modifier, circleRadius = { 1f }) {
Text(text = "Item 1")
Text(text = "Item 2")
Text(text = "Item 3")
Text(text = "Item 4")
}
}

Prominent top app bar using jetpack compose

I wanted to create and add gestures on top app bar some thing similar to below screenshot using jetpack compose:
I am able to create collapsible top bar using the below android docs link:
documentation link but not able to do gestures to expand and collapse along with change in layout using compose. Below is the code I have tried for collapsible toolbar.
val toolbarHeight = 48.dp
val toolbarHeightPx = with(LocalDensity.current) { toolbarHeight.roundToPx().toFloat() }
// our offset to collapse toolbar
val toolbarOffsetHeightPx =
remember { mutableStateOf(0f) }
// now, let's create connection to the nested scroll system and listen to the scroll
// happening inside child LazyColumn
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// try to consume before LazyColumn to collapse toolbar if needed, hence pre-scroll
val delta = available.y
val newOffset = toolbarOffsetHeightPx.value + delta
toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarHeightPx, 0f)
// here's the catch: let's pretend we consumed 0 in any case, since we want
// LazyColumn to scroll anyway for good UX
// We're basically watching scroll without taking it
return Offset.Zero
}
}
}
And below is the gesture link which I want to implement in topbvar
topbar gesture video
Please help me with the links. Thanks!
If you are looking to implement collapsing toolbar like below where the title will animate based on collapsing state this code reference might help you. You need to build a custom layout for it.
#Composable
fun CollapsingTopBar(
modifier: Modifier = Modifier,
collapseFactor: Float = 1f, // A value from (0-1) where 0 means fully expanded
content: #Composable () -> Unit
) {
val map = mutableMapOf<Placeable, Int>()
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
map.clear()
val placeables = mutableListOf<Placeable>()
measurables.map { measurable ->
when (measurable.layoutId) {
BACK_ID -> measurable.measure(constraints)
SHARE_ID -> measurable.measure(constraints)
TITLE_ID ->
measurable.measure(Constraints.fixedWidth(constraints.maxWidth
- (collapseFactor * (placeables.first().width * 2)).toInt()))
else -> throw IllegalStateException("Id Not found")
}.also { placeable ->
map[placeable] = measurable.layoutId as Int
placeables.add(placeable)
}
}
// Set the size of the layout as big as it can
layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach { placeable ->
when (map[placeable]) {
BACK_ID -> placeable.placeRelative(0, 0)
SHARE_ID -> placeable.run {
placeRelative(constraints.maxWidth - width, 0)
}
TITLE_ID -> placeable.run {
val widthOffset = (placeables[0].width * collapseFactor).roundToInt()
val heightOffset = (placeables.first().height - placeable.height) / 2
placeRelative(
widthOffset,
if (collapseFactor == 1f) heightOffset else constraints.maxHeight - height
)
}
}
}
}
}
}
object CollapsingTopBar {
const val BACK_ID = 1001
const val SHARE_ID = 1002
const val TITLE_ID = 1003
const val COLLAPSE_FACTOR = 0.6f
}
#Composable
fun TopBar(
modifier: Modifier = Modifier,
currentHeight: Int,
title: String,
onBack: () -> Unit,
shareShow: () -> Unit
) {
Box(
modifier = modifier.height(currentHeight.dp)
) {
CollapsingTopBar(
collapseFactor = // calculate collapseFactor based on max and min height of the toolbar,
modifier = Modifier
.statusBarsPadding()
) {
Icon(
modifier = Modifier
.wrapContentWidth()
.layoutId(CollapsingTopBar.BACK_ID)
.clickable { onBack() }
.padding(16.dp),
imageVector = Icons.Filled.ArrowBack,
tint = MaterialTheme.colors.onPrimary,
contentDescription = stringResource(id = R.string.text_back)
)
Icon(
modifier = Modifier
.wrapContentSize()
.layoutId(CollapsingTopBar.SHARE_ID)
.clickable { }
.padding(16.dp),
imageVector = Icons.Filled.Share,
tint = MaterialTheme.colors.onPrimary,
contentDescription = stringResource(id = R.string.title_share)
)
Text(
modifier = Modifier
.layoutId(CollapsingTopBar.TITLE_ID)
.wrapContentHeight()
.padding(horizontal = 16.dp),
text = title,
style = MaterialTheme.typography.h4.copy(color = MaterialTheme.colors.onPrimary),
overflow = TextOverflow.Ellipsis
)
}
}
}
Sample reference from Google

Categories

Resources