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

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

Related

PullRefreshIndicator overlaps with ScrollableTabRow

I'm starting to learn about Jetpack Compose. I put together this app where I explore different day-to-day use cases, each of the feature modules within this project is supposed to tackle different scenarios.
One of this feature modules – the chatexample feature module, tries to implement a simple ViewPager where each of the pages is a Fragment, the first page "Messages" is supposed to display a paginated RecyclerView wrapped around a SwipeRefreshLayout. Now, the goal is to implement all this using Jetpack Compose. This is the issue I'm having right now:
The PullRefreshIndicator that I'm using to implement the Pull-To-Refresh action works as expected and everything seems pretty straightforward so far, but I cannot figure out why the ProgresBar stays there on top.
So far I've tried; Carrying on the Modifier from the parent Scaffold all the way through. Making sure I explicitly set the sizes to fit the max height and width. Add an empty Box in the when statement - but nothing has worked so far, I'm guessing I could just remove the PullRefreshIndicator if I see that the ViewModel isn't supposed to be refreshing, but I don't think that's the right thing to do.
To quickly explain the Composables that I'm using here I have:
<Surface>
<Scaffold> // Set with a topBar
<Column>
<ScrollableTabRow>
<Tab/> // Set for the first "Messages" tab
<Tab/> // Set for the second "Dashboard" tab
</ScrollableTabRow>
<HorizontalPager>
// ChatExampleScreen
<Box> // A Box set with the pullRefresh modifier
// Depending on the ChatExamleViewModel we might pull different composables here
</PullRefreshIndicator>
</Box>
// Another ChatExampleScreen for the second tab
</HorizontalPager>
</Column>
<Scaffold>
</Surface>
Honestly, I don't get how the PullRefreshIndicator that is in a completely different Composable (ChatExampleScreen) gets to overlap with the ScrollableTabRow that is outside.
Hope this makes digesting the UI a bit easier. Any tip, advice, or recommendation is appreciated. Thanks! 🙇
Edit: Just to be completely clear, what I'm trying to achieve here is to have a PullRefreshIndicator on each page. Something like this:
On each page, you pull down, see the ProgressBar appear, and when it is done, it goes away, within the same page. Not overlapping with the tabs above.
A comparatively easier solution in my case was to simply give the Box that contains my vertically scrollable Composable and my PullRefreshIndicator a zIndex of -1f:
Box(Modifier.fillMaxSize().zIndex(-1f)) {
LazyColumn(...)
PullRefreshIndicator(...)
}
And that already did the trick for me. I have a very similar setup to the OP, a Scaffold containing a ScrollableTabRow and a HorizontalPager with refreshable lists on the individual tabs.
I want to leave my first answer as I feel it will still be useful to future readers, so heres another one you might consider.
One of the Box in the tabs has a scroll modifier though, because according to the Accompanist Docs and the actual functionality.
… The content needs to be 'vertically scrollable' for SwipeRefresh()
to be able to react to swipe gestures. Layouts such as LazyColumn are
automatically vertically scrollable, but others such as Column or
LazyRow are not. In those instances, you can provide a
Modifier.verticalScroll modifier…
It's from accompanist documentation about the migration of the API but it still applies to this current one in compose framework.
The way I understand it is a scroll event should be present for the PullRefresh to get activated manually (i.e a layout/container with a vertical scroll modifier or a LazyColumn), something that will consume a drag/swipe event in the screen.
Here's the short working sample. All of these are copy-and-paste-able.
Activity:
class PullRefreshActivity: ComponentActivity() {
private val viewModel: MyViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyAppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = { TopAppBarSample() }
) {
MyScreen(
modifier = Modifier.padding(it),
viewModel = viewModel
)
}
}
}
}
}
}
Some data classes:
data class MessageItems(
val message: String = "",
val author: String = ""
)
data class DashboardBanner(
val bannerMessage: String = "",
val content: String = ""
)
ViewModel:
class MyViewModel: ViewModel() {
var isLoading by mutableStateOf(false)
private val _messageState = MutableStateFlow(mutableStateListOf<MessageItems>())
val messageState = _messageState.asStateFlow()
private val _dashboardState = MutableStateFlow(DashboardBanner())
val dashboardState = _dashboardState.asStateFlow()
fun fetchMessages() {
viewModelScope.launch {
isLoading = true
delay(2000L)
_messageState.update {
it.add(
MessageItems(
message = "Hello First Message",
author = "Author 1"
),
)
it.add(
MessageItems(
message = "Hello Second Message",
author = "Author 2"
)
)
it
}
isLoading = false
}
}
fun fetchDashboard() {
viewModelScope.launch {
isLoading = true
delay(2000L)
_dashboardState.update {
it.copy(
bannerMessage = "Hello World!!",
content = "Welcome to Pull Refresh Content!"
)
}
isLoading = false
}
}
}
Tab Screen Composables:
#Composable
fun MessageTab(
myViewModel : MyViewModel
) {
val messages by myViewModel.messageState.collectAsState()
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
items(messages) { item ->
Column(
modifier = Modifier
.fillMaxWidth()
.border(BorderStroke(Dp.Hairline, Color.DarkGray)),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = item.message)
Text(text = item.author)
}
}
}
}
#Composable
fun DashboardTab(
myViewModel: MyViewModel
) {
val banner by myViewModel.dashboardState.collectAsState()
Box(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
contentAlignment = Alignment.Center
) {
Column {
Text(
text = banner.bannerMessage,
fontSize = 52.sp
)
Text(
text = banner.content,
fontSize = 16.sp
)
}
}
}
Finally, the composable that contains the PullRefresh and the Pager/Tab components, and all of them are direct children of a ConstraintLayout. So to achieve a PullRefresh behind the Tabs but still on top of the HorizontalPager, first I had to put the HorizontalPager as the first child, the PullRefresh as the second and the Tabs as the last one, constraining them accordingly to preserve the visual arrangement of a Tab Pager.
#OptIn(ExperimentalMaterialApi::class, ExperimentalPagerApi::class)
#Composable
fun MyScreen(
modifier : Modifier = Modifier,
viewModel: MyViewModel
) {
val refreshing = viewModel.isLoading
val pagerState = rememberPagerState()
val pullRefreshState = rememberPullRefreshState(
refreshing = refreshing,
onRefresh = {
when (pagerState.currentPage) {
0 -> {
viewModel.fetchMessages()
}
1 -> {
viewModel.fetchDashboard()
}
}
},
refreshingOffset = 100.dp // just an arbitrary offset where the refresh will animate
)
ConstraintLayout(
modifier = modifier
.fillMaxSize()
.pullRefresh(pullRefreshState)
) {
val (pager, pullRefresh, tabs) = createRefs()
HorizontalPager(
count = 2,
state = pagerState,
modifier = Modifier.constrainAs(pager) {
top.linkTo(tabs.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
bottom.linkTo(parent.bottom)
height = Dimension.fillToConstraints
}
) { page ->
when (page) {
0 -> {
MessageTab(
myViewModel = viewModel
)
}
1 -> {
DashboardTab(
myViewModel = viewModel
)
}
}
}
PullRefreshIndicator(
modifier = Modifier.constrainAs(pullRefresh) {
top.linkTo(parent.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
},
refreshing = refreshing,
state = pullRefreshState,
)
ScrollableTabRow(
modifier = Modifier.constrainAs(tabs) {
top.linkTo(parent.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
},
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
modifier = Modifier.tabIndicatorOffset(
currentTabPosition = tabPositions[pagerState.currentPage],
)
)
},
) {
Tab(
selected = pagerState.currentPage == 0,
onClick = {},
text = {
Text(
text = "Messages"
)
}
)
Tab(
selected = pagerState.currentPage == 1,
onClick = {},
text = {
Text(
text = "Dashboard"
)
}
)
}
}
}
output:
<Surface>
<Scaffold>
<ConstraintLayout>
// top to ScrollableTabRow's bottom
// start, end, bottom to parent's start, end and bottom
// 0.dp (view), fillToConstraints (compose)
<HorizontalPager>
<PagerScreens/>
</HorizontalPager>
// top, start, end of parent
<PullRefreshIndicator/>
// top, start and end of parent
<ScrollableTabRow>
<Tab/> // Set for the first "Messages" tab
<Tab/> // Set for the second "Dashboard" tab
</ScrollableTabRow>
</ConstraintLayout>
<Scaffold>
</Surface>
I think there's nothing wrong with the PullRefresh api and the Compose/Accompanist Tab/Pager api being used together, it seems like the PullRefresh is just respecting the placement structure of the layout/container it is put into.
Consider this code, no tabs, no pager, just a simple set-up of widgets that is identical to your set-up
Column(
modifier = Modifier.padding(it)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.background(Color.Blue)
)
val pullRefreshState = rememberPullRefreshState(
refreshing = false,
onRefresh = { viewModel.fetchMessages() }
)
Box(
modifier = Modifier.pullRefresh(pullRefreshState)
) {
PullRefreshIndicator(
modifier = Modifier.align(Alignment.TopCenter),
refreshing = false,
state = pullRefreshState,
)
}
}
What it looks like.
The PullRefresh is placed inside a component(Box) that is placed below another component in a Column vertical placement, and since it's below another widget, its initial position will not be hidden like the image sample.
With your set-up, since I noticed that the ViewModel is being shared by the tabs and also the reason why I was confirming if you are decided with your architecture is because the only fix I can think of is moving the PullRefresh up in the sequence of the composable widgets.
First changes I made is in your ChatExampleScreen composable, which ended up like this, all PullRefresh components are removed.
#Composable
fun ChatExampleScreen(
chatexampleViewModel: ChatExampleViewModel,
modifier: Modifier = Modifier
) {
val chatexampleViewModelState by chatexampleViewModel.state.observeAsState()
Box(
modifier = modifier
.fillMaxSize()
) {
when (val result = chatexampleViewModelState) {
is ChatExampleViewModel.State.SuccessfullyLoadedMessages -> {
ChatExampleScreenSuccessfullyLoadedMessages(
chatexampleMessages = result.list,
modifier = modifier,
)
}
is ChatExampleViewModel.State.NoMessagesFetched -> {
ChatExampleScreenEmptyState(
modifier = modifier
)
}
is ChatExampleViewModel.State.NoInternetConnectivity -> {
NoInternetConnectivityScreen(
modifier = modifier
)
}
else -> {
// Agus - Do nothing???
Box(modifier = modifier.fillMaxSize())
}
}
}
}
and in your Activity I moved all the setContent{…} scope into another function named ChatTabsContent and placed everything inside it including the PullRefresh components.
#OptIn(ExperimentalMaterialApi::class)
#Composable
fun ChatTabsContent(
modifier : Modifier = Modifier,
viewModel : ChatExampleViewModel
) {
val chatexampleViewModelIsLoadingState by viewModel.isLoading.observeAsState()
val pullRefreshState = rememberPullRefreshState(
refreshing = chatexampleViewModelIsLoadingState == true,
onRefresh = { viewModel.fetchMessages() }
)
Box(
modifier = modifier
.pullRefresh(pullRefreshState)
) {
Column(
Modifier
.fillMaxSize()
) {
val pagerState = rememberPagerState()
ScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
modifier = Modifier.tabIndicatorOffset(
currentTabPosition = tabPositions[pagerState.currentPage],
)
)
}
) {
Tab(
selected = pagerState.currentPage == 0,
onClick = { },
text = {
Text(
text = "Messages"
)
}
)
Tab(
selected = pagerState.currentPage == 1,
onClick = { },
text = {
Text(
text = "Dashboard"
)
}
)
}
HorizontalPager(
count = 2,
state = pagerState,
modifier = Modifier.fillMaxWidth(),
) { page ->
when (page) {
0 -> {
ChatExampleScreen(
chatexampleViewModel = viewModel,
modifier = Modifier.fillMaxSize()
)
}
1 -> {
ChatExampleScreen(
chatexampleViewModel = viewModel,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
PullRefreshIndicator(
modifier = Modifier.align(Alignment.TopCenter),
refreshing = chatexampleViewModelIsLoadingState == true,
state = pullRefreshState,
)
}
}
which ended up like this
setContent {
TheOneAppTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = { TopAppBarSample() }
) {
ChatTabsContent(
modifier = Modifier.padding(it),
viewModel = viewModel
)
}
}
}
}
Result:
Structural changes.
<Surface>
<Scaffold> // Set with a topBar
<Box>
<Column>
<ScrollableTabRow>
<Tab/> // Set for the first "Messages" tab
<Tab/> // Set for the second "Dashboard" tab
</ScrollableTabRow>
<HorizontalPager>
<Box/>
</HorizontalPager>
</Column>
// pull refresh is now at the most "z" index of the
// box, overlapping the content (tabs/pager)
<PullRefreshIndicator/>
</Box>
<Scaffold>
</Surface>
I haven't explored this API yet, but it looks like it should be used directly in a z-oriented layout/container parent such as Box as the last child.
I just want to share more details about the issue here and what the solution is. I appreciate a lot the solutions shared above and these were definitely key to figuring the problem out.
The bare-minimum solution here is to replace the Box with a ConstraintLayout in the ChatScreenExample composable:
Why? Because as #z.y shared above the PullRefreshIndicator needs to be contained on a "vertically scrollable" composable, and while the Box composable can be set with the vericalScroll() modifier we need to make sure we constraint the height of the content, that's why we had to change to a ConstraintLayout.
Feel free to correct me if I'm missing something.
There is yet another solution to this problem, which is using a .clipToBounds() modifier over the tab content container.

Maintain LazyColumn scroll position after re-composition

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

Compose recomposes even though state value does not change

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

Jetpack Compose #Stable List<T> parameter recomposition

#Composable functions are recomposed
if one the parameters is changed or
if one of the parameters is not #Stable/#Immutable
When passing items: List<Int> as parameter, compose always recomposes, regardless of List is immutable and cannot be changed. (List is interface without #Stable annotation). So any Composable function which accepts List<T> as parameter always gets recomposed, no intelligent recomposition.
How to mark List<T> as stable, so compiler knows that List is immutable and function never needs recomposition because of it?
Only way i found is wrapping like #Immutable data class ImmutableList<T>(val items: List<T>). Demo (when Child1 recomposes Parent, Child2 with same List gets recomposed too):
class TestActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ComposeBasicsTheme {
Parent()
}
}
}
}
#Composable
fun Parent() {
Log.d("Test", "Parent Draw")
val state = remember { mutableStateOf(false) }
val items = remember { listOf(1, 2, 3) }
Column {
// click forces recomposition of Parent
Child1(value = state.value,
onClick = { state.value = !state.value })
//
Child2(items)
}
}
#Composable
fun Child1(
value: Boolean,
onClick: () -> Unit
) {
Log.d("Test", "Child1 Draw")
Text(
"Child1 ($value): Click to recompose Parent",
modifier = Modifier
.clickable { onClick() }
.padding(8.dp)
)
}
#Composable
fun Child2(items: List<Int>) {
Log.d("Test", "Child2 Draw")
Text(
"Child 2 (${items.size})",
modifier = Modifier
.padding(8.dp)
)
}
You mainly have 2 options:
Use a wrapper class annotated with either #Immutable or #Stable (as you already did).
Compose compiler v1.2 added support for the Kotlinx Immutable Collections library.
With Option 2 you just replace List with ImmutableList.
Compose treats the collection types from the library as truly immutable and thus will not trigger unnecessary recompositions.
Please note: At the time of writing this, the library is still in alpha.
I strongly recommend reading this article to get a good grasp on how compose handles stability (plus how to debug stability issues).
Another workaround is to pass around a SnapshotStateList.
Specifically, if you use backing values in your ViewModel as suggested in the Android codelabs, you have the same problem.
private val _myList = mutableStateListOf(1, 2, 3)
val myList: List<Int> = _myList
Composables that use myList are recomposed even if _myList is unchanged. Opt instead to pass the mutable list directly (of course, you should treat the list as read-only still, except now the compiler won't help you).
Example with also the wrapper immutable list:
#Immutable
data class ImmutableList<T>(
val items: List<T>
)
var itemsList = listOf(1, 2, 3)
var itemsImmutable = ImmutableList(itemsList)
#Composable
fun Parent() {
Log.d("Test", "Parent Draw")
val state = remember { mutableStateOf(false) }
val itemsMutableState = remember { mutableStateListOf(1, 2, 3) }
Column {
// click forces recomposition of Parent
Child1(state.value, onClick = { state.value = !state.value })
ChildList(itemsListState) // Recomposes every time
ChildImmutableList(itemsImmutableListState) // Does not recompose
ChildSnapshotStateList(itemsMutableState) // Does not recompose
}
}
#Composable
fun Child1(
value: Boolean,
onClick: () -> Unit
) {
Text(
"Child1 ($value): Click to recompose Parent",
modifier = Modifier
.clickable { onClick() }
.padding(8.dp)
)
}
#Composable
fun ChildList(items: List<Int>) {
Log.d("Test", "List Draw")
Text(
"List (${items.size})",
modifier = Modifier
.padding(8.dp)
)
}
#Composable
fun ChildImmutableList(items: ImmutableList<Int>) {
Log.d("Test", "ImmutableList Draw")
Text(
"ImmutableList (${items.items.size})",
modifier = Modifier
.padding(8.dp)
)
}
#Composable
fun ChildSnapshotStateList(items: SnapshotStateList<Int>) {
Log.d("Test", "SnapshotStateList Draw")
Text(
"SnapshotStateList (${items.size})",
modifier = Modifier
.padding(8.dp)
)
}
Using lambda, you can do this
#Composable
fun Parent() {
Log.d("Test", "Parent Draw")
val state = remember { mutableStateOf(false) }
val items = remember { listOf(1, 2, 3) }
val getItems = remember(items) {
{
items
}
}
Column {
// click forces recomposition of Parent
Child1(value = state.value,
onClick = { state.value = !state.value })
//
Child2(items)
Child3(getItems)
}
}
#Composable
fun Child3(items: () -> List<Int>) {
Log.d("Test", "Child3 Draw")
Text(
"Child 3 (${items().size})",
modifier = Modifier
.padding(8.dp)
)
}

How to keep list state across recompositions using NavGraph in Android's Jetpack Compose?

What should I do to make my composable remember the list state when navigating back to it?
If I understood correctly, when navigating "down" the composable is replaced by another one, so when going "up"/back it will create a new one and if I want something to persist in that situation I must hoist that value.
The thing is, I don't understand how to maintain a clean architecture if everything will be a property of my parent, in this case the activity which holds the NavHost and the composables for the NavGraph.
I've been looking to the Jetnews sample and they don't hoist anything to the NavGraph level, so how did they make the list to stay in the same position?
Activity
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = NavigationScreens.Launcher.route
) {
composable(NavigationScreens.Home.route) {
val viewModel = hiltViewModel<DealsViewModel>(backStackEntry = it)
HomeScreen(viewModel) { game ->
navController.putArgument(NavigationScreens.Pdp.Args.game, game)
navController.navigate(NavigationScreens.Pdp.route)
}
}
}
ViewModel
#HiltViewModel
class DealsViewModel #Inject constructor(
private val fetchGameDealsUseCase: FetchGameDealsUseCase,
private val dispatcherProvider: DispatcherProvider
) : ViewModel() {
private val scope = CoroutineScope(dispatcherProvider.io)
val deals = fetchGameDealsUseCase().cachedIn(scope)
}
HomeScreen
Scaffold(
scaffoldState = scaffoldState
) {
Box(modifier = Modifier.fillMaxSize()) {
SearchField()
DealsScreen(
deals = viewModel.deals,
contentPadding = PaddingValues(top = 84.dp),
onItemClick = onItemClick
)
}
}
DealsScreen
fun DealsScreen(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
deals: Flow<PagingData<Game>>,
onItemClick: (Game) -> Unit,
) {
val nColumns = 3
val content = deals.collectAsLazyPagingItems()
LazyVerticalGrid(
cells = GridCells.Fixed(nColumns),
modifier = Modifier
.fillMaxSize()
.composed { modifier },
contentPadding = contentPadding,
) {
items(content) { game ->
game?.let {
DealsContent(game, onItemClick)
}
}
}
}
The main difference I noted between my code and theirs is that they don't collect the data on the composable, but instead the use a producer which in this case I don't know what to do since I'm using the collectAsLazyPagingItems. Does anyone have a solution for this?
Full code is available at https://github.com/Danil0v3s/wishlisted-android/tree/compose
Edit: I've found that there's actually a bug which makes the composable defaults the list to initial position when the collectAsLazyPagingItems is used inside a NavHost

Categories

Resources