I have a compose view with a LazyList inside. Above the list I show a text with the offset of the first item in the list.
#Composable
fun MyComponent() {
Column(modifier = Modifier.padding(vertical = 10.dp)) {
val state = rememberLazyListState()
val offset = state.firstItemOffset
Text(text = "First item offset: $offset")
Log.e("tag", "MyComponent() ... drawing with offset $offset")
LazyRow(state = state) {
items(100) {
Text(
modifier = Modifier.padding(10.dp),
text = "Item $it"
)
}
}
}
}
private val LazyListState.firstItemOffset: Int?
get() = layoutInfo.visibleItemsInfo.firstOrNull { it.index == 0 }?.offset
I noticed that when i scroll the list, MyComponent gets recomposed, even when the first item is off screen and the returned offset is null and did not change from the last composition. How could I set this up so it only recomposes when the value actually changes?
Bonus question: why does the firstItemOffset getter not need a #Composable annotation for it to update? This way, it only returns an int and should not trigger a recompose at all. To be clear, I want it to recompose when the value changes, I just wonder why the annotation is not needed.
Edit:
My guess is that it recomposed because the instance of layoutInfo changes on every scroll even though the value returned by firstItemOffset stays the same.
It seems that the issue is that the return value of firstItemOffset is not a state, but the used layoutInfo internally is one. So every time layoutInfo changes, a recomposition happens.
I am not sure if this is the best solution, but this works:
private val LazyListState.firstItemOffset: Int?
#Composable get() = remember {
derivedStateOf { layoutInfo.visibleItemsInfo.firstOrNull { it.index == 0 }?.offset }
}.value
Related
I'm building a composable screen, say PostScreen where multiple posts are shown in GridView and when user click on any of them, I'll navigate to DetailScreen where posts are shown in larger box with multiple buttons associated (like, comment).
My logic is, when user click on any post in PostScreen, use an index from PostScreen to scroll to that index in DetailScreen. Issue is, when user click on any post (and arrive to DetailScreen), then move up (or down) wards and then click on action (for example, like a post), a coroutine operation is launched but index is getting reset and DetailScreen scroll to original index instead of staying at liked post. How would i resolve this? (I know about rememberLazyListState())
#Composable
fun DetailScreen(
viewModel: MyViewModel,
index: Int? // index is coming from navGraph
) {
val postIndex by remember { mutableStateOf(index) }
val scope = rememberCoroutineScope()
val posts = remember(viewModel) { viewModel.posts }.collectAsLazyPagingItems()
Scaffold(
topBar = { MyTopBar() }
) { innerPadding ->
JustNestedScreen(
modifier = Modifier.padding(innerPadding),
posts = posts,
onLike = { post ->
// This is causing index to reset, maybe due to re-composition
scope.launch {
viewModel.toggleLike(
postId = post.postId,
isLiked = post.isLiked
)
}
},
indexToScroll = postIndex
)
}
}
#Composable
fun JustNestedScreen(
modifier: Modifier = Modifier,
posts: LazyPagingItems<ExplorePost>,
onLike: (Post) -> Unit,
indexToScroll: Int? = null
) {
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colors.background),
state = listState
) {
items(posts) { post ->
post?.let {
// Display image in box and some buttons
FeedPostItem(
post = it,
onLike = onLike,
)
}
}
indexToScroll?.let { index ->
scope.launch {
listState.scrollToItem(index = index)
}
}
}
}
Use LaunchedEffect. LaunchedEffect's block is only run the first time and then every time keys are changed. If you only want to run it once, use Unit or listState as a key:
LaunchedEffect(listState) {
indexToScroll?.let { index ->
listState.scrollToItem(index = index)
}
}
I'm having a little trouble adding a form inside a Bottom sheet because every time I open the bottomSheet, the previous values continue there. I'm trying to make something like this
#OptIn(ExperimentalMaterialApi::class)
#Composable
fun BottomSheet() {
val bottomSheetScaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = BottomSheetState(BottomSheetValue.Collapsed)
)
val coroutineScope = rememberCoroutineScope()
BottomSheetScaffold(
scaffoldState = bottomSheetScaffoldState,
sheetContent = {
Form {
// save foo somewhere
coroutineScope.launch {
bottomSheetScaffoldState.bottomSheetState.collapse()
}
}
},
sheetPeekHeight = 0.dp
) {
Button(onClick = {
coroutineScope.launch {
bottomSheetScaffoldState.bottomSheetState.expand()
}
}) {
Text(text = "Expand")
}
}
}
#OptIn(ExperimentalMaterialApi::class)
#Composable
fun Form(
onSaveFoo: (String) -> Unit
) {
var foo by remember { mutableStateOf("") }
Column {
Button(onClick = {
onSaveFoo(foo)
}) {
Text(text = "Save")
}
OutlinedTextField(value = foo, onValueChange = { foo = it })
}
}
There is a way to "clean" my form every time the bottom sheet collapses without manually setting all values to "" again?
Something like the BottomShettFragment. If I close and reopen the BottomSheetFragment, the previous values will not be there.
Firstly, they say that it is better to control your state outside of a composable function (in a viewmodel) and pass it as a parameter.
You may clear the textField value, when you decide to collapse your bottomSheet, for example in onSaveFoo function.
Add a MutableStateFlow to your viewmodel, subscribe to its updates via collectAsState extension in your composable. You can get a viewmodel by a composable function viewModel(ViewModelClass::class.java).
In onSaveFoo function update your state with new string or empty string if that's the behaviour you want to achieve. State updates should happen inside viewmodel. So create a method in your viewmodel to update your state and call it when you want to collapse your bottomsheet to clear the text contained in your state.
And another thing, remember saves the value across recompositions. The value will be lost only if your Composable is removed from the Composition. It will happen if you change the content of your bottomSheet.
Something like this:
sheetContent = {
if(bottomSheetScaffoldState.bottomSheetState.isExpanded){
Form {
// save foo somewhere
coroutineScope.launch {
bottomSheetScaffoldState.bottomSheetState.collapse()
}
}
}else{
Spacer(modifier=Modifier.height(16.dp).background(Color.White)//or some other composable
}
},
Here is my problem;
When I add MyText composable in my Screen, I see all Logs (value1, value2, value3) which means it is recomposing every part of my code.
However when I comment the MyText line, I see only value3 on Logcat
How can I fix this ? I know it is not a big problem here but just imagine we have a scrollable Column here and we are trying to pass ScrollState.value to My Text component. Because of this situation, our list becomes so laggy.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Screen()
}
}
}
#Composable
fun Screen(){
var counter by remember {
mutableStateOf(0)
}
Log.i("RECOMPOSE","VALUE1")
Column() {
Text(text = "Just a text")
Log.i("RECOMPOSE","VALUE2")
Button(onClick = { counter = counter.plus(1) }) {
Text(text = counter.toString())
Log.i("RECOMPOSE","VALUE3")
}
MyText(counter)
}
}
#Composable
fun MyText(counter:Int){
Text(text = counter.toString())
}
EDIT
There is main problem, with Scrollable Column;
#Composable
fun Screen(){
val scrollState = rememberScrollState()
Box() {
Column(modifier = Modifier
.verticalScroll(scrollState)
.padding(top = 50.dp)) {
//Some Static Column Elements with images etc.
}
MyText(scrollStateValue = scrollState.value) //Doing some UI staff in this component
}
}
#Composable
fun MyText(scrollStateValue:Int){
Text(text = scrollStateValue.toString())
}
This behaviour is totally expected.
Compose is trying to reduce number of recompositions as much as possible. When you comment out MyText, the only view that depends on counter is Button content, so this is the only view that needs to be recomposed.
By the same logic you shouldn't see VALUE1 logs more than once, but the difference here is that Column is inline function, so if it content needs to be recomposed - it gets recomposed with the containing view.
Using this knowledge, you can easily prevent a view from being recomposed: you need to move part, which doesn't depends on the state, into a separate composable. The fact that it uses scrollState won't make it recompose, only reading state value will trigger recomposition.
#Composable
fun Screen(){
val scrollState = rememberScrollState()
Box() {
YourColumn(scrollState)
MyText(scrollStateValue = scrollState.value) //Doing some UI staff in this component
}
}
#Composable
fun YourColumn(scrollState: ScrollState){
Column(modifier = Modifier
.verticalScroll(scrollState)
.padding(top = 50.dp)) {
//Some Static Column Elements with images etc.
}
}
I'm working on a search page made in Compose with LazyColumn, everything works fine except for the wanted behavior of LazyColumn returing to first item when data changes.
This is my actual implementation of lazy column:
#Composable
fun <DataType : Any> GenericListView(
itemsList: SnapshotStateList<DataType>, // this list comes from the search page viewmodel
modifier: Modifier = Modifier.fillMaxSize(),
spacing: Dp = 24.dp,
padding: PaddingValues = PaddingValues(0.dp),
item: #Composable (DataType) -> Unit
) {
val listState: LazyListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
LazyColumn(
verticalArrangement = Arrangement.spacedBy(spacing),
state = listState,
modifier = modifier.padding(padding)
) {
items(itemsList) {
item(it)
}
}
SideEffect {
Log.i("->->->->->->->", "side effect launched")
coroutineScope.launch {
listState.scrollToItem(0)
}
}
}
As docs says, SideEffect should be called everytime the function is recomposed,
but it appear to be working only in debug mode with breakpoints in SideEffect, otherwise, it works only when the whole page is first created.
I've already tried with LaunchedEffect instead of SideEffect, using itemsList as key, but nothing happened.
Why my code works only in debug mode ?
Or better, an already made working solution to reset position when new data are set ?
SideEffect doesn't work because Compose is not actually recomposing the whole view when the SnapshotStateList is changed: it sees that only LazyColumn is using this state value so only this function needs to be recomposed.
To make it work you can change itemsList to List<DataType> and pass plain list, like itemsList = mutableStateList.toList() - it'll force whole view recomposition.
LaunchedEffect with passed SnapshotStateList doesn't work for kind of the same reason: it compares the address of the state container, which is not changed. To compare the items itself, you again can convert it to a plain list: in this case it'll be compared by items hash.
LaunchedEffect(itemsList.toList()) {
}
You can achieve the mentioned functionality with SideEffect, remember and with some kind of identificator (listId) of the list items. If this identificator changes, the list will scroll to the top, otherwise not.
I have extended your code. (You can choose any type for listId.)
#Composable
fun <DataType : Any> GenericListView(
itemsList: SnapshotStateList<DataType>, // this list comes from the search page viewmodel
modifier: Modifier = Modifier.fillMaxSize(),
spacing: Dp = 24.dp,
padding: PaddingValues = PaddingValues(0.dp),
listId: String? = null,
item: #Composable (DataType) -> Unit
) {
var lastListId: String? by remember {
mutableStateOf(null)
}
val listState: LazyListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
LazyColumn(
verticalArrangement = Arrangement.spacedBy(spacing),
state = listState,
modifier = modifier.padding(padding)
) {
items(itemsList) {
item(it)
}
}
SideEffect {
Log.i("->->->->->->->", "side effect launched")
coroutineScope.launch {
if (lastListId != listId) {
lastListId = listId
listState.scrollToItem(0)
}
}
}
}
I understand that architecturally this is definitely not a good thing to do, but I have embedded a for loop in a composable to update state as follows:
#Composable
fun WorkScreen(name: String?) {
var text by remember {
mutableStateOf(0)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Text(text = "YOU PRESSED ME $text")
}
for (i in 1..100) {
text = i
}
}
My expectation is that when I switch to this screen the for loop should update the mutableState and hence cause a recomposition which causes the time to tick up. However, instead I just get YOU PRESSED ME 0 if I put the for loop below the Box function, or I get YOU PRESSED ME 100 if I put it above the Box function.
The following question: Why my composable not recomposing on changing value for MutableState of HashMap?, does seem to be quite similar, but I'm not sure how it applies here. It seems to me I am updating the text value to be i!
You shouldn't change view state directly from the composable view builder, because compose functions will be recalled often during recomposition, so your calculation will be repeated. You should use side effects instead.
If you need to show dynamic change of the value to user, then you should use animation, as Gabriele's answer suggests.
An other option is updating the value manually. Inside LaunchedEffect you can use suspend functions, so you can change the value with a needed delay:
LaunchedEffect(Unit) {
for (i in 1..100) {
delay(1000) // update once a second
text = i
}
}
You should use an animation where you define how often you want to update the text applying it with a side effect.
For example:
var targetValue by remember { mutableStateOf(0) }
val value by animateIntAsState(
targetValue = targetValue,
animationSpec = tween( durationMillis = 2000 )
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Text(text = "YOU PRESSED ME $value")
}
LaunchedEffect(Unit) {
targetValue = 100
}