-
Notifications
You must be signed in to change notification settings - Fork 3k
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
HIGH: (Comment linking: step 2) [23220] WEB maintain visible content position #32098
Changes from 11 commits
97b0a29
a7360c4
d624dd3
a34fed2
e642647
7dd2e8f
5d67d02
ace8192
9f5084d
e9fa5bf
731514e
5e6d6f6
8042282
a22fabd
3259670
cd317c4
1ac24bb
62ed316
24f0fdc
4f99585
760eba2
ebfd970
e7d3077
8118a2d
8787cb1
dbdb226
b68e8bf
d173edb
0ba8d40
7ecd381
ee09b8a
481bca5
69ee59b
cb4e9b7
a26402d
2babd91
d6f7c55
ff502fa
c9efdc2
a05adff
941ca04
454f8a1
e5568dc
bf5b980
ae17f61
42ec7a7
356adf8
f23ef43
1cf27ad
54921a9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,206 @@ | ||
/* 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, 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(); | ||
|
||
const contentViewLength = contentView.childNodes.length; | ||
for (let i = mvcpMinIndexForVisible; i < contentViewLength; i++) { | ||
const subview = contentView.childNodes[inverted ? contentViewLength - i - 1 : 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, inverted]); | ||
|
||
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]); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Having (Coming from #43600) |
||
|
||
const setupMutationObserver = React.useCallback(() => { | ||
const contentView = getContentView(); | ||
if (contentView == null) { | ||
return; | ||
} | ||
|
||
mutationObserverRef.current?.disconnect(); | ||
|
||
const mutationObserver = new MutationObserver(() => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When list is hidden and reappears, it scrolls down to the bottom. But it should keep its last scroll offset. That is why ignore this callback when list is hidden. Coming from #45434 |
||
// 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} | ||
maintainVisibleContentPosition={maintainVisibleContentPosition} | ||
horizontal={horizontal} | ||
inverted={inverted} | ||
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; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
import MVCPFlatList from './MVCPFlatList'; | ||
|
||
export default MVCPFlatList; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note for posterity: these were disabled to keep this code as similar as possible to the upstream PR it's based on: necolas/react-native-web#2588
Hopefully this component only lives in our codebase relatively temporarily. We probably will want to switch to FlashList soon too for the main chat list.