Closed roryabraham closed 1 year ago
Triggered auto assignment to @kadiealexander (External
), see https://stackoverflow.com/c/expensify/questions/8582 for more details.
@kadiealexander reassigning to @mallenexpensify because we discussed this 1:1
Triggered auto assignment to Contributor-plus team member for initial proposal review - @parasharrajat (Exported
)
Current assignee @roryabraham is eligible for the Exported assigner, not assigning anyone new.
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?
@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. 🤔
@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 (?)
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.
@mallenexpensify Let's double this.
Price doubled to $20,000
@roryabraham could you take a look at this code please: https://github.com/azimgd/MVCPExample/
make sure to
npx patch-package
afternpm 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
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.
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.
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); }); };
Looking at proposals shortly... Need to understand the problem first.
@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:
https://user-images.githubusercontent.com/1902323/158015565-d91fd7f1-5c88-425f-ba18-570e8e3c5c5f.mp4
Now, several remarks:
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).
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.
@Sarveshwins Sorry but requirements mention that this issue needs to be fixed on react-native source.
@zoontek I can't access mentioned repo.
@parasharrajat Sorry, I changed visibility to public.
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.
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.
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) {
@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.
@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?
10
items on append and prepend starting from 1..10
self->_contentView.subviews.count
changed, and the scroll position should be maintainedIt 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
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.
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.
The answer is <= comparison operator:
Thanks @azimgd, that at least clears up some confusion I had about autoScrollToTopThreshold
.
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.
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.
Job doubled to $40,000 https://www.upwork.com/jobs/~01620b5ab4dee0a9f0
After further investigation my initial thought are following:
maintainVisibleContentPosition
won't behave correctly when used under FlatList because self->_contentView.subviews.count
here https://github.com/facebook/react-native/blob/main/React/Views/ScrollView/RCTScrollView.m#L910 will have an amount of items rendered by virtualizer, not the actual array length of data
prop passed to the FlatList
I will give more detailed analysis with the proposal soon.
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.
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.
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.
@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.
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:
VirtualizedList
has data
with 1000 items is rendering items [400, 600]
onStartReached
is called and item 400
is marked as the first visible item. Its position in the ScrollView
is correctly recorded at 0px
.[300-399]
are added to the list. Right before we render them, we try to check the new position of item 400
.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.
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 thecontentOffset
of the ScrollView beforeaddUIBlock
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)
- Let's say
VirtualizedList
hasdata
with 1000 items is rendering items[400, 600]
onStartReached
is called and item400
is marked as the first visible item. Its position in theScrollView
is correctly recorded at0px
.- Items
[300-399]
are added to the list. Right before we render them, we try to check the new position of item400
.- Because
VirtualizedList
has added new items to the list, item400
references a different JS object than it did before. Because the native views are recycled, the native layer can't tell the difference between item400
and item300
.
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.
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)?
This is a draft, I will ping reviewers once completed
HeaderComponent
and FooterComponent
, HeaderComponent is always rendered as the first subview inside ScrollView self->_contentView.subviews[0]
!<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
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)
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
Pass range
returned by computeWindowedRenderLimits
at VirtualizedList into ScrollView
to get frame of the actual first visible item.
I am trying to understand your proposal @azimgd. Sorry for the delay.
I can get it fixed asap @mallenexpensify
How will you fix it @medzomethod? Please read about the contributing guidelines from CONTRIBUTING.md.
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.
this is an obsolete proposal
There are multiple things need to be addressed in order to integrate maintainVisibleContentPosition
with FlatList
:
onStartReached
calls on initial render in this PR onCellLayout
calls inside VirtualizedList.CellRenderer
componentsubview
)onStartReached
callsThis prop is currently implemented under maybeCallOnEdgeReached
here which is called for following scenarios:
_onLayout
: good, check and execute when ScrollView
is rendered. Allows us loading content in both directions on first mount._onScroll
: good, check and execute when ScrollView
is scrolled._onContentSizeChange
: needs modification, since this method is tied to _onLayout
.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:
onStartReached
from onLayout
onStartReached
from onContentSizeChange
maybeCallOnEdgeReached('onContentSizeChange')
and blacklist onStartReached
when callee equals to onContentSizeChangeonCellLayout
callsNeed 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.
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:
ListHeaderComponent
, ListFooterComponent
, ListEmptyComponent
, ItemSeparatorComponent
_computeBlankness
and rendering long empty components that fill empty offset.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
Awesome, I will check it tomorrow.
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.
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.
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.
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.
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 })
}
@roryabraham can you please review Azim's proposal above?
@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.
@roryabraham Even though we could allow passing by props (might be helpful) I imagine that defaultRangeExtractor
will be defined at VirtualizedList
or VirtualizedUtils
.
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
is0
, then the scroll position will be maintained iff:contentOffset
is at leasty
, wherey
is the height of the new content being prependedSimilarly, if
minIndexForVisible
is1
, then the scroll position will be maintained iff:contentOffset
is at leasty
, wherey
is the height of the new content being prependedAnd 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:
contentOffset
of the scroll container by the amount calculated in the previous step, such that: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'smaintainVisibleContentPosition
prop, which is implemented in the native layer.The result of this bug is that our implementation of the
onStartReached
prop inVirtualizedList
suffers from the following issue:contentOffset
of0
),onStartReached
is called.onStartReached
prepends new items into the list.maintainVisibleContentPosition
fails to update thecontentOffset
to account for those new list items.contentOffset
is still0
, so the list position jumps to the start of the new content.contentOffset
is0
,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:
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
wonkinessAccording to the React Native documentation:
This suggests that with an
autoScrollToTopThreshold
of0
, then no auto-scrolling should occur if you have a non-zerocontentOffset
before new items are appended to the list. We have observed that this is not the case by:minIndexForVisible
item out of view)Despite having an
autoScrollToTopThreshold
of0
and a non-zerocontentOffset
, theScrollView
auto-scrolls to the top.Interestingly, this particular
ScrollView
example listed in the reproduction steps can be fixed by removing theautoScrollToTopThreshold
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 anautoScrollToTopThreshold
parameter, the same problem occurs in this FlatList example. It's unclear why removing theautoScrollToTopThreshold
parameter fixes the problem, but setting a value of0
does not. 🤔Workaround:
While there may be workarounds possible via hacks in the JS code involving
setTimeout
or extra calls toscrollToIndex
/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