Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/viewability configs #301

Merged
merged 26 commits into from
Apr 12, 2022
Merged

Feature/viewability configs #301

merged 26 commits into from
Apr 12, 2022

Conversation

fortmarek
Copy link
Contributor

@fortmarek fortmarek commented Apr 11, 2022

Description

This PR partly implements #268.

It's currently missing:

  • enabling viewability config at item level instead of list - I am still on the fence about whether we want to implement this now (if ever) as it is a feature FlatList does not support.

The API itself closely copies FlatList as there was no real reason for us to deviate and by default, we try to stick what FlatList has.

One thing touched on in the original issue was the following:

We need to take into account that developers will be processing data in these callbacks so we need to be very smart in deferring them. We can provide a timestamp with each event instead of raising them as soon as possible.

The current state of the PR does not defer callbacks in any way - rather, it invokes them synchronously. This copies the behavior FlatList has. The current implementation does mean viewability callbacks will potentially worsen performance if JS thread is busy, user is scrolling quickly, and the minimumViewTime is high. The performance drop also depends on the users' implementation.

The idea to defer viewability callbacks when JS thread is not busy (using requestIdleCallback) is still on the table but it has its drawbacks:

  • we will divert from FlatList
  • the API will be slightly more difficult to use, requiring users to use a new timestamp field that would be used to track when the viewability event actually occurred
  • we'd still need to invoke all the callbacks before unmounting a view - in case there would be a lot of pending callbacks, it could lead to dropped frames during the navigation transition away from the view. There might be ways to mitigate it but will further complicate the implementation

Additionally, having a sensible minimumViewTime already ensures that the number of viewability callback invocations will stay low - which is why I have added a default of 250 ms if user does not specify it. But even if user does specify a lower minimumViewTime, even 0, I have not observed any noticeable drop in performance when using and not using viewability callbacks.

One thing we could do is to add timestamp field to the ViewToken object (sent in the viewability callback) now and instruct users to use it. This will enable us making those callbacks deferred in the future without breaking users' implementations.

Update:
We have decided to add the timestamp and instruct people to use it. This enables us to defer the callbacks in the future without breaking users' implementation: 50cb39a

Reviewers’ hat-rack 🎩

  • Try out Twitter and TwitterFlatList and observe viewability callbacks which are currently logged to the console (I suggest commenting out the blank area log in App.tsx

Checklist

@fortmarek fortmarek marked this pull request as draft April 11, 2022 11:20
@fortmarek
Copy link
Contributor Author

As for the performance, I have not found any performance regression when using viewability callbacks using this PR - the average FPS was 45.5 +/- 1.

@fortmarek fortmarek marked this pull request as ready for review April 11, 2022 16:04
Called when the viewability of rows changes, as defined by the `viewabilityConfig` prop. Array of `changed` includes `ViewToken`s that both visible and non-visible items. You can use the `isViewable` flag to filter the items.

:::note
If you are tracking the time a view becomes (non-)visible, use the `timestamp` property.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A short explanation can be helpful here


#### itemVisiblePercentThreshold

Similar to `viewAreaCoveragePercentThreshold`, but considers the percent of the item that is visible, rather than the fraction of the viewable area it covers.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the rule if I specify both viewAreaCoveragePercentThreshold and itemVisiblePercentThreshold?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch, we should throw an exception in that case, as FlatList does.

recordInteraction();
```

Tells the list an interaction has occurred, which should trigger viewability calculations, e.g. if `waitForInteractions` is true and the user has not scrolled. This is typically called by taps on items or by navigation actions.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we be more explicit in stating that devs have to actually call this method? After reading the last line I felt that list will detect this automatically which, I'm assuming, is not the case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, I will rephrase this - it's from the React Native documentation but it certainly can be a bit more clear.

@@ -56,6 +56,14 @@ const Twitter = () => {
ItemSeparatorComponent={Divider}
data={tweets}
initialScrollIndex={debugContext.initialScrollIndex}
viewabilityConfig={{
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FlashList is a pure component so ideally we should avoid sending in new objects. It may be relevant if someone tries to draw inspiration from here. Something good to have but not a necessity if we look at these as maintainer only samples.

@@ -120,6 +130,14 @@ export interface FlashListProps<T> extends FlatListProps<T> {
* If you're using ListEmptyComponent, this event is raised as soon as ListEmptyComponent is rendered.
*/
onLoad?: (info: { elapsedTimeInMs: number }) => void;

/**
* Called when the viewability of rows changes, as defined by the viewablePercentThreshold prop.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the word row is not relevant to FlashList. Even with numColumns > 1 we treat everything as individual items

* Creates a new `ViewabilityHelper` instance with `onViewableItemsChanged` callback and `ViewabilityConfig`
* @returns `ViewabilityHelper` instance
*/
private createViewabilityHelper = (
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There really seems to be a need for a manager class that can setup all the viewability helpers and can forward right events. It would be better if FlashList doesn't do that. It will also reduce the additions required in this file.

windowCorrectionConfig={this.getUpdatedWindowCorrectionConfig()}
/>
</StickyHeaderContainer>
);
}

private onVisibleIndicesChanged = (all: number[]) => {
this.viewabilityHelpers.forEach(
(viewabilityHelper) => (viewabilityHelper.possiblyViewableIndices = all)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateViewableItems method in viewabilityHelper can also accept and new array of items. It will make this cleaner.

this.viewabilityHelpers.forEach((viewabilityHelper) => {
viewabilityHelper.hasInteracted = true;
});
this.updateViewableItems();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also call this.props.onScrollBeginDrag incase the developer has sent it

this.viewabilityHelpers.forEach((viewabilityHelper) => {
viewabilityHelper.updateViewableItems(
this.props.horizontal ?? false,
this.rlvRef?.getCurrentScrollOffset() ?? 0,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may not be enough. Check your calculation with a header. You may need to subtract distanceFromWindow here.

this.timers.forEach(clearTimeout);
}

updateViewableItems(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you mark public methods as public. Makes it easier to read

Copy link
Collaborator

@naqvitalha naqvitalha left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fortmarek I've left some comments. The tests look awesome btw.

Overall, some changes in design can be helpful. Like FlashList and viewablity helper do too much together and FlashList is acting as a manager of multiple instances of viewability helper.
Can we create a manager that takes minimum input from FlashList (a ref also works) and abstracts all the management away? Even better if the manager can have a generic interface.

Also, let's see the impact of this on performance (if any)

@fortmarek
Copy link
Contributor Author

@naqvitalha I have created a new component ViewabilityManager which minimizes changes necessary in FlashList. Other comments have also been addressed.

Copy link
Contributor

@ElviraBurchik ElviraBurchik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tophatted, a couple of nits and questions 👍

isViewable: boolean;
item: string;
key: string;
timestamp: number;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FlatList's ViewToken also has section prop. We don't support it yet, but maybe add a note about it?

`viewabilityConfig` is a default configuration for determining whether items are viewable.

:::warning
Changing viewabilityConfig on the fly is not supported
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does on the fly mean specifically, what's not possible?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you cannot change it after the first render. It's how FlatList phrases it - do you think there's a better way to explain this?

@@ -39,6 +45,12 @@ export const mountFlashList = (props?: {
) => void;
estimatedItemSize?: number;
ListEmptyComponent?: FlashListProps<string>["ListEmptyComponent"];
viewabilityConfig?: ViewabilityConfig | null;
onViewableItemsChanged?:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: since onViewableItemsChanged is optional, is it necessary to define it's type as .. | null | undefined?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is - for example, in the test utils (mountFlashList) we have:

onViewableItemsChanged={props?.onViewableItemsChanged}

This expression would be invalid if undefined is not specified in the type. Defining a property as xxx?: string | null | undefined means you don't have to specify it (in which case it is undefined) but if you do, it has to be equal to the type after :.

Therefore, we should definitely keep undefined, null might not be that useful but I think it's better to keep it as there's not a really strong reason to constrain users in defaulting to undefined.

/**
* Viewable indices regardless of the viewability config
*/
possiblyViewableIndices: number[] = [];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: possiblyViewableIndices is not well explanatory without the comment since, it's actually viewed, but unfiltered items

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

those items are visible but may not fit the criteria - do you have a suggestion for a better name? I'd argue unfilteredViewableIndices is not self-explanatory, too, and can't think of anything better.

constructor(flashListRef: FlashList<T>) {
this.flashListRef = flashListRef;
if (
flashListRef.props.onViewableItemsChanged !== null &&
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should onViewableItemsChanged be both null | undefined? It could be reduced to check for undefined, no? Or it there any specific reason?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As explained here, I'd rather keep it.

updateViewableItems({ viewabilityHelper });
// Initial call
expect(viewableIndicesChanged).toHaveBeenCalledWith(
[0, 1, 2],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why [0, 1, 2] is expected twice?

Copy link
Contributor Author

@fortmarek fortmarek Apr 12, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the first array is what's viewable now whereas the second array defines what is newly viewable. The third then is for what is newly not viewable.

);

updateViewableItems({ viewabilityHelper, scrollOffset: 50 });
expect(viewableIndicesChanged).toHaveBeenCalledWith([0, 1, 2, 3], [3], []);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you please add comments about expected results? 🙌
It's a bit hard to understand on the go

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done 👌

};

private onScroll = () => {
this.viewabilityManager.updateViewableItems();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call this.props.onScroll too

};

public recordInteraction = () => {
this.viewabilityHelpers.forEach((viewabilityHelper) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will run everytime onScrollBeginDrag happens. After the first call we can ignore it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to call updateViewableItems also after first interaction

@@ -414,12 +435,27 @@ class FlashList<T> extends React.PureComponent<
}
initialOffset={initialOffset}
onItemLayout={this.onItemLayout}
onScroll={this.onScroll}
onVisibleIndicesChanged={
this.viewabilityManager.onVisibleIndicesChanged
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid listening to this if devs don't want callbacks?

};

public updateViewableItems = (newViewableIndices?: number[]) => {
const listSize =
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there's no registered viewability helper we can skip all this code. Check for length in the beginning?

Copy link
Collaborator

@naqvitalha naqvitalha left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, awesome work @fortmarek

@fortmarek fortmarek merged commit f321460 into main Apr 12, 2022
@fortmarek fortmarek deleted the feature/viewability-configs branch April 12, 2022 15:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants