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:
Related
I am trying to add scrolling behaviour to a Column by setting verticalScroll(state = rememberScrollState()) modifier.
I checked out some examples in the official compose-jb repository, and it seems that that is the right way to do it, yet in my case the content is not scrollable.
Here the full code:
#Composable
#Preview
fun App() {
MaterialTheme {
// add scroll behaviour
val stateVertical = rememberScrollState(0)
Column(modifier = Modifier.verticalScroll(state = stateVertical)) {
repeat(100){
Text("item: $it")
}
}
}
}
fun main() = application {
Window(onCloseRequest = ::exitApplication) {
App()
}
}
Any ideas why it does not work in my case?
The Column is populated with 100 Text items, more than enough to exceed the default window height.
It actually works!
For some reason I was trying to use click and drag... which lead me to confusion.
I am trying to figure out the right way to remember a color between re-compose, where the color is used in the parent, but calculated in a child.
var color by remember { mutableStateOf(Color.Transparent) }
Row(Modifier.height(100.dp)) {
Box(
Modifier
.width(6.dp)
.fillMaxHeight()
.background(color)
)
Column {
HorizontalPager(
count = pages.size,
state = pagerState
) { page ->
val page = pages[page]
color = page.color
}
}
}
However I am running into two issues
When the color changes when I page seem to get into an infinite re-compose.
The color seems to be a blend of the new page color and the previous page color.
Rather than having color as state, which is causing your recomposition loop. You could derive the same thing by hoisting your pagerState object and referencing the current page. Something like the following:
val pagerState = rememberPagerState()
var color = remember(pagerState.currentPage) { pages[pagerState.currentPage].color }
Row(Modifier.height(100.dp)) {
Box(
Modifier
.width(6.dp)
.fillMaxHeight()
.background(color)
)
Column {
HorizontalPager(
count = pages.size,
state = pagerState
) { page ->
}
}
}
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
I'm trying to get behaviour where I can toggle visibility of a FAB, with a bit of entry/exit animation, in the Compose Scaffold.
The issue
The FAB disappears fine. However, whatever I try, I can't get it to reappear - it's like it totally disappears from the tree, never to return!
This code reproduces the issue:
class TestActivity : ComponentActivity() {
#OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
var fabVisible by remember { mutableStateOf(true) }
Scaffold(
floatingActionButton = {
AnimatedVisibility(visible = fabVisible) {
FloatingActionButton(onClick = {}) {
Icon(Icons.Default.Star, contentDescription = null)
}
}
}
) {
Button(onClick = { fabVisible = !fabVisible }) {
Text("Click to toggle FAB")
}
}
}
}
}
Here's a demo - the FAB does a sort of wipe out, but then never returns:
Workarounds I've attempted
If I do any of the following, the FAB will toggle:
Put the FAB in another area, such as the Scaffold content or topbar area.
Remove the AnimatedVisibility.
Put the FAB inside an explicitly-sized Box:
…
Scaffold(
floatingActionButton = {
Box(
modifier = Modifier
.width(56.0.dp)
.height(56.0.dp)
) {
AnimatedVisibility(visible = fabVisible) {
FloatingActionButton(onClick = {}) {
Icon(Icons.Default.Star, contentDescription = null)
}
}
}
}
)
…
Obviously solutions 1 and 2 don't achieve what I want, and solution 3 (the Box solution) isn't ideal - I have to know the size of the FAB to do this (I imagine there's probably a way to remember its intrinsic size when first visible?) and the animation is odd - it goes from the corner from the middle rather than from the centre, and the elevation suddenly 'pops' right at the end:
What should I do to get the FAB entering and exiting as expected?
Try with scaleIn and scaleOut animations.
AnimatedVisibility(
visible = fabVisible,
enter = scaleIn(),
exit = scaleOut(),
) {
FloatingActionButton(onClick = {}) {
Icon(Icons.Default.Star, contentDescription = null)
}
}
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)
}