Generalized the infinite scrollable list (#4697)
parent
938cd2875b
commit
0827c09c44
8 changed files with 379 additions and 323 deletions
@ -0,0 +1,122 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; |
||||
import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; |
||||
|
||||
export default class IntersectionObserverArticle extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
intersectionObserverWrapper: PropTypes.object, |
||||
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), |
||||
index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), |
||||
listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), |
||||
children: PropTypes.node, |
||||
}; |
||||
|
||||
state = { |
||||
isHidden: false, // set to true in requestIdleCallback to trigger un-render
|
||||
} |
||||
|
||||
shouldComponentUpdate (nextProps, nextState) { |
||||
if (!nextState.isIntersecting && nextState.isHidden) { |
||||
// It's only if we're not intersecting (i.e. offscreen) and isHidden is true
|
||||
// that either "isIntersecting" or "isHidden" matter, and then they're
|
||||
// the only things that matter (and updated ARIA attributes).
|
||||
return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength; |
||||
} else if (nextState.isIntersecting && !this.state.isIntersecting) { |
||||
// If we're going from a non-intersecting state to an intersecting state,
|
||||
// (i.e. offscreen to onscreen), then we definitely need to re-render
|
||||
return true; |
||||
} |
||||
// Otherwise, diff based on "updateOnProps" and "updateOnStates"
|
||||
return super.shouldComponentUpdate(nextProps, nextState); |
||||
} |
||||
|
||||
componentDidMount () { |
||||
if (!this.props.intersectionObserverWrapper) { |
||||
// TODO: enable IntersectionObserver optimization for notification statuses.
|
||||
// These are managed in notifications/index.js rather than status_list.js
|
||||
return; |
||||
} |
||||
this.props.intersectionObserverWrapper.observe( |
||||
this.props.id, |
||||
this.node, |
||||
this.handleIntersection |
||||
); |
||||
|
||||
this.componentMounted = true; |
||||
} |
||||
|
||||
componentWillUnmount () { |
||||
if (this.props.intersectionObserverWrapper) { |
||||
this.props.intersectionObserverWrapper.unobserve(this.props.id, this.node); |
||||
} |
||||
|
||||
this.componentMounted = false; |
||||
} |
||||
|
||||
handleIntersection = (entry) => { |
||||
if (this.node && this.node.children.length !== 0) { |
||||
// save the height of the fully-rendered element
|
||||
this.height = getRectFromEntry(entry).height; |
||||
|
||||
if (this.props.onHeightChange) { |
||||
this.props.onHeightChange(this.props.status, this.height); |
||||
} |
||||
} |
||||
|
||||
this.setState((prevState) => { |
||||
if (prevState.isIntersecting && !entry.isIntersecting) { |
||||
scheduleIdleTask(this.hideIfNotIntersecting); |
||||
} |
||||
return { |
||||
isIntersecting: entry.isIntersecting, |
||||
isHidden: false, |
||||
}; |
||||
}); |
||||
} |
||||
|
||||
hideIfNotIntersecting = () => { |
||||
if (!this.componentMounted) { |
||||
return; |
||||
} |
||||
|
||||
// When the browser gets a chance, test if we're still not intersecting,
|
||||
// and if so, set our isHidden to true to trigger an unrender. The point of
|
||||
// this is to save DOM nodes and avoid using up too much memory.
|
||||
// See: https://github.com/tootsuite/mastodon/issues/2900
|
||||
this.setState((prevState) => ({ isHidden: !prevState.isIntersecting })); |
||||
} |
||||
|
||||
handleRef = (node) => { |
||||
this.node = node; |
||||
} |
||||
|
||||
render () { |
||||
const { children, id, index, listLength } = this.props; |
||||
const { isIntersecting, isHidden } = this.state; |
||||
|
||||
if (!isIntersecting && isHidden) { |
||||
return ( |
||||
<article |
||||
ref={this.handleRef} |
||||
aria-posinset={index} |
||||
aria-setsize={listLength} |
||||
style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }} |
||||
data-id={id} |
||||
tabIndex='0' |
||||
> |
||||
{children && React.cloneElement(children, { hidden: true })} |
||||
</article> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<article ref={this.handleRef} aria-posinset={index} aria-setsize={listLength} data-id={id} tabIndex='0'> |
||||
{children && React.cloneElement(children, { hidden: false })} |
||||
</article> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,179 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
import { ScrollContainer } from 'react-router-scroll'; |
||||
import PropTypes from 'prop-types'; |
||||
import IntersectionObserverArticle from './intersection_observer_article'; |
||||
import LoadMore from './load_more'; |
||||
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; |
||||
import { throttle } from 'lodash'; |
||||
|
||||
export default class ScrollableList extends PureComponent { |
||||
|
||||
static propTypes = { |
||||
scrollKey: PropTypes.string.isRequired, |
||||
onScrollToBottom: PropTypes.func, |
||||
onScrollToTop: PropTypes.func, |
||||
onScroll: PropTypes.func, |
||||
trackScroll: PropTypes.bool, |
||||
shouldUpdateScroll: PropTypes.func, |
||||
isLoading: PropTypes.bool, |
||||
hasMore: PropTypes.bool, |
||||
prepend: PropTypes.node, |
||||
emptyMessage: PropTypes.node, |
||||
children: PropTypes.node, |
||||
}; |
||||
|
||||
static defaultProps = { |
||||
trackScroll: true, |
||||
}; |
||||
|
||||
intersectionObserverWrapper = new IntersectionObserverWrapper(); |
||||
|
||||
handleScroll = throttle(() => { |
||||
if (this.node) { |
||||
const { scrollTop, scrollHeight, clientHeight } = this.node; |
||||
const offset = scrollHeight - scrollTop - clientHeight; |
||||
this._oldScrollPosition = scrollHeight - scrollTop; |
||||
|
||||
if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) { |
||||
this.props.onScrollToBottom(); |
||||
} else if (scrollTop < 100 && this.props.onScrollToTop) { |
||||
this.props.onScrollToTop(); |
||||
} else if (this.props.onScroll) { |
||||
this.props.onScroll(); |
||||
} |
||||
} |
||||
}, 150, { |
||||
trailing: true, |
||||
}); |
||||
|
||||
componentDidMount () { |
||||
this.attachScrollListener(); |
||||
this.attachIntersectionObserver(); |
||||
|
||||
// Handle initial scroll posiiton
|
||||
this.handleScroll(); |
||||
} |
||||
|
||||
componentDidUpdate (prevProps) { |
||||
// Reset the scroll position when a new child comes in in order not to
|
||||
// jerk the scrollbar around if you're already scrolled down the page.
|
||||
if (React.Children.count(prevProps.children) < React.Children.count(this.props.children) && this._oldScrollPosition && this.node.scrollTop > 0) { |
||||
if (this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props)) { |
||||
const newScrollTop = this.node.scrollHeight - this._oldScrollPosition; |
||||
if (this.node.scrollTop !== newScrollTop) { |
||||
this.node.scrollTop = newScrollTop; |
||||
} |
||||
} else { |
||||
this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop; |
||||
} |
||||
} |
||||
} |
||||
|
||||
componentWillUnmount () { |
||||
this.detachScrollListener(); |
||||
this.detachIntersectionObserver(); |
||||
} |
||||
|
||||
attachIntersectionObserver () { |
||||
this.intersectionObserverWrapper.connect({ |
||||
root: this.node, |
||||
rootMargin: '300% 0px', |
||||
}); |
||||
} |
||||
|
||||
detachIntersectionObserver () { |
||||
this.intersectionObserverWrapper.disconnect(); |
||||
} |
||||
|
||||
attachScrollListener () { |
||||
this.node.addEventListener('scroll', this.handleScroll); |
||||
} |
||||
|
||||
detachScrollListener () { |
||||
this.node.removeEventListener('scroll', this.handleScroll); |
||||
} |
||||
|
||||
getFirstChildKey (props) { |
||||
const { children } = props; |
||||
const firstChild = Array.isArray(children) ? children[0] : children; |
||||
return firstChild && firstChild.key; |
||||
} |
||||
|
||||
setRef = (c) => { |
||||
this.node = c; |
||||
} |
||||
|
||||
handleLoadMore = (e) => { |
||||
e.preventDefault(); |
||||
this.props.onScrollToBottom(); |
||||
} |
||||
|
||||
handleKeyDown = (e) => { |
||||
if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) { |
||||
const article = (() => { |
||||
switch (e.key) { |
||||
case 'PageDown': |
||||
return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling; |
||||
case 'PageUp': |
||||
return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling; |
||||
case 'End': |
||||
return this.node.querySelector('[role="feed"] > article:last-of-type'); |
||||
case 'Home': |
||||
return this.node.querySelector('[role="feed"] > article:first-of-type'); |
||||
default: |
||||
return null; |
||||
} |
||||
})(); |
||||
|
||||
|
||||
if (article) { |
||||
e.preventDefault(); |
||||
article.focus(); |
||||
article.scrollIntoView(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
render () { |
||||
const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; |
||||
const childrenCount = React.Children.count(children); |
||||
|
||||
const loadMore = <LoadMore visible={!isLoading && childrenCount > 0 && hasMore} onClick={this.handleLoadMore} />; |
||||
let scrollableArea = null; |
||||
|
||||
if (isLoading || childrenCount > 0 || !emptyMessage) { |
||||
scrollableArea = ( |
||||
<div className='scrollable' ref={this.setRef}> |
||||
<div role='feed' className='item-list' onKeyDown={this.handleKeyDown}> |
||||
{prepend} |
||||
|
||||
{React.Children.map(this.props.children, (child, index) => ( |
||||
<IntersectionObserverArticle key={child.key} id={child.key} index={index} listLength={childrenCount} intersectionObserverWrapper={this.intersectionObserverWrapper}> |
||||
{child} |
||||
</IntersectionObserverArticle> |
||||
))} |
||||
|
||||
{loadMore} |
||||
</div> |
||||
</div> |
||||
); |
||||
} else { |
||||
scrollableArea = ( |
||||
<div className='empty-column-indicator' ref={this.setRef}> |
||||
{emptyMessage} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
if (trackScroll) { |
||||
return ( |
||||
<ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}> |
||||
{scrollableArea} |
||||
</ScrollContainer> |
||||
); |
||||
} else { |
||||
return scrollableArea; |
||||
} |
||||
} |
||||
|
||||
} |
Loading…
Reference in new issue