I am trying to display a shadow at the start and end of a list to indicate that the list still has contents to the left/right.
I want the shadow to be as tall as the whole row so therefore I use Modifier.fillMaxHeight(). Unfortunately this doesn't work unless I specifiy the size of the component at call time.
When I call the function as:
LazyRowWithShadows(modifier = Modifier
.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 12.dp),
shadowColor = colorResource(R.color.blue_white),
shadowAlphaStart = 0f, shadowAlphaEnd = 0.8f,
shadowWidth = 48.dp
) {
// Content
}
The shadows are not displayed, but the rest of the content is. I have to give the modifier a height to make it work eg:
LazyRowWithShadows(modifier = Modifier
.fillMaxWidth()
.height(X.dp), // This is what makes it display, but requires knowing the height of the row
contentPadding = PaddingValues(horizontal = 12.dp),
shadowColor = colorResource(R.color.blue_white),
shadowAlphaStart = 0f, shadowAlphaEnd = 0.8f,
shadowWidth = 48.dp
) {
// Content
}
I would prefer if the LazyRowWithShadows handled displaying the shadows even if no size is specified when calling it. Is there an easy way to do this?
This is the code for the LazyRowWithShadows:
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/** A LazyRow that shows start & end shadows when content is not fully scrolled. */
#ExperimentalAnimationApi
#Composable
fun LazyRowWithShadows(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
shadowColor: Color = Color.Black,
shadowAlphaStart: Float = 0f,
shadowAlphaEnd: Float = 0.3f,
shadowWidth: Dp = 32.dp,
content: LazyListScope.() -> Unit
) {
val listState = rememberLazyListState()
val showStartShadow by remember {
derivedStateOf {
listState.firstVisibleItemScrollOffset > 0 || listState.firstVisibleItemIndex > 0
}
}
val showEndShadow by remember {
derivedStateOf {
val lastItemIfVisible = listState.layoutInfo.visibleItemsInfo.maxByOrNull { it.index }?.takeIf { it.index == listState.layoutInfo.totalItemsCount - 1 }
if (lastItemIfVisible != null) {
val lastItemEndX = lastItemIfVisible.offset + lastItemIfVisible.size
lastItemEndX > listState.layoutInfo.viewportEndOffset
} else {
true
}
}
}
Box(modifier = modifier) {
LazyRow(
state = listState,
contentPadding = contentPadding,
content = content)
// Start scroll shadow
AnimatedVisibility(visible = showStartShadow, modifier = Modifier.align(Alignment.CenterStart)) {
Box(modifier = Modifier
.fillMaxHeight()
.width(shadowWidth)
.background(brush = Brush.horizontalGradient(colors = listOf(shadowColor.copy(alpha = shadowAlphaEnd), shadowColor.copy(alpha = shadowAlphaStart))))
)
}
// End scroll shadow
AnimatedVisibility(visible = showEndShadow, modifier = Modifier.align(Alignment.CenterEnd)) {
Box(modifier = Modifier
.fillMaxHeight()
.width(shadowWidth)
.background(brush = Brush.horizontalGradient(colors = listOf(shadowColor.copy(alpha = shadowAlphaStart), shadowColor.copy(alpha = shadowAlphaEnd))))
)
}
}
}
Edit: I have tried the solution proposed in fillMaxSize modifier not working when combined with VerticalScroll in Jetpack Compose, but it didn't solve my issue. The changes I made to try to solve it using that answer as inspiration are:
BoxWithConstraints(modifier = modifier) { // Changed to BoxWithConstraints
LazyRow(
state = listState,
contentPadding = contentPadding,
content = content)
// Start scroll shadow
AnimatedVisibility(visible = showStartShadow, modifier = Modifier.align(Alignment.CenterStart)) {
Box(modifier = Modifier
.height(this#BoxWithConstraints.maxHeight) // Changed from fillMaxHeight
.width(shadowWidth)
.background(brush = Brush.horizontalGradient(colors = listOf(shadowColor.copy(alpha = shadowAlphaEnd), shadowColor.copy(alpha = shadowAlphaStart))))
)
}
// End scroll shadow
AnimatedVisibility(visible = showEndShadow, modifier = Modifier.align(Alignment.CenterEnd)) {
Box(modifier = Modifier
.height(this#BoxWithConstraints.maxHeight) // Changed from fillMaxHeight
.width(shadowWidth)
.background(brush = Brush.horizontalGradient(colors = listOf(shadowColor.copy(alpha = shadowAlphaStart), shadowColor.copy(alpha = shadowAlphaEnd))))
)
}
}
I also tried adding a height to the AnimatedVisibility elements, but that didn't change anything.
After trying out a couple of ways, using Modifier.onSizeChanged to get the height of LazyRow finally made it work.
Convert this height to dp and pass it to Modifier.height for shadow's Box(s).
Box(modifier = modifier) {
val density = LocalDensity.current
var height by remember { mutableStateOf(0) }
val heightDp = remember(height) { with(density){ height.toDp() } }
LazyRow(
modifier = Modifier.onSizeChanged {
height = it.height
},
state = listState,
contentPadding = contentPadding,
content = content)
// Start scroll shadow
AnimatedVisibility(visible = showStartShadow, modifier = Modifier.align(Alignment.CenterStart)) {
Box(modifier = Modifier
.height(heightDp)
.width(shadowWidth)
.background(brush = Brush.horizontalGradient(colors = listOf(shadowColor.copy(alpha = shadowAlphaEnd), shadowColor.copy(alpha = shadowAlphaStart))))
)
}
// End scroll shadow
AnimatedVisibility(visible = showEndShadow, modifier = Modifier.align(Alignment.CenterEnd)) {
Box(modifier = Modifier
.height(heightDp)
.width(shadowWidth)
.background(brush = Brush.horizontalGradient(colors = listOf(shadowColor.copy(alpha = shadowAlphaStart), shadowColor.copy(alpha = shadowAlphaEnd))))
)
}
}
Related
My current android applications main screen contains a androidx.compose.material.BackdropScaffold. The backdrop looks great and functions exactly as it says on the tin.
however I have an issue with the frontLayerContent which contains a list of items.
I allow the user to interact with the frontLayerContent list while the backdrop is revealed, the issue is with the backdrop in the revealed state the user cannot scroll down to see the last item in the frontLayerContent list.
The solution to this issue is to use backdropState.offset in the modifier of the frontLayerContent which I obtain as follows:-
val backdropState = rememberBackdropScaffoldState(initialValue = BackdropValue.Concealed)
var offset by (backdropState.offset as MutableState)
and set as follows:-
onBackdropReveal = {
if (!backdropState.isAnimationRunning) {
scope.launch {
if (backdropState.isConcealed) {
offset = backdropState.offset.value
backdropState.reveal()
} else {
offset = backdropState.offset.value
backdropState.conceal()
}
}
}
}
and make the offset value available to other composables with:-
frontLayerContent = {
CompositionLocalProvider(LocalBackdropStateOffset provides offset) {
frontLayerContent()
}
}
Then in my frontLayerContent I retrieve the offset value as use as follows:-
val context = LocalContext.current
val offset = LocalBackdropStateOffset.current
Scaffold(
topBar = { Spacer(modifier = Modifier.height(0.dp)) },
modifier = Modifier.fillMaxSize(),
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.offset { IntOffset(x = 0, y = -offset.toInt()) },
verticalArrangement = Arrangement.Top
) {
LazyVerticalGrid(
modifier = Modifier.padding(10.dp),
columns = GridCells.Adaptive(125.dp),
contentPadding = paddingValues,
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
content = {
items(state.size) { index ->
this solution is close to what is required, however it looks as though I have made a mistake somewhere as my frontLayerContent list is always vertically offset even when the backdrop is concealed. In fact revealing or concealing the backdrop does not change the amount of offset of my frontLayerContent list.
how do I fix this issue?
how can I correctly set the frontLayerContent list vertical offset depending on whether the backdrop is concealed or revealed?
UPDATE
I only need to "fix" when the backdrop is revealed. therefore i need a conditional configuration of my frontLayerContent Modifier.offset(y = -offset)
how are you supposed to use the backdropState.offset value when correcting the frontLayerContent offset?
as the compose coordinate systems origin is the top left hand corner (x = 0, y = 0) and y dimension increases down the screen and x dimension increases Left to Right. when the backdrop is revealed the offset is a value (on my pixel 5) of approx 600.dp and concealed value of approx 300.dp. why is the concealed offset not 0.dp? is this taking into account the screens TopAppBar?
UPDATE (2)
Heres a GIF I made earlier
UPDATE (3)
This basic sample shows the problem i am having.
where have i made my mistake that stops me being able to scroll down
to see the complete last item when the backdrop is revealed?
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.BackdropScaffold
import androidx.compose.material.BackdropValue
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.rememberBackdropScaffoldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.elsevier.daisy.ui.theme.MyTheme
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
#OptIn(ExperimentalMaterial3Api::class)
#ExperimentalMaterialApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val scaffoldState = rememberBackdropScaffoldState(initialValue = BackdropValue.Concealed)
val scope = rememberCoroutineScope()
MyTheme {
BackdropScaffold(
scaffoldState = scaffoldState,
frontLayerScrimColor = Color.Unspecified,
frontLayerElevation = 5.dp,
gesturesEnabled = false,
appBar = {
TopAppBar(
title = { Text("Backdrop") },
navigationIcon = {
if (scaffoldState.isConcealed) {
IconButton(
onClick = {
scope.launch { scaffoldState.reveal() }
}
) {
Icon(
Icons.Default.Menu,
contentDescription = "Menu"
)
}
} else {
IconButton(
onClick = {
scope.launch { scaffoldState.conceal() }
}
) {
Icon(
Icons.Default.Close,
contentDescription = "Close"
)
}
}
}
)
},
backLayerContent = {
Column {
Text(
text = "Menu Item 1", modifier = Modifier.padding(8.dp), color = Color.White, style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
color = Color.Black
)
)
Text(
text = "Menu Item 1", modifier = Modifier.padding(8.dp), color = Color.White, style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
color = Color.Black
)
)
}
},
frontLayerContent = {
LazyColumn(modifier = Modifier.fillMaxSize()) {
// Add 5 items
items(55) { index ->
Text(
text = "Item: ${index + 1}",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.padding(5.dp)
)
}
}
},
peekHeight = 60.dp,
) {
}
}
}
}
}
I would suggest adding a padding to the LazyColumn/LazyVerticalGrid itself rather than playing with the offset.
Given your example, I would simply add a conditional padding to the LazyColumn:
val padding = if (
scaffoldState.isRevealed &&
!scaffoldState.isAnimationRunning
) {
64.dp
} else {
0.dp
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(bottom = padding)
) {
// Your items here
}
I've used 64.dp here but use whatever you feel is necessary according to your design.
If instead of using that magic number you want to calculate the height of the backLayerContent you can by using the onGloballyPositioned modifier and save the height in a state.
Add this before your BackdropScaffold
var backLayerContentHeight by remember { mutableStateOf(0.dp) }
Get the backLayerContent height:
backLayerContent = {
val localDensity = LocalDensity.current
Column(
modifier = Modifier.onGloballyPositioned {
val heightInPx = it.size.height
val heightInDp = with(localDensity) { heightInPx.toDp() }
backLayerContentHeight = heightInDp
}
) {
...
}
},
Then you can use this value for the padding:
val padding = if (
scaffoldState.isRevealed &&
!scaffoldState.isAnimationRunning
) {
backLayerContentHeight
} else {
0.dp
}
Alternative to BackdropScaffold
You may want to create a custom implementation to get a similar behavior without workarounds.
This custom component should help:
#Composable
fun CustomBackdropScaffold(
modifier: Modifier = Modifier,
appBar: #Composable () -> Unit,
backLayerContent: #Composable () -> Unit,
frontLayerContent: #Composable () -> Unit,
isRevealed: Boolean,
) {
Surface(modifier = modifier) {
Column {
appBar()
AnimatedVisibility(
visible = isRevealed,
enter = expandVertically(
expandFrom = Alignment.Top,
),
exit = shrinkVertically(
shrinkTowards = Alignment.Top,
),
) {
backLayerContent()
}
frontLayerContent()
}
}
}
Here as an example of how to use it:
var isRevealed by remember { mutableStateOf(false) }
CustomBackdropScaffold(
appBar = {
TopAppBar(
title = { Text("Backdrop") },
navigationIcon = {
if (!isRevealed) {
IconButton(
onClick = {
isRevealed = true
}
) {
Icon(
Icons.Default.Menu,
contentDescription = "Menu"
)
}
} else {
IconButton(
onClick = {
isRevealed = false
}
) {
Icon(
Icons.Default.Close,
contentDescription = "Close"
)
}
}
}
)
},
backLayerContent = {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primary)
) {
Text(
text = "Menu Item 1",
modifier = Modifier.padding(8.dp),
color = Color.White,
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
color = Color.Black
)
)
Text(
text = "Menu Item 1",
modifier = Modifier.padding(8.dp),
color = Color.White,
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
color = Color.Black
)
)
}
},
frontLayerContent = {
LazyColumn(
modifier = Modifier
.fillMaxSize()
) {
// Add 5 items
items(55, key = { it }) { index ->
Text(
text = "Item: ${index + 1}",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.padding(5.dp)
)
}
}
},
isRevealed = isRevealed
)
I am creating a screen with a color picker. Here's the code for the whole screen, including the color picker code. The reason why I'm including the whole screen's code instead of just the color picker's is because I want to give more context.
package com.gitlab.djsushi123.kingenscoreboard.presentation.screen.newplayer
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import com.gitlab.djsushi123.kingenscoreboard.domain.model.Player
import com.google.accompanist.systemuicontroller.SystemUiController
import kotlinx.coroutines.launch
import org.koin.androidx.compose.getViewModel
#Composable
fun NewPlayerScreen(
viewModel: NewPlayerScreenViewModel = getViewModel(),
appNavController: NavHostController,
systemUiController: SystemUiController,
modifier: Modifier = Modifier
) {
val coroutineScope = rememberCoroutineScope()
val state = viewModel.state
val animatedSelectedColor by animateColorAsState(
targetValue = state.playerColor,
animationSpec = tween(durationMillis = 600)
)
val primaryVariantColor = MaterialTheme.colors.primaryVariant
val darkIconsDisposable = MaterialTheme.colors.isLight
val useDarkIcons = with(animatedSelectedColor) {
(red + green + blue) / 3f > 0.5f
}
DisposableEffect(key1 = animatedSelectedColor) {
systemUiController.setStatusBarColor(
color = animatedSelectedColor,
darkIcons = useDarkIcons
)
onDispose {
systemUiController.setStatusBarColor(
color = primaryVariantColor,
darkIcons = darkIconsDisposable
)
}
}
Scaffold(
topBar = {
TopBar(
color = animatedSelectedColor,
onColor = if (useDarkIcons) Color.Black else Color.White,
saveButtonEnabled = state.saveButtonEnabled,
onBackButtonClicked = { appNavController.popBackStack() },
onSave = {
val player = Player(
name = state.playerName,
abbreviation = state.playerAbbreviation,
color = state.playerColor
)
coroutineScope.launch {
viewModel.savePlayer(player)
appNavController.popBackStack()
}
}
)
},
modifier = modifier
) { paddingValues ->
Surface(color = MaterialTheme.colors.surface, modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()) // so that we can scroll with the keyboard open
.padding(paddingValues)
.padding(start = 8.dp, top = 8.dp, end = 8.dp)
) {
// Player name field with error
OutlinedTextField(
value = state.playerName,
onValueChange = {
viewModel.onEvent(
NewPlayerFormEvent.PlayerNameChanged(name = it)
)
},
isError = state.playerNameError != null,
label = { Text(text = "Name") },
placeholder = { Text(text = "John Cena") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words
),
modifier = Modifier.fillMaxWidth()
)
Text(
text = state.playerNameError ?: "",
color = MaterialTheme.colors.error,
textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth()
)
// Player abbreviation field
OutlinedTextField(
value = state.playerAbbreviation,
onValueChange = {
viewModel.onEvent(
NewPlayerFormEvent.PlayerAbbreviationChanged(abbreviation = it)
)
},
isError = state.playerAbbreviationError != null,
label = { Text(text = "Abbreviation") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Characters,
autoCorrect = false
),
modifier = Modifier.fillMaxWidth()
)
Text(
text = state.playerAbbreviationError ?: "",
color = MaterialTheme.colors.error,
textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
ColorPicker(
selected = state.playerColor,
onSelect = { color ->
viewModel.onEvent(
NewPlayerFormEvent.PlayerColorChanged(color = color)
)
},
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
#Composable
private fun TopBar(
color: Color,
onColor: Color,
saveButtonEnabled: Boolean,
onBackButtonClicked: () -> Unit,
onSave: () -> Unit
) {
val saveButtonAlpha by animateFloatAsState(
if (saveButtonEnabled) ContentAlpha.high else ContentAlpha.disabled
)
TopAppBar(
title = { Text(text = "New player", color = onColor) },
navigationIcon = {
IconButton(onClick = onBackButtonClicked) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "Back",
tint = onColor
)
}
},
actions = {
IconButton(onClick = onSave, enabled = saveButtonEnabled) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Save",
tint = onColor.copy(alpha = saveButtonAlpha)
)
}
},
backgroundColor = color
)
}
#Composable
private fun ColorPicker(
selected: Color,
onSelect: (color: Color) -> Unit,
modifier: Modifier = Modifier
) {
val colors: List<Color> = remember {
(0..340 step 8).map { hue ->
Color.hsl(hue = hue.toFloat(), saturation = 0.5f, lightness = 0.5f)
}.dropLast(3)
}
Column(modifier = modifier) {
for (row in 0..(colors.size - 1) / 4) {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
for (tile in 0 until 4) {
val color = colors[row * 4 + tile]
ColorPickerTile(
color = color,
selected = color == selected,
onClick = { onSelect(color) },
modifier = Modifier
.width(70.dp)
.height(70.dp)
)
}
}
}
}
}
#Composable
private fun ColorPickerTile(
color: Color,
selected: Boolean,
onClick: () -> Unit,
shape: Shape = MaterialTheme.shapes.small,
modifier: Modifier = Modifier
) {
val scale = remember { Animatable(initialValue = 1f) }
val borderWidth by animateDpAsState(
targetValue = if (selected) 6.dp else 0.dp,
animationSpec = tween(durationMillis = 350)
)
LaunchedEffect(key1 = selected) {
if (selected) {
launch {
scale.animateTo(
targetValue = 0.90f,
animationSpec = tween(
durationMillis = 50
)
)
scale.animateTo(
targetValue = 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessLow
)
)
}
} else {
launch { scale.animateTo(1f) }
}
}
Box(
modifier = modifier
.scale(scale.value)
.border(
width = borderWidth,
color = if (borderWidth.value == 0f) Color.Transparent else MaterialTheme.colors.onSurface,
shape = shape
)
.clip(shape)
.background(color = color)
.clickable(onClick = onClick)
)
}
My color picker is composed of ColorPickerTiles, which are stacked in a grid that is 4 tiles wide. This grid is a made-up grid from Rows inside a big Column. My problem is related to performance. When we check the "layout inspector" tab in Android Studio, we can see that when the user selects a different color tile in the app, every single ColorPickerTile gets recomposed MULTIPLE TIMES because of an animation I've added. I don't understand why this is necessary and why is this even possible, since the ColorPickerTile composable is restartable and also skippable and none of its parameters changed AFAIK. Here's a screenshot of what I mean:
The fact that these recompositions happen for every single ColorPickerTile many times during an animation means that the whole animation lags, especially on low-end devices. And the lag increases exponentially, with more ColorPickerTiles. My question is, what is causing these useless recompositions? And how would I go about solving the issue?
I'm using Jetpack compose in my project. I have a scrollable column. I want to show a column as the top bar when the user scrolls the screen. For this purpose, I listen to the state of the scroll in this way:
val scrollState = rememberScrollState()
Box {
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(scrollState)
) {
...
...
...
}
TopBar(scrollOffset = (scrollState.value * 0.1))
}
and the TopBar is another composable:
#Composable
fun HiddenTopBar(scrollOffset: Double, onSearchListener: () -> Unit) {
val offset = if (-50 + scrollOffset < 0) (-50 + scrollOffset).dp else 0.dp
Box(
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.offset(y = offset)
.background(MaterialTheme.colors.secondary)
.padding(vertical = MaterialTheme.space.small)
) {
...
...
...
}
}
The problem is that due to constant recomposition, the scroll lags, and it is not smooth. Is there any way I can implement it more efficiently?
Yes, it's because of constant recomposition in performance documentation.
If you were checking a state derived from scroll state such as if it's scrolled you could go for derivedState but you need it on each change, nestedScrollConnection might help i guess.
This sample might help you how to implement it
#Composable
private fun NestedScrollExample() {
val density = LocalDensity.current
val statusBarTop = WindowInsets.statusBars.getTop(density)
val toolbarHeight = 100.dp
val toolbarHeightPx = with(LocalDensity.current) { toolbarHeight.roundToPx().toFloat() }
// our offset to collapse toolbar
val toolbarOffsetHeightPx = remember { mutableStateOf(0f) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
val newOffset = toolbarOffsetHeightPx.value + delta
toolbarOffsetHeightPx.value =
newOffset.coerceIn(-(2 * statusBarTop + toolbarHeightPx), 0f)
return Offset.Zero
}
}
}
Box(
Modifier
.fillMaxSize()
// attach as a parent to the nested scroll system
.nestedScroll(nestedScrollConnection)
) {
Column(
modifier = Modifier
.padding(
PaddingValues(
top = toolbarHeight + 8.dp,
start = 8.dp,
end = 8.dp,
bottom = 8.dp
)
)
.verticalScroll(rememberScrollState())
,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(modifier = Modifier
.fillMaxWidth()
.height(2000.dp))
}
TopAppBar(modifier = Modifier
.height(toolbarHeight)
.offset { IntOffset(x = 0, y = toolbarOffsetHeightPx.value.roundToInt()) },
elevation = 2.dp,
backgroundColor = Color.White,
title = { Text("toolbar offset is ${toolbarOffsetHeightPx.value}") })
}
}
i want something like this image. tried many solution but no result.
I use this code to show how much of the page has been scrolled
BoxWithConstraints() {
val scrollState = rememberScrollState()
val viewMaxHeight = maxHeight.value
val columnMaxScroll = scrollState.maxValue
val scrollStateValue = scrollState.value
val paddingSize = (scrollStateValue * viewMaxHeight) / columnMaxScroll
val animation = animateDpAsState(targetValue = paddingSize.dp)
val scrollBarHeight = viewMaxHeight / items
Column(
Modifier
.verticalScroll(state = scrollState)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (scrollStateValue < columnMaxScroll) {
Box(
modifier = Modifier
.paddingFromBaseline(animation.value)
.padding(all = 4.dp)
.height(scrollBarHeight.dp)
.width(4.dp)
.background(
color = MaterialTheme.colors.primary.copy(alpha = ContentAlpha.disabled),
shape = MaterialTheme.shapes.medium
)
.align(Alignment.TopEnd),
) {}
}
}
}
Result:
My problem is that i need a tab indicator to match exactly to the text that is above it (from designs):
However, all i managed to do is get something looking like this:
My code:
ScrollableTabRow(
selectedTabIndex = selectedSeason,
backgroundColor = Color.White,
edgePadding = 0.dp,
modifier = Modifier
.padding(vertical = 24.dp)
.height(40.dp),
indicator = { tabPositions ->
TabDefaults.Indicator(
color = Color.Red,
height = 4.dp,
modifier = Modifier
.tabIndicatorOffset(tabPositions[selectedSeason])
)
}
) {
item.seasonList().forEachIndexed { index, contentItem ->
Tab(
modifier = Modifier.padding(bottom = 10.dp),
selected = index == selectedSeason,
onClick = { selectedSeason = index }
)
{
Text(
"Season " + contentItem.seasonNumber(),
color = Color.Black,
style = styles.seasonBarTextStyle(index == selectedSeason)
)
}
}
}
}
Also a little bonus question, my code for this screen is inside lazy column, now i need to have this tab row to behave somewhat like a sticky header(when it gets to the top, screen stops scrolling, but i can still scroll the items inside it)
Thanks for your help
I had the same requirement, and came up with a simpler solution. By putting the same horizontal padding on the Tab and on the indicator, the indicator aligns with the tab's content:
ScrollableTabRow(selectedTabIndex = tabIndex,
indicator = { tabPositions ->
Box(
Modifier
.tabIndicatorOffset(tabPositions[tabIndex])
.height(TabRowDefaults.IndicatorHeight)
.padding(end = 20.dp)
.background(color = Color.White)
)
}) {
Tab(modifier = Modifier.padding(end = 20.dp, bottom = 8.dp),
selected = tabIndex == 0, onClick = { tabIndex = 0}) {
Text(text = "Tab 1!")
}
Tab(modifier = Modifier.padding(end = 20.dp, bottom = 8.dp),
selected = tabIndex == 1, onClick = { tabIndex = 1}) {
Text(text = "Tab 2!")
}
}
Have a look at the provided modifier, it internally computes a width value. If you change the Modifier yourself to the code below you can provide a width value.
fun Modifier.ownTabIndicatorOffset(
currentTabPosition: TabPosition,
currentTabWidth: Dp = currentTabPosition.width
): Modifier = composed(
inspectorInfo = debugInspectorInfo {
name = "tabIndicatorOffset"
value = currentTabPosition
}
) {
val indicatorOffset by animateAsState(
targetValue = currentTabPosition.left,
animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing)
)
fillMaxWidth()
.wrapContentSize(Alignment.BottomStart)
.offset(x = indicatorOffset + ((currentTabPosition.width - currentTabWidth) / 2))
.preferredWidth(currentTabWidth)
}
Now to the point of how to get the width of your Text:
Warning: I think it's not the way to do it but I can't figure out a better one atm.
At first, I create a Composable to provide me the width of its contents.
#Composable
fun MeasureWidthOf(setWidth: (Int) -> Unit, content: #Composable () -> Unit) {
Layout(
content = content
) { list: List<Measurable>, constraints: Constraints ->
check(list.size == 1)
val placeable = list.last().measure(constraints)
layout(
width = placeable.width.also(setWidth),
height = placeable.height
) {
placeable.placeRelative(x = 0, y = 0)
}
}
}
Now I can use it in your example (simplified):
// Needed for Android
fun Float.asPxtoDP(density: Float): Dp {
return (this / (density)).dp
}
fun main(args: Array<String>) {
Window(size = IntSize(600, 800)) {
val (selectedSeason, setSelectedSeason) = remember { mutableStateOf(0) }
val seasonsList = mutableListOf(2020, 2021, 2022)
val textWidth = remember { mutableStateListOf(0, 0, 0) }
// Android
val density = AmbientDensity.current.density
ScrollableTabRow(
selectedTabIndex = selectedSeason,
backgroundColor = Color.White,
edgePadding = 0.dp,
modifier = Modifier
.padding(vertical = 24.dp)
.height(40.dp),
indicator = { tabPositions ->
TabDefaults.Indicator(
color = Color.Red,
height = 4.dp,
modifier = Modifier
.ownTabIndicatorOffset(
currentTabPosition = tabPositions[selectedSeason],
// Android:
currentTabWidth = textWidth[selectedSeason].asPxtoDP(density)
// Desktop:
currentTabWidth = textWidth[selectedSeason].dp
)
)
}
) {
seasonsList.forEachIndexed { index, contentItem ->
Tab(
modifier = Modifier.padding(bottom = 10.dp),
selected = index == selectedSeason,
onClick = { setSelectedSeason(index) }
)
{
val text = #Composable {
Text(
text = "Season $contentItem",
color = Color.Black,
textAlign = TextAlign.Center
)
}
if (index == selectedSeason) {
MeasureWidthOf(setWidth = { textWidth[index] = it }) {
text()
}
} else {
text()
}
}
}
}
}
}
Edit (05.01.2021): Simplified Modifier code
Edit (09.01.2021): Fixed density problem on android and tested on Desktop and Android