Maintain LazyColumn scroll position after re-composition - android

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)
}
}

Related

Jetpack Compose: How to detect when TabRow inside HorizontalPager is visible and call a ViewModel function?

I am building a simple app which has a TabLayout with two tabs. One tab is a list of items which you can click and make a note about the item. The other tab is a list of notes you made in the first tab.
I have created a TabLayout using TabRows and HorizontalPager here is the code for that section:
#OptIn(ExperimentalPagerApi::class)
#Composable
fun HorizontalPagerTabLayout(
modifier: Modifier
) {
val tabData = listOf("Records" to Icons.Default.Phone, "Notes" to Icons.Default.Star)
val pagerState = rememberPagerState(
initialPage = 0
)
val tabIndex = pagerState.currentPage
val coroutineScope = rememberCoroutineScope()
Column {
TabRow(
selectedTabIndex = tabIndex,
modifier = modifier
) {
tabData.forEachIndexed { index, _ ->
Tab(
icon = {
Icon(imageVector = tabData[index].second, contentDescription = null)
},
selected = tabIndex == index,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
},
text = {
Text(
tabData[index].first,
color = if (pagerState.currentPage == index) Color.White else Color.LightGray
)
})
}
}
HorizontalPager(state = pagerState, count = tabData.size) { page ->
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
when (page) {
0 -> RecordsScreen(modifier = Modifier)
1 -> NotesScreen(modifier = Modifier)
}
}
}
}
}
In RecordScreen I have a logic that will make a Note about the Record and store it in RoomDB. That works as expected.
In NotesScreen I have a viewModel that will pull all the notes from RoomDB and diplay them in the NotesScreen tab.
Here is the code for NotesScreen:
#Composable
fun NotesScreen(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
modifier: Modifier,
notesViewModel: NotesViewModel = hiltViewModel()
) {
val notesList by notesViewModel.notesStateData.collectAsState()
// FAILED SOLUTION NUMBER 2
// LaunchedEffect(Unit){
// notesViewModel.getAllNotes()
// }
// FAILED SOLUTION NUMBER 3
// DisposableEffect(lifecycleOwner) {
// val observer = LifecycleEventObserver { _, event ->
// when (event) {
// Lifecycle.Event.ON_RESUME -> {
// notesViewModel.getAllNotes()
// }
// else -> {}
// }
// }
// lifecycleOwner.lifecycle.addObserver(observer)
// onDispose {
// lifecycleOwner.lifecycle.removeObserver(observer)
// }
// }
LazyColumn(
contentPadding = (PaddingValues(horizontal = 16.dp, vertical = 8.dp)),
modifier = modifier
) {
items(items = notesList.orEmpty()) { note ->
NoteItem(note)
}
}
}
From the code you can see that I commented out some failed solutions. My first solution is to include notesViewModel.getAllNotes() in init{} block of viewModel. But this calls the function only once, and not every time I get back to NotesScreen.
My second solution is to create a LaunchedEffect(Unit) because I want to call this viewModel function every time the NotesScreen is displayed. But it didn't work, it also calls the getAllNotes() only once.
My third solution is to create a DisposableEffect with observer that will tell me every time ON_RESUME happens, but this also didn't work.
I used debugger and logged some behaviours:
When I use init block, the init block happens as soon as I open the app and not when I open the screen. It seems like the HorizontalPager loads the screen immediately.
When I use second solution the LaunchEffect block happens when I navigate to the screen, but it doesn't get called again even though I switched tabs multiple times.
When I use DisposableEffect all the lifecycle states happen as soon as I open the app, ON_START, ON_CREATE, and ON_RESUME are logged as soon as app is opened even though I am not on NotesScreen yet. This means that the viewModel function got called only once, and now I can't see the new Notes I created untill I restart the app.
My desired behaviour: Every time I open NotesScreen I want to load all the notes from RoomDB, and not only once on app start. I am trying to trigger VM function that gets notes from DB automatically without refresh layouts or buttons but I can't seem to find a solution.
Any help would be heavily appreciated.
You can use snapshotFlow for add page change callback to Pager. It Creates a Flow from observable Snapshot state. Here is a sample code
// Page change callback
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
when (page) {
0 -> viewModel.getAllNotes() // First page
1 -> // Second page
else -> // Other pages
}
}
}

Scrolling to top of list

My jetpack compose app uses a LazyColumn to display data from history. When I am at the beginning of the LazyColumn and data is entered into the database, it will automatically be displayed on top with scrollState.scrollToItem(0, 0) each time. In this case, if I scroll down the list, exit the screen, and then return to it, then I will remain in the same place (at the bottom of the list). I need to fix this so that every time I enter the screen, I end up at the beginning of the list (LazyColumn).
#Composable
fun HistoryTableList(
viewModel: HistoryViewModel = viewModel()
) {
val scrollState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
LazyColumn(
state = scrollState,
modifier = Modifier
.padding(
HistoryListHorizontalPadding,
0.dp,
HistoryListHorizontalPadding,
HistoryListPaddingBottom
)
.fillMaxWidth(),
) {
items(
items = historyItems,
key = { historyRecord ->
historyRecord.uid
}
) { historyRecord ->
historyRecord?.let {
viewModel.onListScrolled(scrollState.firstVisibleItemScrollOffset)
if (scrollState.firstVisibleItemIndex <= 1) {
coroutineScope.launch {
scrollState.scrollToItem(0, 0)
}
}
HistoryTableItem(history = historyRecord)
}
}
}
}

Why in HorizontalPager is the page number returned by the lambda different from the currentPage?

I am using this component and I was having a problem, as I don't get the same result like this:
HorizontalPager(count = tabTitles.size, state = pageState) { page ->
Log.i("Polaris", "Hi, I'm page $page")
Log.i("Polaris", "Hi, I'm currentPage $currentPage")
}
The result I get is:
2022-02-05 17:16:06.461 31070-31070/com.polaris I/Polaris: Hi, I'm page 0
2022-02-05 17:16:06.461 31070-31070/com.polaris I/Polaris: Hi, I'm currentPage 0
2022-02-05 17:16:06.464 31070-31070/com.polaris I/Polaris: Hi, I'm page 1
2022-02-05 17:16:06.464 31070-31070/com.polaris I/Polaris: Hi, I'm currentPage 0
Why does currentPage always return me the current page I'm on and page returns me the current and next page?
I edit the publication to add the rest of the code. The problem is that given the result that the page parameter gives me, the call that I have inside the Prediction component, when going from one page to another, replicates the same information that I get from the service in each one, instead of putting the one that corresponds to each one:
#ExperimentalPagerApi
#Composable
fun Tabs(zodiacName: String?, modifier: Modifier = Modifier) {
val viewModel = getViewModel<DetailViewModel>()
val tabTitles = listOf("Yesterday", "Today", "Tomorrow")
val coroutineScope = rememberCoroutineScope()
val pageState = rememberPagerState()
Column(
modifier
) {
TabRow(
modifier = Modifier.fillMaxWidth(),
selectedTabIndex = pageState.currentPage,
backgroundColor = Color.Transparent,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
color = Color.White,
modifier = Modifier.pagerTabIndicatorOffset(
pageState,
tabPositions
),
height = 2.dp
)
}) {
tabTitles.forEachIndexed { index, title ->
Tab(selected = pageState.currentPage == index,
onClick = {
coroutineScope.launch {
pageState.animateScrollToPage(index)
}
},
text = {
Text(
text = title,
color = Color.White,
fontSize = 14.sp,
fontFamily = Helvetica
)
})
}
}
HorizontalPager(count = tabTitles.size, state = pageState) {
when (currentPage) {
0 -> Prediction(
viewModel = viewModel,
zodiacName = zodiacName,
day = "yesterday"
)
1 -> Prediction(
viewModel = viewModel,
zodiacName = zodiacName,
day = "today"
)
2 -> Prediction(
viewModel = viewModel,
zodiacName = zodiacName,
day = "tomorrow"
)
}
}
}
}
The content of the Prediction component is as follows:
#Composable
fun Prediction(viewModel: DetailViewModel, zodiacName: String?, day: String?) {
val errorMessage = viewModel.errorMessage.value
val horoscope = viewModel.horoscopeResponse.value
if (errorMessage.isEmpty()) {
LazyColumn(
modifier = Modifier
.padding(top = 20.dp, bottom = 10.dp, start = 10.dp, end = 10.dp)
) {
item {
LaunchedEffect(Unit) {
viewModel.getHoroscopeDetail(zodiacName, day)
}
if (horoscope.checkDescriptionContent()) {
PredictionCard(horoscope = horoscope)
} else {
ShimmerCard()
}
}
}
} else {
ErrorComponent(
viewModel = viewModel,
sign = zodiacName,
day = day
)
}
}
Why then if I use currentPage it works fine, it makes the calls and places all the information correctly, but if I use the page parameter contained in the lambda, it duplicates the information?
The HorizontalPager pre-draws a pair of adjacent pages, so that when you start scrolling, you don't have to wait for rendering.
This is exactly what happens in your logs: content is called for the first page and for the second, but currentPage is 0 in both cases.
You must always use the page parameter when creating a view within content, otherwise the view will be built on the wrong value during scrolling. When scrolling is over, the view will be recomposed with the new currentPage value, so it may look fine when scrolling fast, but if you scroll slowly, you will se the problem.
You can easily see how this works in the following example. See how I first scroll through the page without releasing my finger and currentPage shows the "wrong" number, which is updated when I release my finger and the animation ends.
HorizontalPager(
count = 10,
) { page ->
Column {
Text("page $page")
Text("currentPage $currentPage")
}
}
You should review the documentation on how to get your page index current.
enter link description here
val pagerState = rememberPagerState()
LaunchedEffect(pagerState) {
// Collect from the pager state a snapshotFlow reading the currentPage
snapshotFlow { pagerState.currentPage }.collect { page ->
Log.d("abc","currentPage : $page")
}
}

Jetpack Compose, how to reset LazyColumn position when new data are set?

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)
}
}
}
}

How can Android Studio launch the inline fun <T> key()?

The Code A is from the offical sample project here.
The Code B is from Android Studio source code.
I have searched the article about the function key by Google, but I can't find more details about it.
How can Android Studio launch the inline fun <T> key()? Why can't the author use Code C to launch directly?
Code A
key(detailPost.id) {
LazyColumn(
state = detailLazyListState,
contentPadding = contentPadding,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxSize()
.notifyInput {
onInteractWithDetail(detailPost.id)
}
) {
stickyHeader {
val context = LocalContext.current
PostTopBar(
isFavorite = hasPostsUiState.favorites.contains(detailPost.id),
onToggleFavorite = { onToggleFavorite(detailPost.id) },
onSharePost = { sharePost(detailPost, context) },
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End)
)
}
postContentItems(detailPost)
}
}
Code B
#Composable
inline fun <T> key(
#Suppress("UNUSED_PARAMETER")
vararg keys: Any?,
block: #Composable () -> T
) = block()
Code C
LazyColumn(
state = detailLazyListState,
contentPadding = contentPadding,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxSize()
.notifyInput {
onInteractWithDetail(detailPost.id)
}
) {
stickyHeader {
val context = LocalContext.current
PostTopBar(
isFavorite = hasPostsUiState.favorites.contains(detailPost.id),
onToggleFavorite = { onToggleFavorite(detailPost.id) },
onSharePost = { sharePost(detailPost, context) },
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End)
)
}
postContentItems(detailPost)
}
From key documentation:
key is a utility composable that is used to "group" or "key" a block of execution inside of a composition. This is sometimes needed for correctness inside of control-flow that may cause a given composable invocation to execute more than once during composition.
It also contains several examples, so check it out.
Here is a basic example of the usefulness of it. Suppose you have the following Composable. I added DisposableEffect to track its lifecycle.
#Composable
fun SomeComposable(text: String) {
DisposableEffect(text) {
println("appear $text")
onDispose {
println("onDispose $text")
}
}
Text(text)
}
And here's usage:
val items = remember { List(10) { it } }
var offset by remember {
mutableStateOf(0)
}
Button(onClick = {
println("click")
offset += 1
}) {
}
Column {
items.subList(offset, offset + 3).forEach { item ->
key(item) {
SomeComposable(item.toString())
}
}
}
I only display two list items, and move the window each time the button is clicked.
Without key, each click will remove all previous views and create new ones.
But with key(item), only the disappeared item disappears, and the items that are still on the screen are reused without recomposition.
Here are the logs:
appear 0
appear 1
appear 2
click
onDispose 0
appear 3
click
onDispose 1
appear 4
click
onDispose 2
appear 5

Categories

Resources