I am facing a bug. While the user scrolls the lazy list, the topbar should be animated. Animation works just fine, but when animation goes, the top bar gets constantly recomposed.
@Composable
fun ServerSelectionScreen(
modifier: Modifier = Modifier,
navController: NavHostController,
viewModel: ServerSelectionViewModel,
) {
val listState = rememberLazyListState()
val scrollOffset = calculateScrollOffset(listState, 100)
val servers by viewModel.filteredServers.collectAsState()
val isRefreshing by viewModel.isRefreshing.collectAsState()
val error by viewModel.error.collectAsState()
val selectedServerId by viewModel.selectedServerId.collectAsState()
val interactionSource = remember { MutableInteractionSource() }
LaunchedEffect(Unit) {
viewModel.navigationEvents.collect { event ->
when (event) {
is ServerSelectionNavigationEvent.ShowPaywall -> {
navController.navigate(SUBSCRIPTION_SCREEN)
}
}
}
}
Scaffold(
containerColor = Color(0xff071326),
topBar = {
Row(
modifier = Modifier
.statusBarsPadding()
.padding(start = 16.dp, end = 16.dp, top = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
ClickableIcon(
painter = painterResource(id = R.drawable.chevron_left),
contentDescription = "Back",
tint = Color(0xff2694ff),
onClick = { navController.popBackStack() },
interactionSource = interactionSource
)
Spacer(modifier = Modifier.width(8.dp))
ClickableText(
text = stringResource(id = R.string.back_top_bar),
onClick = { navController.popBackStack() },
interactionSource = interactionSource
)
}
Column {
ServerSelectionTopBar(
listState = listState,
scrollOffset = scrollOffset,
onBackClick = {
navController.popBackStack()
},
onSearchQueryChanged = viewModel::onSearchQueryChanged
)
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
SwipeRefresh(
state = rememberSwipeRefreshState(isRefreshing),
onRefresh = { viewModel.refreshServers() },
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(start = 16.dp, end = 16.dp)
) {
when {
error.isNotEmpty() -> Text(
text = error,
color = Color.Red,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
else -> ServerSelectionContent(
listState = listState,
servers = servers,
selectedServerId = selectedServerId,
onSelectServer = { server ->
viewModel.selectServer(server)
if (!server.premium) {
navController.popBackStack()
}
},
getPingDisplay = viewModel::getPingDisplay,
onShowPaywall = {
navController.navigate(SUBSCRIPTION_SCREEN)
}
)
}
}
}
}
}
}
@Composable
fun ServerSelectionContent(
listState: LazyListState,
servers: List<Server>,
selectedServerId: Int?,
onSelectServer: (Server) -> Unit,
onShowPaywall: () -> Unit,
getPingDisplay: (Int?) -> Pair<String, Color>,
modifier: Modifier = Modifier,
) {
LazyColumn(
state = listState,
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(bottom = 24.dp)
) {
items(servers, key = { it.id }) { server ->
val (pingText, pingColor) = getPingDisplay(server.ping)
ServerCardItem(
server = server,
isSelected = server.id == selectedServerId,
onSelectServer = onSelectServer,
pingText = pingText,
pingColor = pingColor
)
}
}
}
@Composable
fun ServerCardItem(
server: Server,
isSelected: Boolean,
onSelectServer: (Server) -> Unit,
pingText: String,
pingColor: Color,
modifier: Modifier = Modifier,
) {
val checkedState = rememberUpdatedState(newValue = isSelected)
val countryName = server.country.name
val cityName = server.location
val image = server.icon
CustomCard(
onClick = {
onSelectServer(server)
},
frontElement = {
CustomCheckCircle(
isChecked = checkedState.value,
onValueChange = {
onSelectServer(server)
},
checkedColor = Color(0xff2694ff),
uncheckedColor = Color.Transparent,
colorBorder = Color(0xff888d97),
iconColor = Color.White,
size = 24f,
)
},
midElement = {
AsyncImage(
model = image,
contentDescription = "Server icon",
modifier = modifier
.size(32.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
Column(
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.Top)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = countryName,
color = Color.White,
style = Typography.labelLarge
)
if (server.premium) {
Image(
painter = painterResource(id = R.drawable.gem_icon),
contentDescription = "star",
modifier = modifier.requiredSize(size = 18.dp)
)
}
}
Text(
text = server.name,
color = Color.White.copy(alpha = 0.5f),
textAlign = TextAlign.Center,
style = Typography.bodySmall
)
}
},
endElement = {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start)
) {
Image(
painter = painterResource(id = R.drawable.signal_bars),
contentDescription = "bars",
modifier = modifier.requiredSize(size = 16.dp),
colorFilter = ColorFilter.tint(pingColor)
)
Text(
text = pingText,
color = Color.White.copy(alpha = 0.5f),
textAlign = TextAlign.End,
style = Typography.bodySmall,
modifier = modifier.wrapContentHeight(align = Alignment.CenterVertically)
)
}
},
containerColor = Color.White.copy(alpha = 0.04f)
)
}
// Function to calculate scroll offset
@Composable
fun calculateScrollOffset(
listState: LazyListState,
maxScrollOffset: Int,
): androidx.compose.runtime.State<Int> {
return remember {
derivedStateOf {
(listState.firstVisibleItemIndex * listState.layoutInfo.viewportSize.height +
listState.firstVisibleItemScrollOffset).coerceIn(0, maxScrollOffset)
}
}
}
// Updated ServerSelectionTopBar composable
@Composable
fun ServerSelectionTopBar(
listState: LazyListState,
scrollOffset: androidx.compose.runtime.State<Int>,
modifier: Modifier = Modifier,
onBackClick: () -> Unit,
onSearchQueryChanged: (String) -> Unit,
) {
val maxScrollOffset = 100
val screenWidthDp = LocalConfiguration.current.screenWidthDp
val density = LocalDensity.current
val interactionSource = remember { MutableInteractionSource() }
// Animate the height
val animatedHeight by animateDpAsState(targetValue = 180.dp - (40.dp * (scrollOffset.value / maxScrollOffset.toFloat())))
// Animate the font size
val animatedFontSize by animateFloatAsState(targetValue = 24f - (8f * (scrollOffset.value / maxScrollOffset.toFloat())))
// Animate the translation Y
val animatedTranslationY by animateFloatAsState(targetValue = 170f - (80f * (scrollOffset.value / maxScrollOffset.toFloat())))
// Animate the translation X
val animatedTranslationX by animateFloatAsState(targetValue = with(density) {
0f + ((screenWidthDp / 2 - 52).dp.toPx() * (scrollOffset.value / maxScrollOffset.toFloat()))
})
Box(
modifier = modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp),
contentAlignment = Alignment.BottomCenter
) {
Column(
modifier = Modifier
.height(animatedHeight)
) {
Box(
modifier = Modifier
.graphicsLayer {
translationX = animatedTranslationX
translationY = animatedTranslationY
}
) {
Text(
text = stringResource(R.string.servers),
color = Color.White,
style = TextStyle(
fontSize = animatedFontSize.sp,
fontWeight = FontWeight.Bold
),
modifier = Modifier
.padding(top = 8.dp, bottom = 24.dp)
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
translationY = animatedTranslationY
}
) {
SearchBar(
hint = stringResource(R.string.search),
modifier = Modifier.fillMaxWidth(),
onTextChange = onSearchQueryChanged
)
}
}
}
}
I have tried using derivedStateOf() but it didn’t help me. Tried isolating animations, also no effect.
I thought that maybe rememberLazyListState() is somehow updates the scroll state, which can cause recomposition, but haven’t found any useful documentation on my case