Repository: wordpress-mobile/WordPress-Android (language: kotlin)

Issue:
[Reader][Bug] Discover scrolls to top when loading more posts
### Expected behavior
When scrolling down the Discover feed to load older posts, after doing a pull to refresh, the screen should not scroll back to the top.

### Actual behavior
When scrolling down the Discover feed to load older posts, **after doing a pull to refresh**, the screen scrolls back to the first item, even though it's loading items on the end of the list (older posts).


https://github.com/wordpress-mobile/WordPress-Android/assets/5091503/79db6249-0fb2-41b8-ba0f-8d67b6dce89a


### Steps to reproduce the behavior
1. Install and log into Jetpack
2. Go to the Reader
3. Go to the Discover feed
4. Swipe to refresh the Discover feed
5. Start scrolling the feed (preferably slowly so it's easy to see the issue)
6. When a progress spinner appears on the bottom and the feed jumps back to the first item
7. You can do steps 4-6 several times to see the issue happening consistently

### Initial investigation
It looks like this bug has existed for a while (since 2020 or before), as the culprit code is this one:
https://github.com/wordpress-mobile/WordPress-Android/blob/81060a8036577dac90abf29e483f1d723ca53777/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModel.kt#L277-L279

It only happens on the Discover Feed, and only happens on the first request for more posts after using the pull-to-refresh, because the way that the loading indicator is shown simply copies the current state (code above) modifying the `loadMoreProgressVisibility` to `true`.

The issue is that the latest UI state for the swipe-to-refresh contains a `scrollToTop` variable that is still `true` (since the last pull to refresh UI state had that set that way [here](https://github.com/wordpress-mobile/WordPress-Android/blob/81060a8036577dac90abf29e483f1d723ca53777/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModel.kt#L154-L159) with `swipeToRefreshTriggered` being set [here](https://github.com/wordpress-mobile/WordPress-Android/blob/81060a8036577dac90abf29e483f1d723ca53777/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModel.kt#L507-L513)). 

Also, the `ViewModel` controls the state of that `scrollToTop` using a class field `swipeToRefreshTriggered`, and resets only that field, but not the actual UI state used for refreshing the screen after the pull-to-refresh content is updated [here](https://github.com/wordpress-mobile/WordPress-Android/blob/81060a8036577dac90abf29e483f1d723ca53777/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModel.kt#L160). So we have those 2 places controlling the same state but not being synchronized properly.

### Fix suggestion
A quick idea for a fix is making sure that whenever the `_uiState` is updated using `.copy` we also set the `scrollToTop` field to the current `swipeToRefreshTriggered` View Model field.

Note this is far from ideal though, since a more proper fix would be to make sure the `_uiState` is the source of truth, also the `scrollToTop` feels more of an `Event` than an actual `State`, so maybe a solution would be making the `scrollToTop` be a separate Event LiveData/Flow that is observed by the UI and is **not** part of the UI State.

##### Tested on OnePlus 5T, Android 10, JPAndroid 24.0-rc-1
##### Tested on Emulator, Android 140, JPAndroid 21.8 (from around May 2023)



Discussion hints:
nan

Pull request: [Reader IA] Fix Discover scrolling to top when loading more posts
Fixes #19968 

This prevents the stale information of this field from being kept in the `uiState` and being consumed by the UI incorrectly multiple times. An event makes more sense here since we want the action to happen once at a very specific moment, only that event is triggered.

### Demo
_Note: the first flow shown in the video is the correct scroll-to-top behavior that should happen if the user made a swipe-to-refresh but scrolled the list. The second flow (after the swipe-to-refresh load is finished) is the one that was fixed, which was simply scrolling until the point where the feed fetches more posts._

https://github.com/wordpress-mobile/WordPress-Android/assets/5091503/fcd916ba-c0d0-43be-b8b5-455868763756

-----

## To Test:
1. Install and log into Jetpack
2. Go to the Reader
3. Go to the Discover feed
4. Swipe to refresh the Discover feed
5. Start scrolling the feed (preferably slowly)
6. **Verify** the screen doesn't jump to the top when the progress indicator appears on the bottom 
7. You can do steps 4-6 several times to see the original issue doesn't happen anymore

-----

## Regression Notes

1. Potential unintended areas of impact

    - N/A

2. What I did to test those areas of impact (or what existing automated tests I relied on)

    - N/A

3. What automated tests I added (or what prevented me from doing so)

    - Add a unit test for the case that was broken before

-----

## PR Submission Checklist:

- [x] I have completed the Regression Notes.
- [x] I have considered adding accessibility improvements for my changes.
- [x] I have considered if this change warrants user-facing release notes and have added them to `RELEASE-NOTES.txt` if necessary.


Test command: ./gradlew :WordPress:testWordpressVanillaDebugUnitTest --tests "org.wordpress.android.ui.reader.discover.ReaderDiscoverViewModelTest"

Fix patch:
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverFragment.kt
index bea9580b298a..00918013c71e 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverFragment.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverFragment.kt
@@ -131,9 +131,6 @@ class ReaderDiscoverFragment : ViewPagerFragment(R.layout.reader_discover_fragme
             when (it) {
                 is DiscoverUiState.ContentUiState -> {
                     (recyclerView.adapter as ReaderDiscoverAdapter).update(it.cards)
-                    if (it.scrollToTop) {
-                        recyclerView.scrollToPosition(0)
-                    }
                 }
                 is DiscoverUiState.EmptyUiState -> {
                     uiHelpers.setTextOrHide(actionableEmptyView.title, it.titleResId)
@@ -153,9 +150,10 @@ class ReaderDiscoverFragment : ViewPagerFragment(R.layout.reader_discover_fragme
             ptrLayout.isEnabled = it.swipeToRefreshEnabled
             ptrLayout.isRefreshing = it.reloadProgressVisibility
         }
+        viewModel.scrollToTopEvent.observeEvent(viewLifecycleOwner) { recyclerView.scrollToPosition(0) }
         viewModel.navigationEvents.observeEvent(viewLifecycleOwner) { handleNavigation(it) }
-        viewModel.snackbarEvents.observeEvent(viewLifecycleOwner, { it.showSnackbar() })
-        viewModel.preloadPostEvents.observeEvent(viewLifecycleOwner, { it.addWebViewCachingFragment() })
+        viewModel.snackbarEvents.observeEvent(viewLifecycleOwner) { it.showSnackbar() }
+        viewModel.preloadPostEvents.observeEvent(viewLifecycleOwner) { it.addWebViewCachingFragment() }
         viewModel.start(parentViewModel)
     }
 
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModel.kt
index d9b5091f88a2..c9b02681ea84 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModel.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModel.kt
@@ -2,6 +2,7 @@ package org.wordpress.android.ui.reader.discover
 
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.MediatorLiveData
+import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.Observer
 import androidx.lifecycle.viewModelScope
 import kotlinx.coroutines.CoroutineDispatcher
@@ -79,6 +80,9 @@ class ReaderDiscoverViewModel @Inject constructor(
     private val _preloadPostEvents = MediatorLiveData<Event<PreLoadPostContent>>()
     val preloadPostEvents: LiveData<Event<PreLoadPostContent>> = _preloadPostEvents
 
+    private val _scrollToTopEvent = MutableLiveData<Event<Unit>>()
+    val scrollToTopEvent: LiveData<Event<Unit>> = _scrollToTopEvent
+
     /**
      * Post which is about to be reblogged after the user selects a target site.
      */
@@ -155,9 +159,11 @@ class ReaderDiscoverViewModel @Inject constructor(
                             convertCardsToUiStates(posts),
                             reloadProgressVisibility = false,
                             loadMoreProgressVisibility = false,
-                            scrollToTop = swipeToRefreshTriggered
                         )
-                        swipeToRefreshTriggered = false
+                        if (swipeToRefreshTriggered) {
+                            _scrollToTopEvent.postValue(Event(Unit))
+                            swipeToRefreshTriggered = false
+                        }
                     } else {
                         _uiState.value = DiscoverUiState.EmptyUiState.ShowNoPostsUiState {
                             _navigationEvents.value = Event(ShowReaderSubs)
@@ -523,7 +529,6 @@ class ReaderDiscoverViewModel @Inject constructor(
         val fullscreenProgressVisibility: Boolean = false,
         val swipeToRefreshEnabled: Boolean = false,
         open val fullscreenEmptyVisibility: Boolean = false,
-        open val scrollToTop: Boolean = false
     ) {
         open val reloadProgressVisibility: Boolean = false
         open val loadMoreProgressVisibility: Boolean = false
@@ -532,7 +537,6 @@ class ReaderDiscoverViewModel @Inject constructor(
             val cards: List<ReaderCardUiState>,
             override val reloadProgressVisibility: Boolean,
             override val loadMoreProgressVisibility: Boolean,
-            override val scrollToTop: Boolean
         ) : DiscoverUiState(contentVisiblity = true, swipeToRefreshEnabled = true)
 
         object LoadingUiState : DiscoverUiState(fullscreenProgressVisibility = true)


Test patch:
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModelTest.kt
index d3d7227e6465..1db7219634ec 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModelTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModelTest.kt
@@ -608,18 +608,19 @@ class ReaderDiscoverViewModelTest : BaseUnitTest() {
     @Test
     fun `Scroll to top is triggered when discover feed is updated after swipe to refresh`() = test {
         // Arrange
-        val uiStates = init().uiStates
+        val scrollToTopCounter = init().scrollToTopCounter
+
         // Act
         viewModel.swipeToRefresh()
         fakeDiscoverFeed.value = createDummyReaderCardsList()
         // Assert
-        assertThat(uiStates.last().scrollToTop).isTrue
+        assertThat(scrollToTopCounter.count).isEqualTo(1)
     }
 
     @Test
     fun `Scroll to top is not triggered when discover feed is updated after load more action`() = test {
         // Arrange
-        val uiStates = init().uiStates
+        val scrollToTopCounter = init().scrollToTopCounter
         val closeToEndIndex = NUMBER_OF_ITEMS.toInt() - INITIATE_LOAD_MORE_OFFSET
         init()
         // Act
@@ -628,7 +629,34 @@ class ReaderDiscoverViewModelTest : BaseUnitTest() {
         }
         fakeDiscoverFeed.value = createDummyReaderCardsList()
         // Assert
-        assertThat(uiStates.last().scrollToTop).isFalse
+        assertThat(scrollToTopCounter.count).isZero()
+    }
+
+    @Test
+    fun `Scroll to top is triggered only once when discover feed is updated with load more after swipe to refresh`() =
+        test {
+            // Arrange
+            val scrollToTopCounter = init().scrollToTopCounter
+            val closeToEndIndex = NUMBER_OF_ITEMS.toInt() - INITIATE_LOAD_MORE_OFFSET
+
+            // Act for swipe to refresh
+            viewModel.swipeToRefresh()
+            fakeDiscoverFeed.value = createDummyReaderCardsList()
+
+            // Assert for swipe to refresh
+            assertThat(scrollToTopCounter.count).isEqualTo(1)
+
+            // Arrange for load more
+            scrollToTopCounter.reset()
+
+            // Act for load more
+            ((viewModel.uiState.value as ContentUiState).cards[closeToEndIndex] as ReaderPostUiState).let {
+                it.onItemRendered.invoke(it)
+            }
+            fakeDiscoverFeed.value = createDummyReaderCardsList()
+
+            // Assert for load more
+            assertThat(scrollToTopCounter.count).isZero()
     }
 
     @Test
@@ -679,11 +707,15 @@ class ReaderDiscoverViewModelTest : BaseUnitTest() {
         viewModel.snackbarEvents.observeForever {
             msgs.add(it)
         }
+        val scrollToTop = SimpleCounter()
+        viewModel.scrollToTopEvent.observeForever {
+            scrollToTop.increment()
+        }
         viewModel.start(parentViewModel)
         if (autoUpdateFeed) {
             fakeDiscoverFeed.value = createDummyReaderCardsList()
         }
-        return Observers(uiStates, navigation, msgs)
+        return Observers(uiStates, navigation, msgs, scrollToTop)
     }
 
     // since we are adding an InterestsYouMayLikeCard we remove one item from the numberOfItems since it counts as 1.
@@ -881,6 +913,18 @@ class ReaderDiscoverViewModelTest : BaseUnitTest() {
     private data class Observers(
         val uiStates: List<DiscoverUiState>,
         val navigation: List<Event<ReaderNavigationEvents>>,
-        val snackbarMsgs: List<Event<SnackbarMessageHolder>>
+        val snackbarMsgs: List<Event<SnackbarMessageHolder>>,
+        val scrollToTopCounter: SimpleCounter, // number of calls to this event
     )
+
+    private class SimpleCounter {
+        var count: Int = 0
+            private set
+
+        fun increment() = count++
+
+        fun reset() {
+            count = 0
+        }
+    }
 }