-
Notifications
You must be signed in to change notification settings - Fork 3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
…leContentPosition HIGH: (Comment linking: step 2) [23220] WEB maintain visible content position
- Loading branch information
Showing
11 changed files
with
839 additions
and
955 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -42,4 +42,3 @@ jobs: | |
with: | ||
DURATION_DEVIATION_PERCENTAGE: 20 | ||
COUNT_DEVIATION: 0 | ||
|
Large diffs are not rendered by default.
Oops, something went wrong.
This file was deleted.
Oops, something went wrong.
File renamed without changes.
File renamed without changes.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,207 @@ | ||
/* eslint-disable es/no-optional-chaining, es/no-nullish-coalescing-operators, react/prop-types */ | ||
import PropTypes from '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, 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(); | ||
|
||
const contentViewLength = contentView.childNodes.length; | ||
for (let i = mvcpMinIndexForVisible; i < contentViewLength; i++) { | ||
const subview = contentView.childNodes[i]; | ||
const subviewOffset = horizontal ? subview.offsetLeft : subview.offsetTop; | ||
if (subviewOffset > scrollOffset || i === contentViewLength - 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(() => { | ||
// This needs to execute after scroll events are dispatched, but | ||
// in the same tick to avoid flickering. rAF provides the right timing. | ||
requestAnimationFrame(() => { | ||
// 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); | ||
} | ||
|
||
adjustForMaintainVisibleContentPosition(); | ||
}); | ||
}); | ||
mutationObserver.observe(contentView, { | ||
attributes: true, | ||
childList: true, | ||
subtree: true, | ||
}); | ||
|
||
mutationObserverRef.current = mutationObserver; | ||
}, [adjustForMaintainVisibleContentPosition, getContentView, getScrollOffset, scrollToOffset]); | ||
|
||
React.useEffect(() => { | ||
requestAnimationFrame(() => { | ||
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} | ||
maintainVisibleContentPosition={maintainVisibleContentPosition} | ||
horizontal={horizontal} | ||
onScroll={onScrollInternal} | ||
scrollEventThrottle={1} | ||
ref={onRef} | ||
/> | ||
); | ||
}); | ||
|
||
MVCPFlatList.displayName = 'MVCPFlatList'; | ||
MVCPFlatList.propTypes = { | ||
maintainVisibleContentPosition: PropTypes.shape({ | ||
minIndexForVisible: PropTypes.number.isRequired, | ||
autoscrollToTopThreshold: PropTypes.number, | ||
}), | ||
horizontal: PropTypes.bool, | ||
}; | ||
|
||
MVCPFlatList.defaultProps = { | ||
maintainVisibleContentPosition: null, | ||
horizontal: false, | ||
}; | ||
|
||
export default MVCPFlatList; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
import MVCPFlatList from './MVCPFlatList'; | ||
|
||
export default MVCPFlatList; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters