Remember LazyColumn Scroll Position - Jetpack Compose - android

I'm trying to save/remember LazyColumn scroll position when I navigate away from one composable screen to another. Even if I pass a rememberLazyListState to a LazyColumn the scroll position is not saved after I get back to my first composable screen. Can someone help me out?
#ExperimentalMaterialApi
#Composable
fun DisplayTasks(
tasks: List<Task>,
navigateToTaskScreen: (Int) -> Unit
) {
val listState = rememberLazyListState()
LazyColumn(state = listState) {
itemsIndexed(
items = tasks,
key = { _, task ->
task.id
}
) { _, task ->
LazyColumnItem(
toDoTask = task,
navigateToTaskScreen = navigateToTaskScreen
)
}
}
}

Well if you literally want to save it, you must store it is something like a viewmodel where it remains preserved. The remembered stuff only lasts till the Composable gets destroyed. If you navigate to another screen, the previous Composables are destroyed and along with them, the scroll state

/**
* Static field, contains all scroll values
*/
private val SaveMap = mutableMapOf<String, KeyParams>()
private data class KeyParams(
val params: String = "",
val index: Int,
val scrollOffset: Int
)
/**
* Save scroll state on all time.
* #param key value for comparing screen
* #param params arguments for find different between equals screen
* #param initialFirstVisibleItemIndex see [LazyListState.firstVisibleItemIndex]
* #param initialFirstVisibleItemScrollOffset see [LazyListState.firstVisibleItemScrollOffset]
*/
#Composable
fun rememberForeverLazyListState(
key: String,
params: String = "",
initialFirstVisibleItemIndex: Int = 0,
initialFirstVisibleItemScrollOffset: Int = 0
): LazyListState {
val scrollState = rememberSaveable(saver = LazyListState.Saver) {
var savedValue = SaveMap[key]
if (savedValue?.params != params) savedValue = null
val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
val savedOffset = savedValue?.scrollOffset ?: initialFirstVisibleItemScrollOffset
LazyListState(
savedIndex,
savedOffset
)
}
DisposableEffect(Unit) {
onDispose {
val lastIndex = scrollState.firstVisibleItemIndex
val lastOffset = scrollState.firstVisibleItemScrollOffset
SaveMap[key] = KeyParams(params, lastIndex, lastOffset)
}
}
return scrollState
}
example of use
LazyColumn(
state = rememberForeverLazyListState(key = "Overview")
)

#Composable
fun persistedLazyScrollState(viewModel: YourViewModel): LazyListState {
val scrollState = rememberLazyListState(viewModel.firstVisibleItemIdx, viewModel.firstVisibleItemOffset)
DisposableEffect(key1 = null) {
onDispose {
viewModel.firstVisibleItemIdx = scrollState.firstVisibleItemIndex
viewModel.firstVisibleItemOffset = scrollState.firstVisibleItemScrollOffset
}
}
return scrollState
}
}
Above I defined a helper function to persist scroll state when a composable is disposed of. All that is needed is a ViewModel with variables for firstVisibleItemIdx and firstVisibleItemOffet.
Column(modifier = Modifier
.fillMaxSize()
.verticalScroll(
persistedScrollState(viewModel = viewModel)
) {
//Your content here
}

The LazyColumn should save scroll position out of the box when navigating to next screen. If it doesn't work, this may be a bug described here (issue tracker). Basically check if the list becomes empty when changing screens, e.g. because you observe a cold Flow or LiveData (so the initial value is used).

#Composable
fun persistedScrollState(viewModel: ParentViewModel): ScrollState {
val scrollState = rememberScrollState(viewModel.scrollPosition)
DisposableEffect(key1 = null) {
onDispose {
viewModel.scrollPosition = scrollState.value
}
}
return scrollState
}
Above I defined a helper function to persist scroll state when a composable is disposed of. All that is needed is a ViewModel with a scroll position variable.
Hope this helps someone!
Column(modifier = Modifier
.fillMaxSize()
.verticalScroll(
persistedScrollState(viewModel = viewModel)
) {
//Your content here
}

Related

Android common ViewModel for two Compose screens

I have an issue with refreshing Compose Lazy List, based on changes in persistence.
The business case - I have a screen (Fragment) with MyObject list contains all objects, there is another screen with only favorites MyObjects. Both use the same Composable as a list element with name, description and "heart" icon to set/unset favorite flag.
On "all" list setting and unsetting favorite flag works well - click on IconToggleButton sets boolean in DB and then switching to Favorite screen shows new item. Unset favorite on "all" screen sets flag to false as expected and when navigates to Favorite screen removes item.
But toggling favorite icon on Favorite screen change boolean in DB - BUT does not refresh and recompose LazyList content. I have to manually switch few times between screens, then eventually both are, let's say, synchronized with DB.
Unset favorite on the last element on Favorite list does not refresh it at all - I have to "like" another object on "all" list, then the Favorite list content is recompose with replacing items.
Moreover - there are some cases, that items on both lists disappears, while they are still in DB. I need to dig it deeper and debug this case, but maybe is related.
Some code's details:
There is a simple Entity
#Entity(tableName = "my_objects")
data class MyObject(
#PrimaryKey(autoGenerate = true)
var id: Long = 0,
#ColumnInfo(name = "name")
val name: String,
#ColumnInfo(name = "favorite")
val favorite: Boolean = false
)
Then there are also DAO, Provider and Repository with Domain Model. In DAO there are methods:
#Query("SELECT * FROM my_objects")
fun getAll(): List<MyObject>
#Query("SELECT * FROM my_objects WHERE favorite = 1")
fun getFavorites(): List<MyObject>
called in Provider and then in Repository.
In MyObjectListViewModel (with mapping from DB model do domain model):
#HiltViewModel
class MyObjectListViewModel #Inject constructor(
private val updateMyObject: UpdateMyObject,
private val getOrderedMyObjectList: GetOrderedMyObjectList,
private val dispatchers: CoroutineDispatcherProvider
) : ViewModel() {
private val mutableMyObjects = MutableLiveData<List<ItemMyObjectModel>>()
val myObjects: LiveData<List<ItemMyObjectModel>> = mutableMyObjects
fun loadMyObjects() {
viewModelScope.launch(dispatchers.io) {
val myObjectListResult = getOrderedMyObjectList()
withContext(dispatchers.main) {
when (myObjectListResult) {
is MyObjectListResult.Success -> {
val viewModelList = myObjectListResult.list.map {
ItemMyObjectModel(it)
}
mutableMyObjects.postValue(viewModelList)
}
}
}
}
}
fun switchFavoriteFlag(itemMyObjectModel: ItemMyObjectModel) {
val myObject = itemMyObjectModel.itemMyObject
myObject.favorite = !myObject.favorite
viewModelScope.launch(dispatchers.io) {
val updatedObject = updateMyObject(myObject) //save via DAO
}
}
}
MyObjectFavoriteListViewModel looks exactly the same, except that load function calls loadFavoriteMyObjects() and it uses GetOrderedFavoriteMyObjectList Repository. BTW - maybe it could be aggregate to one ViewModel, but with pair of LiveData and load function - one pair for all item and one for favorites?
Last but not least - Composables:
#OptIn(ExperimentalMaterial3Api::class)
#Composable
fun MyObjectFavoriteListScreen(
viewModel: MyObjectFavoriteListViewModel,
navigator: MyObjectNavigator
) {
val list by viewModel.myObjects.observeAsState()
val lazyListState = rememberLazyListState()
Scaffold(
floatingActionButton = {
MyObjectListFloatingActionButton(
extended = lazyListState.isScrollingUp() //local extension
) { navigator.openNewMyObjectFromObjectList() }
}
) { padding ->
if (list != null) {
LazyColumn(
contentPadding = PaddingValues(
horizontal = dimensionResource(id = R.dimen.margin_normal),
vertical = dimensionResource(id = R.dimen.margin_normal)
),
state = lazyListState,
modifier = Modifier.padding(padding)
) {
items(list!!) { item ->
MyObjectListItem( // with Card() includes Text() and IconToggleButton()
item = item,
onCardClick = { myObjectId -> navigator.openMyObjectDetailsFromFavouriteList(myObjectId) },
onFavoriteClick = { itemMyObjectModel -> viewModel.switchFavoriteFlag(itemMyObjectModel) }
)
}
}
} else {
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(text = stringResource(id = "No objects available"))
}
}
}
}
I think that one issue could be related with if (list != null) {} (list is observed as State<T?>).
But for sure there is something wrong with states, I am pretty sure that the list should be triggered to recompose, but there is no(?) state to do so.
Any ideas?

Jetpack Compose navigation state doesn't restore

I'm struggling with the Jetpack Compose navigation. I'm following the NowInAndroid architecture, but I don't have the correct behaviour. I have 4 destinations in my bottomBar, one of them is a Feed-type one. In this one, it makes a call to Firebase Database to get multiple products. The problem is that everytime I change the destination (e.j -> SearchScreen) and go back to the Feed one, this (Feed) does not get restored and load all the data again. Someone know what is going on?
This is my navigateTo function ->
fun navigateToBottomBarRoute(route: String) {
val topLevelOptions = navOptions {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
when (route) {
HomeSections.FEED.route -> navController.navigateToFeed(navOptions = topLevelOptions)
HomeSections.SEARCH.route -> navController.navigateToSearch(navOptions = topLevelOptions)
else -> {}
}
}
My Feed Screen ->
#OptIn(ExperimentalLifecycleComposeApi::class)
#Composable
fun Feed(
onProductClick: (Long, String) -> Unit,
onNavigateTo: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: FeedViewModel = hiltViewModel()
) {
val feedUiState: FeedScreenUiState by viewModel.uiState.collectAsStateWithLifecycle()
val productsCollections = getProductsCollections(feedUiState)
Feed(
feedUiState = feedUiState,
productCollections = productsCollections,
onProductClick = onProductClick,
onNavigateTo = onNavigateTo,
onProductCreate = onProductCreate,
modifier = modifier,
)
}
The getProductsCollections function returns a list of ProductCollections
fun getProductsCollections(feedUiState: FeedScreenUiState): List<ProductCollection> {
val productCollection = ArrayList<ProductCollection>()
if (feedUiState.processors is FeedUiState.Success) productCollection.add(feedUiState.processors.productCollection)
if (feedUiState.motherboards is FeedUiState.Success) productCollection.add(feedUiState.motherboards.productCollection)
/** ........ **/
return productCollection
}
I already tried to make rememberable the val productsCollections = getProductsCollections(feedUiState) but does not even load the items.
I hope you guys can help me, thanks!!
I already tried to make rememberable the val productsCollections = getProductsCollections(feedUiState) but does not even load the items.
I want to restore the previous state of the FeedScreen and not reload everytime I change the destination in my bottomBar.

Jetpack Compose, fetch and add more items to LazyColumn using StateFlow

I'm have a LazyColumn that renders a list of items. However, I now want to fetch more items to add to my lazy list. I don't want to re-render items that have already been rendered in the LazyColumn, I just want to add the new items.
How do I do this with a StateFlow? I need to pass a page String to fetch the next group of items, but how do I pass a page into the repository.getContent() method?
class FeedViewModel(
private val resources: Resources,
private val repository: FeedRepository
) : ViewModel() {
// I need to pass a parameter to `repository.getContent()` to get the next block of items
private val _uiState: StateFlow<UiState> = repository.getContent()
.map { content ->
UiState.Ready(content)
}.catch { cause ->
UiState.Error(cause.message ?: resources.getString(R.string.error_generic))
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = SUBSCRIBE_TIMEOUT_FOR_CONFIG_CHANGE),
initialValue = UiState.Loading
)
val uiState: StateFlow<UiState>
get() = _uiState
And in my UI, I have this code to observe the flow and render the LazyColumn:
val lifecycleAwareUiStateFlow: Flow<UiState> = remember(viewModel.uiState, lifecycleOwner) {
viewModel.uiState.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
}
val uiState: UiState by lifecycleAwareUiStateFlow.collectAsState(initial = UiState.Loading)
#Composable
fun FeedLazyColumn(
posts: List<Post> = listOf(),
scrollState: LazyListState
) {
LazyColumn(
modifier = Modifier.padding(vertical = 4.dp),
state = scrollState
) {
// how to add more posts???
items(items = posts) { post ->
Card(post)
}
}
}
I do realize there is a Paging library for Compose, but I'm trying to implement something similar, except the user is in charge of whether or not to load next items.
This is the desired behavior:
I was able to solve this by adding the new posts to the old posts before emitting it. See comments below for the relevant lines.
private val _content = MutableStateFlow<Content>(Content())
private val _uiState: StateFlow<UiState> = repository.getContent()
.mapLatest { content ->
_content.value = _content.value + content // ADDED THIS
UiState.Ready(_content.value)
}.catch { cause ->
UiState.Error(cause.message ?: resources.getString(R.string.error_generic))
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = SUBSCRIBE_TIMEOUT_FOR_CONFIG_CHANGE),
initialValue = UiState.Loading
)
private operator fun Content.plus(content: Content): Content = Content(
posts = this.posts + content.posts,
youTubeNextPageToken = content.youTubeNextPageToken
)
class YouTubeDataSource(private val apiService: YouTubeApiService) :
RemoteDataSource<YouTubeResponse> {
private val nextPageToken = MutableStateFlow<String?>(null)
fun setNextPageToken(nextPageToken: String) {
this.nextPageToken.value = nextPageToken
}
override fun getContent(): Flow<YouTubeResponse> = flow {
// retrigger emit when nextPageToken changes
nextPageToken.collect {
emit(apiService.getYouTubeSnippets(it))
}
}
}

Android Compose remember affects other remember change

I have the next hierarchy:
WalletDetailsScreen
WalletDetailsView
SubWalletView
DefaultOutlinedButton
1st remember domainsVisible is declared in WalletDetailsScreen. Callback is propagated to DefaultOutlinedButton's onClick.
2nd remember copyToClipboardClicked is declared in SubWalletView.
What happens:
User opens the screen.
User taps copy button at first (SubWalletView). (2nd remember)
User taps DefaultOutlinedButton then. 1st remember is changed AND 2ND ONE IS CHANGED AS WELL!
Code:
#Composable
fun WalletDetailsScreen(
snackbarController: SnackbarController,
wallet: Wallet,
onNavIconClicked: () -> Unit
) {
// CHANGING THIS REMEMBER CHANGES 2ND ONE (BUT ONLY IF 2ND WAS FIRED AT LEAST ONCE)
val domainsVisible = rememberMutableStateOf(key = "domains_visible_btn", value = false)
WalletDetailsView(
snackbarController = snackbarController,
wallet = wallet,
domainsVisible = domainsVisible.value,
domainsCount = 0
) {
domainsVisible.toggle()
}
}
#Composable
private fun WalletDetailsView(
snackbarController: SnackbarController,
wallet: Wallet,
domainsVisible: Boolean,
domainsCount: Int,
onDomainsVisibilityClicked: () -> Unit
) {
Column {
wallet.subWallets.forEach { subWallet ->
SubWalletView(snackbarController = snackbarController, subWallet = subWallet)
}
// 1st REMEMBER IS CHANGED HERE
DefaultOutlinedButton(text = text, onClick = onDomainsVisibilityClicked)
}
}
#Composable
private fun SubWalletView(
snackbarController: SnackbarController,
subWallet: SubWallet
) {
// 2ND REMEMBER
val copyToClipboardClicked = rememberMutableStateOf(key = "copy_btn", value = false)
if (copyToClipboardClicked.value) {
CopyToClipboard(text = subWallet.address)
}
// 2ND REMEMBER IS CHANGED HERE
Box(
modifier = Modifier
.clickable { copyToClipboardClicked.toggle() }
.padding(start = 15.dp, top = 5.dp, bottom = 5.dp, end = 5.dp)
) {
// just icon here
}
}
Helpers:
#Composable
fun <T> rememberMutableStateOf(
key: String,
value: T,
policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
) = remember(key) { mutableStateOf(value, policy) }
fun MutableState<Boolean>.toggle() {
value = !value
}
I've tried to add keys to remember but it hasn't helped. Any ideas why changing one remember affects another? This shouldn't happen.
Finally, I figured out what's going on.
Second remember isn't changed actually.
But I rely on it to show shackbar:
if (copyToClipboardClicked.value) {
CopyToClipboard(text = subWallet.address)
ShowSnackbar(...)
copyToClipboardClicked.toggle() // <--- WE NEED THIS
}
And the missed part is that I need to switch flag off. I hadn't done it and that's why the if was triggered on each recomposition.

Remember scroll position in lazycolumn?

I am aware of the remember lazy list state and it works fine
setContent {
Test(myList) // Call Test with a dummy list
}
#Composable
fun Test(data: List<Int>){
val state = rememberLazyListState()
LazyColumn(state = state) {
items(data){ item ->Text("$item")}
}
}
It will remember scroll position and after every rotation and change configuration it will be the same
But whenever I try to catch data from database and use some method like collectAsState
it doesn't work and it seem an issue
setContent{
val myList by viewModel.getList.collectAsState(initial = listOf())
Test(myList)
}
Unfortunately for now there's not a native way to do so, but you can use this code:
val listState = rememberLazyListState()
listState has 3 methods:
firstVisibleItemIndex
firstVisibleItemScrollOffset
isScrollInProgress
All of them are State() so you will always get the data as it updates. For example, if you start scrolling the list, isScrollInProgress will change from false to true.
SAVE AND RESTORE STATE
val listState: LazyListState = rememberLazyListState(viewModel.index, viewModel.offset)
LaunchedEffect(key1 = listState.isScrollInProgress) {
if (!listState.isScrollInProgress) {
viewModel.index = listState.firstVisibleItemIndex
viewModel.offset = listState.firstVisibleItemScrollOffset
}
}

Categories

Resources