Is ScrollableTabRow lazy - android-jetpack-compose

If i create like 1000 Tabs within the ScrollableTabRow will it only compose the Tabs which are visible to the screen or`will compose everytime non-visible Tabs if something changes?
Like same as a LazyColumn composes only visible items.

I've written a minimal example in which i'm just "repeat"ing the same composable a lot of times - with no item{}-like DSL available like the lazy-components have.
#Composable
#Preview
fun MinimalTabExample() {
var selectedTabIndex by remember { mutableStateOf(0) }
ScrollableTabRow(selectedTabIndex = selectedTabIndex) {
repeat(60) { tabNumber ->
Tab(
selected = selectedTabIndex == tabNumber,
onClick = { selectedTabIndex = tabNumber },
text = { Text(text = "Tab #$tabNumber") }
)
}
}
}
Also, if you look into the ScrollableTabRow component you'll find that all the tabs are measured right at the beginning: (tabs are the compose-tabs created above)
val tabPlaceables = subcompose(TabSlots.Tabs, tabs)
.map { it.measure(tabConstraints) }
I guess this is implemented this way due to the height-calculation: "Make the height as high as the highest component"
tabPlaceables.forEach {
layoutWidth += it.width
layoutHeight = maxOf(layoutHeight, it.height)
}

Related

Wrap content of `ScrollableTabRow` composable so it goes past the screen width

I want to make a ScrollableTabRow that goes past the width of the screen to make it clearer that there are more tabs to scroll through. It should look something like this:
When scrolled all the way to the left:
When scrolled somewhere in the middle:
When scrolled all the way to the right:
However, I cannot achieve it using the Material component ScrollableTabRow because the ScrollableTabRow is filling the remaining width of the screen, instead of fully wrapping its content.
When scrolled all the way to the left:
When scrolled all the way to the right:
Here is my code using the ScrollableTabRow composable:
Note: 1.unit is equal to 4.dp
Row {
HorizontalSpacer(width = 4.unit)
ScrollableTabRow(
modifier = Modifier.clip(CircleShape).wrapContentSize(),
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
SpecsheetTabIndicator(tabPositions = tabPositions, pagerState = pagerState)
},
edgePadding = 0.dp,
divider = {},
containerColor = MaterialTheme.colorScheme.primaryContainer,
) {
for ((index, tab) in tabs.withIndex()) {
val textColor by animateColorAsState(
targetValue = when (pagerState.currentPage) {
index -> MaterialTheme.colorScheme.onPrimary
else -> MaterialTheme.colorScheme.onPrimaryContainer
},
)
Tab(
modifier = Modifier
.zIndex(6f)
.clip(CircleShape),
selected = pagerState.currentPage == index,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
},
text = {
Text(
text = tab.text(),
color = textColor,
)
},
)
}
}
HorizontalSpacer(width = 4.unit)
}
Also, as you can see in my code above, the Row is not really scrolling because I am not sure how to implement nested scrolling. Adding a horizontalScroll modifier to the Row makes the app crash because of the unhandled nested scrolling.
I have achieved the behavior I wanted as demonstrated in the first three images using regular rows:
Row(
modifier = Modifier.horizontalScroll(scrollState),
) {
HorizontalSpacer(width = 4.unit)
Row(
modifier = Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer)
) {
for ((index, tab) in tabs.withIndex()) {
val containerColor by animateColorAsState(
targetValue = when (pagerState.currentPage) {
index -> MaterialTheme.colorScheme.primary
else -> Color.Transparent
},
)
val textColor by animateColorAsState(
targetValue = when (pagerState.currentPage) {
index -> MaterialTheme.colorScheme.onPrimary
else -> MaterialTheme.colorScheme.onPrimaryContainer
},
)
Surface(
modifier = Modifier.clip(CircleShape),
color = containerColor,
) {
Tab(
modifier = Modifier
.zIndex(6f)
.clip(CircleShape)
.defaultMinSize(minWidth = 24.unit),
selected = pagerState.currentPage == index,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
},
text = {
Text(
text = tab.text(),
color = textColor,
)
},
)
}
}
}
HorizontalSpacer(width = 4.unit)
}
However, I don't know how to reimplement a lot of the behavior that are built into the ScrollableTabRow like the proper rendering of the indicator and the proper scrolling of the tab row to the selected tab when the selected tab is not visible. Can anyone help me make this work for me?

Jetpack Compose TabRow flickers when content is accompanist webview

I am experiencing flicker or overlapping when having a compose tabBar implementation with webviews as content. If I change the webviews with another view (ex. Box{Text}) it does not happen.
It seems as if the webview is filling more than it's border for a short while (See .gif below)
Update: I have been looking into if it was a recomposition issue (hence the simple test project) and I cannot identify any reason why it should recompose the tab bar.
When I add height to the tab bar, I can see the text is in the tab bar at all times.
A test project can be fetched here: https://github.com/msuhl/ComposeTabTest and is a very standard implementation
#Composable
private fun MyTabRow(
pagerState: PagerState,
coroutineScope: CoroutineScope,
) {
TabRow(
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
color = MaterialTheme.colors.secondary
)
},
) {
tabRowItems.forEachIndexed { index, item ->
Tab(
selected = pagerState.currentPage == index,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
icon = {
Icon(imageVector = item.icon, contentDescription = "")
},
text = {
Text(
text = item.title,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
},
)
}
}
HorizontalPager(
count = tabRowItems.size,
state = pagerState,
) {
ShowWebView("http://google.com")
}
}
It was related to the lazy loading of webview and I was not able to make a direct fix.
Instead I ended up with a working, although kind og hackish, solution
If a LazyColumn is introduced around the webview, the issue does not occur.
In code:
HorizontalPager(
count = tabRowItems.size,
state = pagerState,
) {
LazyColumn {
item {
ShowWebView(url)
}
}
}

Custom TopAppBarDefaults ScrollBehavior in Compose

I'm currently working on a animated TopAppBar using Material3 LargeTopAppBar and Compose.
I'm using TopAppBarDefaults.exitUntilCollapsedScrollBehavior to make the AppBar collapse when scrolled.
When flung downwards it snaps to the expanded view, but when flung upwards it doesn't snap and more often than not just stays in between the two states(collapsed & expanded).
Here's some of the code:
val topappbarstate = rememberTopAppBarState()
val decayAnimationSpec = rememberSplineBasedDecay<Float>()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
decayAnimationSpec, topappbarstate)
Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = {
LargeHeader(
title = "Title",
subtitle = "Subtitle",
scrollBehavior
)
}, content = { innerPadding ->
LazyColumn(contentPadding = innerPadding,) {
items(count = 100) {
Box(Modifier.fillMaxWidth()) {
Text(text = "$it")
}
}
}
},
)
}
The LargeHeader is a function that contains a Motionlayout to animate the Title and Subtitle in the LargeTopAppBar to swing upwards when scrolled but has no impact on the flinging animation of the TopAppBar.
Is there a way to customize this flinging snap of the actual TopAppBar?
Sorry for my english and thanks in advance!

LazyColumn animateScrollBy does nothing?

Update: original question followed by updated sample code that triggers it as requested.
I have a list of times broken down by half hour in a lazy list
E.g 12:00, 12:30
The list is attached to a horizontal pager from the accompanist-pager library
When a user is swiping between pages, I need to update the selected time, and I want the selected time to always be in the center of the screen, if it's not near the top/bottom where that can't be the case.
The only way I've figured out how to accurately do this with the methods provided to us is as below.
Note: i have previously captured the button / list height.
val listState = rememberLazyListState()
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
if (page != selectedPage) {
listState.scrollToItem(0) //Need to be at the top for calc below to work
listState.scrollBy(buttonHeight.toFloat() * page - (listVisibleHeight / 2) + buttonHeight)
}
}
}
Now this works perfectly. When I start at 00:00 and move down the list no scrolling occurs until I reach the middle, and once I reach the middle, the selected time, stays in the middle as the times scroll accordingly, and remains in the middle until I approach the end of the list where it starts moving downwards.
Now there's also a method listState.animateScrollBy(float, animation) which would be ideal so there's an animation if you have clicked on something not in the middle, and it has to animate it back to the middle, but when I use this one absolutely nothing happens?
listState.animateScrollBy(buttonHeight.toFloat() * page - (listVisibleHeight / 2) + buttonHeight)
It just seems flat out broken to me? As if I never called the function. 0 scrolling, even when I try adding a non default animation. Am I doing something wrong here?
It's doing this on compose 1.2.0-alpha04 and 1.2.0-alpha06
I can continue to use scrollBy but without the animation, it will be a little jumpy if the user clicks a time vs swipes to a time.
Example now that I know why it's happening as requested
Special Imports for the pager
implementation "com.google.accompanist:accompanist-pager:0.24.5-alpha"
implementation "com.google.accompanist:accompanist-pager-indicators:0.24.5-alpha"
Note: The pager itself has been wonky since I updated it to fix a crash in an earlier version... it doesn't quite swipe right, it gets stuck at times. I have not looked into it yet at all though.
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.PagerState
#ExperimentalPagerApi
#Composable
fun TestScreen() {
val numberOfRows = 48
val listState = rememberLazyListState()
var selectedRow by remember { mutableStateOf(0) }
var buttonHeight by remember { mutableStateOf(0) }
var listVisibleHeight by remember { mutableStateOf(0) }
val pagerState = PagerState(currentPage = selectedRow)
Row(
modifier = Modifier
.onGloballyPositioned {
listVisibleHeight = it.size.height
}
) {
LazyColumn(
state = listState,
modifier = Modifier
.wrapContentSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
items(numberOfRows) { index ->
Button(
modifier = Modifier
.wrapContentWidth()
.onGloballyPositioned {
buttonHeight = it.size.height
},
onClick = {}
) {
if (index == selectedRow) {
Text(text = index.toString(), color = Color.White)
} else {
Text(text = index.toString(), color = Color.Red)
}
}
}
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
if (page != selectedRow) {
//This doesn't work if selectedRow = page happens before the other code due to recomposition
//If it's after, it executes but it doesn't look right as it moves then highlights
selectedRow = page
listState.scrollToItem(0)
try {
listState.animateScrollBy(distanceToMiddleOfScreen(buttonHeight, listVisibleHeight, page))
} catch (exception: Exception) {
Log.e("scrollBug", "animateScrollBy Exception", exception)
}
}
}
}
HorizontalPager(
count = 48,
state = pagerState,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.background(Color.White),
) { page ->
Text(text = page.toString())
}
}
}
private fun distanceToMiddleOfScreen(itemHeight: Int, totalListHeight: Int, offset: Int): Float {
return itemHeight.toFloat() * offset - (totalListHeight / 2) + itemHeight
}
Your pager behaves strangely because you're creating its state on reach recomposition:
val pagerState = PagerState(currentPage = selectedRow)
This initializer made for you to use in your own state, which you're responsible to remember between recompositions.
In case when you use it in the view, to save its state between recompositions you need to use remember version:
val pagerState = rememberPagerState()
After this your LaunchedEffect won't be restarted and won't stop your animateScrollBy.
I can't think of a general solution of scrolling problem until this feature request is closed. But in case when all your items have equal size, you can calculate current scroll position, final scroll position, and so you can calculate the difference to scroll:
LaunchedEffect(Unit) {
snapshotFlow { pagerState.currentPage }.collect { page ->
if (page != selectedRow) {
selectedRow = page
val currentOffset = listState.firstVisibleItemIndex * buttonHeight + listState.firstVisibleItemScrollOffset
val finalOffset = distanceToMiddleOfScreen(buttonHeight, listVisibleHeight, page)
listState.animateScrollBy(finalOffset - currentOffset)
}
}
}
Also your distanceToMiddleOfScreen is not accurate, it can be fixed like this:
return itemHeight * (offset + 0.5f) - totalListHeight / 2
Result:

Jetpack Compose rememberSystemUiController setStatusBarColor flicker

I have two pages A and B with white and black background colors.
I need to change the status bar color when switching between two pages.
I use rememberSystemUiController to change the status bar color, but there is flickering, jerky, and the transition is not smooth.
Blink effect YouTube
#Composable
fun APager() {
val systemUiController = rememberSystemUiController()
val background = MaterialTheme.colorScheme.background
SideEffect {
systemUiController.setStatusBarColor(background)
}
// ...
}
#Composable
fun BPager() {
val systemUiController = rememberSystemUiController()
val black = MaterialTheme.colorScheme.black
SideEffect {
systemUiController.setStatusBarColor(black)
}
// ...
}
SideEffect launches with every recomposition and your composable is probably recomposing multiple times. Use LauncedEffect instead with a key whose value is set to the currently selected page of the pager. This will prevent launching the effect multiple times. Example:
#OptIn(ExperimentalPagerApi::class)
#Composable
fun MyPager() {
val pagerState = rememberPagerState()
val systemUiController = rememberSystemUiController()
LaunchedEffect(pagerState.currentPage) {
val color = when (pagerState.currentPage) {
0 -> Color.Blue
1 -> Color.Green
2 -> Color.Red
else -> Color.Black
}
systemUiController.setStatusBarColor(color)
}
HorizontalPager(
modifier = Modifier.fillMaxSize(),
count = 10,
state = pagerState,
) { page ->
Text(
text = "Page: $page",
modifier = Modifier.fillMaxWidth()
)
}
}
Flicker due to multiple recombination,Replace SideEffect with LaunchedEffect
val black = MaterialTheme.colorScheme.black
LaunchedEffect(black){
systemUiController.setStatusBarColor(black)
}
but, You can also add a lock
var isChange by remember {
mutableStateOf(true)
}
val black = MaterialTheme.colorScheme.black
SideEffect {
if(isChange){
systemUiController.setStatusBarColor(black).apply {
isChange = false
}
}
}
I think the second method is better, and there is no problem switching pages quickly

Resources