// @flow import * as React from 'react'; type ScrollbarUpdateContext = { size: boolean, position: boolean, }; const AUTOHIDE_TIMEOUT = 1000; type Props = { autoHide: boolean, thumbInset: { x: number, y: number }, children?: React.Node, }; type State = { canScroll: boolean, showScrollIndicators: boolean, }; type ScrollPosition = 'top' | 'bottom' | 'middle'; export default class CustomScrollbars extends React.Component { static defaultProps = { autoHide: true, thumbInset: { x: 2, y: 2 }, }; state = { canScroll: false, showScrollIndicators: true, }; _scrollableElement: ?HTMLElement; _thumbElement: ?HTMLElement; _autoHideTimer: ?TimeoutID; scrollTo(x: number, y: number) { const scrollable = this._scrollableElement; if(scrollable) { scrollable.scrollLeft = x; scrollable.scrollTop = y; } } scrollToElement(child: HTMLElement, scrollPosition: ScrollPosition) { const scrollable = this._scrollableElement; if(scrollable) { // throw if child is not a descendant of scroll view if(!scrollable.contains(child)) { throw new Error('Cannot scroll to an element which is not a descendant of CustomScrollbars.'); } const scrollTop = this._computeScrollTop(scrollable, child, scrollPosition); this.scrollTo(0, scrollTop); } } componentDidMount() { this._updateScrollbarsHelper({ position: true, size: true }); // show scroll indicators briefly when mounted if(this.props.autoHide) { this._startAutoHide(); } } componentWillUnmount() { this._stopAutoHide(); } componentDidUpdate() { this._updateScrollbarsHelper({ position: true, size: true }); } render() { const { autoHide: _autoHide, thumbInset: _thumbInset, children, ...otherProps } = this.props; const showScrollbars = this.state.canScroll && this.state.showScrollIndicators; const thumbAnimationClass = showScrollbars ? ' custom-scrollbars__thumb--visible' : ''; return (
{ children }
); } _onScrollableRef = (ref) => { this._scrollableElement = ref; } _onThumbRef = (ref) => { this._thumbElement = ref; } _onScroll = () => { this._updateScrollbarsHelper({ position: true }); if(this.props.autoHide) { this._startAutoHide(); } } _startAutoHide() { if(this._autoHideTimer) { clearTimeout(this._autoHideTimer); } this._autoHideTimer = setTimeout(() => { this.setState({ showScrollIndicators: false, }); }, AUTOHIDE_TIMEOUT); if(!this.state.showScrollIndicators) { this.setState({ showScrollIndicators: true, }); } } _stopAutoHide() { if(this._autoHideTimer) { clearTimeout(this._autoHideTimer); this._autoHideTimer = null; } } _computeScrollTop(scrollable: HTMLElement, child: HTMLElement, scrollPosition: ScrollPosition) { switch(scrollPosition) { case 'top': return child.offsetTop; case 'bottom': return child.offsetTop - (scrollable.offsetHeight - child.clientHeight); case 'middle': return child.offsetTop - ((scrollable.offsetHeight - child.clientHeight) * 0.5); default: throw new Error(`Unknown enum type for ScrollPosition: ${ scrollPosition }`); } } _computeThumbPosition(scrollable: HTMLElement, thumb: HTMLElement) { // the content height of the scroll view const scrollHeight = scrollable.scrollHeight; // the visible height of the scroll view const visibleHeight = scrollable.offsetHeight; // scroll offset const scrollTop = scrollable.scrollTop; // lowest point of scrollTop const maxScrollTop = scrollHeight - visibleHeight; // calculate scroll position within 0..1 range const scrollPosition = scrollHeight > 0 ? scrollTop / maxScrollTop : 0; const thumbHeight = thumb.clientHeight; // calculate the thumb boundary to make sure that the visual appearance of // a thumb at lowest point matches the bottom of scrollable view const thumbBoundary = visibleHeight - thumbHeight - (this.props.thumbInset.y * 2); // calculate thumb position based on scroll progress and thumb boundary // adding vertical inset to adjust the thumb's appearance const thumbPosition = (thumbBoundary * scrollPosition) + this.props.thumbInset.y; return { x: -this.props.thumbInset.x, y: thumbPosition, }; } _computeThumbHeight(scrollable: HTMLElement) { const scrollHeight = scrollable.scrollHeight; const visibleHeight = scrollable.offsetHeight; const thumbHeight = (visibleHeight / scrollHeight) * visibleHeight; // ensure that the scroll thumb doesn't shrink to nano size return Math.max(thumbHeight, 8); } _updateScrollbarsHelper(updateFlags: $Shape) { const scrollable = this._scrollableElement; const thumb = this._thumbElement; if(scrollable && thumb) { this._updateScrollbars(scrollable, thumb, updateFlags); } } _updateScrollbars(scrollable: HTMLElement, thumb: HTMLElement, context: $Shape) { if(context.size) { const thumbHeight = this._computeThumbHeight(scrollable); thumb.style.setProperty('height', thumbHeight + 'px'); // hide thumb when there is nothing to scroll const canScroll = (thumbHeight < scrollable.offsetHeight); if(this.state.canScroll !== canScroll) { this.setState({ canScroll }); // flash the scroll indicators when the view becomes scrollable if(this.props.autoHide && canScroll) { this._startAutoHide(); } } } if(context.position) { const { x, y } = this._computeThumbPosition(scrollable, thumb); thumb.style.setProperty('transform', `translate(${x}px, ${y}px)`); } } }