Expensify / App

Welcome to New Expensify: a complete re-imagination of financial collaboration, centered around chat. Help us build the next generation of Expensify by sharing feedback and contributing to the code.
https://new.expensify.com
MIT License
3.56k stars 2.9k forks source link

[HOLD #147480][$40,000] React Native ScrollView bug: maintainVisibleContentPosition #7925

Closed roryabraham closed 1 year ago

roryabraham commented 2 years ago

If you haven’t already, check out our contributing guidelines for onboarding and email contributors@expensify.com to request to join our Slack channel!


Action Performed:

Here is a very minimal reproduction of this bug in the rn-tester sample project, that's not dependent upon any new code in the React Native codebase. Run the rn-tester app, find the ScrollView example, and observe the following behavior:

If minIndexForVisible is 0, then the scroll position will be maintained iff:

  1. The first item in the list is not visible, AND
  2. The current contentOffset is at least y, where y is the height of the new content being prepended

Similarly, if minIndexForVisible is 1, then the scroll position will be maintained iff:

  1. The second item in the list is not visible, AND
  2. The current contentOffset is at least y, where y is the height of the new content being prepended

And so on... If either of those conditions are not met, the scroll position will not be maintained.

Expected Result:

As long as the list has enough content that it is scrollable, the contentOffset should be adjusted such that the scroll position is maintained when new items are added to the start or end of the list.

Actual Result:

The contentOffset is not adjusted, and the scroll position is not maintained.

Additional Details

Background

How does the maintainVisibleContentPosition prop work?

At a high level, when new UI elements are added to the start of the list, React Native will:

  1. Measure the position of the first visible item before the new items are added
  2. Add the new items to the view.
  3. Measure the difference in position between the first visible item found in step 1 with its new position.
  4. Increase the contentOffset of the scroll container by the amount calculated in the previous step, such that:
    1. The same first item is visible before and after adding the new items to the list, and
    2. All the newly-prepended items are out of view, and further towards the start of the list.

However, this prop does not work consistently – sometimes in step 3 the difference in position is incorrectly calculated to be zero. Furthermore, we have noticed that this seems only to happen consistently when the content length of newly-prepended list items is long.

Motivation

For a few months now, we have been endeavoring to get a working solution for a bidirectional-scrolling virtualized list in React Native. After working through many potential solutions, we have come very close to a working solution directly in React Native's VirtualizedList through the code in this PR. However, after lots of debugging we determined that the issues we were seeing weren't caused by the JS / VirtualizedList at all, but instead by this bug in ScrollView's maintainVisibleContentPosition prop, which is implemented in the native layer.

The result of this bug is that our implementation of the onStartReached prop in VirtualizedList suffers from the following issue:

  1. When you reach the start of the list (contentOffset of 0), onStartReached is called.
  2. The callback to onStartReached prepends new items into the list.
  3. maintainVisibleContentPosition fails to update the contentOffset to account for those new list items.
  4. The new list items are rendered, but the contentOffset is still 0, so the list position jumps to the start of the new content.
  5. Because the contentOffset is 0, onStartReached is called again, and we get an infinite loop (at least, until there's no more content to load).

Android considerations

Another important piece of information is that the maintainVisibleContentPosition prop is not yet available on Android (implementation in progress). We have examined the in-progress Android implementation and found that it is very similar to the iOS one, and likely shares the same problem.

For the sake of this issue, the scope is focused on iOS, but we believe that the solution in one platform will be applicable in the other.

Potential cause

According to review comments from a Meta engineer, this bug is likely caused by a race condition between the items being added to the list and content offset being adjusted.

They also suggest implementing a binary search for the first visible item, which seems like it might improve the issue, but (in my opinion) is unlikely to resolve the race condition entirely.

Evidence of potential race condition?

In the FlatList example linked above, if you tweak these parameters as follows:

const PAGE_SIZE = 10;
const INITIAL_PAGE_OFFSET = 50;
const NUM_PAGES = 100;

The problem is mitigated but not completely solved (a few pages load before maintainVisibleContentPosition seems to "catch up" and function as expected). This hints that the problem may indeed be a race condition as suggested above.

autoScrollToTopThreshold wonkiness

According to the React Native documentation:

The optional autoscrollToTopThreshold can be used to make the content automatically scroll to the top after making the adjustment if the user was within the threshold of the top before the adjustment was made.

This suggests that with an autoScrollToTopThreshold of 0, then no auto-scrolling should occur if you have a non-zero contentOffset before new items are appended to the list. We have observed that this is not the case by:

  1. Scrolling down a few pixels (without taking the minIndexForVisible item out of view)
  2. Prepending an item.

Despite having an autoScrollToTopThreshold of 0 and a non-zero contentOffset, the ScrollView auto-scrolls to the top.

Interestingly, this particular ScrollView example listed in the reproduction steps can be fixed by removing the autoScrollToTopThreshold parameter entirely. While this might be a hint at how to solve this, it does not seem to be a viable solution for us. Even without an autoScrollToTopThreshold parameter, the same problem occurs in this FlatList example. It's unclear why removing the autoScrollToTopThreshold parameter fixes the problem, but setting a value of 0 does not. 🤔

Workaround:

While there may be workarounds possible via hacks in the JS code involving setTimeout or extra calls to scrollToIndex/scrollToOffset, these would not solve the root problem. In order to have a proposal accepted, it must fix the problem in the React Native codebase itself, probably in the native layer.

Platform:

Right now, this problem has been confirmed on iOS. It very likely exists on Android as well in this implementation. We are working on confirming the issue there, and will be following the progress of that pull request.

For the scope of this issue, we'll only require a fix in iOS or Android (preferably iOS), submitted as a PR against the our react-native fork: https://github.com/Expensify/react-native. Applying the same fix in the other platform should be comparatively easy and can be treated as a follow-up.


View all open jobs on GitHub

Upwork Automation - Do Not Edit
  • Upwork Job URL: https://www.upwork.com/jobs/~01fe321bebf9b78f69
  • Upwork Job ID: 1606337510652706816
  • Last Price Increase: 2022-12-23
MelvinBot commented 2 years ago

Triggered auto assignment to @kadiealexander (External), see https://stackoverflow.com/c/expensify/questions/8582 for more details.

roryabraham commented 2 years ago

@kadiealexander reassigning to @mallenexpensify because we discussed this 1:1

MelvinBot commented 2 years ago

Triggered auto assignment to Contributor-plus team member for initial proposal review - @parasharrajat (Exported)

MelvinBot commented 2 years ago

Current assignee @roryabraham is eligible for the Exported assigner, not assigning anyone new.

mallenexpensify commented 2 years ago

We live! https://www.upwork.com/jobs/~01620b5ab4dee0a9f0

zoontek commented 2 years ago

I'm not sure I get the issue correctly since, when setting:

<ScrollView
  automaticallyAdjustContentInsets={false}
  maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
  // …

contentOffset is indeed adjusted, scroll position maintained:

https://user-images.githubusercontent.com/1902323/155970666-d1294c7f-c624-432a-9530-87a704d81bc3.MP4


<ScrollView
  automaticallyAdjustContentInsets={false}
  maintainVisibleContentPosition={{
    minIndexForVisible: 0,
    autoscrollToTopThreshold: 0,
  }}

contentOffset is adjusted, scroll position is updated if minIndexForVisible element +- autoscrollToTopThreshold is visible (approximately, sure).

https://user-images.githubusercontent.com/1902323/155971211-39613016-2901-498e-858e-2df52f184b9e.MP4


<ScrollView
  automaticallyAdjustContentInsets={false}
  maintainVisibleContentPosition={{
    minIndexForVisible: 1,
    autoscrollToTopThreshold: 1,
  }}

This case is really broken, with contentOffset being incorrect when removing elements. But it's not the purpose of this issue.

https://user-images.githubusercontent.com/1902323/155971475-b4e1b7ec-0bbc-4ad1-9875-7c333d6fa043.MP4

@roryabraham Maybe I'm missing something?

roryabraham commented 2 years ago

@zoontek Thanks for following up. You may have missed this piece of the issue description:

Interestingly, https://github.com/facebook/react-native/pull/33184 listed in the reproduction steps can be fixed by removing the autoScrollToTopThreshold parameter entirely. While this might be a hint at how to solve this, it does not seem to be a viable solution for us. Even without an autoScrollToTopThreshold parameter, the same problem occurs in https://github.com/Expensify/react-native/pull/7. It's unclear why removing the autoScrollToTopThreshold parameter fixes the problem, but setting a value of 0 does not. 🤔

zoontek commented 2 years ago

@roryabraham No, I read it, but as I understand setting any autoScrollToTopThreshold value will activate auto scroll to top if the given index is visible (when a new element is added). Which it does (?)

roryabraham commented 2 years ago

Hi @zoontek – so you're saying that autoScrollToTopThreshold doesn't represent a pixel offset, but an offset represented in visual items? Interesting, as I did not glean that from the RN docs or implementation.

Nonetheless, I think this is still a valid bug report in that maintainVisibleContentPosition does not work consistently / appears subject to some race condition(s). Perhaps our minimal reproduction example is faulty / needs improvement, but the bug we observed is clearly reproducible in this PR branch, and I believe that bug surfaces in the native layer at the location described in the issue template.

roryabraham commented 2 years ago

@mallenexpensify Let's double this.

mallenexpensify commented 2 years ago

Price doubled to $20,000

azimgd commented 2 years ago

@roryabraham could you take a look at this code please: https://github.com/azimgd/MVCPExample/

make sure to npx patch-package after npm install

Here is the demo which maintains visible content position in both directions, is that the actual result you are looking for in this issue?:

https://user-images.githubusercontent.com/4882133/157487057-ad6e4acf-c13b-4efe-bf07-68a47793c309.mov

CODEFIX DIFF

       CGRect newFrame = self->_firstVisibleView.frame;
-      CGFloat deltaY = newFrame.origin.y - self->_prevFirstVisibleFrame.origin.y;
-      if (ABS(deltaY) > 0.1) {
+      CGFloat deltaY = self->_scrollView.contentSize.height - self->_prevContentSizeHeight;
+        
+      if (ABS(deltaY) > 0.1 && newFrame.origin.y != self->_prevFirstVisibleFrame.origin.y) {

This will store the content size before and after new items are added. Delta will be calculated accordingly and correct content offset is applied without triggering onScroll event.

Changing minIndexForVisible is not focusing on that exact index right now (always defaults to current offset), but I will get that working once I validate the exact requirements.

Sarveshwins commented 2 years ago

could you take a look at this code please: https://github.com/Sarveshwins/ScrollViewbug

Here is the demo which maintains visible content position in both directions.

https://user-images.githubusercontent.com/82214917/157656995-7e47c968-2656-479a-a1d0-22fc2b06491b.mov

const [sizeOfHeight, setsizeOfHeight] = useState(40); const [bottomSizeOfHeight, setBottomSizeOfHeight] = useState(40); const ChangeHeight = () => { setsizeOfHeight(prevState => { return prevState === 40 ? 90 : 40; }); }; const ChangeBottomHeight = () => { setBottomSizeOfHeight(prevState => { return prevState === 40 ? 90 : 40; }); }; function getRandomColor() { let letters = '0123456789ABCDEF'; let color = '#'; for (let i = 0; i < 6; i++) { color += letters[Math.floor(Math.random() 16)]; } return color; } const generate = (size = 10) => { const rows = new Array(size).fill(true).map((item, index) => ({ key: index, size: Math.round(Math.random() 100), color: getRandomColor(), })); return rows; }; const [dataForVisible, setdataForVisible] = useState(generate()); const OnAppend = () => { setdataForVisible(state => { return [ { size: Math.round(Math.random() * 100), color: getRandomColor(), widthsize: sizeOfHeight, },

    ...state,
  ];
});

}; const onFilter = () => { setdataForVisible(state => { return state.filter((item, index) => index != 0); }); }; const addAtBottom = () => { setdataForVisible(state => { return [ ...state, { size: Math.round(Math.random() * 100), color: getRandomColor(), widthsize: bottomSizeOfHeight, }, ]; }); };

const DeletAtBottom = () => { setdataForVisible(state => { return state.filter((item, index) => index != state.length - 1); }); };

parasharrajat commented 2 years ago

Looking at proposals shortly... Need to understand the problem first.

zoontek commented 2 years ago

@roryabraham I took a bit of time on this one, made a deep dive in the code to understand it better (as I find the documentation not that clear) (current work is WIP)

maintainVisibleContentPosition={{
  minIndexForVisible: 0,
}}

Scroll is correctly preserved since the first entirely visible view is indeed at index >= 0:

https://user-images.githubusercontent.com/1902323/157931513-0dbb6dcd-fda3-49cd-96c1-c589c062a2e4.mp4

Now with autoscrollToTopThreshold. As we can see, each item here has a 37dp height:

Which means it should scroll to top when threshold (scrollview contentOffset.y) is < 37dp:

maintainVisibleContentPosition={{
  minIndexForVisible: 0,
  autoscrollToTopThreshold: 37,
}}

https://user-images.githubusercontent.com/1902323/157931957-eabf77a2-6fd7-412b-be3d-cae5dad7b9a7.mp4

But keep in mind that adding / removing items will update indexes (minIndexForVisible item will be updated) and autoscrollToTopThreshold limit:

autoscrollToTopThreshold schema

https://user-images.githubusercontent.com/1902323/158015565-d91fd7f1-5c88-425f-ba18-570e8e3c5c5f.mp4


Now, several remarks:

  1. Should scroll position be preserved if the item at minIndexForVisible is not visible? Currently it is, setting it to 10 for example will maintain scroll position even if at initial list state, this item is not visible yet. (we should set self->_prevFirstVisibleFrame and self->_firstVisibleView to nil to avoid maintaining position in such cases - but I understand why it has been ignored - minIndexForVisible should be set to 0 or 1 at max, allowing you to handle a loader which will always be the first item in your list, even if you prepending a lot of new items).

  2. ABS(deltaY) alone is a mistake. deltaY > 0.1 (adding item) and deltaY < -0.1 (removing item) should be handled in a different way since the adjustment might not be the same.

Quick example of the current behaviour:

maintainVisibleContentPosition={{
  minIndexForVisible: 1,
}}

https://user-images.githubusercontent.com/1902323/158016088-bd654b22-e732-42ed-bcf8-e4831700166d.mp4

Improved behavior:

https://user-images.githubusercontent.com/1902323/158016289-2534597b-f1bf-4b33-9c5c-bbb2c9cacc22.mp4

The current code (I removed horizontal scroll handling for example clarity, but the logic is the same):

- (void)uiManagerWillPerformMounting:(RCTUIManager *)manager
{
  RCTAssertUIManagerQueue();

  [manager prependUIBlock:^(__unused RCTUIManager *uiManager,
                            __unused NSDictionary<NSNumber *, UIView *> *viewRegistry) {
    NSUInteger minIndexForVisible = [self->_maintainVisibleContentPosition[@"minIndexForVisible"] integerValue];
    NSUInteger itemCount = self->_contentView.subviews.count;
    CGFloat scrollViewContentOffsetY = self->_scrollView.contentOffset.y;

    for (NSUInteger index = minIndexForVisible; index < itemCount; ++index) {
      // Find the first entirely visible view. This must be done after we update the content offset
      // or it will tend to grab rows that were made visible by the shift in position
      UIView *subview = self->_contentView.subviews[index];
      CGFloat subviewOriginY = subview.frame.origin.y;

      if (subviewOriginY >= scrollViewContentOffsetY || index == itemCount - 1) {
        self->_prevFirstVisibleFrame = subview.frame;
        self->_firstVisibleView = subview;
        break;
      }
    }
  }];

  [manager addUIBlock:^(__unused RCTUIManager *uiManager,
                        __unused NSDictionary<NSNumber *, UIView *> *viewRegistry) {
    if (self->_maintainVisibleContentPosition == nil) {
      return; // The prop might have changed in the previous UIBlocks, so need to abort here.
    }

    NSNumber *autoscrollToTopThreshold = self->_maintainVisibleContentPosition[@"autoscrollToTopThreshold"];
    CGRect newFrame = self->_firstVisibleView.frame;
    CGFloat deltaY = newFrame.origin.y - self->_prevFirstVisibleFrame.origin.y;

    if (ABS(deltaY) > 0.1) {
      // Handle items removal properly
      self->_scrollView.contentOffset = deltaY < -0.1 && self->_scrollView.contentOffset.y < newFrame.size.height
        ? CGPointMake(self->_scrollView.contentOffset.x, 0)
        : CGPointMake(self->_scrollView.contentOffset.x, self->_scrollView.contentOffset.y + deltaY);

      if (autoscrollToTopThreshold != nil) {
        // If the offset WAS within the threshold of the start, animate to the start.
        if (self->_scrollView.contentOffset.y - deltaY <= [autoscrollToTopThreshold integerValue]) {
          [self scrollToOffset:CGPointMake(self->_scrollView.contentOffset.x, 0) animated:YES];
        }
      }
    }
  }];
}

And the repository for the JS part: https://github.com/zoontek/ScrollViewIssue (you will have to manually replace uiManagerWillPerformMounting in Pods > Development Pods > React-Core > Default > Views > ScrollView > RCTScrollView.m).

Again: there is no real changes here (except to improve items removal handling). The original API has a few quirks that can be fixed, but given my explanations (mainly about autoscrollToTopThreshold behaviour), does the initial issue described in the ticket really exists? Maybe would you want to fix minIndexForVisible and autoscrollToTopThreshold in time? By that, I mean that once the content of the ScrollView is "stable" and the maintainVisibleContentPosition prop is set, we keep a delta of the prepended items index and their size to adjust minIndexForVisible and autoscrollToTopThreshold? That would be a bit weird, but it's doable.

with deltas schema
parasharrajat commented 2 years ago

@Sarveshwins Sorry but requirements mention that this issue needs to be fixed on react-native source.

parasharrajat commented 2 years ago

@zoontek I can't access mentioned repo. image

zoontek commented 2 years ago

@parasharrajat Sorry, I changed visibility to public.

parasharrajat commented 2 years ago

First, sorry for the delay. I was trying to set up the project on limited connectivity and trying to get grip on the problem.

Well! The Rn docs are really bad. I have a few questions. What should be the correct behavior of autoscrollToTopThreshold. When this is 0 and we prepend a new item the list should scroll and show that item. right? Following that when autoscrollToTopThreshold is 37, prepending new items should be scrolled to view if contentOffset.y is less than 37.

@azimgd I think that is the required behavior but I will confirm. In your example, I saw that you are not using autoscrollToTopThreshold. Issue Details explain the problem with it. Let's see if an example fixes it. image

Again: there are no real changes here (except to improve items removal handling). The original API has a few quirks that can be fixed, but given my explanations (mainly about autoscrollToTopThreshold behavior), does the initial issue described in the ticket really exists?

@zoontek I don't have a proper answer to it. But I think that the issue in the above-attached image should not happen. I tested your PR but haven't compared it with the reproduction build. I will update you later.

Also, this problem was initially faced on https://github.com/Expensify/react-native/pull/7. Maybe changes on that PR can help you answer a few questions.

azimgd commented 2 years ago

Interestingly, https://github.com/facebook/react-native/pull/33184 listed in the reproduction steps can be fixed by removing the autoScrollToTopThreshold parameter entirely. While this might be a hint at how to solve this, it does not seem to be a viable solution for us. Even without an autoScrollToTopThreshold parameter, the same problem occurs in https://github.com/Expensify/react-native/pull/7. It's unclear why removing the autoScrollToTopThreshold parameter fixes the problem, but setting a value of 0 does not. 🤔

The answer is <= comparison operator:

// RCTScrollView.m

/**
 * This is why removing the autoScrollToTopThreshold parameter entirely works
 */
if (autoscrollThreshold != nil) {
  /**
   * having autoscrollThreshold === 0; will evaluate to 0 <= 0
   */
  if (self->_scrollView.contentOffset.y - deltaY <= [autoscrollThreshold integerValue]) {
    [self scrollToOffset:CGPointMake(self->_scrollView.contentOffset.x, 0) animated:YES];
  }
}

Where fix for this would be:

if (autoscrollThreshold != nil && autoscrollThreshold != 0) {
parasharrajat commented 2 years ago

@azimgd I was trying out your example and It was very confusing due to all the color changes and also, indexes on the item change. Could you please simplify it so that assigned indexes don't change and we can clearly see the newly added items?

It seems that list is scrolling when minIndexforVisible: 0 on each prepend.

azimgd commented 2 years ago

@parasharrajat

I was trying out your example and It was very confusing due to all the color changes and also

Just want to make sure you run npx patch-package, which applies the patch into node_modules/react-native/React/Views/ScrollView/RCTScrollView.m

Could you please simplify it so that assigned indexes don't change and we can clearly see the newly added items?

It seems that list is scrolling when minIndexforVisible: 0 on each prepend.

I have not yet applied the fix mentioned at https://github.com/Expensify/App/issues/7925#issuecomment-1068305274

Let me know if you have any more questions and please keep in mind that we are still in the proposal stage

parasharrajat commented 2 years ago

Just want to make sure you run npx patch-package

Aha, I missed that. Ops.

Let me know if you have any more questions and please keep in mind that we are still in the proposal stage

Yup. I agree. But as you asked me to test your repo and I am trying to do that.

roryabraham commented 2 years ago

Sorry for how long it took me to triage these proposals.

@azimgd I've applied this patch to the rn-tester app and unfortunately found that it did not solve the problem for me. Can you please try testing the patch on this PR branch and see if it works for you? I'm guessing your demo isn't adding items with enough total height to trigger the bug that we're seeing. Was the patch actually necessary to fix your demo?

This will store the content size before and after new items are added

More precisely, your solution does not attempt to measure the location of specific subview and use that to determine the delta, and instead measures the full content size before and after new items are added when evaluating the delta.

As far as I can tell, this will really only have the effect of breaking minIndexForVisible without addressing the underlying race condition.

roryabraham commented 2 years ago

The answer is <= comparison operator:

Thanks @azimgd, that at least clears up some confusion I had about autoScrollToTopThreshold.

azimgd commented 2 years ago

More precisely, your solution does not attempt to measure the location of specific subview and use that to determine the delta, and instead measures the full content size before and after new items are added when evaluating the delta.

this is correct, I was planning to implement the solution on top of that logic.

I will work on that PR, and update you soon.

roryabraham commented 2 years ago

Thanks @zoontek for the detailed analysis:

Should scroll position be preserved if the item at minIndexForVisible is not visible?

I would think so, yes. Let's assume we had this maintainVisibleContentPosition configuration:

maintainVisibleContenPosition = {
    minIndexForVisible: 0,
    autoScrollToTopThreshold: 25,
};

I imagine that if you are in the middle of a list (index 0 is not visible, and we are more than 25px away from the start), then new content is prepended to the list, we would maintain scroll position.

Again: there is no real changes here (except to improve items removal handling)

Agree. Looks like those are good improvements but they don't address the issue we're facing.

but given my explanations (mainly about autoscrollToTopThreshold behaviour), does the initial issue described in the ticket really exists?

So there was definitely a flaw in the original post for the issue. The minimal reproduction we provided (using ScrollView alone) does not actually reflect the issue, but was a red herring that @azimgd explained the cause for in this comment.

We can remove autoscrollToTopThreshold entirely and the issue still exists. In order to reproduce it, try building the rn-tester app off of this branch and look at the FlatList example. By adjusting the page size you can see that the issue is only consistently reproducible when the new content has enough height, and is likely caused by some race condition.

mallenexpensify commented 2 years ago

Job doubled to $40,000 https://www.upwork.com/jobs/~01620b5ab4dee0a9f0

azimgd commented 2 years ago

After further investigation my initial thought are following:

I will give more detailed analysis with the proposal soon.

parasharrajat commented 2 years ago

Thanks, @azimgd, I am eager to see the detailed analysis. You can create an external doc and link it here if the post is long. You can use the same to prepare a complete solution for each step you would take which can be reviewed as a whole at some point.

parasharrajat commented 2 years ago

Hey @fabriziobertoglio1987,

Hope are you doing great. Sorry to ping you without notice. But I have seen your work on RN issues and really fond of your efforts to make RN better. I thought you might be interested in this job.

Thanks.

zoontek commented 2 years ago

So there was definitely a flaw in the original post for the issue. The minimal reproduction we provided (using ScrollView alone) does not actually reflect the issue

@roryabraham Maybe it doesn't really happen in your reproduction case because the issue is, indeed, tied to virtualization (could only happen inside a FlatList / SectionList?) (like @azimgd said)

If this is the case, it will be impossible to provides a real fix for this API (maybe change it). As the items might not be rendered inside the FlatList, we cannot compute the deltaY properly without specifying getItemLayout (But if it currently doesn't work when the layout size is given, it can be done!)

Don't forget that we could start at a specific item index with initialScrollIndex / have an inverted list, meaning it might have N items before, which might never have been rendered. autoScrollToTopThreshold would be to compute without getItemLayout in such cases.

Screenshot 2022-03-21 at 21 34 11
roryabraham commented 2 years ago

@zoontek In regards to your last post and the question posed in the image: How can we compute this since the items are currently not rendered and their height is variable?

I could be wrong, but my understanding is that the dynamically computed height will be available in addUIBlock, and since it's running in the UI thread we can synchronously adjust the contentOffset of the ScrollView before addUIBlock renders the new items.

roryabraham commented 2 years ago

I agree it's possible that the problem exists only with a VirtualizedList, but I'm still not sure why. My loose understanding of @azimgd's latest theory is that it has something to do with referential integrity. Something like this:

  1. Let's say VirtualizedList has data with 1000 items is rendering items [400, 600]
  2. onStartReached is called and item 400 is marked as the first visible item. Its position in the ScrollView is correctly recorded at 0px.
  3. Items [300-399] are added to the list. Right before we render them, we try to check the new position of item 400.
  4. Because VirtualizedList has added new items to the list, item 400 references a different JS object than it did before. Because the native views are recycled, the native layer can't tell the difference between item 400 and item 300.

Honestly not sure, but that's my best attempt at understanding @azimgd's latest hypothesis for now.

zoontek commented 2 years ago

I could be wrong, but my understanding is that the dynamically computed height will be available in addUIBlock, and since it's running in the UI thread we can synchronously adjust the contentOffset of the ScrollView before addUIBlock renders the new items.

No, you are right, but it doesn't solve the main issue: by rendering items on scrolling to the top of the list, you shift autoscrollToTopThreshold limit. This could be fixed by precising getItemLayout + fix the ScrollView module to support forced and consistant items layout size (currently, VirtualizedList is based on ScrollView native module (https://github.com/facebook/react-native/blob/0.68-stable/Libraries/Lists/VirtualizedList.js)

  1. Let's say VirtualizedList has data with 1000 items is rendering items [400, 600]
  2. onStartReached is called and item 400 is marked as the first visible item. Its position in the ScrollView is correctly recorded at 0px.
  3. Items [300-399] are added to the list. Right before we render them, we try to check the new position of item 400.
  4. Because VirtualizedList has added new items to the list, item 400 references a different JS object than it did before. Because the native views are recycled, the native layer can't tell the difference between item 400 and item 300.

Let's say we set maintainVisibleContentPosition: 400 and autoscrollToTopThreshold: 50 in your exemple. By scrolling to the top, say we render items 3 per 3 and items are immediately unmounted once they exit the screen.

Screenshot 2022-03-21 at 23 39 30

As you see, autoscrollToTopThreshold will not be related to the "real" top of the list but will depend of how much items are rendered above, and their size. But if we are aware that the first rendered items in the list is and we know via getItemLayout items constant height, we could determine if we are in the autoscrollToTop threshold.

Also, what will autoscrollToTopThreshold supposed to do? Scroll to the rendered top or scroll to the first item (might not be rendered at this point)?

azimgd commented 2 years ago

This is a draft, I will ping reviewers once completed

FlatList

<ScrollView>
{head.map(renderItem)} → https://github.com/Expensify/react-native/blob/master/Libraries/Lists/VirtualizedList.js#L910-L926
{virtualized.map(renderItem)} → https://github.com/Expensify/react-native/blob/master/Libraries/Lists/VirtualizedList.js#L962-L969
{tail.map(renderItem)} → https://github.com/Expensify/react-native/blob/master/Libraries/Lists/VirtualizedList.js#L991-L998
</ScrollView>

Output of NSLog(@"Subviews count: %lu", self->_contentView.subviews.count); at prependUIBlock:

2022-03-22 18:25:23.468276+0500 MVCPExample[66740:6211501] Subviews count: 13
2022-03-22 18:25:23.468414+0500 MVCPExample[66740:6211501] Subviews count: 13
2022-03-22 18:25:23.468551+0500 MVCPExample[66740:6211501] Subviews count: 13
2022-03-22 18:25:23.468674+0500 MVCPExample[66740:6211501] Subviews count: 13
2022-03-22 18:25:23.468751+0500 MVCPExample[66740:6211501] Subviews count: 13
2022-03-22 18:25:23.469204+0500 MVCPExample[66740:6211501] Subviews count: 13
2022-03-22 18:25:23.474187+0500 MVCPExample[66740:6211501] Subviews count: 13
2022-03-22 18:25:23.545705+0500 MVCPExample[66740:6211501] Subviews count: 13
2022-03-22 18:25:23.627980+0500 MVCPExample[66740:6211501] Subviews count: 23
2022-03-22 18:25:23.707817+0500 MVCPExample[66740:6211501] Subviews count: 33
2022-03-22 18:25:23.788797+0500 MVCPExample[66740:6211501] Subviews count: 43
2022-03-22 18:25:23.855413+0500 MVCPExample[66740:6211501] Subviews count: 53
2022-03-22 18:25:23.921377+0500 MVCPExample[66740:6211501] Subviews count: 63
2022-03-22 18:25:23.988802+0500 MVCPExample[66740:6211501] Subviews count: 73
2022-03-22 18:25:24.064840+0500 MVCPExample[66740:6211501] Subviews count: 83
2022-03-22 18:25:24.129288+0500 MVCPExample[66740:6211501] Subviews count: 93
2022-03-22 18:25:24.187206+0500 MVCPExample[66740:6211501] Subviews count: 95

Proposal One

https://github.com/Expensify/react-native/blob/master/React/Views/ScrollView/RCTScrollView.m#L922

// add self->_scrollView.contentOffset.y > 0 

CGFloat y = self->_scrollView.contentOffset.y + bottomInset;
hasNewView = subview.frame.origin.y > y && y > 0;

This will cause https://github.com/Expensify/react-native/blob/master/React/Views/ScrollView/RCTScrollView.m#L925

ii == self->_contentView.subviews.count - 1

To grab last subview until virtualizer has completed it's job with rendering actual items (around 4 component render cycles for the sample of 100 items)

Proposal Two

Save the content height in prependUIBlock

[manager
      prependUIBlock:^(__unused RCTUIManager *uiManager, __unused NSDictionary<NSNumber *, UIView *> *viewRegistry) {
self->_prevContentSizeHeight = self->_scrollView.contentSize.height;

https://github.com/Expensify/react-native/blob/master/React/Views/ScrollView/RCTScrollView.m#L954

CGFloat deltaY = self->_scrollView.contentSize.height - self->_prevContentSizeHeight;

https://user-images.githubusercontent.com/4882133/159498505-1a80f709-3d84-4d74-b989-30dae1c598e3.mp4

Adjust content height when new items are prepended, minIndexForVisible is omited here

Proposal three

Pass range returned by computeWindowedRenderLimits at VirtualizedList into ScrollView to get frame of the actual first visible item.

parasharrajat commented 2 years ago

I am trying to understand your proposal @azimgd. Sorry for the delay.

medzomethod commented 2 years ago

I can get it fixed asap @mallenexpensify

parasharrajat commented 2 years ago

How will you fix it @medzomethod? Please read about the contributing guidelines from CONTRIBUTING.md.

parasharrajat commented 2 years ago

I tried to understand your proposal @azimgd but I didn't completely get the good sense of what are you trying to achieve by all of those changes? I know this is a draft but thought of sharing the review with you.

I am new to native code but I can understand the logic. It would be helpful for me if you can also tell us what are those variables you referenced in your proposals. You reviewed the native code and understand it but they might not be known to others (like me).

so e.g. self->_contentView => renderedContent subview.frame => Item Dimensions.

Proposal one, two, etc will be better tagged as steps.

My understanding is that you are trying to get the correct first visible item on the content view when new items are prepended. Please let me know if I am wrong. Thanks. I will try to learn and understand the changes as much as possible.

azimgd commented 2 years ago

this is an obsolete proposal

There are multiple things need to be addressed in order to integrate maintainVisibleContentPosition with FlatList:

fix onStartReached calls

This prop is currently implemented under maybeCallOnEdgeReached here which is called for following scenarios:

We need to modify and prevent execution of onStartReached inside ScrollView.onLayout when offset is changed within native code because this happens for the single event:

  1. calls onStartReached from onLayout
  2. append content
  3. calls onStartReached from onContentSizeChange
  4. sets offset

Possible solutions

  1. pass callee name maybeCallOnEdgeReached('onContentSizeChange') and blacklist onStartReached when callee equals to onContentSizeChange
  2. compare execution timestamps and don't call if the diff is just couple milliseconds (not the best)

fix onCellLayout calls

Need to actually investigate more why fillRateHelper is disabled on development, and onCellLayout from VirtualizedList on called inside CellRenderer resulting frame dimensions not being cached at this._frames.

Just changing this to: const onLayout = this.props.onLayout; for now.

calculate correct coordinates for each FlatList item (aka subview)

This is the most important we need to address.

Coordinates for minIndexForVisible are calculated at prependUIBlock here

This works in the following way, say we have a ScrollView with 5 children and want to get the position of children #3:

<ScrollView>
  <View key={1}>
  <View key={2}>
  <View key={3}>
  <View key={4}>
  <View key={5}>
</ScrollView>

Should be easy enough, we can iterate through the children of ScrollView and pick 3rd item using this code:

UIView *subview = nil
for (NSUInteger ii = 0; ii < self->_contentView.subviews.count; ++ii) {
  // dummy condition ofc, calculation could be changed according to offsets or whatever
  if (ii === 2) subview = self->_contentView.subviews.count[ii]

However this approach of iterating through self->_contentView.subviews.count will break for Flatlist due to:

example:

// virtualized state = { first: 3, last: 4 }
<ScrollView>
  <View key={1}> //always rendered because this is head
  // emptiness
  <View key={3}>
  <View style={{ height: 1000px }}> // blank view
  <View key={5}> //always rendered because this is tail
</ScrollView>

Having offsets for minIndexForVisible calculations on Obj-C side is not realistic without changing API for me. But, since we already have all frame sizes calculated at VirtualizedList, this could be simply passed into native code.

// this.frames = { 0: {height: 50, start: 0, end: 50}, 1: {height: 150, start: 50, end: 200} }
<View key={0} style={{ height: 50 }}>
<View key={1} style={{ height: 150 }}>

Extend maintainVisibleContentPosition here to include new frames object.

<ScrollView
  {...props}
  maintainVisibleContentPosition={{
    ...props.maintainVisibleContentPosition,
    frames: {...this._frames},
  }}

then we can change our obj-c code:

- for (NSUInteger ii = minIdx; ii < self->_contentView.subviews.count; ++ii) {
+ for (NSUInteger ii = minIdx; ii < [self->_maintainVisibleContentPosition[@"frames"] count] - 1; ++ii) { 

This is the easiest solution I could come up that for now with the least amount of modifications required. Other solutions like simply setting offset to contentSize - prevContentSize definitely works and could be used if you are not really planning on using minIndexForVisible and simply having persistent scroll on appending items are described in my comment above https://github.com/Expensify/App/issues/7925#issuecomment-1075208622

@roryabraham @parasharrajat

parasharrajat commented 2 years ago

Awesome, I will check it tomorrow.

parasharrajat commented 2 years ago

Sorry for the delay, I couldn't review the proposal yesterday. Upgrading the macOS version on my Mac has broken the IOS simulator. I am trying to fix it.

parasharrajat commented 2 years ago

I am still facing issues with the IOS simulator. The app is crashing on launch. But there is nothing to test so far.


fix onStartReached calls

So, you mean that we need to prevent the onStartReached on the mount.

_onContentSizeChange: needs modification, since this method is tied to _onLayout.

this needs to be changed due to the order of operations.

calls onStartReached from onLayout
append content
calls onStartReached from onContentSizeChange
sets offset

Set Offsets should be done before calls onStartReached from onContentSizeChange for this to work correctly.

prevent multiple onStartReached calls on initial render in this https://github.com/Expensify/react-native/pull/7

Why do we have to do this?

fix onCellLayout calls

I think I would need more details to understand it.

calculate correct coordinates for each FlatList item (aka subview)

<ScrollView
  {...props}
  maintainVisibleContentPosition={{
    ...props.maintainVisibleContentPosition,
    frames: {...this._frames},
  }}

This was already discussed above in detail. But I don't think this is going to be an acceptable change in the RN codebase. IMO Dimensions should be passed internally but it depends on how Scrollview works.

parasharrajat commented 2 years ago

I am not familiar with the inner workings of RN FlatList and it seems quite complex to understand quickly. Also, I am not good at native code. I might take some time to really understand the suggestions here. Looking at the urgency of the issue, I would request someone else to take over.

@roryabraham Could you please continue the discussion here? I'm going to unassign. Let me know if something is needed. Always happy to help.

roryabraham commented 2 years ago

Thanks for all your investigation @azimgd. I unfortunately have not had time to fully investigate your latest proposal yet, but I am hoping to do that in the next few days and get back to you. Thanks.

azimgd commented 2 years ago

Adjusting state<{first, last}> at VirtualizedList fixes maintainVisibleContentPosition prop on ios.

state<{first, last}> aka range, is holding the windowed render limits result.

→ data.length = 1000
→ {first: 0, last: 150} // renders only 0-150, virtualizes 150-1000

→ data.length = 2000
→ {first: 1000, last: 1150} // renders only 1000-1150, virtualizes 0-1000, 1150-2000

once items are prepended into <FlatList data={items} /> we should simply calculate amount of items prepended and shift range to make maintainVisibleContentPosition work.

you can observe it by hardcoding range at VirtualizedList#getDerivedStateFromProps:

static getDerivedStateFromProps(newProps: Props, prevState: State): State {
    return (() => {
      const perPage = 1000
      const itemCount = newProps.data.length
      if (itemCount === 1 * perPage) return { first: 0, last: 150 }
      if (itemCount === 2 * perPage) return { first: 1 * perPage - 100, last: 1 * perPage + 100 }
      if (itemCount === 3 * perPage) return { first: 2 * perPage - 100, last: 2 * perPage + 100 }
      if (itemCount === 4 * perPage) return { first: 3 * perPage - 100, last: 3 * perPage + 100 }
      if (itemCount === 5 * perPage) return { first: 4 * perPage - 100, last: 4 * perPage + 100 }
    })()
}

Proposal is to inject customRangeExtractor function into getDerivedStateFromProps and invoke it from there, e.g.:

const customRangeExtractor = ({ itemCount, first, last }) => {    
      const itemsPerPage = 1000
      for (let i = itemsPerPage + itemsPerPage; i <= itemCount; i = i + itemsPerPage) {
        if (i !== itemCount) continue;
        const nextFirst = i - itemsPerPage
        const nextLast = i - itemsPerPage + 150
        return { first: nextFirst, last: nextLast }
      }
      return { first, last }
}

static getDerivedStateFromProps(newProps: Props, prevState: State): State {
      return customRangeExtractor({ itemCount: newProps.data.length, ...prevState })
}
example component ```js import React from 'react'; import { FlatList, SafeAreaView, StyleSheet, Text, View, } from 'react-native'; const useData = () => { const [data, setData] = React.useState([]); const prepend = () => { const next = Array.from(Array(1000).keys()).map(item => Math.random() + item); setData(state => [...next, ...state]) } const append = () => { const next = Array.from(Array(1000).keys()).map(item => Math.random() + item); setData(state => [...state, ...next]) } return { data, prepend, append, } } const renderItem = ({ index, item }) => { const height = Math.max(parseInt(`${item}`.substring(7, 9), 10), 50); return ( {item} ); }; const App = () => { const data = useData(); return ( Prepend item} maintainVisibleContentPosition={{ minIndexForVisible: 1 }} /> ); }; export default App; ```
mallenexpensify commented 2 years ago

@roryabraham can you please review Azim's proposal above?

roryabraham commented 2 years ago

@azimgd I think your latest proposal gets pretty close to the core of the issue – though I'm not sure about the customRangeExtractor solution. Would the developer implementing the bidirectional FlatList have to provide that customRangerExtractor implementation themselves?

I think we'll be able to implement a generic solution in VirtualizedList that solves the range problem ... I'm investigating to see what that might look like.

azimgd commented 2 years ago

@roryabraham Even though we could allow passing by props (might be helpful) I imagine that defaultRangeExtractor will be defined at VirtualizedList or VirtualizedUtils.