Skip to content

Commit

Permalink
update implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
janicduplessis committed Oct 20, 2023
1 parent d82cf20 commit 3c5d279
Show file tree
Hide file tree
Showing 4 changed files with 193 additions and 133 deletions.
190 changes: 190 additions & 0 deletions src/components/FlatList/MVCPFlatList.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/* eslint-disable es/no-optional-chaining, es/no-nullish-coalescing-operators, react/prop-types */

import React from 'react';
import {FlatList} from 'react-native';

function mergeRefs(...args) {
return function forwardRef(node) {
args.forEach((ref) => {
if (ref == null) {
return;
}
if (typeof ref === 'function') {
ref(node);
return;
}
if (typeof ref === 'object') {
// eslint-disable-next-line no-param-reassign
ref.current = node;
return;
}
console.error(`mergeRefs cannot handle Refs of type boolean, number or string, received ref ${String(ref)}`);
});
};
}

function useMergeRefs(...args) {
return React.useMemo(
() => mergeRefs(...args),
// eslint-disable-next-line
[...args],
);
}

const MVCPFlatList = React.forwardRef(({maintainVisibleContentPosition, horizontal, inverted, onScroll, ...props}, forwardedRef) => {
const {minIndexForVisible: mvcpMinIndexForVisible, autoscrollToTopThreshold: mvcpAutoscrollToTopThreshold} = maintainVisibleContentPosition ?? {};
const scrollRef = React.useRef(null);
const prevFirstVisibleOffsetRef = React.useRef(null);
const firstVisibleViewRef = React.useRef(null);
const mutationObserverRef = React.useRef(null);
const lastScrollOffsetRef = React.useRef(0);

const getScrollOffset = React.useCallback(() => {
if (scrollRef.current == null) {
return 0;
}
return horizontal ? scrollRef.current.getScrollableNode().scrollLeft : scrollRef.current.getScrollableNode().scrollTop;
}, [horizontal]);

const getContentView = React.useCallback(() => scrollRef.current?.getScrollableNode().childNodes[0], []);

const scrollToOffset = React.useCallback(
(offset, animated) => {
const behavior = animated ? 'smooth' : 'instant';
scrollRef.current?.getScrollableNode().scroll(horizontal ? {left: offset, behavior} : {top: offset, behavior});
},
[horizontal],
);

const prepareForMaintainVisibleContentPosition = React.useCallback(() => {
if (mvcpMinIndexForVisible == null) {
return;
}

const contentView = getContentView();
if (contentView == null) {
return;
}

const scrollOffset = getScrollOffset();

for (let i = mvcpMinIndexForVisible; i < contentView.childNodes.length; i++) {
const subview = contentView.childNodes[i];
const subviewOffset = horizontal ? subview.offsetLeft : subview.offsetTop;
if (subviewOffset > scrollOffset || i === contentView.childNodes.length - 1) {
prevFirstVisibleOffsetRef.current = subviewOffset;
firstVisibleViewRef.current = subview;
break;
}
}
}, [getContentView, getScrollOffset, mvcpMinIndexForVisible, horizontal]);

const adjustForMaintainVisibleContentPosition = React.useCallback(() => {
if (mvcpMinIndexForVisible == null) {
return;
}

const firstVisibleView = firstVisibleViewRef.current;
const prevFirstVisibleOffset = prevFirstVisibleOffsetRef.current;
if (firstVisibleView == null || prevFirstVisibleOffset == null) {
return;
}

const firstVisibleViewOffset = horizontal ? firstVisibleView.offsetLeft : firstVisibleView.offsetTop;
const delta = firstVisibleViewOffset - prevFirstVisibleOffset;
if (Math.abs(delta) > 0.5) {
const scrollOffset = getScrollOffset();
prevFirstVisibleOffsetRef.current = firstVisibleViewOffset;
scrollToOffset(scrollOffset + delta, false);
if (mvcpAutoscrollToTopThreshold != null && scrollOffset <= mvcpAutoscrollToTopThreshold) {
scrollToOffset(0, true);
}
}
}, [getScrollOffset, scrollToOffset, mvcpMinIndexForVisible, mvcpAutoscrollToTopThreshold, horizontal]);

const setupMutationObserver = React.useCallback(() => {
const contentView = getContentView();
if (contentView == null) {
return;
}

mutationObserverRef.current?.disconnect();

const mutationObserver = new MutationObserver(() => {
// Chrome adjusts scroll position when elements are added at the top of the
// view. We want to have the same behavior as react-native / Safari so we
// reset the scroll position to the last value we got from an event.
const lastScrollOffset = lastScrollOffsetRef.current;
const scrollOffset = getScrollOffset();
if (lastScrollOffset !== scrollOffset) {
scrollToOffset(lastScrollOffset, false);
}

// This needs to execute after scroll events are dispatched, but
// in the same tick to avoid flickering. rAF provides the right timing.
requestAnimationFrame(() => {
adjustForMaintainVisibleContentPosition();
});
});
mutationObserver.observe(contentView, {
attributes: true,
childList: true,
subtree: true,
});

mutationObserverRef.current = mutationObserver;
}, [adjustForMaintainVisibleContentPosition, getContentView, getScrollOffset, scrollToOffset]);

React.useEffect(() => {
prepareForMaintainVisibleContentPosition();
setupMutationObserver();
}, [prepareForMaintainVisibleContentPosition, setupMutationObserver]);

const setMergedRef = useMergeRefs(scrollRef, forwardedRef);

const onRef = React.useCallback(
(newRef) => {
// Make sure to only call refs and re-attach listeners if the node changed.
if (newRef == null || newRef === scrollRef.current) {
return;
}

setMergedRef(newRef);
prepareForMaintainVisibleContentPosition();
setupMutationObserver();
},
[prepareForMaintainVisibleContentPosition, setMergedRef, setupMutationObserver],
);

React.useEffect(() => {
const mutationObserver = mutationObserverRef.current;
return () => {
mutationObserver?.disconnect();
};
}, []);

const onScrollInternal = React.useCallback(
(ev) => {
lastScrollOffsetRef.current = getScrollOffset();

prepareForMaintainVisibleContentPosition();

onScroll?.(ev);
},
[getScrollOffset, prepareForMaintainVisibleContentPosition, onScroll],
);

return (
<FlatList
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
horizontal={horizontal}
inverted={inverted}
onScroll={onScrollInternal}
scrollEventThrottle={1}
ref={onRef}
/>
);
});

export default MVCPFlatList;
3 changes: 3 additions & 0 deletions src/components/FlatList/index.web.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import MVCPFlatList from './MVCPFlatList';

export default MVCPFlatList;
128 changes: 0 additions & 128 deletions src/components/InvertedFlatList/MVCPScrollView/MVCPScrollView.js

This file was deleted.

5 changes: 0 additions & 5 deletions src/components/InvertedFlatList/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import React, {forwardRef, useEffect, useRef} from 'react';
import PropTypes from 'prop-types';
import {DeviceEventEmitter, FlatList, StyleSheet} from 'react-native';
import _ from 'underscore';
import MVCPScrollView from './MVCPScrollView/MVCPScrollView';
import BaseInvertedFlatList from './BaseInvertedFlatList';
import styles from '../../styles/styles';
import CONST from '../../CONST';
Expand Down Expand Up @@ -122,10 +121,6 @@ function InvertedFlatList(props) {
// We need to keep batch size to one to workaround a bug in react-native-web.
// This can be removed once https://github.com/Expensify/App/pull/24482 is merged.
maxToRenderPerBatch={1}

// We need to use our own scroll component to workaround a maintainVisibleContentPosition for web
// eslint-disable-next-line react/jsx-props-no-spreading
renderScrollComponent={(_props) => <MVCPScrollView {..._props} />}
/>
);
}
Expand Down

0 comments on commit 3c5d279

Please sign in to comment.