PullRefreshIndicator does not disappear after Refreshing - android

I have migrated from Accompanist SwipeRefresh (deprecated) to PullRefreshIndicator which is now the default indicator for Compose.
The first issue I encountered was PullRefreshIndicator overlapping other components, but I managed this by using zIndex as explained on this SO question.
The problem now is that the indicator is not disappearing/hidden after a refresh as illustrated below.
I have narrowed the issue down to the state type passed to rememberPullRefreshState().
val state by viewModel.fixturesState.collectAsState()
val pullRefreshState = rememberPullRefreshState(
refreshing = state.isLoading/isRefreshing,
onRefresh = { viewModel.onEvent(HomeEvent.OnSwipeRefresh) }
)
In my ViewModel I have two states i.e. isLoading and isRefreshing . State isLoading is triggered when the data is loading while State isRefreshing is activated as an Event when the user pulls down to refresh. The isLoading state is toggled between false and true by flow emission in relation to the data fetch. The isRefreshing state is switched to true following a refresh action by the user and is switched back to false from inside the method that is fetching the data.
This is the link to HomeScreen.
If I pass isLoading the indicator is correctly hidden after the refresh.
val pullRefreshState = rememberPullRefreshState(
refreshing = state.isLoading,
onRefresh = { viewModel.onEvent(HomeEvent.OnSwipeRefresh) }
)
However, if I pass isRefreshing the indicator doesn't go away after the refresh.
val pullRefreshState = rememberPullRefreshState(
refreshing = state.isRefreshing,
onRefresh = { viewModel.onEvent(HomeEvent.OnSwipeRefresh) }
)
I could could leave the code this way (stick with isLoading) and move on but I notice some inconsistencies because everytime there is loading, the indicator briefly appears and then goes away which is not desirable for UI/UX.
Anybody with any leads to rectify this issue please help. I am using Material 3 [1.1.0-alpha04] and Compose Version 1.3

I ended up introducing a delay of 1000 milliseconds before setting refreshing to false:
fun refresh() = refreshScope.launch {
refreshing = true
viewModel.goDoStuff()
delay(1000L)
refreshing = false
}

Related

Infinite recomposition on LazyColumn items in Jetpack Compose

I've got simple LazyColumn:
LazyColumn {
val lazySportEvents: LazyPagingItems<RecyclerItem> = stateValue.pagingItems.collectAsLazyPagingItems()
lazySportEvents.apply {
when (loadState.refresh) {
is LoadState.NotLoading -> {
itemsIndexed(
lazyPagingItems = lazySportEvents,
itemContent = { index, item ->
when (item) {
is SportEvent -> Text(item.name)
is AdItem -> AndroidView(
factory = { context ->
AdImageView(context).apply{
loadAdImage(item.id)
}
}
)
}
}
)
}
}
}
}
When I scroll screen down, everything loads fine. But when I scroll up, I end up with fun loadAdImage() called. It means that recomposition for AdItem happened even if that is the very same item (values and reference) like before scrolling screen down! Why does recomposition even happen then? I would like to omit it, to not load the same ad image every time while scrolling.
Is it even possible to skip recomposition for lazy paging items?
Edit: I realised the recomposition for items was infinite and that caused aforementioned behavior.
It turned out that when I changed
modifier = Modifier.fillParentMaxWidth()
to
modifier = Modifier.fillMaxWidth()
for some Boxes in the composable layout, infinite recomposition in LazyColumn stopped.
Edit: while working on the fix I experienced some Gradle dependencies caching problems. Anyway, when the problem approached for the 2nd time - the solution was to... upgrade compose.foundation dependency:
implementation "androidx.compose.foundation:foundation:$1.2.0-alpha07"
Not sure which one helped.

Jetpack Compose do on compose, but not on recomposition - track ContentViewed

I'm trying to implement some kind of LaunchedEffectOnce as I want to track a ContentViewed event. So my requirement is that every time the user sees the content provided by the composable, an event should get tracked.
Here is some example code of my problem:
#Composable
fun MyScreen(viewModel: MyViewModel = get()){
val items by viewModel.itemsToDisplay.collectAsState(initial = emptyList())
ItemList(items)
// when the UI is displayed, the VM should track an event (only once)
LaunchedEffectOnce { viewModel.trackContentViewed() }
}
#Composable
private fun LaunchedEffectOnce(doOnce: () -> Unit) {
var wasExecuted by rememberSaveable { mutableStateOf(false) }
if (!wasExecuted) {
LaunchedEffect(key1 = rememberUpdatedState(newValue = executed)) {
doOnce()
wasExecuted = true
}
}
}
This code is doing do the following:
Tracks event when MyScreen is composed
Does NOT track when the user enters a list item screen and navigates back to MyScreen
Does NOT track the event on recomposition (like orientation change)
But what I wan't to achieve is the following:
Tracks event when MyScreen is composed
Tracks when the user enters a list item screen and navigates back to MyScreen
Does NOT track the event on recomposition (like orientation change)
My ViewModel looks like that:
class MyViewModel() : ViewModel() {
val itemsToDisplay: Flow<List<Item>> = GetItemsUseCase()
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 1)
val contentTracking: Flow<Tracking?> = GetTrackingUseCase()
.distinctUntilChanged { old, new -> old === new }
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 1)
fun trackContentViewed(){
// track last element in contentTracking
}
}
I really hope someone can help me and can explain what I'm doing wrong here. Thanks in advance!
Assuming the following are true
your view model is scoped to the Fragment in which MyScreen enters composition
your composables leave the composition when you navigate to an item screen and re-enter composition when you navigate back
then you can simply track inside the view model itself whether specific content was already viewed in this view model's scope. Then when you navigate to any of the items screens you reset that "tracking state".
If you need to track only a single element of content then just a Boolean variable would be enough, but in case you need to track more than one element, you can use either a HashSet or a mutableSetOf (which returns a LinkedHashSet instead). Then when you navigate to any of the item screen you reset that variable or clear the Set.
Your VM code would then change to
class MyViewModel() : ViewModel() {
// ... you existing code remains unchanged
private var viewedContent = mutableSetOf<Any>()
fun trackContentViewed(key: Any){
if (viewedContent.add(key)) {
// track last element in contentTracking
Log.d("Example", "Key $key tracked for 'first time'")
} else {
// content already viewed for this key
Log.d("Example", "Key $key already tracked before")
}
}
fun clearTrackedContent() {
viewedContent.clear()
}
}
and the MyScreen composable would change to
#Composable
fun MyScreen(viewModel: MyViewModel = get()){
// ... you existing code remains unchanged
// Every time this UI enters the composition (but not on recomposition)
// the VM will be notified
LaunchedEffect(Unit) {
viewModel.trackContentViewed(key = "MyScreen") // or some other key
}
}
Where you start the navigation to an item screen (probably in some onClick handler on items) you would call viewmodel.clearTrackedContent().
Since (1) is true when ViewModels are requested inside a Fragment/Activity and if (2) is also true in your case, then the VM instance will survive configuration changes (orientation change, language change...) and the Set will take care of tracking.
If (2) is not true in your case, then you have two options:
if at least recomposition happens when navigating back, replace LaunchedEffect with SideEffect { viewModel.trackContentViewed(key = "MyScreen") }
if your composables are not even recomposed then you will have to call viewModel.trackContentViewed also when navigating back.

Issue with Side-effects - LaunchedEffect and SideEffect in Jetpack Compose

Why SideEffect gets called ever-time my composable is invalidated , but same does-not hold true for LaunchedEffect?
sealed class SomeState {
object Error:SomeState()
data class Content(): SomeState
}
class MyViewModel:ViewModel {
internal val response: MutableLiveData<SomeState> by lazy {
MutableLiveData<SomeState>()
}
}
// This is top-level composable, it wont be recomposed ever
#Composable
fun MyComposableScreen(
viewModel:MyVm,
launchActivity:()->Unit
){
val someDialog = remember { mutableStateOf(false) }
MyComposableContent()
GenericErrorDialog(someDialog = someDialog)
when (val state = viewModel.response.observeAsState().value) {
// Query 1
is Content -> LaunchedEffect(Unit) { launchActivity() }
Error -> {
// Query 2
// Gets called everytime this composable gets invalidated, for eg in case of TextField change, compiler is invalidating it.
// But if i change it to LaunchedEffect(Unit), invalidation has no effect,LaunchedEffect only gets called when there is new update to the LiveData. why?
SideEffect { someDialog.value = true}
}
}
}
// This is the content, which can be recomposed in case of email is changed
#Composable
fun MyComposableContent(
onEmailChange:(email) -> Unit,
email:String,
){
TextField(
email = email,
onValueChange = onEmailChange
)
}
I have doubts related to Query 1 and Query 2 both are part of top-level composable which will never be re-composed, but can be invalidated,
when (val state = viewModel.response.observeAsState().value) { // observing to live-data
// Query 1
is Content -> LaunchedEffect(Unit) { launchActivity() }
Error -> {
// Query 2
SideEffect { someDialog.value = true}
}
}
In case of is
Content -> LaunchedEffect(Unit) { launchActivity() }
I believe this should be fine as we want to launch an activity only when LaunchedEffect is part of the first time composition, and it will be only part of the composition if live data state is Content
I faced issue in second scenario,
Error -> {
// Query 2
SideEffect { someDialog.value = true // shows a dialog}
}
If last state of live-data, is Error in viewModel. And every time i make changes in the TextField my top level MyComposableScreen was getting invalidated(not recomposed) by compose compiler, and since last state of live-data was set as error, SideEffect was running every time, which is fine as it should run for every successful composition and re-composition.
But, if i change it from SideEffect to LaunchedEffect(Unit){someDialog.value = true} dialog box was not showing up every time MyComposableScreen was invalidated, thats the desired behavior.
LaunchedEffect(Unit) gets called only if there live-data emits the new state again because of any UI-action.
But, I am not sure regarding the reasoning behind it, why the code inside LaunchedEffect(Unit){someDialog.value = true} does not trigger after composable gets invalidated but the code inside SideEffect gets triggered after composable gets invalidated?
To make it more clear
I understand the difference
SideEffect -> on every successful composition and re-composition, if it's part of it
LaunchedEffect -> when its enters composition and span across re-composition unless the keys are changed.
But in above scenario - this code particularly
#Composable
fun MyTopLevelComposable(viewModel:MyViewModel){
when (val state = viewModel.response.observeAsState().value) { // observing live-data state
is Content -> LaunchedEffect(Unit) { launchActivity() }
Error -> SideEffect { someDialog.value = true}
}
}
It will never get recomposed. The only reason for this composable to be called again could be if compose compiler invalidates the view.
My Query is -> when view/composable gets invalidated
SideEffect {someDialog.value = true} executes, because it will again go through composition not re-composition as viewModel.response(which is live-data) last state was Error
But if change it to LaunchedEffect(Unit) {someDialog.value = true} it doesn't executes again after the composable is invalidated. It only reacts to a new state emitted by the live-data.
Question is why? Invalidate should start composition again, and since it's a composition. not re-composition LaunchedEffect should behave similarly to SideEffect in this scenario, as both are reacting to composition.
In Compose, there is no such thing as invalidating a view.
When you keep your when in the same scope as the state variable, changing the state variable recomposes the contents of when, but when you move it to a separate composable, only updating viewModel.response can recompose it - Compose tries to reduce the number of views to recompose as much as possible.
LaunchedEffect(Unit) will be re-run in two cases:
If it was removed from the view tree during one of the previous recompositions and then added again. For example, if you wrap LaunchedEffect in if and the condition is first false and then true. Or, in your case, if when will choose Error -> after is Content ->, this will also remove LaunchedEffect from the view tree.
If one of the keys passed to LaunchedEffect has changed.
It looks like your problem is that LaunchedEffect does not restart when new content value come in, to solve this, you need to pass this value as key in LaunchedEffect, instead of Unit:
LaunchedEffect(state) { launchActivity() }
They just behave differently for a reason. Have a look at the documentation.
For LaunchEffect, it only gets call the first time because you've specified Unit for its key. If you'd like it to trigger at a specific recomposition, use the state value you'd like to observe. Each time it changes, the LaunchEffect will be triggered.

Material Swipe To Dismiss in Compose maks incorrect items for dismissal

I'm implementing drag/swipe to dismiss functionality in a simple notepad app implemented in Compose. I've run into a strange issue where SwipeToDismiss() in a LazyColumn dismisses not only the selected item but those after it as well.
Am I doing something wrong or is this a bug with SwipeToDismiss()? (I'm aware that it's marked ExperimentalMaterialApi)
I've used the Google recommended approach from here:
https://developer.android.com/reference/kotlin/androidx/compose/material/package-summary#swipetodismiss
this is where it happens:
/* ...more code... */
LazyColumn {
items(items = results) { result ->
Card {
val dismissState = rememberDismissState()
//for some reason the dismmissState is EndToStart for all the
//items after the deleted item, even adding new items becomes impossible
if (dismissState.isDismissed(EndToStart)) {
val scope = rememberCoroutineScope()
scope.launch {
dismissed(result)
}
}
SwipeToDismiss(
state = dismissState,
modifier = Modifier.padding(vertical = 4.dp),
/* ...more code... */
and here is my project with the file in question
https://github.com/davida5/ComposeNotepad/blob/main/app/src/main/java/com/anotherday/day17/ui/NotesList.kt
You need to provide key for the LazyColumn's items.
By default, each item's state is keyed against the position of the
item in the list. However, this can cause issues if the data set
changes, since items which change position effectively lose any
remembered state.
Example
LazyColumn {
items(
items = stateList,
key = { _, listItem ->
listItem.hashCode()
},
) { item ->
// As it is ...
}
}
Reference

How to save paging state of LazyColumn during navigation in Jetpack Compose

I'm using androidx.paging:paging-compose (v1.0.0-alpha-14), together with Jetpack Compose (v1.0.3), I have a custom PagingSource which is responsible for pulling items from backend.
I also use compose navigation component.
The problem is I don't know how to save a state of Pager flow between navigating to different screen via NavHostController and going back (scroll state and cached items).
I was trying to save state via rememberSaveable but it cannot be done as it is not something which can be putted to Bundle.
Is there a quick/easy step to do it?
My sample code:
#Composable
fun SampleScreen(
composeNavController: NavHostController? = null,
myPagingSource: PagingSource<Int, MyItem>,
) {
val pager = remember { // rememberSaveable doesn't seems to work here
Pager(
config = PagingConfig(
pageSize = 25,
),
initialKey = 0,
pagingSourceFactory = myPagingSource
)
}
val lazyPagingItems = pager.flow.collectAsLazyPagingItems()
LazyColumn() {
itemsIndexed(items = lazyPagingItems) { index, item ->
MyRowItem(item) {
composeNavController?.navigate(...)
}
}
}
}
I found a solution!
#Composable
fun Sample(data: Flow<PagingData<Something>>):
val listState: LazyListState = rememberLazyListState()
val items: LazyPagingItems<Something> = data.collectAsLazyPagingItems()
when {
items.itemCount == 0 -> LoadingScreen()
else -> {
LazyColumn(state = listState, ...) {
...
}
}
}
...
I just found out what the issue is when using Paging.
The reason the list scroll position is not remembered with Paging when navigating boils down to what happens below the hood.
It looks like this:
Composable with LazyColumn is created.
We asynchronously request our list data from the pager. Current pager list item count = 0.
The UI draws a lazyColumn with 0 items.
The pager responds with data, e.g. 10 items, and the UI is recomposed to show them.
User scrolls e.g. all the way down and clicks the bottom item, which navigates them elsewhere.
User navigates back using e.g. the back button.
Uh oh. Due to navigation, our composable with LazyColumn is recomposed. We start again with asynchronously requesting pager data. Note: pager item count = 0 again!
rememberLazyListState is evaluated, and it tells the UI that the user scrolled down all the way, so it now should go back to the same offset, e.g. to the fifth item.
This is the point where the UI screams in wild confusion, as the pager has 0 items, so the lazyColumn has 0 items.
The UI cannot handle the scroll offset to the fifth item. The scroll position is set to just show from item 0, as there are only 0 items.
What happens next:
The pager responds that there are e.g. 10 items again, causing another recomposition.
After recomposition, we see our list again, with scroll position starting on item 0.
To confirm this is the case with your code, add a simple log statement just above the LazyColumn call:
Log.w("TEST", "List state recompose. " +
"first_visible=${listState.firstVisibleItemIndex}, " +
"offset=${listState.firstVisibleItemScrollOffset}, " +
"amount items=${items.itemCount}")
You should see, upon navigating back, a log line stating the exact same first_visible and offset, but with amount items=0.
The line directly after that will show that first_visible and offset are reset to 0.
My solution works, because it skips using the listState until the pager has loaded the data.
Once loaded, the correct values still reside in the listState, and the scroll position is correctly restored.
Source: https://issuetracker.google.com/issues/177245496
Save the list state in your viewmodel and reload it when you navigate back to the screen containing the list. You can use LazyListState in your viewmodel to save the state and pass that into your composable as a parameter. Something like this:
class MyViewModel: ViewModel() {
var listState = LazyListState()
}
#Composable
fun MessageListHandler() {
MessageList(
messages: viewmodel.messages,
listState = viewmode.listState
)
}
#Composable
fun MessageList(
messages: List<Message>,
listState: LazyListState) {
LazyColumn(state = listState) {
}
}
If you don't like the limitations that Navigation Compose puts on you, you can try using Jetmagic. It allows you to pass any object between screens and even manages your viewmodels in a way that makes them easier to access from any composable:
https://github.com/JohannBlake/Jetmagic
The issue is that when you navigate forward and back your composable will recompose and collectAsLazyPagingItems() will be called again, triggering a new network request.
If you want to avoid this issue, you should call pager.flow.cacheIn(viewModelScope) on your ViewModel with activity scope (the ViewModel instance is kept across fragments) before calling collectAsLazyPagingItems().
LazyPagingItems is not intended as a persistent data store; it is just a simple wrapper for the UI layer. Pager data should be cached in the ViewModel.
please try using '.cachedIn(viewModelScope) '
simple example:
#Composable
fun Simple() {
val simpleViewModel:SimpleViewModel = viewModel()
val list = simpleViewModel.simpleList.collectAsLazyPagingItems()
when (list.loadState.refresh) {
is LoadState.Error -> {
//..
}
is LoadState.Loading -> {
BoxProgress()
}
is LoadState.NotLoading -> {
when (list.itemCount) {
0 -> {
//..
}
else -> {
LazyColumn(){
items(list) { b ->
//..
}
}
}
}
}
}
//..
}
class SimpleViewModel : ViewModel() {
val simpleList = Pager(
PagingConfig(PAGE_SIZE),
pagingSourceFactory = { SimpleSource() }).flow.cachedIn(viewModelScope)
}

Categories

Resources